Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/cortex-tui/src/app/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,28 @@ impl AppState {
.filter(|t| !t.status.is_terminal())
.count()
}

/// Enter subagent conversation view
pub fn view_subagent_conversation(&mut self, session_id: String) {
self.viewing_subagent = Some(session_id.clone());
self.set_view(AppView::SubagentConversation(session_id));
}

/// Return to main conversation from subagent view
pub fn return_to_main_conversation(&mut self) {
self.viewing_subagent = None;
self.set_view(AppView::Session);
}

/// Check if viewing a subagent conversation
pub fn is_viewing_subagent(&self) -> bool {
self.viewing_subagent.is_some()
}

/// Get the currently viewed subagent session ID
pub fn get_viewing_subagent(&self) -> Option<&String> {
self.viewing_subagent.as_ref()
}
}

// ============================================================================
Expand Down
5 changes: 4 additions & 1 deletion src/cortex-tui/src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ pub struct AppState {
pub message_queue: VecDeque<String>,
/// Active subagent tasks being displayed.
pub active_subagents: Vec<SubagentTaskDisplay>,
/// Currently viewed subagent session ID (for SubagentConversation view)
pub viewing_subagent: Option<String>,
/// MCP servers list for management
pub mcp_servers: Vec<crate::modal::mcp_manager::McpServerInfo>,
/// Context files added to the session
Expand Down Expand Up @@ -220,6 +222,7 @@ impl AppState {
question_hovered_tab: None,
message_queue: VecDeque::new(),
active_subagents: Vec::new(),
viewing_subagent: None,
mcp_servers: Vec::new(),
context_files: Vec::new(),
log_level: String::from("info"),
Expand Down Expand Up @@ -299,7 +302,7 @@ impl Default for AppState {
impl AppState {
/// Set the current view
pub fn set_view(&mut self, view: AppView) {
self.previous_view = Some(self.view);
self.previous_view = Some(self.view.clone());
self.view = view;
}

Expand Down
4 changes: 3 additions & 1 deletion src/cortex-tui/src/app/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ use cortex_core::widgets::DisplayMode;
pub type OperationMode = DisplayMode;

/// The current view/screen being displayed
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum AppView {
#[default]
Session,
Approval,
Questions,
Settings,
Help,
/// Viewing a subagent's conversation (stores the subagent session_id)
SubagentConversation(String),
}

/// Which UI element currently has focus
Expand Down
7 changes: 7 additions & 0 deletions src/cortex-tui/src/runner/event_loop/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,13 @@ impl EventLoop {

/// Handle ESC key with double-tap to quit
fn handle_esc(&mut self, terminal: &mut CortexTerminal) -> Result<()> {
// Priority 1: If viewing a subagent conversation, return to main conversation
if self.app_state.is_viewing_subagent() {
self.app_state.return_to_main_conversation();
self.render(terminal)?;
return Ok(());
}

// Check if app is idle (nothing active that ESC should cancel)
let is_idle = !self.app_state.streaming.is_streaming
&& self.app_state.pending_approval.is_none()
Expand Down
20 changes: 15 additions & 5 deletions src/cortex-tui/src/runner/event_loop/rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ impl EventLoop {
terminal.draw(|frame| {
let area = frame.area();

match self.app_state.view {
match &self.app_state.view {
AppView::Session => {
let view = crate::views::MinimalSessionView::new(&self.app_state);
frame.render_widget(view, area);
Expand Down Expand Up @@ -63,6 +63,12 @@ impl EventLoop {
let view = crate::views::MinimalSessionView::new(&self.app_state);
frame.render_widget(view, area);
}

AppView::SubagentConversation(_session_id) => {
// Render the subagent conversation view (same as session for now)
let view = crate::views::MinimalSessionView::new(&self.app_state);
frame.render_widget(view, area);
}
}

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

match self.app_state.view {
match &self.app_state.view {
AppView::Session
| AppView::Approval
| AppView::Questions
| AppView::Settings
| AppView::Help => {
| AppView::Help
| AppView::SubagentConversation(_) => {
let input_height: u16 = 1;
let hints_height: u16 = 1;
let status_height: u16 = if self.app_state.streaming.is_streaming {
Expand Down Expand Up @@ -263,7 +270,6 @@ impl EventLoop {
use std::cell::RefCell;
let selected_lines: RefCell<Vec<String>> = RefCell::new(Vec::new());

let view = self.app_state.view;
let active_modal = self.app_state.active_modal.clone();
let card_active = self.card_handler.is_active();

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

// Full render
match view {
match &self.app_state.view {
AppView::Session => {
let widget = crate::views::MinimalSessionView::new(&self.app_state);
frame.render_widget(widget, area);
Expand All @@ -298,6 +304,10 @@ impl EventLoop {
let widget = crate::views::MinimalSessionView::new(&self.app_state);
frame.render_widget(widget, area);
}
AppView::SubagentConversation(_session_id) => {
let widget = crate::views::MinimalSessionView::new(&self.app_state);
frame.render_widget(widget, area);
}
}

// Render modals
Expand Down
9 changes: 9 additions & 0 deletions src/cortex-tui/src/views/minimal_session/rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ use crate::views::tool_call::{ContentSegment, ToolCallDisplay, ToolStatus};
use super::text_utils::wrap_text;
use super::VERSION;

/// Renders the "← Back to main conversation" hint when viewing a subagent.
/// Displays in the top-left area of the screen.
pub fn render_back_to_main_hint(area: Rect, buf: &mut Buffer, colors: &AdaptiveColors) {
let hint = "← Back to main (Esc)";
let style = Style::default().fg(colors.text_dim);
// Render at the start of the area with 1 character padding
buf.set_string(area.x + 1, area.y, hint, style);
}

/// Renders a single message to lines.
pub fn render_message(
msg: &Message,
Expand Down
13 changes: 12 additions & 1 deletion src/cortex-tui/src/views/minimal_session/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,9 @@ impl<'a> Widget for MinimalSessionView<'a> {
// 8. Key hints - only show when NOT in interactive mode
if !self.app_state.is_interactive_mode() {
let hints_area = Rect::new(area.x, next_y, area.width, hints_height);
let context = if is_task_running {
let context = if self.app_state.is_viewing_subagent() {
HintContext::SubagentView
} else if is_task_running {
HintContext::TaskRunning
} else {
HintContext::Idle
Expand All @@ -642,6 +644,15 @@ impl<'a> Widget for MinimalSessionView<'a> {
hints = hints.with_thinking_budget(budget);
}
hints.render(hints_area, buf);

// Render "← Back to main (Esc)" hint when viewing a subagent
if self.app_state.is_viewing_subagent() {
crate::views::minimal_session::rendering::render_back_to_main_hint(
hints_area,
buf,
&self.colors,
);
}
}
}
}
86 changes: 67 additions & 19 deletions src/cortex-tui/src/views/tool_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,54 +182,74 @@ pub fn format_tool_summary(name: &str, args: &Value) -> String {

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

match name.to_lowercase().as_str() {
"read" => {
let line_count = output.lines().count();
format!("Read {line_count} lines")
format!("Read {line_count} lines")
}
"edit" => "Applied edit".to_string(),
"edit" | "multiedit" => "↳ Applied edit".to_string(),
"create" => "↳ File created".to_string(),
"execute" | "bash" => {
let line_count = output.lines().count();
if line_count == 0 {
"Completed".to_string()
"Completed".to_string()
} else if line_count == 1 {
truncate_str(output.trim(), 60)
format!("↳ {}", truncate_str(output.trim(), 60))
} else {
format!("{line_count} lines of output")
format!("{line_count} lines of output")
}
}
"glob" => {
let file_count = output.lines().count();
match file_count {
0 => "No matches".to_string(),
1 => "1 file".to_string(),
n => format!("{n} files"),
0 => "No matches".to_string(),
1 => "↳ Found 1 file".to_string(),
n => format!("↳ Found {n} files"),
}
}
"websearch" | "codesearch" => {
"grep" => {
let match_count = output.lines().count();
match match_count {
0 => "↳ No matches".to_string(),
1 => "↳ Found 1 match".to_string(),
n => format!("↳ Found {n} matches"),
}
}
"ls" => {
let item_count = output.lines().count();
match item_count {
0 => "↳ Empty directory".to_string(),
1 => "↳ Listed 1 item".to_string(),
n => format!("↳ Listed {n} items"),
}
}
"websearch" | "codesearch" | "fetchurl" => {
let char_count = output.len();
if char_count > 1000 {
format!("Retrieved ~{} chars", char_count)
format!("Retrieved ~{} chars", char_count)
} else {
"Retrieved results".to_string()
"Retrieved results".to_string()
}
}
"write" => "File written".to_string(),
"write" => "↳ File written".to_string(),
"todowrite" => "↳ Todos updated".to_string(),
"task" => "↳ Task completed".to_string(),
_ => {
let line_count = output.lines().count();
if line_count == 0 {
"Completed".to_string()
"Completed".to_string()
} else {
format!("{line_count} lines")
format!("{line_count} items")
}
}
}
Expand Down Expand Up @@ -376,21 +396,49 @@ mod tests {
fn test_format_result_summary_read() {
let output = "line1\nline2\nline3\nline4\nline5";
let summary = format_result_summary("read", output, true);
assert_eq!(summary, "Read 5 lines");
assert_eq!(summary, "Read 5 lines");
}

#[test]
fn test_format_result_summary_error() {
let output = "file not found";
let summary = format_result_summary("read", output, false);
assert_eq!(summary, "Error: file not found");
assert_eq!(summary, "Error: file not found");
}

#[test]
fn test_format_result_summary_glob() {
let output = "file1.rs\nfile2.rs\nfile3.rs";
let summary = format_result_summary("glob", output, true);
assert_eq!(summary, "3 files");
assert_eq!(summary, "↳ Found 3 files");
}

#[test]
fn test_format_result_summary_ls() {
let output = "file1.rs\nfile2.rs\ndir1";
let summary = format_result_summary("ls", output, true);
assert_eq!(summary, "↳ Listed 3 items");
}

#[test]
fn test_format_result_summary_grep() {
let output = "match1\nmatch2";
let summary = format_result_summary("grep", output, true);
assert_eq!(summary, "↳ Found 2 matches");
}

#[test]
fn test_format_result_summary_execute_multiline() {
let output = "line1\nline2\nline3";
let summary = format_result_summary("execute", output, true);
assert_eq!(summary, "↳ 3 lines of output");
}

#[test]
fn test_format_result_summary_default() {
let output = "item1\nitem2\nitem3\nitem4";
let summary = format_result_summary("unknown_tool", output, true);
assert_eq!(summary, "↳ 4 items");
}

#[test]
Expand Down
6 changes: 6 additions & 0 deletions src/cortex-tui/src/widgets/key_hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub enum HintContext {
Approval,
/// Selection list is focused
Selection,
/// Viewing a subagent's conversation
SubagentView,
}

impl HintContext {
Expand All @@ -53,6 +55,10 @@ impl HintContext {
("Esc", "close"),
("/", "filter"),
],
HintContext::SubagentView => vec![
("Esc", "back to main"),
("↑/↓", "scroll"),
],
}
}
}
Expand Down
Loading