Files
zero/internal/tools/write_file.go
KRATOS 0e7318ff72 feat(tui): clickable plan-step detail + plan/sidebar UX & rendering polish (#315)
* fix(tui): plan panel reflects live progress + prompt cadence

The plan panel could read as stuck mid-task even while work continued. Three
fixes:
- Header showed steps[0] forever, so the title never advanced as the plan
  progressed; show the current step (in_progress, else first incomplete).
- updateFromItems matched steps by content only, so an in-place reword reset a
  step's elapsed timer to zero; add positional carry-over when the step count is
  unchanged.
- Prompt: tell the model to mark each step completed + the next in_progress
  before starting it, so the plan tracks real progress instead of jumping at the
  end.

Regression tests included.

* feat(tui): click a plan step to see what was built for it

Each plan step now records the file mutations (write_file/edit_file/apply_patch)
made while it was in_progress, captured from tool-result rows. Clicking a step
row in the context sidebar drops a transcript card listing those changes — the
implementation for that step. Step->line mapping mirrors sidebarAgentSelectables'
offset accounting (one line per step); capture is keyed to the active step and
cleared per run. Read-only and additive. Tests cover the offset math + capture.

* feat(tui): plan-step detail shows diffs + commands

Extend the clickable plan-step detail: capture bash/exec_command runs in addition
to file mutations, store each tool's full output (the diff for edit/apply_patch,
stdout/stderr for commands), and render the card grouped into Changes and
Commands with a short truncated excerpt of each item's diff/output.

* fix(tui): plan-step detail toggles instead of stacking duplicates

Re-clicking a plan step now hides its card (a stable transcript id + drop-by-id),
and clicking a different step replaces it, so at most one detail card is shown.
Previously each click appended a fresh card and they piled up.

* feat(tui): elaborate plan-step detail by status (what we did / will do)

* feat(tui): plan-step card leads with a prose explanation (agent's own narration)

* feat(tui): write plan-step explanations with the model on click

Clicking a PLAN step now shows a fresh, plain-English write-up of that
step, generated on demand by the active model:

- past tense ('what we did') for completed/failed steps, future tense
  ('what we'll do') for pending/in_progress steps
- immediate feedback: the card drops in showing 'Writing explanation…'
  and updates in place (by stable row id) when the text returns
- cached per step (content+status) so re-clicking is instant with no
  second model call; re-clicking still toggles the card closed
- the prompt is built from already-captured local data (step text,
  status, notes, plus a compact digest of file edits, commands, and the
  agent's narration) and asks for short, non-technical prose
- reuses the TUI's existing provider via a one-shot StreamCompletion;
  no new client, no hardcoded provider. Falls back to the local summary
  when no provider is configured or the request fails.

* fix(tui): minimal plan-step card styling (dim border, status-tinted title)

* feat(tui): auto-hide context sidebar when it has no agents or plan

* feat(tui): widen chat reading column to fill wide terminals with breathing room

* fix(tui): toggle context sidebar silently (no transcript notice)

* fix(tui): mark plan complete when a finished task forgot the final update_plan

* fix(tui): remove the lime streaming-text glow (render responses in static ink)

* fix(tui): remove the shaded panel band behind tool-result cards

* fix(tui): Ctrl+B hides the plan entirely (no pinned-panel fallback)

* style(tui): gofmt-align model struct fields

* feat: narrate the build as a clean story (hide plumbing cards, live plan progress, narration prompt, write_file line counts)

* feat(tui): render agent narration bright (ink) so the build reads as a story

* feat(agent): demand an elaborate, structured closing summary (not a single line)

* feat(tui): mark agent narration with a leading bullet (selection-aligned)

* feat: post-turn recap line (※ recap:) with /config recaps on|off toggle

* feat(tui): show 'still generating…' during quiet stretches + count tool-call streaming

* feat(tui): ACTIVITY panel under PLAN + push the model to step the plan per file

* fix(tui): show the quiet 'still generating' hint only when the sidebar is hidden

* feat(tui): code preview in write/edit/apply cards (card-only, no model token cost)

* fix(tui): address CodeRabbit review — stale plan timestamps, stale explanation, Ctrl+B persistence, UTF-8 truncation

* fix: address CodeRabbit re-review round 2 (7 actionable + 2 nitpicks)

* chore: drop accidentally-committed WIP internal/cli/dryrun_test.go

It is an unrelated, pre-existing dry-run test that does not compile (references a
non-existent agent.Options.DryRun field, an unused import, and out-of-scope
sandbox symbols). It was swept into the prior commit by 'git add -A' and is not
part of this PR's scope.
2026-06-25 20:23:29 +05:30

147 lines
5.7 KiB
Go

package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
type writeFileTool struct {
baseTool
workspaceRoot string
scope PathScope
}
func NewWriteFileTool(workspaceRoot string) Tool {
return NewScopedWriteFileTool(workspaceRoot, nil)
}
func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
return writeFileTool{
baseTool: baseTool{
name: "write_file",
description: "Create a new file, refusing to overwrite existing files unless overwrite is true.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Absolute or relative path of the file to write."},
"content": {Type: "string", Description: "Full file contents to write."},
"overwrite": {Type: "boolean", Description: "Whether to allow overwriting an existing file.", Default: false},
},
Required: []string{"path", "content"},
AdditionalProperties: false,
},
safety: promptSafety(SideEffectWrite, "Creates or overwrites files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
}
}
func (tool writeFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool writeFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
content, err := fileContentArg(args)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
overwrite, err := boolArg(args, "overwrite", false)
if err != nil {
return errorResult("Error: Invalid arguments for write_file: " + err.Error())
}
absolutePath, relativePath, err := resolveScopedTargetPath(tool.workspaceRoot, tool.scope, requestedPath)
if err != nil {
return errorResult("Error writing file " + requestedPath + ": " + err.Error())
}
existed := false
if _, err := os.Stat(absolutePath); err == nil {
existed = true
if !overwrite {
return errorResult("Error: " + relativePath + " already exists. Pass overwrite: true to replace it.")
}
} else if !os.IsNotExist(err) {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
// On overwrite, refuse to clobber a tracked file that changed on disk outside
// Zero since it was last read — the new content was likely composed against a
// stale view. Only read current bytes when there is a baseline to compare,
// so a first-touch create/overwrite stays a single write with no extra read.
if existed {
if _, tracked := options.FileTracker.Version(absolutePath); tracked {
// Fail CLOSED: if the tracked file can't be re-read to verify it, refuse
// the overwrite rather than clobbering a file whose current state is
// unknown (it may have been replaced or removed out from under us).
current, rerr := os.ReadFile(absolutePath)
if rerr != nil {
return errorResult(fileConflictMessage(relativePath))
}
if cerr := options.FileTracker.CheckConflict(absolutePath, current); cerr != nil {
return errorResult(fileConflictMessage(relativePath))
}
}
}
// Capture the prior content (before we replace it) so an overwrite can show a
// real diff; a fresh create stays "" and previews as all-additions.
priorContent := ""
if existed {
if prev, rerr := os.ReadFile(absolutePath); rerr == nil {
priorContent = string(prev)
}
}
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
return errorResult("Error writing file " + relativePath + ": " + err.Error())
}
// Baseline the freshly written content so a later edit/overwrite in this
// session compares against what is now on disk.
newInfo, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
verb := "Created"
if existed {
verb = "Overwrote"
}
// Report line count (not bytes): "Wrote 282 lines" reads as real work at a
// glance, where a byte total is opaque noise.
lines := strings.Count(content, "\n")
if content != "" && !strings.HasSuffix(content, "\n") {
lines++
}
summary := fmt.Sprintf("%s %s (%d lines).", verb, relativePath, lines)
result := okResult(summary)
result.ChangedFiles = []string{relativePath}
// Card-only preview: a real unified diff (all-green for a create, red/green for
// an overwrite) on Display.Preview. Output stays the summary, so the model never
// re-reads the file — the rich preview costs zero model tokens.
result.Display = Display{Summary: summary, Kind: "file", Preview: boundedUnifiedDiff(relativePath, priorContent, content)}
return result
}
// fileContentArg reads the file body from "content" or a common alias that weaker
// models sometimes use instead (contents/text/body/data/file_content). It
// delegates to the shared aliasedStringArg so the present-but-non-string type
// error ("content must be a string") and the required-but-missing error
// ("content is required") stay consistent with every other tool. An empty string
// is allowed (writing an empty file), so allowEmpty is true.
func fileContentArg(args map[string]any) (string, error) {
return aliasedStringArg(args, []string{"content", "contents", "text", "body", "data", "file_content"}, "", true, true)
}