From eb5b793ea2841970e93850a81e65f29479e2b2fa Mon Sep 17 00:00:00 2001 From: "kh.ai" Date: Fri, 17 Oct 2025 14:55:46 -0700 Subject: [PATCH] wip --- codex-rs/tui/src/bottom_pane/mod.rs | 9 ++ codex-rs/tui/src/chatwidget.rs | 146 +++++++++++++++++- ...exec_and_status_layout_vt100_snapshot.snap | 1 + ...atwidget__tests__status_widget_active.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 3 + codex-rs/tui/src/security_review.rs | 4 +- codex-rs/tui/src/status_indicator_widget.rs | 44 +++++- 7 files changed, 198 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2f146aefff..690c5dac27 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -47,6 +47,7 @@ pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; use crate::status_indicator_widget::StatusIndicatorWidget; +pub(crate) use crate::status_indicator_widget::StatusSnapshot; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; @@ -288,6 +289,14 @@ impl BottomPane { } } + pub(crate) fn update_status_snapshot(&mut self, snapshot: StatusSnapshot) { + if let Some(status) = self.status.as_mut() { + status.update_snapshot(snapshot); + } else { + self.update_status_header(snapshot.header); + } + } + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { self.ctrl_c_quit_hint = true; self.composer diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7673fe82d1..0f388a6a98 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -77,6 +77,7 @@ use crate::bottom_pane::SecurityReviewScopeConfirmView; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::StatusSnapshot; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; @@ -127,10 +128,13 @@ use codex_git_tooling::GhostCommit; use codex_git_tooling::GitToolingError; use codex_git_tooling::create_ghost_commit; use codex_git_tooling::restore_ghost_commit; +use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; const MAX_TRACKED_GHOST_COMMITS: usize = 20; +const MAX_STATUS_THINKING_LINES: usize = 3; +const MAX_STATUS_TOOL_CALLS: usize = 4; // Track information about an in-flight exec command. struct RunningCommand { @@ -258,6 +262,9 @@ pub(crate) struct ChatWidget { full_reasoning_buffer: String, // Current status header shown in the status indicator. current_status_header: String, + status_progress: Option, + status_thinking_lines: Vec, + status_tool_calls: Vec<(String, String)>, // Previous status header to restore after a transient stream retry. retry_status_header: Option, conversation_id: Option, @@ -371,7 +378,88 @@ impl ChatWidget { return; } self.current_status_header = header.clone(); - self.bottom_pane.update_status_header(header); + if self.security_review_context.is_some() { + self.bottom_pane.update_status_header(header); + } + } + + fn clear_status_tracking(&mut self) { + self.status_progress = None; + self.status_thinking_lines.clear(); + self.status_tool_calls.clear(); + } + + fn push_status_snapshot(&mut self) { + if self.security_review_context.is_some() { + return; + } + + let tool_calls: Vec = if self.status_tool_calls.len() <= MAX_STATUS_TOOL_CALLS { + self.status_tool_calls + .iter() + .map(|(_, label)| label.clone()) + .collect() + } else { + self.status_tool_calls[self.status_tool_calls.len() - MAX_STATUS_TOOL_CALLS..] + .iter() + .map(|(_, label)| label.clone()) + .collect() + }; + + let snapshot = StatusSnapshot { + header: self.current_status_header.clone(), + progress: self.status_progress, + thinking: self.status_thinking_lines.clone(), + tool_calls, + }; + self.bottom_pane.update_status_snapshot(snapshot); + } + + fn update_thinking_lines_from_reasoning(&mut self) { + if self.security_review_context.is_some() { + return; + } + + let mut lines: Vec = self + .reasoning_buffer + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| truncate_text(line, 160)) + .collect(); + + if lines.len() > MAX_STATUS_THINKING_LINES { + let start = lines.len() - MAX_STATUS_THINKING_LINES; + lines = lines.split_off(start); + } + + self.status_thinking_lines = lines; + } + + fn add_tool_call(&mut self, key: String, label: String) { + if self.security_review_context.is_some() { + return; + } + self.status_tool_calls + .retain(|(existing_key, _)| existing_key != &key); + self.status_tool_calls.push((key, label)); + self.push_status_snapshot(); + } + + fn remove_tool_call(&mut self, key: &str) { + if self.security_review_context.is_some() { + return; + } + let original_len = self.status_tool_calls.len(); + self.status_tool_calls + .retain(|(existing_key, _)| existing_key != key); + if self.status_tool_calls.len() != original_len { + self.push_status_snapshot(); + } + } + + fn format_exec_command(command: &[String]) -> String { + shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } // --- Small event handlers --- @@ -420,13 +508,13 @@ impl ChatWidget { // current reasoning block and extract the first bold element // (between **/**) as the chunk header. Show this header as status. self.reasoning_buffer.push_str(&delta); + self.update_thinking_lines_from_reasoning(); if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. self.set_status_header(header); - } else { - // Fallback while we don't yet have a bold header: leave existing header as-is. } + self.push_status_snapshot(); self.request_redraw(); } @@ -458,15 +546,19 @@ impl ChatWidget { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); self.retry_status_header = None; + self.clear_status_tracking(); self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); + self.push_status_snapshot(); self.request_redraw(); } fn on_task_complete(&mut self, last_agent_message: Option) { // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); + self.clear_status_tracking(); + self.push_status_snapshot(); // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); self.running_commands.clear(); @@ -583,6 +675,26 @@ impl ChatWidget { } fn on_plan_update(&mut self, update: UpdatePlanArgs) { + if self.security_review_context.is_none() { + if update.plan.is_empty() { + self.status_progress = None; + } else { + let total = update.plan.len() as f32; + let completed = update + .plan + .iter() + .filter(|item| matches!(item.status, StepStatus::Completed)) + .count() as f32; + let in_progress = update + .plan + .iter() + .filter(|item| matches!(item.status, StepStatus::InProgress)) + .count() as f32; + let progress = ((completed + in_progress * 0.5) / total).clamp(0.0, 1.0); + self.status_progress = Some(progress); + } + self.push_status_snapshot(); + } self.add_to_history(history_cell::new_plan_update(update)); } @@ -656,12 +768,15 @@ impl ChatWidget { self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); } - fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { + fn on_web_search_begin(&mut self, ev: WebSearchBeginEvent) { self.flush_answer_stream_with_separator(); + let key = format!("web:{}", ev.call_id.as_str()); + self.add_tool_call(key, "Searching web".to_string()); } fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { self.flush_answer_stream_with_separator(); + self.remove_tool_call(&format!("web:{}", ev.call_id.as_str())); self.add_to_history(history_cell::new_web_search_call(format!( "Searched: {}", ev.query @@ -698,6 +813,7 @@ impl ChatWidget { self.retry_status_header = Some(self.current_status_header.clone()); } self.set_status_header(message); + self.push_status_snapshot(); } /// Periodic tick to commit at most one queued line to history with a small delay, @@ -774,6 +890,7 @@ impl ChatWidget { } pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { + self.remove_tool_call(&format!("exec:{}", ev.call_id.as_str())); let running = self.running_commands.remove(&ev.call_id); let (command, parsed) = match running { Some(rc) => (rc.command, rc.parsed_cmd), @@ -864,6 +981,11 @@ impl ChatWidget { pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. + let command_display = Self::format_exec_command(&ev.command); + let command_display = truncate_text(&command_display, 120); + let label = format!("Running {command_display}"); + let key = format!("exec:{}", ev.call_id.as_str()); + self.add_tool_call(key, label); self.running_commands.insert( ev.call_id.clone(), RunningCommand { @@ -896,6 +1018,13 @@ impl ChatWidget { } pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { + let label_text = { + let invocation = &ev.invocation; + format!("Calling {}::{}", invocation.server, invocation.tool) + }; + let label_text = truncate_text(&label_text, 120); + let key = format!("mcp:{}", ev.call_id.as_str()); + self.add_tool_call(key, label_text); self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( @@ -913,6 +1042,7 @@ impl ChatWidget { duration, result, } = ev; + self.remove_tool_call(&format!("mcp:{}", call_id.as_str())); let extra_cell = match self .active_cell @@ -1003,6 +1133,9 @@ impl ChatWidget { reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), + status_progress: None, + status_thinking_lines: Vec::new(), + status_tool_calls: Vec::new(), retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), @@ -1071,6 +1204,9 @@ impl ChatWidget { reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), + status_progress: None, + status_thinking_lines: Vec::new(), + status_tool_calls: Vec::new(), retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), @@ -2420,7 +2556,7 @@ impl ChatWidget { self.security_review_task = None; let context = self.security_review_context.take(); - let (mode, scope_paths, output_root, repo_path, model, provider, started_at, last_log) = + let (mode, scope_paths, _output_root, repo_path, model, provider, started_at, last_log) = if let Some(ctx) = context { ( ctx.mode, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index c3bdf60bd2..ba2c620aef 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -10,6 +10,7 @@ expression: term.backend().vt100().screen().contents() Read diff_render.rs • Investigating rendering code (0s • esc to interrupt) + ↺ **Investigating rendering code** › Summarize recent commits diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap index 9fbebfb500..1555ad6f2d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -1,10 +1,10 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1577 expression: terminal.backend() --- " " "• Analyzing (0s • esc to interrupt) " +" ↺ **Analyzing** " " " " " "› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c504246e7c..eee39d20f9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -277,6 +277,9 @@ fn make_chatwidget_manual() -> ( reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), + status_progress: None, + status_thinking_lines: Vec::new(), + status_tool_calls: Vec::new(), retry_status_header: None, conversation_id: None, frame_requester: FrameRequester::test_dummy(), diff --git a/codex-rs/tui/src/security_review.rs b/codex-rs/tui/src/security_review.rs index e1091708ca..50cbf4f0c1 100644 --- a/codex-rs/tui/src/security_review.rs +++ b/codex-rs/tui/src/security_review.rs @@ -2953,7 +2953,7 @@ async fn generate_threat_model( ) .await .map_err(|err| { - let mut failure_logs = vec![ + let failure_logs = vec![ "Threat model provider returned a response that could not be parsed.".to_string(), format!("Model error: {err}"), "Double-check API credentials and network availability for the security review process.".to_string(), @@ -2991,7 +2991,7 @@ async fn generate_threat_model( ) .await .map_err(|err| { - let mut failure_logs = vec![ + let failure_logs = vec![ "Threat model retry still failed to decode the provider response.".to_string(), format!("Model error: {err}"), "Verify the provider is returning JSON (no HTML/proxy pages) and that credentials are correct.".to_string(), diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 4bcc5cf267..96796093d8 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -20,9 +20,23 @@ use crate::key_hint; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; +#[derive(Debug, Clone)] +pub(crate) struct StatusSnapshot { + pub(crate) header: String, + pub(crate) progress: Option, + pub(crate) thinking: Vec, + pub(crate) tool_calls: Vec, +} + pub(crate) struct StatusIndicatorWidget { /// Animated header text (defaults to "Working"). header: String, + /// Percentage progress to display, if available. + progress: Option, + /// Recent reasoning lines emitted by the model. + thinking_lines: Vec, + /// Labels of in-flight tool calls. + tool_calls: Vec, /// Queued user messages to display under the status line. queued_messages: Vec, @@ -54,6 +68,9 @@ impl StatusIndicatorWidget { pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { header: String::from("Working"), + progress: None, + thinking_lines: Vec::new(), + tool_calls: Vec::new(), queued_messages: Vec::new(), elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), @@ -65,12 +82,14 @@ impl StatusIndicatorWidget { } pub fn desired_height(&self, width: u16) -> u16 { - // Status line + optional blank line + wrapped queued messages (up to 3 lines per message) - // + optional ellipsis line per truncated message + 1 spacer line + // Status line + optional thinking/tool call lines + optional blank line + wrapped queued messages + // (up to 3 lines per message) + optional ellipsis per truncated message + keybind + spacer line let inner_width = width.max(1) as usize; let mut total: u16 = 1; // status line + total = total.saturating_add(self.thinking_lines.len() as u16); + total = total.saturating_add(self.tool_calls.len() as u16); if !self.queued_messages.is_empty() { - total = total.saturating_add(1); // blank line between status and queued messages + total = total.saturating_add(1); // blank line between supplemental and queued messages } let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix if text_width > 0 { @@ -108,6 +127,14 @@ impl StatusIndicatorWidget { &self.header } + pub(crate) fn update_snapshot(&mut self, snapshot: StatusSnapshot) { + self.update_header(snapshot.header); + self.progress = snapshot.progress; + self.thinking_lines = snapshot.thinking; + self.tool_calls = snapshot.tool_calls; + self.frame_requester.schedule_frame(); + } + /// Replace the queued messages displayed beneath the header. pub(crate) fn set_queued_messages(&mut self, queued: Vec) { self.queued_messages = queued; @@ -181,10 +208,21 @@ impl WidgetRef for StatusIndicatorWidget { key_hint::plain(KeyCode::Esc).into(), " to interrupt)".dim(), ]); + if let Some(progress) = self.progress { + let pct = (progress.clamp(0.0, 1.0) * 100.0).round(); + spans.push(" ".into()); + spans.push(format!("{pct:.0}%").dim()); + } // Build lines: status, then queued messages, then spacer. let mut lines: Vec> = Vec::new(); lines.push(Line::from(spans)); + for thinking in &self.thinking_lines { + lines.push(vec![" ↺ ".magenta(), thinking.clone().magenta()].into()); + } + for call in &self.tool_calls { + lines.push(vec![" ↳ ".cyan(), call.clone().cyan()].into()); + } if !self.queued_messages.is_empty() { lines.push(Line::from("")); }