mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
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.
177 lines
4.9 KiB
Go
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
|
|
}
|