Merge branch 'agentydragon-23-interactive-container-command-affordance' into agentydragon

This commit is contained in:
Rai (Michael Pokorny)
2025-06-25 04:52:46 -07:00
7 changed files with 82 additions and 8 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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