From 33c779aa9894e1347aeaa4c73e536cf842aee684 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Tue, 21 Apr 2026 10:53:31 -0700 Subject: feat: Implement write_file tool with overwrite safety (#3432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements the `write_file` client-side tool — creates new files or overwrites existing ones with an explicit `overwrite` flag for safety. - **Overwrite flag**: Writing to an existing file without `overwrite: true` returns an error directing the LLM to set the flag or use `edit_file` for targeted changes. Prevents accidental overwrites. - **Snapshots**: Existing files are backed up before overwriting (same infrastructure as `edit_file`). - **Content preview**: Completed writes show the first 10 lines in gray with line numbers, plus "+ N more lines" for longer files. - **Atomic writes**: Uses `tempfile` + fsync + rename (same as `edit_file`). - **File tracker update**: After writing, the file is registered in the tracker so subsequent `edit_file` calls work without a separate read. - **Permission**: Shares the `"Write"` rule with `edit_file` — one permission covers both tools. --- crates/atuin-ai/src/tui/dispatch.rs | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) (limited to 'crates/atuin-ai/src/tui/dispatch.rs') diff --git a/crates/atuin-ai/src/tui/dispatch.rs b/crates/atuin-ai/src/tui/dispatch.rs index fea26953..46eebd9b 100644 --- a/crates/atuin-ai/src/tui/dispatch.rs +++ b/crates/atuin-ai/src/tui/dispatch.rs @@ -232,6 +232,10 @@ fn execute_tool( let edit_call = edit_call.clone(); execute_edit_tool(handle, tx, tool_id, edit_call); } + ClientToolCall::Write(write_call) => { + let write_call = write_call.clone(); + execute_write_tool(handle, tx, tool_id, write_call); + } _ => { execute_simple_tool(handle, tx, tool_id, tool, db); } @@ -387,6 +391,69 @@ fn execute_edit_tool( }); } +/// Execute a write_file tool call. +/// +/// Snapshots the existing file (if any) before overwriting, writes atomically, +/// stores a content preview on the tracker, and updates the file tracker. +fn execute_write_tool( + handle: &Handle, + tx: &mpsc::Sender, + tool_id: String, + write_call: crate::tools::WriteToolCall, +) { + let h = handle.clone(); + let tx = tx.clone(); + + tokio::spawn(async move { + let resolved = write_call.resolved_path(); + + // 1. Snapshot the existing file before overwriting (if it exists). + if resolved.exists() + && let Ok(original_content) = std::fs::read(&resolved) + { + let snap_path = resolved.clone(); + h.update(move |state| { + if let Some(ref mut store) = state.snapshot_store + && let Err(e) = store.ensure_snapshot(&snap_path, &original_content) + { + tracing::warn!("failed to create file snapshot: {e}"); + } + }); + } + + // 2. Execute: check exists/overwrite, atomic write + let (outcome, new_bytes) = write_call.execute(&resolved); + + // 3. Build content preview on success + let write_preview = if new_bytes.is_some() { + Some(crate::diff::WritePreview::from_content(&write_call.content)) + } else { + None + }; + + // 4. Update tracker, store preview, and finish + let tc_id = tool_id; + h.update(move |state| { + if let Some(ref new_bytes) = new_bytes + && let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified()) + { + state + .file_tracker + .update_after_edit(&resolved, new_bytes, mtime); + } + if let Some(preview) = write_preview + && let Some(tracked) = state.tool_tracker.get_mut(&tc_id) + { + tracked.write_preview = Some(preview); + } + state.finish_tool_call(&tc_id, outcome); + if !state.tool_tracker.has_pending() { + let _ = tx.send(AiTuiEvent::ContinueAfterTools); + } + }); + }); +} + /// Execute a shell tool with streaming VT100 preview. fn execute_shell_tool( handle: &Handle, -- cgit v1.3.1