agentydragon(tasks): implement interactive shell-command hotkey + tests

This commit is contained in:
Rai (Michael Pokorny)
2025-06-24 22:27:31 -07:00
parent 1f6385392d
commit 4e7840454b
11 changed files with 292 additions and 20 deletions

View File

@@ -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 `<N>% 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 `<N>% 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 H1H6 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 tasks **Status** and **Implementation** sections in place rather than maintaining a static list here.
### Branch & Worktree Workflow

View File

@@ -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 agents 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 <cmd>` 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

View File

@@ -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()?;

View File

@@ -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,
},
}

View File

@@ -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),

View File

@@ -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();

View File

@@ -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;
}
}
}
}

View File

@@ -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<UserMessage>,
/// raw ResponseItem stream for context-left calculation
history_items: Vec<ResponseItem>,
/// 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);

View File

@@ -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;

View File

@@ -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.",
}
}

View File

@@ -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), "af");
}
#[test]