diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-21 10:53:31 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 10:53:31 -0700 |
| commit | 33c779aa9894e1347aeaa4c73e536cf842aee684 (patch) | |
| tree | bfe0f60252518798ccf4621c7bea06021e64d2f4 /crates/atuin-ai/src/tui/view/mod.rs | |
| parent | feat: AI tool rendering overhaul + edit_file tool (#3423) (diff) | |
| download | atuin-33c779aa9894e1347aeaa4c73e536cf842aee684.zip | |
feat: Implement write_file tool with overwrite safety (#3432)
## 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.
Diffstat (limited to 'crates/atuin-ai/src/tui/view/mod.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 68 |
1 files changed, 59 insertions, 9 deletions
diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index bdbece9c..6e13e406 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -175,7 +175,7 @@ fn tool_call_view(tool_call: &TrackedTool, in_git_project: bool) -> Elements { /// keep the standard set. fn permission_options_for_tool(tool: &ClientToolCall, in_git_project: bool) -> Vec<SelectOption> { match tool { - ClientToolCall::Edit(_) => vec![ + ClientToolCall::Edit(_) | ClientToolCall::Write(_) => vec![ SelectOption::builder() .label("Allow") .value(PermissionResult::Allow.as_value_str()) @@ -296,8 +296,8 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { turn::ToolRenderData::FileEdit { path, preview } => { file_edit_tool_view(&tool_key, &details.status, path, preview.as_ref()) }, - turn::ToolRenderData::FileWrite { path } => { - file_write_tool_view(&details.status, path) + turn::ToolRenderData::FileWrite { path, preview } => { + file_write_tool_view(&tool_key, &details.status, path, preview.as_ref()) }, turn::ToolRenderData::Remote => { tool_status_view(&details.name, &details.status) @@ -577,10 +577,16 @@ fn file_edit_tool_view( } } -/// Render a file write tool call status with the target path. -fn file_write_tool_view(status: &turn::ToolResultStatus, path: &std::path::Path) -> Elements { - let display_path = path.display(); - match status { +/// Render a file write tool call with content preview. +fn file_write_tool_view( + key: &str, + status: &turn::ToolResultStatus, + path: &std::path::Path, + preview: Option<&crate::diff::WritePreview>, +) -> Elements { + let display_path = format_path_for_display(path); + + let status_line = match status { turn::ToolResultStatus::Pending => { element! { Spinner( @@ -591,18 +597,62 @@ fn file_write_tool_view(status: &turn::ToolResultStatus, path: &std::path::Path) } } turn::ToolResultStatus::Success => { + let line_info = preview + .map(|p| format!(" ({} lines)", p.total_lines)) + .unwrap_or_default(); element! { - Spinner(label: format!("Wrote: {display_path}"), done: true) + Spinner(label: format!("Wrote: {display_path}{line_info}"), done: true) } } turn::ToolResultStatus::Error => { element! { Text { Span(text: "✗ ", style: Style::default().fg(Color::Red)) - Span(text: format!("Write {display_path}: denied"), style: Style::default().fg(Color::Red)) + Span(text: format!("Write {display_path}: failed"), style: Style::default().fg(Color::Red)) } } } + }; + + let Some(preview) = preview else { + return status_line; + }; + if preview.lines.is_empty() { + return status_line; + } + + let gutter_width = preview.total_lines.to_string().len().max(2) as u16 + 1; + let remaining = preview.remaining_lines(); + + element! { + View(key: key.to_string()) { + #(status_line) + + View(key: format!("{key}-content"), padding_left: Cells::from(2)) { + #(for (idx, line) in preview.lines.iter().enumerate() { + HStack(key: format!("{key}-line-{idx}")) { + View(width: WidthConstraint::Fixed(gutter_width)) { + Text { Span( + text: format!("{:>width$}", idx + 1, width = (gutter_width - 1) as usize), + style: Style::default().fg(Color::DarkGray) + ) } + } + View { + Text { Span(text: line, style: Style::default().fg(Color::DarkGray)) } + } + } + }) + + #(if remaining > 0 { + Text { + Span( + text: format!(" ... +{remaining} more lines"), + style: Style::default().fg(Color::DarkGray) + ) + } + }) + } + } } } |
