v0.1.49 1a49277

partialjson.go RawHistoryFolder

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: zot 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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close