detached 7da9114

Gate live tool rendering behind preceding stream text

commit
7da9114a0532d1d16547d76ad3a3a7c11af98aff
author
patriceckhart · · 2026-06-07 14:58 UTC
parent
63e33d9
signature
unsigned
refs
tag: v0.2.15
2 files · +190 -11
M packages/agent/modes/interactive.go +89 -11
@@ -260,17 +260,28 @@ type Interactive struct {
260260 streamFlushPending bool
261261 toolCalls map[string]*tui.ToolCallView
262262 toolOrder []string
263- statusErr string
264- statusOK string
265- liveBlock []string // live streaming/tool progress rendered outside scrollback
266- helpBlock []string // rendered above the chat when /help was typed
267- cumUsage provider.Usage
268- lastCtxInput int // input_tokens of the most recent turn — approximates current context size
269- busy bool
270- dirty chan struct{}
271- cancelTurn context.CancelFunc
272- scrollOffset int // rows from the bottom; 0 = pinned to latest
273- prevScrollOffset int // last value redraw snapped against; tracks intent
263+ // toolGate records, per tool-call id, how many runes of paced
264+ // assistant text must have drained into `streaming` before that
265+ // tool block may appear. It exists to make stream ordering
266+ // deterministic: a tool call can arrive from the provider while
267+ // the prose that logically precedes it is still being typed out
268+ // by the pacer. Without gating, the tool block would render
269+ // immediately while the intro paragraph keeps filling in below
270+ // it. We snapshot the total expected stream length (already
271+ // streamed + still pending) at the moment the tool starts, and
272+ // hold the block back until the pacer reaches it.
273+ toolGate map[string]int
274+ statusErr string
275+ statusOK string
276+ liveBlock []string // live streaming/tool progress rendered outside scrollback
277+ helpBlock []string // rendered above the chat when /help was typed
278+ cumUsage provider.Usage
279+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
280+ busy bool
281+ dirty chan struct{}
282+ cancelTurn context.CancelFunc
283+ scrollOffset int // rows from the bottom; 0 = pinned to latest
284+ prevScrollOffset int // last value redraw snapped against; tracks intent
274285
275286 // prevChatLen and prevChatCols track the chat buffer's size at the
276287 // last redraw so that when content grows below the user's viewport
@@ -440,6 +451,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
440451 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
441452 rend: renderer,
442453 toolCalls: map[string]*tui.ToolCallView{},
454+ toolGate: map[string]int{},
443455 dirty: make(chan struct{}, 8),
444456 dialog: newLoginDialog(),
445457 modelDialog: newModelDialog(),
@@ -824,6 +836,14 @@ func (i *Interactive) buildChatLocked(cols int) []string {
824836 i.view.ToolCalls = i.view.ToolCalls[:0]
825837 if i.busy {
826838 for _, id := range i.toolOrder {
839+ // Deterministic ordering: a tool block stays hidden until
840+ // the paced assistant text that preceded it has finished
841+ // typing out. toolOrder is append-only in arrival order,
842+ // so once one tool is still gated, every later tool is too,
843+ // so stop here to avoid showing a tool out of sequence.
844+ if !i.toolGateOpenLocked(id) {
845+ break
846+ }
827847 if tc, ok := i.toolCalls[id]; ok {
828848 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
829849 }
@@ -2412,6 +2432,7 @@ func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd strin
24122432 i.cfg.Model = model
24132433 i.toolCalls = map[string]*tui.ToolCallView{}
24142434 i.toolOrder = nil
2435+ i.toolGate = map[string]int{}
24152436 i.helpBlock = nil
24162437 i.parkedTurn = 0
24172438 i.statusErr = ""
@@ -3081,6 +3102,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
30813102 i.mu.Lock()
30823103 i.toolCalls = map[string]*tui.ToolCallView{}
30833104 i.toolOrder = nil
3105+ i.toolGate = map[string]int{}
30843106 i.statusErr = ""
30853107 i.statusOK = ""
30863108 i.helpBlock = nil
@@ -3200,6 +3222,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
32003222 i.mu.Lock()
32013223 i.toolCalls = map[string]*tui.ToolCallView{}
32023224 i.toolOrder = nil
3225+ i.toolGate = map[string]int{}
32033226 i.helpBlock = nil
32043227 i.parkedTurn = 0
32053228 i.statusOK = "cwd " + i.cfg.CWD
@@ -4078,6 +4101,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
40784101 i.lastCtxInput = 0
40794102 i.toolCalls = map[string]*tui.ToolCallView{}
40804103 i.toolOrder = nil
4104+ i.toolGate = map[string]int{}
40814105 i.view.InvalidateRenderCache()
40824106 // Pop queued prompt if any.
40834107 if len(i.queued) > 0 {
@@ -4266,6 +4290,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
42664290 i.streamOn = true
42674291 i.toolCalls = map[string]*tui.ToolCallView{}
42684292 i.toolOrder = nil
4293+ i.toolGate = map[string]int{}
42694294 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
42704295 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
42714296 i.scrollOffset = 0 // jump back to the bottom on new turn
@@ -4533,6 +4558,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
45334558 // will populate fresh entries for this turn's tools.
45344559 i.toolCalls = map[string]*tui.ToolCallView{}
45354560 i.toolOrder = nil
4561+ i.toolGate = map[string]int{}
45364562 case core.EvTextDelta:
45374563 // Buffer into streamPending; the paintPace ticker drains
45384564 // it into i.streaming a few runes at a time for a smooth
@@ -4566,6 +4592,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
45664592 Streaming: true,
45674593 }
45684594 i.toolOrder = append(i.toolOrder, e.ID)
4595+ i.gateToolLocked(e.ID)
45694596 }
45704597 case core.EvToolUseArgs:
45714598 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -4597,6 +4624,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
45974624 Args: tui.ShortArgs(e.Name, e.Args),
45984625 }
45994626 i.toolOrder = append(i.toolOrder, e.ID)
4627+ i.gateToolLocked(e.ID)
46004628 }
46014629 case core.EvToolResult:
46024630 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -5531,6 +5559,55 @@ func (i *Interactive) resetStreamingStateLocked() {
55315559 i.streamPending = i.streamPending[:0]
55325560 i.streamFlushPending = false
55335561 i.streamOn = false
5562+ i.openAllToolGatesLocked()
5563+}
5564+
5565+// openAllToolGatesLocked drops every pending tool gate so that any
5566+// tool registered during this turn renders unconditionally from now
5567+// on. Called when streaming finalizes (the paced text has fully
5568+// drained and `streaming` is about to reset to length 0): without
5569+// this, the gate comparison against a freshly-reset streaming buffer
5570+// would wrongly re-hide tools that had already cleared their gate.
5571+// Must be called with i.mu held.
5572+func (i *Interactive) openAllToolGatesLocked() {
5573+ for id := range i.toolGate {
5574+ i.toolGate[id] = 0
5575+ }
5576+}
5577+
5578+// gateToolLocked records the stream position at which a tool call may
5579+// become visible. The gate is the total length the streaming buffer
5580+// will reach once the pacer has drained everything currently queued
5581+// (already painted + still pending). Holding the tool block back
5582+// until the pacer crosses that mark guarantees the prose emitted
5583+// before the tool call finishes typing out above it, instead of the
5584+// tool block snapping in while the paragraph is still filling in.
5585+//
5586+// We only gate while text is actively streaming. If no stream is in
5587+// flight (gate 0), the tool shows immediately, which is the correct
5588+// behaviour for tool-only turns and replayed sessions. First
5589+// registration wins so a later EvToolCall can't move an existing
5590+// gate. Must be called with i.mu held.
5591+func (i *Interactive) gateToolLocked(id string) {
5592+ if _, ok := i.toolGate[id]; ok {
5593+ return
5594+ }
5595+ if !i.streamOn {
5596+ i.toolGate[id] = 0
5597+ return
5598+ }
5599+ i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
5600+}
5601+
5602+// toolGateOpenLocked reports whether the gated tool block may render
5603+// yet, i.e. the pacer has drained enough text to reach the position
5604+// recorded when the tool call arrived. Must be called with i.mu held.
5605+func (i *Interactive) toolGateOpenLocked(id string) bool {
5606+ gate, ok := i.toolGate[id]
5607+ if !ok || gate == 0 {
5608+ return true
5609+ }
5610+ return i.streaming.Len() >= gate
55345611 }
55355612
55365613 // assistantMessageSideEffects runs the non-visual hooks attached to
@@ -5603,6 +5680,7 @@ func (i *Interactive) runStreamPacer(ctx context.Context) {
56035680 i.streamFlushPending = false
56045681 i.streaming.Reset()
56055682 i.streamOn = false
5683+ i.openAllToolGatesLocked()
56065684 i.mu.Unlock()
56075685 i.invalidate()
56085686 continue
@@ -260,17 +260,28 @@ type Interactive struct {
260 streamFlushPending bool260 streamFlushPending bool
261 toolCalls map[string]*tui.ToolCallView261 toolCalls map[string]*tui.ToolCallView
262 toolOrder []string262 toolOrder []string
263- statusErr string263+ // toolGate records, per tool-call id, how many runes of paced
264- statusOK string264+ // assistant text must have drained into `streaming` before that
265- liveBlock []string // live streaming/tool progress rendered outside scrollback265+ // tool block may appear. It exists to make stream ordering
266- helpBlock []string // rendered above the chat when /help was typed266+ // deterministic: a tool call can arrive from the provider while
267- cumUsage provider.Usage267+ // the prose that logically precedes it is still being typed out
268- lastCtxInput int // input_tokens of the most recent turn — approximates current context size268+ // by the pacer. Without gating, the tool block would render
269- busy bool269+ // immediately while the intro paragraph keeps filling in below
270- dirty chan struct{}270+ // it. We snapshot the total expected stream length (already
271- cancelTurn context.CancelFunc271+ // streamed + still pending) at the moment the tool starts, and
272- scrollOffset int // rows from the bottom; 0 = pinned to latest272+ // hold the block back until the pacer reaches it.
273- prevScrollOffset int // last value redraw snapped against; tracks intent273+ toolGate map[string]int
274+ statusErr string
275+ statusOK string
276+ liveBlock []string // live streaming/tool progress rendered outside scrollback
277+ helpBlock []string // rendered above the chat when /help was typed
278+ cumUsage provider.Usage
279+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
280+ busy bool
281+ dirty chan struct{}
282+ cancelTurn context.CancelFunc
283+ scrollOffset int // rows from the bottom; 0 = pinned to latest
284+ prevScrollOffset int // last value redraw snapped against; tracks intent
274 285
275 // prevChatLen and prevChatCols track the chat buffer's size at the286 // prevChatLen and prevChatCols track the chat buffer's size at the
276 // last redraw so that when content grows below the user's viewport287 // last redraw so that when content grows below the user's viewport
@@ -440,6 +451,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
440 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),451 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
441 rend: renderer,452 rend: renderer,
442 toolCalls: map[string]*tui.ToolCallView{},453 toolCalls: map[string]*tui.ToolCallView{},
454+ toolGate: map[string]int{},
443 dirty: make(chan struct{}, 8),455 dirty: make(chan struct{}, 8),
444 dialog: newLoginDialog(),456 dialog: newLoginDialog(),
445 modelDialog: newModelDialog(),457 modelDialog: newModelDialog(),
@@ -824,6 +836,14 @@ func (i *Interactive) buildChatLocked(cols int) []string {
824 i.view.ToolCalls = i.view.ToolCalls[:0]836 i.view.ToolCalls = i.view.ToolCalls[:0]
825 if i.busy {837 if i.busy {
826 for _, id := range i.toolOrder {838 for _, id := range i.toolOrder {
839+ // Deterministic ordering: a tool block stays hidden until
840+ // the paced assistant text that preceded it has finished
841+ // typing out. toolOrder is append-only in arrival order,
842+ // so once one tool is still gated, every later tool is too,
843+ // so stop here to avoid showing a tool out of sequence.
844+ if !i.toolGateOpenLocked(id) {
845+ break
846+ }
827 if tc, ok := i.toolCalls[id]; ok {847 if tc, ok := i.toolCalls[id]; ok {
828 i.view.ToolCalls = append(i.view.ToolCalls, *tc)848 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
829 }849 }
@@ -2412,6 +2432,7 @@ func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd strin
2412 i.cfg.Model = model2432 i.cfg.Model = model
2413 i.toolCalls = map[string]*tui.ToolCallView{}2433 i.toolCalls = map[string]*tui.ToolCallView{}
2414 i.toolOrder = nil2434 i.toolOrder = nil
2435+ i.toolGate = map[string]int{}
2415 i.helpBlock = nil2436 i.helpBlock = nil
2416 i.parkedTurn = 02437 i.parkedTurn = 0
2417 i.statusErr = ""2438 i.statusErr = ""
@@ -3081,6 +3102,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
3081 i.mu.Lock()3102 i.mu.Lock()
3082 i.toolCalls = map[string]*tui.ToolCallView{}3103 i.toolCalls = map[string]*tui.ToolCallView{}
3083 i.toolOrder = nil3104 i.toolOrder = nil
3105+ i.toolGate = map[string]int{}
3084 i.statusErr = ""3106 i.statusErr = ""
3085 i.statusOK = ""3107 i.statusOK = ""
3086 i.helpBlock = nil3108 i.helpBlock = nil
@@ -3200,6 +3222,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
3200 i.mu.Lock()3222 i.mu.Lock()
3201 i.toolCalls = map[string]*tui.ToolCallView{}3223 i.toolCalls = map[string]*tui.ToolCallView{}
3202 i.toolOrder = nil3224 i.toolOrder = nil
3225+ i.toolGate = map[string]int{}
3203 i.helpBlock = nil3226 i.helpBlock = nil
3204 i.parkedTurn = 03227 i.parkedTurn = 0
3205 i.statusOK = "cwd " + i.cfg.CWD3228 i.statusOK = "cwd " + i.cfg.CWD
@@ -4078,6 +4101,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
4078 i.lastCtxInput = 04101 i.lastCtxInput = 0
4079 i.toolCalls = map[string]*tui.ToolCallView{}4102 i.toolCalls = map[string]*tui.ToolCallView{}
4080 i.toolOrder = nil4103 i.toolOrder = nil
4104+ i.toolGate = map[string]int{}
4081 i.view.InvalidateRenderCache()4105 i.view.InvalidateRenderCache()
4082 // Pop queued prompt if any.4106 // Pop queued prompt if any.
4083 if len(i.queued) > 0 {4107 if len(i.queued) > 0 {
@@ -4266,6 +4290,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
4266 i.streamOn = true4290 i.streamOn = true
4267 i.toolCalls = map[string]*tui.ToolCallView{}4291 i.toolCalls = map[string]*tui.ToolCallView{}
4268 i.toolOrder = nil4292 i.toolOrder = nil
4293+ i.toolGate = map[string]int{}
4269 i.shellBlock = nil // sending a prompt clears any parked shell-escape log4294 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
4270 i.extNotes = nil // ext notes are one-shot; a new prompt clears them4295 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
4271 i.scrollOffset = 0 // jump back to the bottom on new turn4296 i.scrollOffset = 0 // jump back to the bottom on new turn
@@ -4533,6 +4558,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4533 // will populate fresh entries for this turn's tools.4558 // will populate fresh entries for this turn's tools.
4534 i.toolCalls = map[string]*tui.ToolCallView{}4559 i.toolCalls = map[string]*tui.ToolCallView{}
4535 i.toolOrder = nil4560 i.toolOrder = nil
4561+ i.toolGate = map[string]int{}
4536 case core.EvTextDelta:4562 case core.EvTextDelta:
4537 // Buffer into streamPending; the paintPace ticker drains4563 // Buffer into streamPending; the paintPace ticker drains
4538 // it into i.streaming a few runes at a time for a smooth4564 // it into i.streaming a few runes at a time for a smooth
@@ -4566,6 +4592,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4566 Streaming: true,4592 Streaming: true,
4567 }4593 }
4568 i.toolOrder = append(i.toolOrder, e.ID)4594 i.toolOrder = append(i.toolOrder, e.ID)
4595+ i.gateToolLocked(e.ID)
4569 }4596 }
4570 case core.EvToolUseArgs:4597 case core.EvToolUseArgs:
4571 if tc, ok := i.toolCalls[e.ID]; ok {4598 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -4597,6 +4624,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4597 Args: tui.ShortArgs(e.Name, e.Args),4624 Args: tui.ShortArgs(e.Name, e.Args),
4598 }4625 }
4599 i.toolOrder = append(i.toolOrder, e.ID)4626 i.toolOrder = append(i.toolOrder, e.ID)
4627+ i.gateToolLocked(e.ID)
4600 }4628 }
4601 case core.EvToolResult:4629 case core.EvToolResult:
4602 if tc, ok := i.toolCalls[e.ID]; ok {4630 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -5531,6 +5559,55 @@ func (i *Interactive) resetStreamingStateLocked() {
5531 i.streamPending = i.streamPending[:0]5559 i.streamPending = i.streamPending[:0]
5532 i.streamFlushPending = false5560 i.streamFlushPending = false
5533 i.streamOn = false5561 i.streamOn = false
5562+ i.openAllToolGatesLocked()
5563+}
5564+
5565+// openAllToolGatesLocked drops every pending tool gate so that any
5566+// tool registered during this turn renders unconditionally from now
5567+// on. Called when streaming finalizes (the paced text has fully
5568+// drained and `streaming` is about to reset to length 0): without
5569+// this, the gate comparison against a freshly-reset streaming buffer
5570+// would wrongly re-hide tools that had already cleared their gate.
5571+// Must be called with i.mu held.
5572+func (i *Interactive) openAllToolGatesLocked() {
5573+ for id := range i.toolGate {
5574+ i.toolGate[id] = 0
5575+ }
5576+}
5577+
5578+// gateToolLocked records the stream position at which a tool call may
5579+// become visible. The gate is the total length the streaming buffer
5580+// will reach once the pacer has drained everything currently queued
5581+// (already painted + still pending). Holding the tool block back
5582+// until the pacer crosses that mark guarantees the prose emitted
5583+// before the tool call finishes typing out above it, instead of the
5584+// tool block snapping in while the paragraph is still filling in.
5585+//
5586+// We only gate while text is actively streaming. If no stream is in
5587+// flight (gate 0), the tool shows immediately, which is the correct
5588+// behaviour for tool-only turns and replayed sessions. First
5589+// registration wins so a later EvToolCall can't move an existing
5590+// gate. Must be called with i.mu held.
5591+func (i *Interactive) gateToolLocked(id string) {
5592+ if _, ok := i.toolGate[id]; ok {
5593+ return
5594+ }
5595+ if !i.streamOn {
5596+ i.toolGate[id] = 0
5597+ return
5598+ }
5599+ i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
5600+}
5601+
5602+// toolGateOpenLocked reports whether the gated tool block may render
5603+// yet, i.e. the pacer has drained enough text to reach the position
5604+// recorded when the tool call arrived. Must be called with i.mu held.
5605+func (i *Interactive) toolGateOpenLocked(id string) bool {
5606+ gate, ok := i.toolGate[id]
5607+ if !ok || gate == 0 {
5608+ return true
5609+ }
5610+ return i.streaming.Len() >= gate
5534 }5611 }
5535 5612
5536 // assistantMessageSideEffects runs the non-visual hooks attached to5613 // assistantMessageSideEffects runs the non-visual hooks attached to
@@ -5603,6 +5680,7 @@ func (i *Interactive) runStreamPacer(ctx context.Context) {
5603 i.streamFlushPending = false5680 i.streamFlushPending = false
5604 i.streaming.Reset()5681 i.streaming.Reset()
5605 i.streamOn = false5682 i.streamOn = false
5683+ i.openAllToolGatesLocked()
5606 i.mu.Unlock()5684 i.mu.Unlock()
5607 i.invalidate()5685 i.invalidate()
5608 continue5686 continue
A packages/agent/modes/tool_gate_test.go +101 -0
@@ -0,0 +1,101 @@
1+package modes
2+
3+import "testing"
4+
5+func newGateTestInteractive() *Interactive {
6+ return &Interactive{
7+ toolGate: map[string]int{},
8+ dirty: make(chan struct{}, 1),
9+ }
10+}
11+
12+// While paced text is still draining, a tool that arrives mid-stream
13+// must stay gated until the streaming buffer reaches the position
14+// captured when the tool started.
15+func TestToolGateHoldsToolUntilTextDrains(t *testing.T) {
16+ i := newGateTestInteractive()
17+
18+ // Simulate a turn that streamed 10 runes already with 20 more
19+ // queued in the pacer when the tool call arrives.
20+ i.streamOn = true
21+ i.streaming.WriteString("0123456789") // 10 painted
22+ i.streamPending = []rune("01234567890123456789")
23+
24+ i.gateToolLocked("t1")
25+ if got := i.toolGate["t1"]; got != 30 {
26+ t.Fatalf("gate = %d, want 30 (10 painted + 20 pending)", got)
27+ }
28+
29+ if i.toolGateOpenLocked("t1") {
30+ t.Fatal("tool should be gated while only 10/30 runes painted")
31+ }
32+
33+ // Drain halfway: still gated.
34+ i.streaming.WriteString("0123456789") // 20 painted
35+ if i.toolGateOpenLocked("t1") {
36+ t.Fatal("tool should still be gated at 20/30")
37+ }
38+
39+ // Reach the gate: now visible.
40+ i.streaming.WriteString("0123456789") // 30 painted
41+ if !i.toolGateOpenLocked("t1") {
42+ t.Fatal("tool should be visible once streaming reaches the gate")
43+ }
44+}
45+
46+// A tool call on a turn with no active text stream shows immediately.
47+func TestToolGateOpenWhenNoStream(t *testing.T) {
48+ i := newGateTestInteractive()
49+ i.streamOn = false
50+
51+ i.gateToolLocked("t1")
52+ if got := i.toolGate["t1"]; got != 0 {
53+ t.Fatalf("gate = %d, want 0 for non-streaming turn", got)
54+ }
55+ if !i.toolGateOpenLocked("t1") {
56+ t.Fatal("tool should be visible immediately when no text is streaming")
57+ }
58+}
59+
60+// First registration wins: a later EvToolCall must not move an
61+// existing gate (e.g. push it forward as more text arrives).
62+func TestToolGateFirstRegistrationWins(t *testing.T) {
63+ i := newGateTestInteractive()
64+ i.streamOn = true
65+ i.streaming.WriteString("01234") // 5
66+ i.streamPending = []rune("01234")
67+
68+ i.gateToolLocked("t1") // gate 10
69+ first := i.toolGate["t1"]
70+
71+ // More text queues up, then the same tool is re-registered.
72+ i.streamPending = append(i.streamPending, []rune("567890")...)
73+ i.gateToolLocked("t1")
74+
75+ if i.toolGate["t1"] != first {
76+ t.Fatalf("gate moved from %d to %d; first registration must win", first, i.toolGate["t1"])
77+ }
78+}
79+
80+// Once streaming finalizes, the buffer resets to length 0; gates that
81+// were already satisfied must not re-hide their tools.
82+func TestOpenAllToolGatesSurvivesStreamReset(t *testing.T) {
83+ i := newGateTestInteractive()
84+ i.streamOn = true
85+ i.streaming.WriteString("0123456789")
86+ i.gateToolLocked("t1") // gate 10, satisfied
87+
88+ if !i.toolGateOpenLocked("t1") {
89+ t.Fatal("precondition: tool should be open before reset")
90+ }
91+
92+ // Finalize the stream (mirrors resetStreamingStateLocked / pacer
93+ // flush): buffer resets to 0 but gates are opened first.
94+ i.openAllToolGatesLocked()
95+ i.streaming.Reset()
96+ i.streamOn = false
97+
98+ if !i.toolGateOpenLocked("t1") {
99+ t.Fatal("tool re-hidden after stream reset; gate should have been opened")
100+ }
101+}
@@ -0,0 +1,101 @@
1+package modes
2+
3+import "testing"
4+
5+func newGateTestInteractive() *Interactive {
6+ return &Interactive{
7+ toolGate: map[string]int{},
8+ dirty: make(chan struct{}, 1),
9+ }
10+}
11+
12+// While paced text is still draining, a tool that arrives mid-stream
13+// must stay gated until the streaming buffer reaches the position
14+// captured when the tool started.
15+func TestToolGateHoldsToolUntilTextDrains(t *testing.T) {
16+ i := newGateTestInteractive()
17+
18+ // Simulate a turn that streamed 10 runes already with 20 more
19+ // queued in the pacer when the tool call arrives.
20+ i.streamOn = true
21+ i.streaming.WriteString("0123456789") // 10 painted
22+ i.streamPending = []rune("01234567890123456789")
23+
24+ i.gateToolLocked("t1")
25+ if got := i.toolGate["t1"]; got != 30 {
26+ t.Fatalf("gate = %d, want 30 (10 painted + 20 pending)", got)
27+ }
28+
29+ if i.toolGateOpenLocked("t1") {
30+ t.Fatal("tool should be gated while only 10/30 runes painted")
31+ }
32+
33+ // Drain halfway: still gated.
34+ i.streaming.WriteString("0123456789") // 20 painted
35+ if i.toolGateOpenLocked("t1") {
36+ t.Fatal("tool should still be gated at 20/30")
37+ }
38+
39+ // Reach the gate: now visible.
40+ i.streaming.WriteString("0123456789") // 30 painted
41+ if !i.toolGateOpenLocked("t1") {
42+ t.Fatal("tool should be visible once streaming reaches the gate")
43+ }
44+}
45+
46+// A tool call on a turn with no active text stream shows immediately.
47+func TestToolGateOpenWhenNoStream(t *testing.T) {
48+ i := newGateTestInteractive()
49+ i.streamOn = false
50+
51+ i.gateToolLocked("t1")
52+ if got := i.toolGate["t1"]; got != 0 {
53+ t.Fatalf("gate = %d, want 0 for non-streaming turn", got)
54+ }
55+ if !i.toolGateOpenLocked("t1") {
56+ t.Fatal("tool should be visible immediately when no text is streaming")
57+ }
58+}
59+
60+// First registration wins: a later EvToolCall must not move an
61+// existing gate (e.g. push it forward as more text arrives).
62+func TestToolGateFirstRegistrationWins(t *testing.T) {
63+ i := newGateTestInteractive()
64+ i.streamOn = true
65+ i.streaming.WriteString("01234") // 5
66+ i.streamPending = []rune("01234")
67+
68+ i.gateToolLocked("t1") // gate 10
69+ first := i.toolGate["t1"]
70+
71+ // More text queues up, then the same tool is re-registered.
72+ i.streamPending = append(i.streamPending, []rune("567890")...)
73+ i.gateToolLocked("t1")
74+
75+ if i.toolGate["t1"] != first {
76+ t.Fatalf("gate moved from %d to %d; first registration must win", first, i.toolGate["t1"])
77+ }
78+}
79+
80+// Once streaming finalizes, the buffer resets to length 0; gates that
81+// were already satisfied must not re-hide their tools.
82+func TestOpenAllToolGatesSurvivesStreamReset(t *testing.T) {
83+ i := newGateTestInteractive()
84+ i.streamOn = true
85+ i.streaming.WriteString("0123456789")
86+ i.gateToolLocked("t1") // gate 10, satisfied
87+
88+ if !i.toolGateOpenLocked("t1") {
89+ t.Fatal("precondition: tool should be open before reset")
90+ }
91+
92+ // Finalize the stream (mirrors resetStreamingStateLocked / pacer
93+ // flush): buffer resets to 0 but gates are opened first.
94+ i.openAllToolGatesLocked()
95+ i.streaming.Reset()
96+ i.streamOn = false
97+
98+ if !i.toolGateOpenLocked("t1") {
99+ t.Fatal("tool re-hidden after stream reset; gate should have been opened")
100+ }
101+}

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 477

↑↓ moveenter openesc close