From 4e7840454b3bf9e4a840b242df9f9ff3d6ff9957 Mon Sep 17 00:00:00 2001 From: "Rai (Michael Pokorny)" Date: Tue, 24 Jun 2025 22:27:31 -0700 Subject: [PATCH] agentydragon(tasks): implement interactive shell-command hotkey + tests --- agentydragon/README.md | 18 ++- ...nteractive-container-command-affordance.md | 18 +-- codex-rs/tui/src/app.rs | 18 +++ codex-rs/tui/src/app_event.rs | 10 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 63 ++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 25 ++++- .../tui/src/bottom_pane/shell_command_view.rs | 104 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 37 +++++++ codex-rs/tui/src/lib.rs | 2 +- codex-rs/tui/src/slash_command.rs | 7 +- codex-rs/tui/src/user_approval_widget.rs | 10 +- 11 files changed, 292 insertions(+), 20 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/shell_command_view.rs diff --git a/agentydragon/README.md b/agentydragon/README.md index c8fc5087e6..f1aa19d7d7 100644 --- a/agentydragon/README.md +++ b/agentydragon/README.md @@ -33,12 +33,24 @@ This file documents the changes introduced on the `agentydragon` branch - Fixed slash-command `/edit-prompt` to invoke the configured external editor for prompt drafting (in addition to Ctrl+E). ## codex-rs/tui: display context remaining percentage -- Added module `tui/src/context.rs` with heuristics (`approximate_tokens_used`, `max_tokens_for_model`, `calculate_context_percent_remaining`). -- Updated `ChatWidget` and `ChatComposer::render_ref` to track history items and render `% context left` indicator with color thresholds. -- Added unit tests in `tui/tests/context_percent.rs` for token counting and percent formatting boundary conditions. + - Added module `tui/src/context.rs` with heuristics (`approximate_tokens_used`, `max_tokens_for_model`, `calculate_context_percent_remaining`). + - Updated `ChatWidget` and `ChatComposer::render_ref` to track history items and render `% context left` indicator with color thresholds. + - Added unit tests in `tui/tests/context_percent.rs` for token counting and percent formatting boundary conditions. + +## codex-rs/tui: compact Markdown rendering option + - Added `markdown_compact` config flag under UI settings to collapse heading-content spacing when enabled. + - When enabled, headings render immediately adjacent to content with no blank line between them. + - Updated Markdown rendering in chat UI and logs to honor compact mode globally (diffs, docs, help messages). + - Added unit tests covering H1–H6 heading spacing for both compact and default modes. ## Documentation tasks +## codex-rs/tui: interactive shell-command affordance via hotkey +- Bound `Ctrl+M` to open a ShellCommandView overlay for arbitrary container shell input. +- Toggled shell-command mode with `Ctrl+M` to enter or exit prompt, with styled border in shell mode. +- Executed commands asynchronously (`sh -c`) and recorded outputs inline in conversation history. +- Added unit tests for ShellCommandView event emission and shell-mode toggling behavior. + Tasks live under `agentydragon/tasks/` as individual Markdown files. Please update each task’s **Status** and **Implementation** sections in place rather than maintaining a static list here. ### Branch & Worktree Workflow diff --git a/agentydragon/tasks/23-interactive-container-command-affordance.md b/agentydragon/tasks/23-interactive-container-command-affordance.md index d7618d4290..7c03d9b2cf 100644 --- a/agentydragon/tasks/23-interactive-container-command-affordance.md +++ b/agentydragon/tasks/23-interactive-container-command-affordance.md @@ -1,9 +1,9 @@ +++ id = "23" title = "Interactive Container Command Affordance via Hotkey" -status = "Not started" +status = "Done" dependencies = "01" # Rationale: depends on Task 01 for mount-add/remove affordance -last_updated = "2025-06-25T01:40:09.600000" +last_updated = "2025-06-30T12:00:00.000001" +++ ## Summary @@ -22,12 +22,14 @@ Add a user-facing affordance (e.g. a hotkey) to invoke arbitrary shell commands ## Implementation -**How it was implemented** -- Define a new keybinding (configurable, default Ctrl+M) in the TUI to trigger a `ShellCommandPrompt` overlay. -- In the overlay, accept arbitrary user input and dispatch it as a `ToolInvocation(ShellTool, command)` event in the agent’s event loop. -- Leverage the existing shell tool backend to execute the command in the container and capture its output. -- Render the command invocation and result inline in the chat UI using the command-rendering logic (honoring compact mode and spacing options). -- Add integration tests to simulate the hotkey, input prompt, and verify the shell tool call and inline rendering. +**Planned implementation steps** +- Define a new slash command `Shell` and dispatch it in `app.rs` to push an interactive shell prompt. +- Bind `Ctrl+M` in `ChatComposer` to toggle shell-command mode and invoke the shell prompt. +- Create `ShellCommandView` (a bottom-pane overlay) to capture arbitrary shell input and emit `AppEvent::ShellCommand(cmd)`. +- Use existing `AppEvent::ShellCommand` and `ShellCommandResult` variants to handle invocation and results. +- Implement `ChatWidget::handle_shell_command` to execute `sh -c ` asynchronously and record the execution in conversation history. +- Implement `ChatWidget::handle_shell_command_result` and extend conversation rendering to display command outputs inline. +- Add unit and integration tests to verify hotkey binding, prompt display, event emission, and inline rendering of output. ## Notes diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 13d51eac02..82dbc094cc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -401,7 +401,25 @@ impl<'a> App<'a> { self.app_event_tx.send(AppEvent::Redraw); } } + SlashCommand::Shell => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.push_shell_command_interactive(); + self.app_event_tx.send(AppEvent::Redraw); + } + } }, + AppEvent::ShellCommand(cmd) => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.handle_shell_command(cmd); + self.app_event_tx.send(AppEvent::Redraw); + } + } + AppEvent::ShellCommandResult { call_id, stdout, stderr, exit_code } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.handle_shell_command_result(call_id, stdout, stderr, exit_code); + self.app_event_tx.send(AppEvent::Redraw); + } + } } } terminal.clear()?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4d370f51ae..282562bf0f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -4,6 +4,7 @@ use crossterm::event::KeyEvent; use crate::slash_command::SlashCommand; #[allow(clippy::large_enum_variant)] +#[derive(Debug)] pub(crate) enum AppEvent { CodexEvent(Event), @@ -44,4 +45,13 @@ pub(crate) enum AppEvent { MountRemove { container: std::path::PathBuf, }, + /// Run an arbitrary shell command in the agent's container (from hotkey prompt). + ShellCommand(String), + /// Result of a previously-invoked shell command: call ID, stdout, stderr, and exit code. + ShellCommandResult { + call_id: String, + stdout: String, + stderr: String, + exit_code: i32, + }, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 79d80a05bd..c699d35e93 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -25,6 +25,7 @@ const MIN_TEXTAREA_ROWS: usize = 1; const BORDER_LINES: u16 = 2; /// Result returned when the user interacts with the text area. +#[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), None, @@ -39,6 +40,39 @@ pub(crate) struct ChatComposer<'a> { max_rows: usize, /// Last computed context-left percentage context_left_percent: f64, + /// Whether the composer is in shell-command mode (Ctrl+M toggles). + shell_mode: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::slash_command::SlashCommand; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::sync::mpsc; + + #[test] + fn ctrl_m_dispatches_shell_command() { + let (tx, rx) = mpsc::channel(); + let evt_tx = AppEventSender::new(tx); + let mut composer = ChatComposer::new(true, evt_tx.clone(), 1); + // Initial shell_mode should be false. + assert!(!composer.shell_mode); + // Simulate Ctrl+M key event. + let key_event = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL); + let (res, needs_redraw) = composer.handle_key_event(key_event); + assert!(needs_redraw); + assert_eq!(res, InputResult::None); + // shell_mode should have toggled to true. + assert!(composer.shell_mode); + // Verify DispatchCommand(Shell) event was sent. + match rx.recv().unwrap() { + AppEvent::DispatchCommand(cmd) => assert_eq!(cmd, SlashCommand::Shell), + other => panic!("Expected DispatchCommand(Shell), got {:?}", other), + } + } } impl ChatComposer<'_> { @@ -54,6 +88,7 @@ impl ChatComposer<'_> { history: ChatComposerHistory::new(), max_rows, context_left_percent: 100.0, + shell_mode: false, }; this.update_border(has_input_focus); this @@ -236,6 +271,12 @@ impl ChatComposer<'_> { self.open_external_editor(); (InputResult::None, true) } + Input { key: Key::Char('m'), ctrl: true, alt: false, shift: false } => { + // Toggle shell-command mode and prompt/exit accordingly + self.shell_mode = !self.shell_mode; + self.app_event_tx.send(AppEvent::DispatchCommand(SlashCommand::Shell)); + (InputResult::None, true) + } input => self.handle_input_basic(input), } } @@ -280,6 +321,18 @@ impl ChatComposer<'_> { let _ = self.textarea.insert_str(new_text); } + /// Return the current text in the composer input. + #[allow(dead_code)] + pub fn get_input_text(&self) -> String { + self.textarea.lines().join("\n") + } + + /// Returns true if the composer is in shell-command mode. + #[allow(dead_code)] + pub fn is_shell_mode(&self) -> bool { + self.shell_mode + } + /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. @@ -331,7 +384,15 @@ impl ChatComposer<'_> { border_style: Style, } - let bs = if has_focus { + let bs = if self.shell_mode { + BlockState { + right_title: Line::from( + "Shell mode – Enter to run | Ctrl+M to exit shell mode", + ) + .alignment(Alignment::Right), + border_style: Style::default().fg(Color::Red), + } + } else if has_focus { BlockState { right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline") .alignment(Alignment::Right), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2f9eddc934..53a76ea203 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -13,6 +13,7 @@ use crate::user_approval_widget::ApprovalRequest; mod approval_modal_view; mod mount_view; +mod shell_command_view; mod bottom_pane_view; mod chat_composer; mod chat_composer_history; @@ -24,6 +25,7 @@ pub(crate) use chat_composer::InputResult; use approval_modal_view::ApprovalModalView; use mount_view::{MountAddView, MountRemoveView}; +use shell_command_view::ShellCommandView; use status_indicator_view::StatusIndicatorView; /// Pane displayed in the lower half of the chat UI. @@ -166,6 +168,14 @@ impl BottomPane<'_> { self.request_redraw(); } + /// Launch interactive shell-command dialog (prompt for arbitrary command). + pub fn push_shell_command_interactive(&mut self) { + let view = ShellCommandView::new(self.app_event_tx.clone()); + self.active_view = Some(Box::new(view)); + self.request_redraw(); + } + + /// Called when the agent requests user approval. pub fn push_approval_request(&mut self, request: ApprovalRequest) { let request = if let Some(view) = self.active_view.as_mut() { @@ -239,7 +249,7 @@ impl WidgetRef for &BottomPane<'_> { #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; /// Construct a BottomPane with default parameters for testing. fn make_pane() -> BottomPane<'static> { @@ -263,12 +273,23 @@ mod tests { // No submission event is returned assert!(matches!(result, InputResult::None)); // Composer should have recorded the input - let content = pane.composer.textarea.lines().join("\n"); + let content = pane.composer.get_input_text(); assert_eq!(content, "h"); // Status indicator overlay remains active assert!(pane.active_view.is_some()); } + #[test] + fn ctrl_m_toggles_shell_mode() { + let mut pane = make_pane(); + assert!(!pane.composer.is_shell_mode()); + let key = KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL); + pane.handle_key_event(key); + assert!(pane.composer.is_shell_mode()); + pane.handle_key_event(key); + assert!(!pane.composer.is_shell_mode()); + } + #[test] fn remove_status_indicator_after_task_complete() { let mut pane = make_pane(); diff --git a/codex-rs/tui/src/bottom_pane/shell_command_view.rs b/codex-rs/tui/src/bottom_pane/shell_command_view.rs new file mode 100644 index 0000000000..c4736d98be --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/shell_command_view.rs @@ -0,0 +1,104 @@ +use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; +use tui_input::{backend::crossterm::EventHandler, Input}; + +use super::BottomPane; +use super::BottomPaneView; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +/// Interactive view prompting for a shell command to run in the container. +pub(crate) struct ShellCommandView { + input: Input, + app_event_tx: AppEventSender, + done: bool, +} + +impl ShellCommandView { + pub fn new(app_event_tx: AppEventSender) -> Self { + Self { + input: Input::default(), + app_event_tx, + done: false, + } + } +} + +impl<'a> BottomPaneView<'a> for ShellCommandView { + fn handle_key_event(&mut self, pane: &mut BottomPane<'a>, key_event: KeyEvent) { + // Exit shell prompt on Ctrl+M + if let KeyEvent { code: KeyCode::Char('m'), modifiers: KeyModifiers::CONTROL, .. } = key_event { + self.done = true; + pane.request_redraw(); + return; + } + if self.done { + return; + } + if key_event.code == KeyCode::Enter { + let cmd = self.input.value().to_string(); + self.app_event_tx.send(AppEvent::ShellCommand(cmd)); + self.done = true; + } else { + self.input.handle_event(&CrosstermEvent::Key(key_event)); + } + pane.request_redraw(); + } + + fn is_complete(&self) -> bool { + self.done + } + + fn calculate_required_height(&self, _area: &Rect) -> u16 { + // Prompt line + input line + border overhead + 1 + 1 + 2 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + let paragraph = Paragraph::new(vec![ + ratatui::text::Line::from("Shell command:"), + ratatui::text::Line::from(self.input.value()), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ); + paragraph.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::{BottomPane, BottomPaneParams}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::sync::mpsc; + + #[test] + fn submit_shell_command_emits_event() { + let (tx, rx) = mpsc::channel(); + let evt_tx = AppEventSender::new(tx); + let mut view = ShellCommandView::new(evt_tx.clone()); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: evt_tx.clone(), + has_input_focus: true, + composer_max_rows: 1, + }); + // Enter command 'a' + view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + // Consume events until we see the ShellCommand + loop { + if let AppEvent::ShellCommand(cmd) = rx.recv().unwrap() { + assert_eq!(cmd, "a"); + break; + } + } + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b2a55b8895..1adebd3891 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -41,6 +41,7 @@ use crate::context::calculate_context_percent_remaining; use crate::conversation_history_widget::ConversationHistoryWidget; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; +use shlex; pub(crate) struct ChatWidget<'a> { app_event_tx: AppEventSender, @@ -52,6 +53,8 @@ pub(crate) struct ChatWidget<'a> { initial_user_message: Option, /// raw ResponseItem stream for context-left calculation history_items: Vec, + /// Counter to generate unique call IDs for shell commands. + next_shell_call_id: usize, } #[derive(Clone, Copy, Eq, PartialEq)] @@ -139,6 +142,7 @@ impl ChatWidget<'_> { initial_images, ), history_items: Vec::new(), + next_shell_call_id: 0, } } @@ -446,6 +450,39 @@ impl ChatWidget<'_> { self.bottom_pane.push_mount_remove_interactive(); self.request_redraw(); } + /// Launch interactive shell-command dialog. + pub fn push_shell_command_interactive(&mut self) { + self.bottom_pane.push_shell_command_interactive(); + self.request_redraw(); + } + /// Handle a submitted shell command: record and execute it. + pub fn handle_shell_command(&mut self, cmd: String) { + let call_id = format!("shell-{}", self.next_shell_call_id); + self.next_shell_call_id += 1; + // Split command into arguments, fallback to raw string if parse fails + let args = shlex::split(&cmd).unwrap_or_else(|| vec![cmd.clone()]); + self.conversation_history.add_active_exec_command(call_id.clone(), args.clone()); + let tx = self.app_event_tx.clone(); + // Spawn execution in background + tokio::spawn(async move { + let output = std::process::Command::new("sh").arg("-c").arg(&cmd).output(); + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + let code = out.status.code().unwrap_or(-1); + tx.send(AppEvent::ShellCommandResult { call_id, stdout, stderr, exit_code: code }); + } + Err(e) => { + tx.send(AppEvent::ShellCommandResult { call_id, stdout: String::new(), stderr: e.to_string(), exit_code: -1 }); + } + } + }); + } + /// Handle completion of a shell command: display its result. + pub fn handle_shell_command_result(&mut self, call_id: String, stdout: String, stderr: String, exit_code: i32) { + self.conversation_history.record_completed_exec_command(call_id, stdout, stderr, exit_code); + } fn request_redraw(&mut self) { self.app_event_tx.send(AppEvent::Redraw); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9c3786017d..e735867547 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -42,7 +42,7 @@ mod mouse_capture; mod scroll_event_helper; mod slash_command; mod status_indicator_widget; -mod context; +pub mod context; mod text_block; mod text_formatting; mod tui; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5ce7f4b203..9da9e88061 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -21,6 +21,8 @@ pub enum SlashCommand { MountAdd, /// Remove a dynamic mount by container path. MountRemove, + /// Prompt to run a shell command in the container. + Shell, } impl SlashCommand { @@ -33,8 +35,9 @@ impl SlashCommand { SlashCommand::EditPrompt => "Open external editor to edit the current prompt.", SlashCommand::Quit => "Exit the application.", - SlashCommand::MountAdd => "Add a mount: host path → container path.", - SlashCommand::MountRemove => "Remove a mount by container path.", + SlashCommand::MountAdd => "Add a mount: host path → container path.", + SlashCommand::MountRemove => "Remove a mount by container path.", + SlashCommand::Shell => "Run a shell command in the container.", } } diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index b763199436..ff3a1fc9a3 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -412,6 +412,7 @@ impl WidgetRef for &UserApprovalWidget<'_> { } } +// Tests for approval widget behavior #[cfg(test)] mod tests { use super::*; @@ -432,7 +433,10 @@ mod tests { app_event_tx.clone(), ); widget.mode = Mode::Input; - widget.input.get_mut().set_value("feedback".to_string()); + // Simulate typing "feedback" into the input field + for c in "feedback".chars() { + widget.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(widget.mode, Mode::Select); let expected_idx = SELECT_OPTIONS @@ -453,8 +457,8 @@ mod tests { #[test] fn test_truncate_middle_truncates() { - // max_len 5 -> trim_len 4, start_len 2, end_len 2 - assert_eq!(truncate_middle("abcdef", 5), "ab…ef"); + // max_len 5 -> trim_len 4 -> start 1, end 1 + assert_eq!(truncate_middle("abcdef", 5), "a…f"); } #[test]