diff --git a/agentydragon/README.md b/agentydragon/README.md index d910baaa50..ca69cf3bb9 100644 --- a/agentydragon/README.md +++ b/agentydragon/README.md @@ -48,6 +48,12 @@ This file documents the changes introduced on the `agentydragon` branch ## 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 6d87a33468..8c34e0b9bd 100644 --- a/agentydragon/tasks/23-interactive-container-command-affordance.md +++ b/agentydragon/tasks/23-interactive-container-command-affordance.md @@ -3,7 +3,7 @@ id = "23" title = "Interactive Container Command Affordance via Hotkey" status = "Done" dependencies = "01" # Rationale: depends on Task 01 for mount-add/remove affordance -last_updated = "2025-06-26T15:00:00.000000" +last_updated = "2025-06-30T12:00:00.000001" +++ ## Summary @@ -22,7 +22,7 @@ Add a user-facing affordance (e.g. a hotkey) to invoke arbitrary shell commands ## Implementation -**How it was implemented** +**How it was implemented** - Added a new slash command `Shell` and updated dispatch logic in `app.rs` to push a shell-command view. - Bound `Ctrl+M` in `ChatComposer` to dispatch `SlashCommand::Shell` for hotkey-driven shell prompt. - Created `ShellCommandView` (bottom pane overlay) to capture arbitrary user input and emit `AppEvent::ShellCommand(cmd)`. @@ -33,6 +33,7 @@ Add a user-facing affordance (e.g. a hotkey) to invoke arbitrary shell commands - Unit test in `shell_command_view.rs` asserting correct event emission (skipping redraws). - Integration test in `chat_composer.rs` asserting `Ctrl+M` opens the shell prompt view and allows input. + ## Notes - This feature aids debugging and inspection without leaving the agent workflow. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e97894abb4..0043b10c93 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -499,7 +499,7 @@ impl<'a> App<'a> { 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/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 46bdd97609..a1389cd65f 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 @@ -232,7 +267,8 @@ impl ChatComposer<'_> { (InputResult::None, true) } Input { key: Key::Char('m'), ctrl: true, alt: false, shift: false } => { - // Launch shell-command prompt + // 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) } @@ -286,6 +322,12 @@ impl ChatComposer<'_> { 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. @@ -337,7 +379,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 f89e0f40ca..5533dee735 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -257,9 +257,9 @@ impl WidgetRef for &BottomPane<'_> { #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::sync::mpsc; use crate::app_event::AppEvent; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; /// Construct a BottomPane with default parameters for testing. fn make_pane() -> BottomPane<'static> { @@ -303,6 +303,17 @@ mod tests { assert!(pane.active_view.as_mut().unwrap().should_hide_when_task_is_done()); } + #[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 index 114dcb3941..2b9c9dbb75 100644 --- a/codex-rs/tui/src/bottom_pane/shell_command_view.rs +++ b/codex-rs/tui/src/bottom_pane/shell_command_view.rs @@ -1,4 +1,4 @@ -use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent}; +use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -29,6 +29,12 @@ impl ShellCommandView { 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; } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e706e11a3c..fd2d4fdb3b 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -40,7 +40,7 @@ impl SlashCommand { SlashCommand::MountRemove => "Remove a mount by container path.", SlashCommand::InspectEnv => "Inspect sandbox and container environment (mounts, permissions, network)", SlashCommand::Shell => "Run a shell command in the container.", - SlashCommand::Quit => "Quit", + SlashCommand::Quit => "Exit the application.", } }