01 dynamic mount commands

This commit is contained in:
Rai (Michael Pokorny)
2025-06-24 14:30:34 -07:00
parent 2f0109faeb
commit b23d44cb5c
9 changed files with 386 additions and 13 deletions

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

@@ -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.
@@ -135,6 +137,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() {

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