detached c610a3a

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.
commit
c610a3a645912a04fc3a94237e345e92dd71bf27
author
patriceckhart · · 2026-04-20 06:37 UTC
parent
3e59a3c
signature
unsigned
refs
tag: v0.0.51
8 files · +527 -14
M internal/agent/modes/interactive.go +42 -6
@@ -1979,14 +1979,50 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
19791979 if i.cfg.OnAssistant != nil {
19801980 i.cfg.OnAssistant(e.Message)
19811981 }
1982+ case core.EvToolUseStart:
1983+ // Live streaming: pre-create the view so the user sees the
1984+ // tool call being composed in real time. Any subsequent
1985+ // EvToolCall for the same ID updates the same struct (the
1986+ // final parsed args + name are already known here).
1987+ if _, exists := i.toolCalls[e.ID]; !exists {
1988+ i.toolCalls[e.ID] = &tui.ToolCallView{
1989+ ID: e.ID,
1990+ Name: e.Name,
1991+ Streaming: true,
1992+ }
1993+ i.toolOrder = append(i.toolOrder, e.ID)
1994+ }
1995+ case core.EvToolUseArgs:
1996+ if tc, ok := i.toolCalls[e.ID]; ok {
1997+ tc.RawJSONBuf += e.Delta
1998+ // Refresh the live path as soon as it parses; used in
1999+ // the header (▸ write /Users/pat/Desktop/demo.ts)
2000+ // while the content is still streaming.
2001+ if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2002+ tc.LivePath = p
2003+ } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2004+ tc.LivePath = p
2005+ }
2006+ }
2007+ case core.EvToolUseEnd:
2008+ if tc, ok := i.toolCalls[e.ID]; ok {
2009+ tc.Streaming = false
2010+ }
19822011 case core.EvToolCall:
1983- tcv := &tui.ToolCallView{
1984- ID: e.ID,
1985- Name: e.Name,
1986- Args: shortArgs(e.Args),
2012+ // If we already pre-created the view during streaming, just
2013+ // refresh the final Args summary. Otherwise create a new one
2014+ // (non-streaming providers or legacy paths).
2015+ if tc, ok := i.toolCalls[e.ID]; ok {
2016+ tc.Args = shortArgs(e.Args)
2017+ tc.Streaming = false
2018+ } else {
2019+ i.toolCalls[e.ID] = &tui.ToolCallView{
2020+ ID: e.ID,
2021+ Name: e.Name,
2022+ Args: shortArgs(e.Args),
2023+ }
2024+ i.toolOrder = append(i.toolOrder, e.ID)
19872025 }
1988- i.toolCalls[e.ID] = tcv
1989- i.toolOrder = append(i.toolOrder, e.ID)
19902026 case core.EvToolResult:
19912027 if tc, ok := i.toolCalls[e.ID]; ok {
19922028 tc.Done = true
@@ -1979,14 +1979,50 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
1979 if i.cfg.OnAssistant != nil {1979 if i.cfg.OnAssistant != nil {
1980 i.cfg.OnAssistant(e.Message)1980 i.cfg.OnAssistant(e.Message)
1981 }1981 }
1982+ case core.EvToolUseStart:
1983+ // Live streaming: pre-create the view so the user sees the
1984+ // tool call being composed in real time. Any subsequent
1985+ // EvToolCall for the same ID updates the same struct (the
1986+ // final parsed args + name are already known here).
1987+ if _, exists := i.toolCalls[e.ID]; !exists {
1988+ i.toolCalls[e.ID] = &tui.ToolCallView{
1989+ ID: e.ID,
1990+ Name: e.Name,
1991+ Streaming: true,
1992+ }
1993+ i.toolOrder = append(i.toolOrder, e.ID)
1994+ }
1995+ case core.EvToolUseArgs:
1996+ if tc, ok := i.toolCalls[e.ID]; ok {
1997+ tc.RawJSONBuf += e.Delta
1998+ // Refresh the live path as soon as it parses; used in
1999+ // the header (▸ write /Users/pat/Desktop/demo.ts)
2000+ // while the content is still streaming.
2001+ if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2002+ tc.LivePath = p
2003+ } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
2004+ tc.LivePath = p
2005+ }
2006+ }
2007+ case core.EvToolUseEnd:
2008+ if tc, ok := i.toolCalls[e.ID]; ok {
2009+ tc.Streaming = false
2010+ }
1982 case core.EvToolCall:2011 case core.EvToolCall:
1983- tcv := &tui.ToolCallView{2012+ // If we already pre-created the view during streaming, just
1984- ID: e.ID,2013+ // refresh the final Args summary. Otherwise create a new one
1985- Name: e.Name,2014+ // (non-streaming providers or legacy paths).
1986- Args: shortArgs(e.Args),2015+ if tc, ok := i.toolCalls[e.ID]; ok {
2016+ tc.Args = shortArgs(e.Args)
2017+ tc.Streaming = false
2018+ } else {
2019+ i.toolCalls[e.ID] = &tui.ToolCallView{
2020+ ID: e.ID,
2021+ Name: e.Name,
2022+ Args: shortArgs(e.Args),
2023+ }
2024+ i.toolOrder = append(i.toolOrder, e.ID)
1987 }2025 }
1988- i.toolCalls[e.ID] = tcv
1989- i.toolOrder = append(i.toolOrder, e.ID)
1990 case core.EvToolResult:2026 case core.EvToolResult:
1991 if tc, ok := i.toolCalls[e.ID]; ok {2027 if tc, ok := i.toolCalls[e.ID]; ok {
1992 tc.Done = true2028 tc.Done = true
M internal/agent/modes/json.go +8 -0
@@ -49,6 +49,14 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
4949 m["time"] = e.Message.Time
5050 case core.EvTextDelta:
5151 m["delta"] = e.Delta
52+ case core.EvToolUseStart:
53+ m["id"] = e.ID
54+ m["name"] = e.Name
55+ case core.EvToolUseArgs:
56+ m["id"] = e.ID
57+ m["delta"] = e.Delta
58+ case core.EvToolUseEnd:
59+ m["id"] = e.ID
5260 case core.EvToolCall:
5361 m["id"] = e.ID
5462 m["name"] = e.Name
@@ -49,6 +49,14 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
49 m["time"] = e.Message.Time49 m["time"] = e.Message.Time
50 case core.EvTextDelta:50 case core.EvTextDelta:
51 m["delta"] = e.Delta51 m["delta"] = e.Delta
52+ case core.EvToolUseStart:
53+ m["id"] = e.ID
54+ m["name"] = e.Name
55+ case core.EvToolUseArgs:
56+ m["id"] = e.ID
57+ m["delta"] = e.Delta
58+ case core.EvToolUseEnd:
59+ m["id"] = e.ID
52 case core.EvToolCall:60 case core.EvToolCall:
53 m["id"] = e.ID61 m["id"] = e.ID
54 m["name"] = e.Name62 m["name"] = e.Name
M internal/agent/tools/write.go +19 -3
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "strings"
910
1011 "github.com/patriceckhart/zot/internal/core"
1112 "github.com/patriceckhart/zot/internal/provider"
@@ -50,9 +51,24 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
5051 return core.ToolResult{}, err
5152 }
5253
53- msg := fmt.Sprintf("wrote %d bytes to %s", len(a.Content), a.Path)
54+ // Return the file content as the result body, just like `read`
55+ // does. The TUI renders it with a syntax-highlighted gutter so
56+ // the on-screen view after a `write` matches the pre-write
57+ // streaming preview seamlessly. The model also sees the written
58+ // content in its tool_result, which is useful on follow-up turns
59+ // where it wants to reference what it just wrote without a
60+ // second `read` call.
61+ totalLines := strings.Count(a.Content, "\n")
62+ if len(a.Content) > 0 && !strings.HasSuffix(a.Content, "\n") {
63+ totalLines++ // count the last unterminated line
64+ }
5465 return core.ToolResult{
55- Content: []provider.Content{provider.TextBlock{Text: msg}},
56- Details: map[string]any{"path": path, "bytes": len(a.Content)},
66+ Content: []provider.Content{provider.TextBlock{Text: a.Content}},
67+ Details: map[string]any{
68+ "path": path,
69+ "bytes": len(a.Content),
70+ "total_lines": totalLines,
71+ "start_line": 1,
72+ },
5773 }, nil
5874 }
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 10
10 "github.com/patriceckhart/zot/internal/core"11 "github.com/patriceckhart/zot/internal/core"
11 "github.com/patriceckhart/zot/internal/provider"12 "github.com/patriceckhart/zot/internal/provider"
@@ -50,9 +51,24 @@ func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress f
50 return core.ToolResult{}, err51 return core.ToolResult{}, err
51 }52 }
52 53
53- msg := fmt.Sprintf("wrote %d bytes to %s", len(a.Content), a.Path)54+ // Return the file content as the result body, just like `read`
55+ // does. The TUI renders it with a syntax-highlighted gutter so
56+ // the on-screen view after a `write` matches the pre-write
57+ // streaming preview seamlessly. The model also sees the written
58+ // content in its tool_result, which is useful on follow-up turns
59+ // where it wants to reference what it just wrote without a
60+ // second `read` call.
61+ totalLines := strings.Count(a.Content, "\n")
62+ if len(a.Content) > 0 && !strings.HasSuffix(a.Content, "\n") {
63+ totalLines++ // count the last unterminated line
64+ }
54 return core.ToolResult{65 return core.ToolResult{
55- Content: []provider.Content{provider.TextBlock{Text: msg}},66+ Content: []provider.Content{provider.TextBlock{Text: a.Content}},
56- Details: map[string]any{"path": path, "bytes": len(a.Content)},67+ Details: map[string]any{
68+ "path": path,
69+ "bytes": len(a.Content),
70+ "total_lines": totalLines,
71+ "start_line": 1,
72+ },
57 }, nil73 }, nil
58 }74 }
M internal/core/agent.go +6 -4
@@ -228,10 +228,12 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
228228 // nothing
229229 case provider.EventTextDelta:
230230 sink(EvTextDelta{Delta: e.Delta})
231- case provider.EventToolStart, provider.EventToolArgs, provider.EventToolEnd:
232- // The provider emits these to support partial-JSON UIs.
233- // We surface the complete tool call once per block at EvToolCall
234- // below (after EventDone), so for now we drop them.
231+ case provider.EventToolStart:
232+ sink(EvToolUseStart{ID: e.ID, Name: e.Name})
233+ case provider.EventToolArgs:
234+ sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
235+ case provider.EventToolEnd:
236+ sink(EvToolUseEnd{ID: e.ID})
235237 case provider.EventUsage:
236238 cum := a.cost.Add(e.Usage)
237239 sink(EvUsage{Usage: e.Usage, Cumulative: cum})
@@ -228,10 +228,12 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
228 // nothing228 // nothing
229 case provider.EventTextDelta:229 case provider.EventTextDelta:
230 sink(EvTextDelta{Delta: e.Delta})230 sink(EvTextDelta{Delta: e.Delta})
231- case provider.EventToolStart, provider.EventToolArgs, provider.EventToolEnd:231+ case provider.EventToolStart:
232- // The provider emits these to support partial-JSON UIs.232+ sink(EvToolUseStart{ID: e.ID, Name: e.Name})
233- // We surface the complete tool call once per block at EvToolCall233+ case provider.EventToolArgs:
234- // below (after EventDone), so for now we drop them.234+ sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
235+ case provider.EventToolEnd:
236+ sink(EvToolUseEnd{ID: e.ID})
235 case provider.EventUsage:237 case provider.EventUsage:
236 cum := a.cost.Add(e.Usage)238 cum := a.cost.Add(e.Usage)
237 sink(EvUsage{Usage: e.Usage, Cumulative: cum})239 sink(EvUsage{Usage: e.Usage, Cumulative: cum})
M internal/core/events.go +34 -0
@@ -43,6 +43,40 @@ type EvToolCall struct {
4343
4444 func (EvToolCall) Type() string { return "tool_call" }
4545
46+// EvToolUseStart fires the moment the provider announces a new
47+// tool_use block during streaming, before any arg JSON has
48+// arrived. Gives UIs a hook to pre-render a live "tool is being
49+// composed" panel so the user sees the model typing the call in
50+// real time. Name is already final at this point; Args is empty.
51+type EvToolUseStart struct {
52+ ID string
53+ Name string
54+}
55+
56+func (EvToolUseStart) Type() string { return "tool_use_start" }
57+
58+// EvToolUseArgs fires for each delta fragment of the tool_use
59+// block's argument JSON. Concatenating every delta for a given
60+// ID produces the full JSON string; during streaming it's likely
61+// truncated mid-value. UIs can extract partial string fields
62+// (e.g. the `content` arg of `write`) with an escape-aware scan.
63+type EvToolUseArgs struct {
64+ ID string
65+ Delta string
66+}
67+
68+func (EvToolUseArgs) Type() string { return "tool_use_args" }
69+
70+// EvToolUseEnd fires when the provider marks the tool_use block
71+// complete. At this point the full args JSON is known; a separate
72+// EvToolCall follows once the assistant message is assembled,
73+// carrying the parsed block that actually runs.
74+type EvToolUseEnd struct {
75+ ID string
76+}
77+
78+func (EvToolUseEnd) Type() string { return "tool_use_end" }
79+
4680 type EvToolProgress struct {
4781 ID string
4882 Text string
@@ -43,6 +43,40 @@ type EvToolCall struct {
43 43
44 func (EvToolCall) Type() string { return "tool_call" }44 func (EvToolCall) Type() string { return "tool_call" }
45 45
46+// EvToolUseStart fires the moment the provider announces a new
47+// tool_use block during streaming, before any arg JSON has
48+// arrived. Gives UIs a hook to pre-render a live "tool is being
49+// composed" panel so the user sees the model typing the call in
50+// real time. Name is already final at this point; Args is empty.
51+type EvToolUseStart struct {
52+ ID string
53+ Name string
54+}
55+
56+func (EvToolUseStart) Type() string { return "tool_use_start" }
57+
58+// EvToolUseArgs fires for each delta fragment of the tool_use
59+// block's argument JSON. Concatenating every delta for a given
60+// ID produces the full JSON string; during streaming it's likely
61+// truncated mid-value. UIs can extract partial string fields
62+// (e.g. the `content` arg of `write`) with an escape-aware scan.
63+type EvToolUseArgs struct {
64+ ID string
65+ Delta string
66+}
67+
68+func (EvToolUseArgs) Type() string { return "tool_use_args" }
69+
70+// EvToolUseEnd fires when the provider marks the tool_use block
71+// complete. At this point the full args JSON is known; a separate
72+// EvToolCall follows once the assistant message is assembled,
73+// carrying the parsed block that actually runs.
74+type EvToolUseEnd struct {
75+ ID string
76+}
77+
78+func (EvToolUseEnd) Type() string { return "tool_use_end" }
79+
46 type EvToolProgress struct {80 type EvToolProgress struct {
47 ID string81 ID string
48 Text string82 Text string
A internal/tui/partialjson.go +177 -0
@@ -0,0 +1,177 @@
1+package tui
2+
3+import "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.
20+func 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.
121+func 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+
156+func 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+}
@@ -0,0 +1,177 @@
1+package tui
2+
3+import "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.
20+func 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.
121+func 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+
156+func 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+}
A internal/tui/partialjson_test.go +155 -0
@@ -0,0 +1,155 @@
1+package tui
2+
3+import "testing"
4+
5+func TestExtractLastNewText(t *testing.T) {
6+ cases := []struct {
7+ name string
8+ raw string
9+ wantValue string
10+ wantOK bool
11+ wantDone bool
12+ wantIdx int
13+ }{
14+ {
15+ name: "no newText yet",
16+ raw: `{"path":"/x","edits":[{"oldText":"a"}`,
17+ wantValue: "",
18+ wantOK: false,
19+ wantDone: false,
20+ wantIdx: 0,
21+ },
22+ {
23+ name: "single newText partial",
24+ raw: `{"edits":[{"oldText":"a","newText":"b`,
25+ wantValue: "b",
26+ wantOK: true,
27+ wantDone: false,
28+ wantIdx: 1,
29+ },
30+ {
31+ name: "single newText complete",
32+ raw: `{"edits":[{"oldText":"a","newText":"b"}]`,
33+ wantValue: "b",
34+ wantOK: true,
35+ wantDone: true,
36+ wantIdx: 1,
37+ },
38+ {
39+ name: "two edits, second still streaming",
40+ raw: `{"edits":[{"oldText":"x","newText":"y"},{"oldText":"a","newText":"hello wor`,
41+ wantValue: "hello wor",
42+ wantOK: true,
43+ wantDone: false,
44+ wantIdx: 2,
45+ },
46+ }
47+ for _, c := range cases {
48+ t.Run(c.name, func(t *testing.T) {
49+ v, ok, done, idx := ExtractLastNewText(c.raw)
50+ if v != c.wantValue || ok != c.wantOK || done != c.wantDone || idx != c.wantIdx {
51+ t.Errorf("want (v=%q ok=%v done=%v idx=%d), got (v=%q ok=%v done=%v idx=%d)",
52+ c.wantValue, c.wantOK, c.wantDone, c.wantIdx, v, ok, done, idx)
53+ }
54+ })
55+ }
56+}
57+
58+func TestExtractPartialStringField(t *testing.T) {
59+ cases := []struct {
60+ name string
61+ raw string
62+ field string
63+ wantValue string
64+ wantOK bool
65+ wantDone bool
66+ }{
67+ {
68+ name: "empty buffer",
69+ raw: "",
70+ field: "content",
71+ wantValue: "",
72+ wantOK: false,
73+ wantDone: false,
74+ },
75+ {
76+ name: "no such field",
77+ raw: `{"path":"/x","foo":"bar"}`,
78+ field: "content",
79+ wantValue: "",
80+ wantOK: false,
81+ wantDone: false,
82+ },
83+ {
84+ name: "complete",
85+ raw: `{"path":"/x","content":"hello"}`,
86+ field: "content",
87+ wantValue: "hello",
88+ wantOK: true,
89+ wantDone: true,
90+ },
91+ {
92+ name: "partial mid-word",
93+ raw: `{"path":"/x","content":"hel`,
94+ field: "content",
95+ wantValue: "hel",
96+ wantOK: true,
97+ wantDone: false,
98+ },
99+ {
100+ name: "escaped quote",
101+ raw: `{"content":"say \"hi\""}`,
102+ field: "content",
103+ wantValue: `say "hi"`,
104+ wantOK: true,
105+ wantDone: true,
106+ },
107+ {
108+ name: "escaped newline inside string",
109+ raw: `{"content":"line1\nline2"}`,
110+ field: "content",
111+ wantValue: "line1\nline2",
112+ wantOK: true,
113+ wantDone: true,
114+ },
115+ {
116+ name: "trailing backslash (unfinished escape)",
117+ raw: `{"content":"line1\`,
118+ field: "content",
119+ wantValue: "line1",
120+ wantOK: true,
121+ wantDone: false,
122+ },
123+ {
124+ name: "incomplete unicode escape",
125+ raw: `{"content":"before\u00`,
126+ field: "content",
127+ wantValue: "before",
128+ wantOK: true,
129+ wantDone: false,
130+ },
131+ {
132+ name: "key before value",
133+ raw: `{"content":`,
134+ field: "content",
135+ wantValue: "",
136+ wantOK: false,
137+ wantDone: false,
138+ },
139+ }
140+
141+ for _, c := range cases {
142+ t.Run(c.name, func(t *testing.T) {
143+ v, ok, done := ExtractPartialStringField(c.raw, c.field)
144+ if v != c.wantValue {
145+ t.Errorf("value: want %q, got %q", c.wantValue, v)
146+ }
147+ if ok != c.wantOK {
148+ t.Errorf("ok: want %v, got %v", c.wantOK, ok)
149+ }
150+ if done != c.wantDone {
151+ t.Errorf("done: want %v, got %v", c.wantDone, done)
152+ }
153+ })
154+ }
155+}
@@ -0,0 +1,155 @@
1+package tui
2+
3+import "testing"
4+
5+func TestExtractLastNewText(t *testing.T) {
6+ cases := []struct {
7+ name string
8+ raw string
9+ wantValue string
10+ wantOK bool
11+ wantDone bool
12+ wantIdx int
13+ }{
14+ {
15+ name: "no newText yet",
16+ raw: `{"path":"/x","edits":[{"oldText":"a"}`,
17+ wantValue: "",
18+ wantOK: false,
19+ wantDone: false,
20+ wantIdx: 0,
21+ },
22+ {
23+ name: "single newText partial",
24+ raw: `{"edits":[{"oldText":"a","newText":"b`,
25+ wantValue: "b",
26+ wantOK: true,
27+ wantDone: false,
28+ wantIdx: 1,
29+ },
30+ {
31+ name: "single newText complete",
32+ raw: `{"edits":[{"oldText":"a","newText":"b"}]`,
33+ wantValue: "b",
34+ wantOK: true,
35+ wantDone: true,
36+ wantIdx: 1,
37+ },
38+ {
39+ name: "two edits, second still streaming",
40+ raw: `{"edits":[{"oldText":"x","newText":"y"},{"oldText":"a","newText":"hello wor`,
41+ wantValue: "hello wor",
42+ wantOK: true,
43+ wantDone: false,
44+ wantIdx: 2,
45+ },
46+ }
47+ for _, c := range cases {
48+ t.Run(c.name, func(t *testing.T) {
49+ v, ok, done, idx := ExtractLastNewText(c.raw)
50+ if v != c.wantValue || ok != c.wantOK || done != c.wantDone || idx != c.wantIdx {
51+ t.Errorf("want (v=%q ok=%v done=%v idx=%d), got (v=%q ok=%v done=%v idx=%d)",
52+ c.wantValue, c.wantOK, c.wantDone, c.wantIdx, v, ok, done, idx)
53+ }
54+ })
55+ }
56+}
57+
58+func TestExtractPartialStringField(t *testing.T) {
59+ cases := []struct {
60+ name string
61+ raw string
62+ field string
63+ wantValue string
64+ wantOK bool
65+ wantDone bool
66+ }{
67+ {
68+ name: "empty buffer",
69+ raw: "",
70+ field: "content",
71+ wantValue: "",
72+ wantOK: false,
73+ wantDone: false,
74+ },
75+ {
76+ name: "no such field",
77+ raw: `{"path":"/x","foo":"bar"}`,
78+ field: "content",
79+ wantValue: "",
80+ wantOK: false,
81+ wantDone: false,
82+ },
83+ {
84+ name: "complete",
85+ raw: `{"path":"/x","content":"hello"}`,
86+ field: "content",
87+ wantValue: "hello",
88+ wantOK: true,
89+ wantDone: true,
90+ },
91+ {
92+ name: "partial mid-word",
93+ raw: `{"path":"/x","content":"hel`,
94+ field: "content",
95+ wantValue: "hel",
96+ wantOK: true,
97+ wantDone: false,
98+ },
99+ {
100+ name: "escaped quote",
101+ raw: `{"content":"say \"hi\""}`,
102+ field: "content",
103+ wantValue: `say "hi"`,
104+ wantOK: true,
105+ wantDone: true,
106+ },
107+ {
108+ name: "escaped newline inside string",
109+ raw: `{"content":"line1\nline2"}`,
110+ field: "content",
111+ wantValue: "line1\nline2",
112+ wantOK: true,
113+ wantDone: true,
114+ },
115+ {
116+ name: "trailing backslash (unfinished escape)",
117+ raw: `{"content":"line1\`,
118+ field: "content",
119+ wantValue: "line1",
120+ wantOK: true,
121+ wantDone: false,
122+ },
123+ {
124+ name: "incomplete unicode escape",
125+ raw: `{"content":"before\u00`,
126+ field: "content",
127+ wantValue: "before",
128+ wantOK: true,
129+ wantDone: false,
130+ },
131+ {
132+ name: "key before value",
133+ raw: `{"content":`,
134+ field: "content",
135+ wantValue: "",
136+ wantOK: false,
137+ wantDone: false,
138+ },
139+ }
140+
141+ for _, c := range cases {
142+ t.Run(c.name, func(t *testing.T) {
143+ v, ok, done := ExtractPartialStringField(c.raw, c.field)
144+ if v != c.wantValue {
145+ t.Errorf("value: want %q, got %q", c.wantValue, v)
146+ }
147+ if ok != c.wantOK {
148+ t.Errorf("ok: want %v, got %v", c.wantOK, ok)
149+ }
150+ if done != c.wantDone {
151+ t.Errorf("done: want %v, got %v", c.wantDone, done)
152+ }
153+ })
154+ }
155+}
M internal/tui/view.go +86 -1
@@ -130,6 +130,24 @@ type ToolCallView struct {
130130 Result string // rendered result preview (truncated)
131131 Error bool
132132 Done bool
133+
134+ // Streaming is true while the model is still typing the tool
135+ // call's JSON arguments. The TUI renders a live preview of any
136+ // interesting string fields (for `write`, the `content`; for
137+ // `bash`, the `command`) so the user can watch the file being
138+ // composed. Set to false as soon as EvToolUseEnd arrives.
139+ Streaming bool
140+
141+ // RawJSONBuf is the accumulator of every EvToolUseArgs delta
142+ // the stream has delivered for this tool call. Used by the
143+ // partial-JSON extractor to peel off the live string value
144+ // of one named field on each render.
145+ RawJSONBuf string
146+
147+ // LivePath is the `path` arg extracted as soon as it parses
148+ // out of RawJSONBuf. Shown next to the tool name in the header
149+ // so the user can see which file is being written to.
150+ LivePath string
133151 }
134152
135153 // MessageAnchor records where a rendered message starts in the chat
@@ -408,8 +426,29 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
408426
409427 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
410428 var lines []string
411- head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+tc.Args)
429+
430+ // Header. While the call is still streaming, prefer the live path
431+ // extracted from the partial args so the user sees the target
432+ // file as soon as it's known, even before the full JSON arrived.
433+ arg := tc.Args
434+ if arg == "" && tc.LivePath != "" {
435+ arg = tc.LivePath
436+ }
437+ head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg)
412438 lines = append(lines, head)
439+
440+ // Live streaming body: pulled out of the partial JSON buffer for
441+ // tools whose interesting content is a string field (currently
442+ // write's `content` and edit's `new_text` chunks). Rendered with
443+ // the same rules + highlighter the final result would use, so the
444+ // transition from streaming to result is visually seamless.
445+ if tc.Streaming && tc.Result == "" {
446+ if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
447+ lines = append(lines, body...)
448+ }
449+ return lines
450+ }
451+
413452 if tc.Result != "" {
414453 color := v.Theme.ToolOut
415454 if tc.Error {
@@ -428,6 +467,52 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
428467 return lines
429468 }
430469
470+// renderLiveToolBody renders the in-flight preview of a streaming
471+// tool call. Supported tools:
472+//
473+// - write: shows the partial `content` field, syntax-highlighted
474+// by the target path's language.
475+// - edit: shows the partial `newText` of the edit currently being
476+// streamed, prefixed with a "editing foo.ts (edit 2)" header so
477+// the user can see which of a multi-edit batch is in progress.
478+//
479+// Anything else returns nil and only the tool-call header shows.
480+func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
481+ switch tc.Name {
482+ case "write", "Write":
483+ partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content")
484+ if !ok || partial == "" {
485+ return nil
486+ }
487+ return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width)
488+ case "edit", "Edit":
489+ partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf)
490+ if !ok || partial == "" {
491+ return nil
492+ }
493+ // Header line hints which edit is streaming and, when more
494+ // than one has landed, how many the model is doing.
495+ hint := fmt.Sprintf(" edit %d (streaming)", idx)
496+ body := []string{v.Theme.FG256(v.Theme.Muted, hint), ""}
497+ body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
498+ return v.wrapLiveBody(body, width)
499+ }
500+ return nil
501+}
502+
503+// wrapLiveBody wraps a list of content lines with the standard
504+// tool-result rules (top + bottom), collapsing to the preview height
505+// if the body is tall. Shared between write and edit streaming.
506+func (v *View) wrapLiveBody(body []string, width int) []string {
507+ body = v.collapseToolBody(body, false)
508+ rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
509+ out := make([]string, 0, len(body)+2)
510+ out = append(out, rule)
511+ out = append(out, body...)
512+ out = append(out, rule)
513+ return out
514+}
515+
431516 // toolResultBlock wraps text in thin horizontal rules (top + bottom),
432517 // indenting the body with four spaces. The rules span the content column.
433518 // renderToolResultContent renders the body of a tool result block.
@@ -130,6 +130,24 @@ type ToolCallView struct {
130 Result string // rendered result preview (truncated)130 Result string // rendered result preview (truncated)
131 Error bool131 Error bool
132 Done bool132 Done bool
133+
134+ // Streaming is true while the model is still typing the tool
135+ // call's JSON arguments. The TUI renders a live preview of any
136+ // interesting string fields (for `write`, the `content`; for
137+ // `bash`, the `command`) so the user can watch the file being
138+ // composed. Set to false as soon as EvToolUseEnd arrives.
139+ Streaming bool
140+
141+ // RawJSONBuf is the accumulator of every EvToolUseArgs delta
142+ // the stream has delivered for this tool call. Used by the
143+ // partial-JSON extractor to peel off the live string value
144+ // of one named field on each render.
145+ RawJSONBuf string
146+
147+ // LivePath is the `path` arg extracted as soon as it parses
148+ // out of RawJSONBuf. Shown next to the tool name in the header
149+ // so the user can see which file is being written to.
150+ LivePath string
133 }151 }
134 152
135 // MessageAnchor records where a rendered message starts in the chat153 // MessageAnchor records where a rendered message starts in the chat
@@ -408,8 +426,29 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
408 426
409 func (v *View) renderToolCall(tc ToolCallView, width int) []string {427 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
410 var lines []string428 var lines []string
411- head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+tc.Args)429+
430+ // Header. While the call is still streaming, prefer the live path
431+ // extracted from the partial args so the user sees the target
432+ // file as soon as it's known, even before the full JSON arrived.
433+ arg := tc.Args
434+ if arg == "" && tc.LivePath != "" {
435+ arg = tc.LivePath
436+ }
437+ head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg)
412 lines = append(lines, head)438 lines = append(lines, head)
439+
440+ // Live streaming body: pulled out of the partial JSON buffer for
441+ // tools whose interesting content is a string field (currently
442+ // write's `content` and edit's `new_text` chunks). Rendered with
443+ // the same rules + highlighter the final result would use, so the
444+ // transition from streaming to result is visually seamless.
445+ if tc.Streaming && tc.Result == "" {
446+ if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
447+ lines = append(lines, body...)
448+ }
449+ return lines
450+ }
451+
413 if tc.Result != "" {452 if tc.Result != "" {
414 color := v.Theme.ToolOut453 color := v.Theme.ToolOut
415 if tc.Error {454 if tc.Error {
@@ -428,6 +467,52 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
428 return lines467 return lines
429 }468 }
430 469
470+// renderLiveToolBody renders the in-flight preview of a streaming
471+// tool call. Supported tools:
472+//
473+// - write: shows the partial `content` field, syntax-highlighted
474+// by the target path's language.
475+// - edit: shows the partial `newText` of the edit currently being
476+// streamed, prefixed with a "editing foo.ts (edit 2)" header so
477+// the user can see which of a multi-edit batch is in progress.
478+//
479+// Anything else returns nil and only the tool-call header shows.
480+func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
481+ switch tc.Name {
482+ case "write", "Write":
483+ partial, ok, _ := ExtractPartialStringField(tc.RawJSONBuf, "content")
484+ if !ok || partial == "" {
485+ return nil
486+ }
487+ return v.wrapLiveBody(v.renderRawFile(partial, tc.LivePath, 1), width)
488+ case "edit", "Edit":
489+ partial, ok, _, idx := ExtractLastNewText(tc.RawJSONBuf)
490+ if !ok || partial == "" {
491+ return nil
492+ }
493+ // Header line hints which edit is streaming and, when more
494+ // than one has landed, how many the model is doing.
495+ hint := fmt.Sprintf(" edit %d (streaming)", idx)
496+ body := []string{v.Theme.FG256(v.Theme.Muted, hint), ""}
497+ body = append(body, v.renderRawFile(partial, tc.LivePath, 1)...)
498+ return v.wrapLiveBody(body, width)
499+ }
500+ return nil
501+}
502+
503+// wrapLiveBody wraps a list of content lines with the standard
504+// tool-result rules (top + bottom), collapsing to the preview height
505+// if the body is tall. Shared between write and edit streaming.
506+func (v *View) wrapLiveBody(body []string, width int) []string {
507+ body = v.collapseToolBody(body, false)
508+ rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
509+ out := make([]string, 0, len(body)+2)
510+ out = append(out, rule)
511+ out = append(out, body...)
512+ out = append(out, rule)
513+ return out
514+}
515+
431 // toolResultBlock wraps text in thin horizontal rules (top + bottom),516 // toolResultBlock wraps text in thin horizontal rules (top + bottom),
432 // indenting the body with four spaces. The rules span the content column.517 // indenting the body with four spaces. The rules span the content column.
433 // renderToolResultContent renders the body of a tool result block.518 // renderToolResultContent renders the body of a tool result block.

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