mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Merge Tasks 01, 07, 13
This commit is contained in:
@@ -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 task’s **Status** and **Implementation** sections in place rather than maintaining a static list here.
|
||||
|
||||
@@ -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 TUI’s `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 bottom‑pane 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
150
codex-rs/tui/src/bottom_pane/mount_view.rs
Normal file
150
codex-rs/tui/src/bottom_pane/mount_view.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user