mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
01 dynamic mount commands
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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() {
|
||||
|
||||
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.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user