zot/internal/agent/tools/write.go
patriceckhart c610a3a645 feat(tui): live-stream file body during write/edit tool calls
You see the file being composed in real time now. While the model
is typing the tool_use JSON, the TUI renders a rules-wrapped
syntax-highlighted preview that grows as deltas arrive. When the
tool actually runs, the preview transitions to the final result
without flicker.

Before: the tool header appeared post-response, then "wrote N bytes"
for write / "applied 1 edit" for edit. No live feedback.

Now: as soon as the `path` field parses out of the partial JSON,
the header shows `▸ write /Users/pat/Desktop/demo.ts`. As the
`content` / `newText` string streams in, each delta extends the
highlighted preview body immediately. Collapsed at the usual
preview height with the standard `ctrl+o to expand` footer.

Implementation:

- internal/core/events.go: three new AgentEvent types,
  EvToolUseStart / EvToolUseArgs / EvToolUseEnd. They carry the
  tool id, name, and raw JSON deltas from the provider stream.
- internal/core/agent.go: forwards the equivalent provider events
  instead of dropping them. EvToolCall (with fully-parsed args)
  still fires at EventDone as before, so existing consumers
  don't need to change.
- internal/tui/partialjson.go: small escape-aware extractor that
  pulls one string field's value out of a partial JSON buffer as
  it grows. Handles \\ \" \n \t \r \b \f \/ and \uXXXX escapes;
  tolerates trailing incomplete escapes (returns the complete
  prefix and waits for more bytes). Second helper,
  ExtractLastNewText, walks to the most recent "newText":"..."
  inside an edits array so edit's streaming preview shows the
  edit currently being composed (not an earlier one that's
  already finished).
- internal/tui/view.go: ToolCallView gains Streaming, RawJSONBuf,
  LivePath fields. renderToolCall dispatches to renderLiveToolBody
  while Streaming=true and Result=="". For `write` it shows the
  partial `content`; for `edit` it shows `  edit N (streaming)`
  plus the partial `newText`. Shared wrapLiveBody keeps the rule
  + collapse boilerplate in one place.
- internal/agent/modes/interactive.go: handles the three new
  events. EvToolUseStart pre-creates the ToolCallView so the
  header appears instantly; EvToolUseArgs appends the delta and
  refreshes LivePath; EvToolUseEnd flips Streaming off. The
  pre-existing EvToolCall branch now updates the already-created
  view rather than replacing it.
- internal/agent/modes/json.go: emits tool_use_start /
  tool_use_args / tool_use_end events so `zot --json` consumers
  can build their own live previews.
- internal/agent/tools/write.go: tool result is now the written
  file body (same shape as read's result) with total_lines +
  start_line details. Keeps the visual transition from streaming
  preview to final result seamless, and gives the model the file
  contents in its own tool_result for follow-up turns.

Tests:

- internal/tui/partialjson_test.go: 9 cases on
  ExtractPartialStringField (complete, partial mid-word, escape
  variants, unfinished escapes) and 4 on ExtractLastNewText
  (no newText, partial, complete, multi-edit).

Verified end-to-end via `zot --json "write ..."` and
`zot --json "edit ..."` against the real API: 246 tool_use_args
delta events on a 30-line write, preview fields extracted live,
final file written correctly.
2026-04-20 08:37:14 +02:00

74 lines
2.2 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/patriceckhart/zot/internal/core"
"github.com/patriceckhart/zot/internal/provider"
)
// WriteTool writes content to a file, creating parent directories.
type WriteTool struct {
CWD string
Sandbox *Sandbox
}
type writeArgs struct {
Path string `json:"path"`
Content string `json:"content"`
}
const writeSchema = `{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`
func (t *WriteTool) Name() string { return "write" }
func (t *WriteTool) Description() string {
return "Write a file. Creates parent dirs. Overwrites."
}
func (t *WriteTool) Schema() json.RawMessage { return json.RawMessage(writeSchema) }
func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
var a writeArgs
if err := json.Unmarshal(raw, &a); err != nil {
return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
}
if a.Path == "" {
return core.ToolResult{}, fmt.Errorf("path is required")
}
path := resolvePath(t.CWD, a.Path)
if err := t.Sandbox.CheckPath(path); err != nil {
return core.ToolResult{}, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return core.ToolResult{}, err
}
if err := os.WriteFile(path, []byte(a.Content), 0o644); err != nil {
return core.ToolResult{}, err
}
// Return the file content as the result body, just like `read`
// does. The TUI renders it with a syntax-highlighted gutter so
// the on-screen view after a `write` matches the pre-write
// streaming preview seamlessly. The model also sees the written
// content in its tool_result, which is useful on follow-up turns
// where it wants to reference what it just wrote without a
// second `read` call.
totalLines := strings.Count(a.Content, "\n")
if len(a.Content) > 0 && !strings.HasSuffix(a.Content, "\n") {
totalLines++ // count the last unterminated line
}
return core.ToolResult{
Content: []provider.Content{provider.TextBlock{Text: a.Content}},
Details: map[string]any{
"path": path,
"bytes": len(a.Content),
"total_lines": totalLines,
"start_line": 1,
},
}, nil
}