Merge Tasks 01, 07, 13

This commit is contained in:
Rai (Michael Pokorny)
2025-06-24 14:39:54 -07:00
13 changed files with 495 additions and 27 deletions

View File

@@ -18,6 +18,10 @@ This file documents the changes introduced on the `agentydragon` branch
## Dependency updates
- Added `uuid` crate to `codex-rs/cli` and `codex-rs/tui`.
## codex-rs/tui: Undo feedback decision with Esc key
- Pressing `Esc` in feedback-entry mode now cancels feedback entry and returns to the select menu, preserving the partially entered feedback text.
- Added a unit test for the ESC cancellation behavior in `tui/src/user_approval_widget.rs`.
## Documentation tasks
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.

View File

@@ -4,8 +4,8 @@
## Status
**General Status**: Not started
**Summary**: Not started; missing Implementation details (How it was implemented and How it works).
**General Status**: Completed
**Summary**: Implemented inline DSL and interactive dialogs for `/mount-add` and `/mount-remove`, with dynamic sandbox policy updates.
## Goal
Implement the `/mount-add` and `/mount-remove` slash commands in the TUI, supporting two modes:
@@ -27,10 +27,30 @@ These commands should:
## Implementation
**How it was implemented**
*(Not implemented yet)*
- Added two new slash commands (`mount-add`, `mount-remove`) to the TUIs `slash-command` popup.
- Inline DSL parsing: commands typed as `/mount-add host=... container=... mode=...` or `/mount-remove container=...` are detected and handled immediately by parsing key/value args, performing the mount/unmount, and updating the `Config.sandbox_policy` in memory.
- Interactive dialogs: selecting `/mount-add` or `/mount-remove` without args opens a bottompane form (`MountAddView` or `MountRemoveView`) that prompts sequentially for the required fields and then triggers the same mount logic.
- Mount logic implemented in `do_mount_add`/`do_mount_remove`:
- Creates/removes a symlink under `cwd` pointing to the host path (`std::os::unix::fs::symlink` on Unix, platform equivalents on Windows).
- Uses new `SandboxPolicy` methods (`allow_disk_write_folder`/`revoke_disk_write_folder`) to grant or revoke `DiskWriteFolder` permissions for the host path.
- Emits success or error messages via `tracing::info!`/`tracing::error!`, which appear in the TUI log pane.
**How it works**
*(Not implemented yet)*
1. **Inline DSL**
- User types:
```
/mount-add host=/path/to/host container=path/in/cwd mode=ro
```
- The first-stage popup intercepts the mount-add command with args, dispatches `InlineMountAdd`, and the app parses the args and runs the mount logic immediately.
2. **Interactive dialog**
- User types `/mount-add` (or selects it via the popup) without args.
- A small form appears that prompts for `host`, `container`, then `mode`.
- Upon completion, the same mount logic runs.
3. **Unmount**
- `/mount-remove container=...` (inline) or `/mount-remove` (interactive) remove the symlink and revoke write permissions.
4. **Policy update**
- `allow_disk_write_folder` appends a `DiskWriteFolder` permission for new mounts.
- `revoke_disk_write_folder` removes the corresponding permission on unmount.
## Notes
- This builds on the static `[[sandbox.mounts]]` support introduced earlier.

View File

@@ -4,8 +4,8 @@
## Status
**General Status**: Not started
**Summary**: Not started; missing Implementation details (How it was implemented and How it works).
**General Status**: Completed
**Summary**: ESC key now cancels feedback entry and returns to the select menu, preserving any entered text; implementation and tests added.
## Goal
Enhance the user-approval dialog so that if the user opted to leave feedback (“No, enter feedback”) they can press `Esc` to cancel the feedback flow and return to the previous approval choice menu (e.g. “Yes, proceed” vs. “No, enter feedback”).
@@ -17,10 +17,13 @@ Enhance the user-approval dialog so that if the user opted to leave feedback (
## Implementation
**How it was implemented**
*(Not implemented yet)*
- In `tui/src/user_approval_widget.rs`, updated `UserApprovalWidget::handle_input_key` so that pressing `Esc` in input mode switches `mode` back to `Select` (rather than sending a deny decision), and restores `selected_option` to the feedback entry item without clearing the input buffer.
- Added a unit test in the same module to verify that `Esc` cancels input mode, preserves the feedback text, and does not emit any decision event.
**How it works**
*(Not implemented yet)*
- When the widget is in `Mode::Input` (feedback-entry), receiving `KeyCode::Esc` resets `mode` to `Select` and sets `selected_option` to the index of the “Edit or give feedback” option.
- The `input` buffer remains intact, so any partially typed feedback is preserved for if/when the user re-enters feedback mode.
- No approval decision is sent on `Esc`, so the modal remains active and the user can still approve, deny, or re-enter feedback.
## Notes
- Changes in `tui/src/bottom_pane/approval_modal_view.rs` and input handling in the approval modal.
- Changes in `tui/src/user_approval_widget.rs` to treat `Esc` in input mode as a cancel-feedback action and added corresponding tests.

View File

@@ -4,8 +4,8 @@
## Status
**General Status**: Not started
**Summary**: Not started; missing Implementation details (How it was implemented and How it works).
**General Status**: Done
**Summary**: Implemented interactive prompt overlay allowing user input during streaming without aborting runs.
## Goal
@@ -23,10 +23,12 @@ Allow users to interleave composing prompts and issuing slash-commands while the
## Implementation
**How it was implemented**
*(Not implemented yet)*
- Modified `BottomPane::handle_key_event` in `tui/src/bottom_pane/mod.rs` to special-case the `StatusIndicatorView` while `is_task_running`, forwarding key events to `ChatComposer` and preserving the overlay.
- Updated `BottomPane::render_ref` to always render the composer first and then overlay the active view, ensuring the input box remains visible and editable under the status indicator.
- Added unit tests in `tui/src/bottom_pane/mod.rs` to verify input is forwarded during task execution and that the status indicator overlay is removed upon task completion.
**How it works**
*(Not implemented yet)*
During LLM streaming or tool execution, the `StatusIndicatorView` remains active as an overlay. The modified event handler detects this overlay and forwards user key events to the underlying `ChatComposer` without dismissing the overlay. On task completion (`set_task_running(false)`), the overlay is automatically removed (via `should_hide_when_task_is_done`), returning to the normal input-only view.
## Notes

View File

@@ -253,6 +253,25 @@ impl SandboxPolicy {
}
}
impl SandboxPolicy {
/// Grant disk-write permission for the specified folder.
pub fn allow_disk_write_folder(&mut self, folder: std::path::PathBuf) {
self.permissions.push(SandboxPermission::DiskWriteFolder { folder });
}
/// Revoke any disk-write permission for the specified folder.
pub fn revoke_disk_write_folder<P: AsRef<std::path::Path>>(&mut self, folder: P) {
let target = folder.as_ref();
self.permissions.retain(|perm| {
if let SandboxPermission::DiskWriteFolder { folder: f } = perm {
f != target
} else {
true
}
});
}
}
/// Permissions that should be granted to the sandbox in which the agent
/// operates.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]

View File

@@ -60,6 +60,95 @@ struct ChatWidgetArgs {
initial_images: Vec<PathBuf>,
}
/// Parse raw argument string for `/mount-add host=... container=... mode=...`.
fn parse_mount_add_args(raw: &str) -> Result<(std::path::PathBuf, std::path::PathBuf, String), String> {
let mut host = None;
let mut container = None;
let mut mode = "rw".to_string();
for token in raw.split_whitespace() {
let mut parts = token.splitn(2, '=');
let key = parts.next().unwrap();
let value = parts.next().ok_or_else(|| format!("invalid argument '{}'", token))?;
match key {
"host" => host = Some(std::path::PathBuf::from(value)),
"container" => container = Some(std::path::PathBuf::from(value)),
"mode" => mode = value.to_string(),
_ => return Err(format!("unknown argument '{}'", key)),
}
}
let host = host.ok_or_else(|| "missing 'host' argument".to_string())?;
let container = container.ok_or_else(|| "missing 'container' argument".to_string())?;
Ok((host, container, mode))
}
/// Parse raw argument string for `/mount-remove container=...`.
fn parse_mount_remove_args(raw: &str) -> Result<std::path::PathBuf, String> {
let mut container = None;
for token in raw.split_whitespace() {
let mut parts = token.splitn(2, '=');
let key = parts.next().unwrap();
let value = parts.next().ok_or_else(|| format!("invalid argument '{}'", token))?;
if key == "container" {
container = Some(std::path::PathBuf::from(value));
} else {
return Err(format!("unknown argument '{}'", key));
}
}
container.ok_or_else(|| "missing 'container' argument".to_string())
}
/// Handle inline mount-add DSL event.
fn handle_inline_mount_add(config: &mut Config, raw: &str) -> Result<(), String> {
let (host, container, mode) = parse_mount_add_args(raw)?;
do_mount_add(config, &host, &container, &mode).map_err(|e| e.to_string())
}
/// Handle inline mount-remove DSL event.
fn handle_inline_mount_remove(config: &mut Config, raw: &str) -> Result<(), String> {
let container = parse_mount_remove_args(raw)?;
do_mount_remove(config, &container).map_err(|e| e.to_string())
}
/// Perform mount-add: create symlink under cwd and update sandbox policy.
fn do_mount_add(
config: &mut Config,
host: &std::path::PathBuf,
container: &std::path::PathBuf,
mode: &str,
) -> std::io::Result<()> {
let host_abs = std::fs::canonicalize(host)?;
let target = config.cwd.join(container);
if target.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("target '{}' already exists", target.display()),
));
}
#[cfg(unix)]
std::os::unix::fs::symlink(&host_abs, &target)?;
#[cfg(windows)]
{
if host_abs.is_file() {
std::os::windows::fs::symlink_file(&host_abs, &target)?;
} else {
std::os::windows::fs::symlink_dir(&host_abs, &target)?;
}
}
if mode.contains('w') {
config.sandbox_policy.allow_disk_write_folder(host_abs);
}
Ok(())
}
/// Perform mount-remove: remove symlink under cwd and revoke sandbox policy.
fn do_mount_remove(config: &mut Config, container: &std::path::PathBuf) -> std::io::Result<()> {
let target = config.cwd.join(container);
let host = std::fs::read_link(&target)?;
std::fs::remove_file(&target)?;
config.sandbox_policy.revoke_disk_write_folder(host);
Ok(())
}
impl<'a> App<'a> {
pub(crate) fn new(
config: Config,
@@ -208,6 +297,30 @@ impl<'a> App<'a> {
AppEvent::Redraw => {
self.draw_next_frame(terminal)?;
}
AppEvent::InlineMountAdd(args) => {
if let Err(err) = handle_inline_mount_add(&mut self.config, &args) {
tracing::error!("mount-add failed: {err}");
}
self.app_event_tx.send(AppEvent::Redraw);
}
AppEvent::InlineMountRemove(args) => {
if let Err(err) = handle_inline_mount_remove(&mut self.config, &args) {
tracing::error!("mount-remove failed: {err}");
}
self.app_event_tx.send(AppEvent::Redraw);
}
AppEvent::MountAdd { host, container, mode } => {
if let Err(err) = do_mount_add(&mut self.config, &host, &container, &mode) {
tracing::error!("mount-add failed: {err}");
}
self.app_event_tx.send(AppEvent::Redraw);
}
AppEvent::MountRemove { container } => {
if let Err(err) = do_mount_remove(&mut self.config, &container) {
tracing::error!("mount-remove failed: {err}");
}
self.app_event_tx.send(AppEvent::Redraw);
}
AppEvent::KeyEvent(key_event) => {
match key_event {
KeyEvent {
@@ -273,6 +386,18 @@ impl<'a> App<'a> {
SlashCommand::Quit => {
break;
}
SlashCommand::MountAdd => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.push_mount_add_interactive();
self.app_event_tx.send(AppEvent::Redraw);
}
}
SlashCommand::MountRemove => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.push_mount_remove_interactive();
self.app_event_tx.send(AppEvent::Redraw);
}
}
},
}
}

View File

@@ -27,5 +27,21 @@ pub(crate) enum AppEvent {
/// Dispatch a recognized slash command from the UI (composer) to the app
/// layer so it can be handled centrally.
/// Dispatch a recognized slash command from the UI (composer) to the app
/// layer so it can be handled centrally (interactive dialog).
DispatchCommand(SlashCommand),
/// Inline mount-add DSL: raw argument string (`host=... container=... mode=...`).
InlineMountAdd(String),
/// Inline mount-remove DSL: raw argument string (`container=...`).
InlineMountRemove(String),
/// Perform mount-add: create symlink and update sandbox policy.
MountAdd {
host: std::path::PathBuf,
container: std::path::PathBuf,
mode: String,
},
/// Perform mount-remove: remove symlink and update sandbox policy.
MountRemove {
container: std::path::PathBuf,
},
}

View File

@@ -18,6 +18,7 @@ use super::command_popup::CommandPopup;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::slash_command::SlashCommand;
/// Minimum number of visible text rows inside the textarea.
const MIN_TEXTAREA_ROWS: usize = 1;
@@ -136,14 +137,23 @@ impl ChatComposer<'_> {
ctrl: false,
} => {
if let Some(cmd) = popup.selected_command() {
// Send command to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
// Clear textarea so no residual text remains.
let first_line = self.textarea.lines().first().unwrap_or("").to_string();
let args = first_line
.strip_prefix(&format!("/{}", cmd.command()))
.unwrap_or("")
.trim();
if (cmd == SlashCommand::MountAdd || cmd == SlashCommand::MountRemove) && !args.is_empty() {
let evt = if cmd == SlashCommand::MountAdd {
AppEvent::InlineMountAdd(args.to_string())
} else {
AppEvent::InlineMountRemove(args.to_string())
};
self.app_event_tx.send(evt);
} else {
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
}
self.textarea.select_all();
self.textarea.cut();
// Hide popup since the command has been dispatched.
self.command_popup = None;
return (InputResult::None, true);
}

View File

@@ -12,6 +12,7 @@ use crate::app_event_sender::AppEventSender;
use crate::user_approval_widget::ApprovalRequest;
mod approval_modal_view;
mod mount_view;
mod bottom_pane_view;
mod chat_composer;
mod chat_composer_history;
@@ -22,6 +23,7 @@ pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use approval_modal_view::ApprovalModalView;
use mount_view::{MountAddView, MountRemoveView};
use status_indicator_view::StatusIndicatorView;
/// Pane displayed in the lower half of the chat UI.
@@ -63,6 +65,16 @@ impl BottomPane<'_> {
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
if let Some(mut view) = self.active_view.take() {
// During task execution, allow input to pass through status indicator overlay
if self.is_task_running && view.should_hide_when_task_is_done() {
// restore overlay view and forward input to composer
self.active_view = Some(view);
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
if needs_redraw {
self.request_redraw();
}
return input_result;
}
view.handle_key_event(self, key_event);
if !view.is_complete() {
self.active_view = Some(view);
@@ -135,6 +147,20 @@ impl BottomPane<'_> {
}
}
/// Launch interactive mount-add dialog (host, container, [mode]).
pub fn push_mount_add_interactive(&mut self) {
let view = MountAddView::new(self.app_event_tx.clone());
self.active_view = Some(Box::new(view));
self.request_redraw();
}
/// Launch interactive mount-remove dialog (container path).
pub fn push_mount_remove_interactive(&mut self) {
let view = MountRemoveView::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() {
@@ -197,11 +223,54 @@ impl BottomPane<'_> {
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Show BottomPaneView if present.
// Always render composer, then overlay any active view (e.g., status indicator or modal)
(&self.composer).render_ref(area, buf);
if let Some(ov) = &self.active_view {
ov.render(area, buf);
} else {
(&self.composer).render_ref(area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// Construct a BottomPane with default parameters for testing.
fn make_pane() -> BottomPane<'static> {
let (tx, _rx) = std::sync::mpsc::channel();
let app_event_tx = AppEventSender::new(tx);
BottomPane::new(BottomPaneParams {
app_event_tx,
has_input_focus: true,
composer_max_rows: 3,
})
}
#[test]
fn forward_input_during_status_indicator() {
let mut pane = make_pane();
// Start task to show status indicator overlay
pane.set_task_running(true);
// Simulate typing 'h'
let key = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE);
let result = pane.handle_key_event(key);
// No submission event is returned
assert!(matches!(result, InputResult::None));
// Composer should have recorded the input
let content = pane.composer.textarea.lines().join("\n");
assert_eq!(content, "h");
// Status indicator overlay remains active
assert!(pane.active_view.is_some());
}
#[test]
fn remove_status_indicator_after_task_complete() {
let mut pane = make_pane();
pane.set_task_running(true);
assert!(pane.active_view.is_some());
pane.set_task_running(false);
// Overlay should be removed when task finishes
assert!(pane.active_view.is_none());
}
}

View File

@@ -0,0 +1,150 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
use ratatui::text::Line;
use tui_input::Input;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::BottomPane;
use super::BottomPaneView;
/// Interactive view prompting for dynamic mount-add (host/container/mode).
enum MountAddStage {
Host,
Container,
Mode,
}
pub(crate) struct MountAddView<'a> {
stage: MountAddStage,
host_input: Input,
container_input: Input,
mode_input: Input,
app_event_tx: AppEventSender,
done: bool,
}
impl MountAddView<'_> {
pub fn new(app_event_tx: AppEventSender) -> Self {
Self {
stage: MountAddStage::Host,
host_input: Input::default(),
container_input: Input::default(),
mode_input: Input::default(),
app_event_tx,
done: false,
}
}
}
impl<'a> BottomPaneView<'a> for MountAddView<'a> {
fn handle_key_event(&mut self, pane: &mut BottomPane<'a>, key_event: KeyEvent) {
if self.done {
return;
}
match self.stage {
MountAddStage::Host => {
if key_event.code == KeyCode::Enter {
self.stage = MountAddStage::Container;
} else {
self.host_input.handle_event(&key_event);
}
}
MountAddStage::Container => {
if key_event.code == KeyCode::Enter {
self.stage = MountAddStage::Mode;
} else {
self.container_input.handle_event(&key_event);
}
}
MountAddStage::Mode => {
if key_event.code == KeyCode::Enter {
let host = std::path::PathBuf::from(self.host_input.value());
let container = std::path::PathBuf::from(self.container_input.value());
let mode = {
let m = self.mode_input.value();
if m.is_empty() { "rw".to_string() } else { m }
};
self.app_event_tx.send(AppEvent::MountAdd { host, container, mode });
self.done = true;
} else {
self.mode_input.handle_event(&key_event);
}
}
}
pane.request_redraw();
}
fn is_complete(&self) -> bool {
self.done
}
fn calculate_required_height(&self, _area: &Rect) -> u16 {
// Prompt + input + border
1 + 1 + 2
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let (prompt, input) = match self.stage {
MountAddStage::Host => ("Host path:", self.host_input.value()),
MountAddStage::Container => ("Container path:", self.container_input.value()),
MountAddStage::Mode => ("Mode (rw|ro):", self.mode_input.value()),
};
let paragraph = Paragraph::new(vec![Line::from(prompt), Line::from(input)])
.block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded));
paragraph.render(area, buf);
}
}
/// Interactive view prompting for dynamic mount-remove (container path).
pub(crate) struct MountRemoveView<'a> {
container_input: Input,
app_event_tx: AppEventSender,
done: bool,
}
impl MountRemoveView<'_> {
pub fn new(app_event_tx: AppEventSender) -> Self {
Self {
container_input: Input::default(),
app_event_tx,
done: false,
}
}
}
impl<'a> BottomPaneView<'a> for MountRemoveView<'a> {
fn handle_key_event(&mut self, pane: &mut BottomPane<'a>, key_event: KeyEvent) {
if self.done {
return;
}
if key_event.code == KeyCode::Enter {
let container = std::path::PathBuf::from(self.container_input.value());
self.app_event_tx.send(AppEvent::MountRemove { container });
self.done = true;
} else {
self.container_input.handle_event(&key_event);
}
pane.request_redraw();
}
fn is_complete(&self) -> bool {
self.done
}
fn calculate_required_height(&self, _area: &Rect) -> u16 {
1 + 1 + 2
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let paragraph = Paragraph::new(vec![
Line::from("Container path to unmount:"),
Line::from(self.container_input.value()),
])
.block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded));
paragraph.render(area, buf);
}
}

View File

@@ -413,6 +413,18 @@ impl ChatWidget<'_> {
self.bottom_pane.update_status_text(line);
}
/// Launch interactive mount-add dialog.
pub fn push_mount_add_interactive(&mut self) {
self.bottom_pane.push_mount_add_interactive();
self.request_redraw();
}
/// Launch interactive mount-remove dialog.
pub fn push_mount_remove_interactive(&mut self) {
self.bottom_pane.push_mount_remove_interactive();
self.request_redraw();
}
fn request_redraw(&mut self) {
self.app_event_tx.send(AppEvent::Redraw);
}

View File

@@ -15,6 +15,10 @@ pub enum SlashCommand {
New,
ToggleMouseMode,
Quit,
/// Add a dynamic mount (host path → container path).
MountAdd,
/// Remove a dynamic mount by container path.
MountRemove,
}
impl SlashCommand {
@@ -22,10 +26,11 @@ impl SlashCommand {
pub fn description(self) -> &'static str {
match self {
SlashCommand::New => "Start a new chat.",
SlashCommand::ToggleMouseMode => {
"Toggle mouse mode (enable for scrolling, disable for text selection)"
}
SlashCommand::ToggleMouseMode =>
"Toggle mouse mode (enable for scrolling, disable for text selection)",
SlashCommand::Quit => "Exit the application.",
SlashCommand::MountAdd => "Add a mount: host path → container path.",
SlashCommand::MountRemove => "Remove a mount by container path.",
}
}

View File

@@ -276,8 +276,10 @@ impl UserApprovalWidget<'_> {
self.send_decision_with_feedback(ReviewDecision::Denied, feedback);
}
KeyCode::Esc => {
// Cancel input treat as deny without feedback.
self.send_decision(ReviewDecision::Denied);
self.mode = Mode::Select;
if let Some(idx) = SELECT_OPTIONS.iter().position(|opt| opt.enters_input_mode) {
self.selected_option = idx;
}
}
_ => {
// Feed into input widget for normal editing.
@@ -370,3 +372,34 @@ impl WidgetRef for &UserApprovalWidget<'_> {
Widget::render(List::new(lines), response_chunk, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn esc_in_input_mode_cancels_input_and_preserves_value() {
let (tx, rx) = mpsc::channel();
let app_event_tx = AppEventSender::new(tx);
let mut widget = UserApprovalWidget::new(
ApprovalRequest::Exec {
id: "id".into(),
command: Vec::new(),
cwd: std::env::current_dir().unwrap(),
reason: None,
},
app_event_tx.clone(),
);
widget.mode = Mode::Input;
widget.input.get_mut().set_value("feedback".to_string());
widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(widget.mode, Mode::Select);
let expected_idx = SELECT_OPTIONS.iter().position(|opt| opt.enters_input_mode).unwrap();
assert_eq!(widget.selected_option, expected_idx);
assert_eq!(widget.input.value(), "feedback");
assert!(rx.try_recv().is_err());
assert!(!widget.done);
}
}