Skip to content

Commit 0f82b85

Browse files
authored
feat(cortex-tui): subagent navigation and improved tool summaries (#540)
* feat(cortex-tui): add subagent conversation navigation - Add SubagentConversation(String) variant to AppView enum for viewing subagent conversations - Add viewing_subagent: Option<String> field to AppState to track current subagent - Add view_subagent_conversation(), return_to_main_conversation(), is_viewing_subagent(), get_viewing_subagent() methods - Add SubagentView context to HintContext with hints for Esc (back to main) and scroll - Add render_back_to_main_hint() function for displaying back navigation hint - Update match expressions to handle new SubagentConversation variant * fix(cortex-tui): improve tool result summary formatting - Use '↳' prefix consistently across all tool summaries - LS tool shows 'items' instead of generic 'lines' - Glob/Grep show 'Found N files/matches' for clarity - Add specific handling for Create, MultiEdit, TodoWrite, Task tools - Default fallback shows 'items' instead of 'lines' - Add unit tests for new summary formats * feat(cortex-tui): add ESC key handler for subagent view navigation - ESC key returns from subagent conversation to main conversation - Display '← Back to main (Esc)' hint when viewing subagent - Add SubagentView context for appropriate key hints
1 parent 81bad94 commit 0f82b85

9 files changed

Lines changed: 145 additions & 27 deletions

File tree

src/cortex-tui/src/app/methods.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,28 @@ impl AppState {
457457
.filter(|t| !t.status.is_terminal())
458458
.count()
459459
}
460+
461+
/// Enter subagent conversation view
462+
pub fn view_subagent_conversation(&mut self, session_id: String) {
463+
self.viewing_subagent = Some(session_id.clone());
464+
self.set_view(AppView::SubagentConversation(session_id));
465+
}
466+
467+
/// Return to main conversation from subagent view
468+
pub fn return_to_main_conversation(&mut self) {
469+
self.viewing_subagent = None;
470+
self.set_view(AppView::Session);
471+
}
472+
473+
/// Check if viewing a subagent conversation
474+
pub fn is_viewing_subagent(&self) -> bool {
475+
self.viewing_subagent.is_some()
476+
}
477+
478+
/// Get the currently viewed subagent session ID
479+
pub fn get_viewing_subagent(&self) -> Option<&String> {
480+
self.viewing_subagent.as_ref()
481+
}
460482
}
461483

462484
// ============================================================================

src/cortex-tui/src/app/state.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ pub struct AppState {
8888
pub message_queue: VecDeque<String>,
8989
/// Active subagent tasks being displayed.
9090
pub active_subagents: Vec<SubagentTaskDisplay>,
91+
/// Currently viewed subagent session ID (for SubagentConversation view)
92+
pub viewing_subagent: Option<String>,
9193
/// MCP servers list for management
9294
pub mcp_servers: Vec<crate::modal::mcp_manager::McpServerInfo>,
9395
/// Context files added to the session
@@ -220,6 +222,7 @@ impl AppState {
220222
question_hovered_tab: None,
221223
message_queue: VecDeque::new(),
222224
active_subagents: Vec::new(),
225+
viewing_subagent: None,
223226
mcp_servers: Vec::new(),
224227
context_files: Vec::new(),
225228
log_level: String::from("info"),
@@ -299,7 +302,7 @@ impl Default for AppState {
299302
impl AppState {
300303
/// Set the current view
301304
pub fn set_view(&mut self, view: AppView) {
302-
self.previous_view = Some(self.view);
305+
self.previous_view = Some(self.view.clone());
303306
self.view = view;
304307
}
305308

src/cortex-tui/src/app/types.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ use cortex_core::widgets::DisplayMode;
55
pub type OperationMode = DisplayMode;
66

77
/// The current view/screen being displayed
8-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
99
pub enum AppView {
1010
#[default]
1111
Session,
1212
Approval,
1313
Questions,
1414
Settings,
1515
Help,
16+
/// Viewing a subagent's conversation (stores the subagent session_id)
17+
SubagentConversation(String),
1618
}
1719

1820
/// Which UI element currently has focus

src/cortex-tui/src/runner/event_loop/input.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,13 @@ impl EventLoop {
367367

368368
/// Handle ESC key with double-tap to quit
369369
fn handle_esc(&mut self, terminal: &mut CortexTerminal) -> Result<()> {
370+
// Priority 1: If viewing a subagent conversation, return to main conversation
371+
if self.app_state.is_viewing_subagent() {
372+
self.app_state.return_to_main_conversation();
373+
self.render(terminal)?;
374+
return Ok(());
375+
}
376+
370377
// Check if app is idle (nothing active that ESC should cancel)
371378
let is_idle = !self.app_state.streaming.is_streaming
372379
&& self.app_state.pending_approval.is_none()

src/cortex-tui/src/runner/event_loop/rendering.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ impl EventLoop {
3535
terminal.draw(|frame| {
3636
let area = frame.area();
3737

38-
match self.app_state.view {
38+
match &self.app_state.view {
3939
AppView::Session => {
4040
let view = crate::views::MinimalSessionView::new(&self.app_state);
4141
frame.render_widget(view, area);
@@ -63,6 +63,12 @@ impl EventLoop {
6363
let view = crate::views::MinimalSessionView::new(&self.app_state);
6464
frame.render_widget(view, area);
6565
}
66+
67+
AppView::SubagentConversation(_session_id) => {
68+
// Render the subagent conversation view (same as session for now)
69+
let view = crate::views::MinimalSessionView::new(&self.app_state);
70+
frame.render_widget(view, area);
71+
}
6672
}
6773

6874
// Render modal overlays (legacy)
@@ -162,12 +168,13 @@ impl EventLoop {
162168
let (width, height) = self.app_state.terminal_size;
163169
let area = Rect::new(0, 0, width, height);
164170

165-
match self.app_state.view {
171+
match &self.app_state.view {
166172
AppView::Session
167173
| AppView::Approval
168174
| AppView::Questions
169175
| AppView::Settings
170-
| AppView::Help => {
176+
| AppView::Help
177+
| AppView::SubagentConversation(_) => {
171178
let input_height: u16 = 1;
172179
let hints_height: u16 = 1;
173180
let status_height: u16 = if self.app_state.streaming.is_streaming {
@@ -263,7 +270,6 @@ impl EventLoop {
263270
use std::cell::RefCell;
264271
let selected_lines: RefCell<Vec<String>> = RefCell::new(Vec::new());
265272

266-
let view = self.app_state.view;
267273
let active_modal = self.app_state.active_modal.clone();
268274
let card_active = self.card_handler.is_active();
269275

@@ -273,7 +279,7 @@ impl EventLoop {
273279
let screen_height = area.height;
274280

275281
// Full render
276-
match view {
282+
match &self.app_state.view {
277283
AppView::Session => {
278284
let widget = crate::views::MinimalSessionView::new(&self.app_state);
279285
frame.render_widget(widget, area);
@@ -298,6 +304,10 @@ impl EventLoop {
298304
let widget = crate::views::MinimalSessionView::new(&self.app_state);
299305
frame.render_widget(widget, area);
300306
}
307+
AppView::SubagentConversation(_session_id) => {
308+
let widget = crate::views::MinimalSessionView::new(&self.app_state);
309+
frame.render_widget(widget, area);
310+
}
301311
}
302312

303313
// Render modals

src/cortex-tui/src/views/minimal_session/rendering.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ use crate::views::tool_call::{ContentSegment, ToolCallDisplay, ToolStatus};
2020
use super::text_utils::wrap_text;
2121
use super::VERSION;
2222

23+
/// Renders the "← Back to main conversation" hint when viewing a subagent.
24+
/// Displays in the top-left area of the screen.
25+
pub fn render_back_to_main_hint(area: Rect, buf: &mut Buffer, colors: &AdaptiveColors) {
26+
let hint = "← Back to main (Esc)";
27+
let style = Style::default().fg(colors.text_dim);
28+
// Render at the start of the area with 1 character padding
29+
buf.set_string(area.x + 1, area.y, hint, style);
30+
}
31+
2332
/// Renders a single message to lines.
2433
pub fn render_message(
2534
msg: &Message,

src/cortex-tui/src/views/minimal_session/view.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,9 @@ impl<'a> Widget for MinimalSessionView<'a> {
630630
// 8. Key hints - only show when NOT in interactive mode
631631
if !self.app_state.is_interactive_mode() {
632632
let hints_area = Rect::new(area.x, next_y, area.width, hints_height);
633-
let context = if is_task_running {
633+
let context = if self.app_state.is_viewing_subagent() {
634+
HintContext::SubagentView
635+
} else if is_task_running {
634636
HintContext::TaskRunning
635637
} else {
636638
HintContext::Idle
@@ -642,6 +644,15 @@ impl<'a> Widget for MinimalSessionView<'a> {
642644
hints = hints.with_thinking_budget(budget);
643645
}
644646
hints.render(hints_area, buf);
647+
648+
// Render "← Back to main (Esc)" hint when viewing a subagent
649+
if self.app_state.is_viewing_subagent() {
650+
crate::views::minimal_session::rendering::render_back_to_main_hint(
651+
hints_area,
652+
buf,
653+
&self.colors,
654+
);
655+
}
645656
}
646657
}
647658
}

src/cortex-tui/src/views/tool_call.rs

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -182,54 +182,74 @@ pub fn format_tool_summary(name: &str, args: &Value) -> String {
182182

183183
/// Format a result summary based on tool name and output
184184
///
185-
/// Returns a short summary like "Read 450 lines" or "Error: file not found"
185+
/// Returns a short summary like "↳ Read 450 lines" or "↳ Error: file not found"
186+
/// Uses '↳' prefix consistently across all tools for visual consistency.
186187
pub fn format_result_summary(name: &str, output: &str, success: bool) -> String {
187188
if !success {
188189
// Extract first line of error, truncated
189190
let first_line = output.lines().next().unwrap_or("unknown error");
190191
let truncated = truncate_str(first_line, 50);
191-
return format!("Error: {truncated}");
192+
return format!("Error: {truncated}");
192193
}
193194

194195
match name.to_lowercase().as_str() {
195196
"read" => {
196197
let line_count = output.lines().count();
197-
format!("Read {line_count} lines")
198+
format!("Read {line_count} lines")
198199
}
199-
"edit" => "Applied edit".to_string(),
200+
"edit" | "multiedit" => "↳ Applied edit".to_string(),
201+
"create" => "↳ File created".to_string(),
200202
"execute" | "bash" => {
201203
let line_count = output.lines().count();
202204
if line_count == 0 {
203-
"Completed".to_string()
205+
"Completed".to_string()
204206
} else if line_count == 1 {
205-
truncate_str(output.trim(), 60)
207+
format!("↳ {}", truncate_str(output.trim(), 60))
206208
} else {
207-
format!("{line_count} lines of output")
209+
format!("{line_count} lines of output")
208210
}
209211
}
210212
"glob" => {
211213
let file_count = output.lines().count();
212214
match file_count {
213-
0 => "No matches".to_string(),
214-
1 => "1 file".to_string(),
215-
n => format!("{n} files"),
215+
0 => "No matches".to_string(),
216+
1 => "↳ Found 1 file".to_string(),
217+
n => format!("↳ Found {n} files"),
216218
}
217219
}
218-
"websearch" | "codesearch" => {
220+
"grep" => {
221+
let match_count = output.lines().count();
222+
match match_count {
223+
0 => "↳ No matches".to_string(),
224+
1 => "↳ Found 1 match".to_string(),
225+
n => format!("↳ Found {n} matches"),
226+
}
227+
}
228+
"ls" => {
229+
let item_count = output.lines().count();
230+
match item_count {
231+
0 => "↳ Empty directory".to_string(),
232+
1 => "↳ Listed 1 item".to_string(),
233+
n => format!("↳ Listed {n} items"),
234+
}
235+
}
236+
"websearch" | "codesearch" | "fetchurl" => {
219237
let char_count = output.len();
220238
if char_count > 1000 {
221-
format!("Retrieved ~{} chars", char_count)
239+
format!("Retrieved ~{} chars", char_count)
222240
} else {
223-
"Retrieved results".to_string()
241+
"Retrieved results".to_string()
224242
}
225243
}
226-
"write" => "File written".to_string(),
244+
"write" => "↳ File written".to_string(),
245+
"todowrite" => "↳ Todos updated".to_string(),
246+
"task" => "↳ Task completed".to_string(),
227247
_ => {
228248
let line_count = output.lines().count();
229249
if line_count == 0 {
230-
"Completed".to_string()
250+
"Completed".to_string()
231251
} else {
232-
format!("{line_count} lines")
252+
format!("{line_count} items")
233253
}
234254
}
235255
}
@@ -376,21 +396,49 @@ mod tests {
376396
fn test_format_result_summary_read() {
377397
let output = "line1\nline2\nline3\nline4\nline5";
378398
let summary = format_result_summary("read", output, true);
379-
assert_eq!(summary, "Read 5 lines");
399+
assert_eq!(summary, "Read 5 lines");
380400
}
381401

382402
#[test]
383403
fn test_format_result_summary_error() {
384404
let output = "file not found";
385405
let summary = format_result_summary("read", output, false);
386-
assert_eq!(summary, "Error: file not found");
406+
assert_eq!(summary, "Error: file not found");
387407
}
388408

389409
#[test]
390410
fn test_format_result_summary_glob() {
391411
let output = "file1.rs\nfile2.rs\nfile3.rs";
392412
let summary = format_result_summary("glob", output, true);
393-
assert_eq!(summary, "3 files");
413+
assert_eq!(summary, "↳ Found 3 files");
414+
}
415+
416+
#[test]
417+
fn test_format_result_summary_ls() {
418+
let output = "file1.rs\nfile2.rs\ndir1";
419+
let summary = format_result_summary("ls", output, true);
420+
assert_eq!(summary, "↳ Listed 3 items");
421+
}
422+
423+
#[test]
424+
fn test_format_result_summary_grep() {
425+
let output = "match1\nmatch2";
426+
let summary = format_result_summary("grep", output, true);
427+
assert_eq!(summary, "↳ Found 2 matches");
428+
}
429+
430+
#[test]
431+
fn test_format_result_summary_execute_multiline() {
432+
let output = "line1\nline2\nline3";
433+
let summary = format_result_summary("execute", output, true);
434+
assert_eq!(summary, "↳ 3 lines of output");
435+
}
436+
437+
#[test]
438+
fn test_format_result_summary_default() {
439+
let output = "item1\nitem2\nitem3\nitem4";
440+
let summary = format_result_summary("unknown_tool", output, true);
441+
assert_eq!(summary, "↳ 4 items");
394442
}
395443

396444
#[test]

src/cortex-tui/src/widgets/key_hints.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub enum HintContext {
3030
Approval,
3131
/// Selection list is focused
3232
Selection,
33+
/// Viewing a subagent's conversation
34+
SubagentView,
3335
}
3436

3537
impl HintContext {
@@ -53,6 +55,10 @@ impl HintContext {
5355
("Esc", "close"),
5456
("/", "filter"),
5557
],
58+
HintContext::SubagentView => vec![
59+
("Esc", "back to main"),
60+
("↑/↓", "scroll"),
61+
],
5662
}
5763
}
5864
}

0 commit comments

Comments
 (0)