zot/internal/tui/partialjson.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

177 lines
4.9 KiB
Go

package tui
import "strings"
// ExtractPartialStringField scans raw (a partial JSON object's bytes)
// for the given top-level string field and returns the unescaped
// value seen so far. If the value is still being written, it returns
// what's available with ok=true but done=false. If the closing
// unescaped quote has been reached, done=true.
//
// This is deliberately small and best-effort: zot uses it to show
// the live body of a `write` tool call while the model is still
// typing it, before the full JSON object has been received. It
// assumes the field is a top-level key (no nested lookup), matches
// the first occurrence, and tolerates unfinished `\uXXXX` escapes
// by dropping a trailing incomplete escape sequence.
//
// A production-grade JSON parser would be overkill for this use
// case; we only care about extracting one field incrementally.
func ExtractPartialStringField(raw, field string) (value string, ok, done bool) {
needle := "\"" + field + "\":"
idx := strings.Index(raw, needle)
if idx < 0 {
return "", false, false
}
// Skip over the key and any whitespace up to the opening quote.
rest := raw[idx+len(needle):]
j := 0
for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t' || rest[j] == '\n' || rest[j] == '\r') {
j++
}
if j >= len(rest) || rest[j] != '"' {
// Field wasn't a string, or the opening quote hasn't arrived.
return "", false, false
}
j++ // past opening quote
var sb strings.Builder
sb.Grow(len(rest) - j)
for j < len(rest) {
c := rest[j]
if c == '\\' {
// Escape sequence. Need at least one more byte; if not
// present yet, stop emitting here and wait for more.
if j+1 >= len(rest) {
return sb.String(), true, false
}
esc := rest[j+1]
switch esc {
case '"':
sb.WriteByte('"')
j += 2
case '\\':
sb.WriteByte('\\')
j += 2
case '/':
sb.WriteByte('/')
j += 2
case 'n':
sb.WriteByte('\n')
j += 2
case 't':
sb.WriteByte('\t')
j += 2
case 'r':
sb.WriteByte('\r')
j += 2
case 'b':
sb.WriteByte('\b')
j += 2
case 'f':
sb.WriteByte('\f')
j += 2
case 'u':
// \uXXXX — needs 4 more hex digits. If we don't have
// them yet, drop the incomplete sequence and wait.
if j+6 > len(rest) {
return sb.String(), true, false
}
r := parseHex4(rest[j+2 : j+6])
if r < 0 {
// Malformed; stop, return what we have.
return sb.String(), true, false
}
sb.WriteRune(rune(r))
j += 6
default:
// Unknown escape; keep the backslash and the next
// byte as literals so the render shows something.
sb.WriteByte(c)
sb.WriteByte(esc)
j += 2
}
continue
}
if c == '"' {
// End of string.
return sb.String(), true, true
}
sb.WriteByte(c)
j++
}
// Ran out of input before finding the closing quote.
return sb.String(), true, false
}
// ExtractLastNewText finds the most recent `"newText"` field
// inside an array of edit objects, scanning from the end of raw
// backwards so we get the one currently being streamed rather
// than an earlier completed edit. Returns the partial string
// value the same way ExtractPartialStringField does, plus the
// 1-indexed edit number in the array (so the UI can show
// "edit 2 of N" or similar).
//
// This is aimed at the `edit` tool's streaming shape:
//
// {"path":"...","edits":[{"oldText":"x","newText":"y"},
// {"oldText":"a","newText":"b<streaming>
//
// We want to show `b<streaming>` while it grows.
func ExtractLastNewText(raw string) (value string, ok, done bool, editIdx int) {
// Find every occurrence of `"newText":` and return a partial
// extraction starting at the last one. Earlier occurrences
// have already finished streaming.
needle := "\"newText\":"
last := -1
for i := 0; i+len(needle) <= len(raw); {
idx := strings.Index(raw[i:], needle)
if idx < 0 {
break
}
last = i + idx
i = last + len(needle)
}
if last < 0 {
return "", false, false, 0
}
// Count how many `"newText":` occurrences preceded this one; +1
// gives us the 1-indexed edit number.
editIdx = strings.Count(raw[:last], needle) + 1
suffix := raw[last+len(needle):]
j := 0
for j < len(suffix) && (suffix[j] == ' ' || suffix[j] == '\t' || suffix[j] == '\n' || suffix[j] == '\r') {
j++
}
if j >= len(suffix) || suffix[j] != '"' {
return "", false, false, editIdx
}
// Reuse the single-field extractor by feeding it a synthetic
// {"newText":...} wrapper so all its escape handling stays in
// one place.
value, ok, done = ExtractPartialStringField("{\"newText\":"+suffix[j:], "newText")
return value, ok, done, editIdx
}
func parseHex4(s string) int {
if len(s) != 4 {
return -1
}
n := 0
for i := 0; i < 4; i++ {
var d int
c := s[i]
switch {
case c >= '0' && c <= '9':
d = int(c - '0')
case c >= 'a' && c <= 'f':
d = int(c-'a') + 10
case c >= 'A' && c <= 'F':
d = int(c-'A') + 10
default:
return -1
}
n = n<<4 | d
}
return n
}