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 da1df276a2
11 changed files with 223 additions and 17 deletions

View File

@@ -33,9 +33,15 @@ 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

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-26T15:00:00.000000"
+++
## Summary
@@ -23,11 +23,15 @@ 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.
- 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)`.
- Extended `AppEvent` with `ShellCommand(String)` and `ShellCommandResult { call_id, stdout, stderr, exit_code }` variants for round-trip messaging.
- Implemented `ChatWidget::handle_shell_command` to execute `sh -c <cmd>` asynchronously (tokio::spawn) and send back `ShellCommandResult`.
- Updated `ConversationHistoryWidget` to reuse existing exec-command cells to display shell commands and their output inline.
- Added tests:
- 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

View File

@@ -401,6 +401,24 @@ 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);
}
},
}
}

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

@@ -236,6 +236,11 @@ impl ChatComposer<'_> {
self.open_external_editor();
(InputResult::None, true)
}
Input { key: Key::Char('m'), ctrl: true, alt: false, shift: false } => {
// Launch shell-command prompt
self.app_event_tx.send(AppEvent::DispatchCommand(SlashCommand::Shell));
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
}
@@ -280,6 +285,12 @@ 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")
}
/// 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.

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,13 @@ 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() {
@@ -263,7 +272,7 @@ 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());

View File

@@ -0,0 +1,104 @@
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent};
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) {
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));
// Skip initial redraw event(s)
let mut event;
loop {
event = rx.recv().unwrap();
if matches!(event, AppEvent::ShellCommand(_)) {
break;
}
}
if let AppEvent::ShellCommand(cmd) = event {
assert_eq!(cmd, "a");
} else {
panic!("expected ShellCommand event, got {:?}", event);
}
}
}

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]