zot/internal/agent/modes/json.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

125 lines
3.4 KiB
Go

package modes
import (
"context"
"encoding/json"
"fmt"
"io"
"github.com/patriceckhart/zot/internal/core"
"github.com/patriceckhart/zot/internal/provider"
)
// RunJSON runs the agent to completion, writing one JSON object per
// AgentEvent as newline-delimited JSON.
func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error {
enc := json.NewEncoder(out)
write := func(v any) {
_ = enc.Encode(v)
}
var runErr error
sink := func(ev core.AgentEvent) {
write(EventToJSON(ev))
}
if err := ag.Prompt(ctx, prompt, images, sink); err != nil {
runErr = err
}
if runErr != nil {
fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`)
}
return runErr
}
// EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
// schema is deliberately simple and flat. Exported so the RPC mode can
// reuse the same serialisation as `zot --json`.
func EventToJSON(ev core.AgentEvent) map[string]any {
m := map[string]any{"type": ev.Type()}
switch e := ev.(type) {
case core.EvTurnStart:
m["step"] = e.Step
case core.EvUserMessage:
m["content"] = ContentToJSON(e.Message.Content)
m["time"] = e.Message.Time
case core.EvAssistantMessage:
m["content"] = ContentToJSON(e.Message.Content)
m["time"] = e.Message.Time
case core.EvTextDelta:
m["delta"] = e.Delta
case core.EvToolUseStart:
m["id"] = e.ID
m["name"] = e.Name
case core.EvToolUseArgs:
m["id"] = e.ID
m["delta"] = e.Delta
case core.EvToolUseEnd:
m["id"] = e.ID
case core.EvToolCall:
m["id"] = e.ID
m["name"] = e.Name
var args any
_ = json.Unmarshal(e.Args, &args)
m["args"] = args
case core.EvToolProgress:
m["id"] = e.ID
m["text"] = e.Text
case core.EvToolResult:
m["id"] = e.ID
m["is_error"] = e.Result.IsError
m["content"] = ContentToJSON(e.Result.Content)
case core.EvUsage:
m["input"] = e.Usage.InputTokens
m["output"] = e.Usage.OutputTokens
m["cache_read"] = e.Usage.CacheReadTokens
m["cache_write"] = e.Usage.CacheWriteTokens
m["cost_usd"] = e.Usage.CostUSD
m["cumulative"] = map[string]any{
"input": e.Cumulative.InputTokens,
"output": e.Cumulative.OutputTokens,
"cache_read": e.Cumulative.CacheReadTokens,
"cache_write": e.Cumulative.CacheWriteTokens,
"cost_usd": e.Cumulative.CostUSD,
}
case core.EvTurnEnd:
m["stop"] = string(e.Stop)
if e.Err != nil {
m["error"] = e.Err.Error()
}
}
return m
}
// ContentToJSON serialises a transcript content slice into the same
// shape used in EventToJSON. Exported alongside EventToJSON for the
// RPC mode.
func ContentToJSON(blocks []provider.Content) []map[string]any {
out := make([]map[string]any, 0, len(blocks))
for _, b := range blocks {
switch v := b.(type) {
case provider.TextBlock:
out = append(out, map[string]any{"type": "text", "text": v.Text})
case provider.ImageBlock:
out = append(out, map[string]any{"type": "image", "mime_type": v.MimeType, "bytes": len(v.Data)})
case provider.ToolCallBlock:
var args any
_ = json.Unmarshal(v.Arguments, &args)
out = append(out, map[string]any{"type": "tool_call", "id": v.ID, "name": v.Name, "args": args})
case provider.ToolResultBlock:
out = append(out, map[string]any{
"type": "tool_result",
"call_id": v.CallID,
"is_error": v.IsError,
"content": ContentToJSON(v.Content),
})
}
}
return out
}
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}