1package tui
2
3import "strings"
4
5// ExtractPartialStringField scans raw (a partial JSON object's bytes)
6// for the given top-level string field and returns the unescaped
7// value seen so far. If the value is still being written, it returns
8// what's available with ok=true but done=false. If the closing
9// unescaped quote has been reached, done=true.
10//
11// This is deliberately small and best-effort: zut uses it to show
12// the live body of a `write` tool call while the model is still
13// typing it, before the full JSON object has been received. It
14// assumes the field is a top-level key (no nested lookup), matches
15// the first occurrence, and tolerates unfinished `\uXXXX` escapes
16// by dropping a trailing incomplete escape sequence.
17//
18// A production-grade JSON parser would be overkill for this use
19// case; we only care about extracting one field incrementally.
20func ExtractPartialStringField(raw, field string) (value string, ok, done bool) {
21 needle := "\"" + field + "\":"
22 idx := strings.Index(raw, needle)
23 if idx < 0 {
24 return "", false, false
25 }
26 // Skip over the key and any whitespace up to the opening quote.
27 rest := raw[idx+len(needle):]
28 j := 0
29 for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t' || rest[j] == '\n' || rest[j] == '\r') {
30 j++
31 }
32 if j >= len(rest) || rest[j] != '"' {
33 // Field wasn't a string, or the opening quote hasn't arrived.
34 return "", false, false
35 }
36 j++ // past opening quote
37
38 var sb strings.Builder
39 sb.Grow(len(rest) - j)
40 for j < len(rest) {
41 c := rest[j]
42 if c == '\\' {
43 // Escape sequence. Need at least one more byte; if not
44 // present yet, stop emitting here and wait for more.
45 if j+1 >= len(rest) {
46 return sb.String(), true, false
47 }
48 esc := rest[j+1]
49 switch esc {
50 case '"':
51 sb.WriteByte('"')
52 j += 2
53 case '\\':
54 sb.WriteByte('\\')
55 j += 2
56 case '/':
57 sb.WriteByte('/')
58 j += 2
59 case 'n':
60 sb.WriteByte('\n')
61 j += 2
62 case 't':
63 sb.WriteByte('\t')
64 j += 2
65 case 'r':
66 sb.WriteByte('\r')
67 j += 2
68 case 'b':
69 sb.WriteByte('\b')
70 j += 2
71 case 'f':
72 sb.WriteByte('\f')
73 j += 2
74 case 'u':
75 // \uXXXX — needs 4 more hex digits. If we don't have
76 // them yet, drop the incomplete sequence and wait.
77 if j+6 > len(rest) {
78 return sb.String(), true, false
79 }
80 r := parseHex4(rest[j+2 : j+6])
81 if r < 0 {
82 // Malformed; stop, return what we have.
83 return sb.String(), true, false
84 }
85 sb.WriteRune(rune(r))
86 j += 6
87 default:
88 // Unknown escape; keep the backslash and the next
89 // byte as literals so the render shows something.
90 sb.WriteByte(c)
91 sb.WriteByte(esc)
92 j += 2
93 }
94 continue
95 }
96 if c == '"' {
97 // End of string.
98 return sb.String(), true, true
99 }
100 sb.WriteByte(c)
101 j++
102 }
103 // Ran out of input before finding the closing quote.
104 return sb.String(), true, false
105}
106
107// ExtractLastNewText finds the most recent `"newText"` field
108// inside an array of edit objects, scanning from the end of raw
109// backwards so we get the one currently being streamed rather
110// than an earlier completed edit. Returns the partial string
111// value the same way ExtractPartialStringField does, plus the
112// 1-indexed edit number in the array (so the UI can show
113// "edit 2 of N" or similar).
114//
115// This is aimed at the `edit` tool's streaming shape:
116//
117// {"path":"...","edits":[{"oldText":"x","newText":"y"},
118// {"oldText":"a","newText":"b<streaming>
119//
120// We want to show `b<streaming>` while it grows.
121func ExtractLastNewText(raw string) (value string, ok, done bool, editIdx int) {
122 // Find every occurrence of `"newText":` and return a partial
123 // extraction starting at the last one. Earlier occurrences
124 // have already finished streaming.
125 needle := "\"newText\":"
126 last := -1
127 for i := 0; i+len(needle) <= len(raw); {
128 idx := strings.Index(raw[i:], needle)
129 if idx < 0 {
130 break
131 }
132 last = i + idx
133 i = last + len(needle)
134 }
135 if last < 0 {
136 return "", false, false, 0
137 }
138 // Count how many `"newText":` occurrences preceded this one; +1
139 // gives us the 1-indexed edit number.
140 editIdx = strings.Count(raw[:last], needle) + 1
141 suffix := raw[last+len(needle):]
142 j := 0
143 for j < len(suffix) && (suffix[j] == ' ' || suffix[j] == '\t' || suffix[j] == '\n' || suffix[j] == '\r') {
144 j++
145 }
146 if j >= len(suffix) || suffix[j] != '"' {
147 return "", false, false, editIdx
148 }
149 // Reuse the single-field extractor by feeding it a synthetic
150 // {"newText":...} wrapper so all its escape handling stays in
151 // one place.
152 value, ok, done = ExtractPartialStringField("{\"newText\":"+suffix[j:], "newText")
153 return value, ok, done, editIdx
154}
155
156func parseHex4(s string) int {
157 if len(s) != 4 {
158 return -1
159 }
160 n := 0
161 for i := 0; i < 4; i++ {
162 var d int
163 c := s[i]
164 switch {
165 case c >= '0' && c <= '9':
166 d = int(c - '0')
167 case c >= 'a' && c <= 'f':
168 d = int(c-'a') + 10
169 case c >= 'A' && c <= 'F':
170 d = int(c-'A') + 10
171 default:
172 return -1
173 }
174 n = n<<4 | d
175 }
176 return n
177}