mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
agentydragon(tasks): implement interactive shell-command hotkey + tests
This commit is contained in:
@@ -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 H1–H6 heading spacing for both compact and default modes.
|
||||
|
||||
## Documentation tasks
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
104
codex-rs/tui/src/bottom_pane/shell_command_view.rs
Normal file
104
codex-rs/tui/src/bottom_pane/shell_command_view.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user