detached 761f038

Merge pull request #41 from bnema/feat/fact-driven-activity

feat(modes): show fact-driven activity
commit
761f0382bfa69be9f988e0e0fd50d996569c6d99
author
bnema · · 2026-08-06 08:00 UTC
committer
GitHub · · 2026-08-06 08:00 UTC
merge of
42a5079 + c2e9fac · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.0
41 files · +1227 -205
M docs/rpc.md +12 -2
@@ -192,10 +192,16 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
192192 | `type` | Fields | Meaning |
193193 |---|---|---|
194194 | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) |
195+| `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started |
196+| `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer |
195197 | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript |
196-| `assistant_start` | (none) | About to receive assistant streaming |
198+| `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming |
197199 | `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply |
198-| `tool_call` | `id`, `name`, `args` | The model wants to call a tool |
200+| `tool_use_start` | `id`, `name` | Provider started streaming a tool call |
201+| `tool_use_args` | `id`, `delta` | Partial tool-call argument JSON |
202+| `tool_use_end` | `id` | Provider completed the tool-call argument stream |
203+| `tool_call` | `id`, `name`, `args` | The model proposed a tool call |
204+| `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute |
199205 | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |
200206 | `tool_result` | `id`, `is_error`, `content` | Tool finished |
201207 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
@@ -209,6 +215,10 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
209215 | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. |
210216 | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. |
211217
218+A `compact` request emits request-lifecycle, `assistant_start`, and summary
219+`text_delta` events before `compact_done`. It does not emit model-loop turn or
220+tool events.
221+
212222 ## Message shape
213223
214224 Used by `get_messages` and inside `user_message` / `assistant_message` events.
@@ -192,10 +192,16 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
192 | `type` | Fields | Meaning |192 | `type` | Fields | Meaning |
193 |---|---|---|193 |---|---|---|
194 | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) |194 | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) |
195+| `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started |
196+| `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer |
195 | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript |197 | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript |
196-| `assistant_start` | (none) | About to receive assistant streaming |198+| `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming |
197 | `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply |199 | `text_delta` | `delta` | Partial assistant text. Concatenate to build the full reply |
198-| `tool_call` | `id`, `name`, `args` | The model wants to call a tool |200+| `tool_use_start` | `id`, `name` | Provider started streaming a tool call |
201+| `tool_use_args` | `id`, `delta` | Partial tool-call argument JSON |
202+| `tool_use_end` | `id` | Provider completed the tool-call argument stream |
203+| `tool_call` | `id`, `name`, `args` | The model proposed a tool call |
204+| `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute |
199 | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |205 | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |
200 | `tool_result` | `id`, `is_error`, `content` | Tool finished |206 | `tool_result` | `id`, `is_error`, `content` | Tool finished |
201 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |207 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
@@ -209,6 +215,10 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
209 | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. |215 | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. |
210 | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. |216 | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. |
211 217
218+A `compact` request emits request-lifecycle, `assistant_start`, and summary
219+`text_delta` events before `compact_done`. It does not emit model-loop turn or
220+tool events.
221+
212 ## Message shape222 ## Message shape
213 223
214 Used by `get_messages` and inside `user_message` / `assistant_message` events.224 Used by `get_messages` and inside `user_message` / `assistant_message` events.
M docs/themes.md +8 -6
@@ -105,7 +105,6 @@ Spinner-only:
105105 "name": "custom-spinner",
106106 "description": "Only changes the busy spinner.",
107107 "spinner_frames": ["◢", "◣", "◤", "◥"],
108- "spinner_messages": ["working"],
109108 "spinner_interval_ms": 120
110109 }
111110 ```
@@ -117,11 +116,10 @@ light default. The inverse also works.
117116 ```json
118117 {
119118 "name": "custom-spinner",
120- "description": "An alternative spinner for zut that only displays a single spinner text.",
119+ "description": "An alternative busy-spinner animation for zut.",
121120 "colors": {
122121 "dark": {
123122 "spinner_frames": ["◢", "◣", "◤", "◥"],
124- "spinner_messages": ["working"],
125123 "spinner_interval_ms": 120
126124 }
127125 }
@@ -157,7 +155,6 @@ All fields are optional.
157155 "selection_bg": 24,
158156 "selection_fg": 231,
159157 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
160- "spinner_messages": ["thinking", "working"],
161158 "spinner_interval_ms": 80,
162159 "syntax_base_style": "monokai",
163160 "syntax": {
@@ -198,7 +195,7 @@ You may also put overrides directly at the top level or directly under
198195 "name": "tiny",
199196 "accent": 204,
200197 "colors": {
201- "spinner_messages": ["shipping"]
198+ "spinner_frames": ["◐", "◓", "◑", "◒"]
202199 }
203200 }
204201 ```
@@ -249,10 +246,15 @@ Spinner settings can appear at top level, under `colors`, or under
249246
250247 - `spinner_frames` — list of frame strings. Single-cell glyphs keep
251248 status-bar alignment clean.
252-- `spinner_messages` — list of messages; zut picks one per turn.
253249 - `spinner_interval_ms` — frame interval in milliseconds. Missing or
254250 invalid falls back to 80ms.
255251
252+Working-status text is owned by zut and reflects the current operation. The
253+legacy `spinner_messages` field is ignored when present in existing theme
254+files. In this v0 API, the former `tui.Theme.SpinnerMessages` and
255+`tui.ThemeOverrides.SpinnerMessages` Go fields are removed; no compatibility
256+alias is provided.
257+
256258 ## Syntax fields
257259
258260 Syntax highlighting uses Chroma style entries. Values may include
@@ -105,7 +105,6 @@ Spinner-only:
105 "name": "custom-spinner",105 "name": "custom-spinner",
106 "description": "Only changes the busy spinner.",106 "description": "Only changes the busy spinner.",
107 "spinner_frames": ["◢", "◣", "◤", "◥"],107 "spinner_frames": ["◢", "◣", "◤", "◥"],
108- "spinner_messages": ["working"],
109 "spinner_interval_ms": 120108 "spinner_interval_ms": 120
110 }109 }
111 ```110 ```
@@ -117,11 +116,10 @@ light default. The inverse also works.
117 ```json116 ```json
118 {117 {
119 "name": "custom-spinner",118 "name": "custom-spinner",
120- "description": "An alternative spinner for zut that only displays a single spinner text.",119+ "description": "An alternative busy-spinner animation for zut.",
121 "colors": {120 "colors": {
122 "dark": {121 "dark": {
123 "spinner_frames": ["◢", "◣", "◤", "◥"],122 "spinner_frames": ["◢", "◣", "◤", "◥"],
124- "spinner_messages": ["working"],
125 "spinner_interval_ms": 120123 "spinner_interval_ms": 120
126 }124 }
127 }125 }
@@ -157,7 +155,6 @@ All fields are optional.
157 "selection_bg": 24,155 "selection_bg": 24,
158 "selection_fg": 231,156 "selection_fg": 231,
159 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],157 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
160- "spinner_messages": ["thinking", "working"],
161 "spinner_interval_ms": 80,158 "spinner_interval_ms": 80,
162 "syntax_base_style": "monokai",159 "syntax_base_style": "monokai",
163 "syntax": {160 "syntax": {
@@ -198,7 +195,7 @@ You may also put overrides directly at the top level or directly under
198 "name": "tiny",195 "name": "tiny",
199 "accent": 204,196 "accent": 204,
200 "colors": {197 "colors": {
201- "spinner_messages": ["shipping"]198+ "spinner_frames": ["◐", "◓", "◑", "◒"]
202 }199 }
203 }200 }
204 ```201 ```
@@ -249,10 +246,15 @@ Spinner settings can appear at top level, under `colors`, or under
249 246
250 - `spinner_frames` — list of frame strings. Single-cell glyphs keep247 - `spinner_frames` — list of frame strings. Single-cell glyphs keep
251 status-bar alignment clean.248 status-bar alignment clean.
252-- `spinner_messages` — list of messages; zut picks one per turn.
253 - `spinner_interval_ms` — frame interval in milliseconds. Missing or249 - `spinner_interval_ms` — frame interval in milliseconds. Missing or
254 invalid falls back to 80ms.250 invalid falls back to 80ms.
255 251
252+Working-status text is owned by zut and reflects the current operation. The
253+legacy `spinner_messages` field is ignored when present in existing theme
254+files. In this v0 API, the former `tui.Theme.SpinnerMessages` and
255+`tui.ThemeOverrides.SpinnerMessages` Go fields are removed; no compatibility
256+alias is provided.
257+
256 ## Syntax fields258 ## Syntax fields
257 259
258 Syntax highlighting uses Chroma style entries. Values may include260 Syntax highlighting uses Chroma style entries. Values may include
M examples/themes/custom-spinner.json +1 -4
@@ -1,6 +1,6 @@
11 {
22 "name": "custom-spinner",
3- "description": "An alternative spinner for zut that only displays a single spinner text.",
3+ "description": "An alternative busy-spinner animation for zut.",
44 "colors": {
55 "dark": {
66 "spinner_frames": [
@@ -13,9 +13,6 @@
1313 "\u28EF",
1414 "\u28F7"
1515 ],
16- "spinner_messages": [
17- "working"
18- ],
1916 "spinner_interval_ms": 120
2017 }
2118 }
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "custom-spinner",2 "name": "custom-spinner",
3- "description": "An alternative spinner for zut that only displays a single spinner text.",3+ "description": "An alternative busy-spinner animation for zut.",
4 "colors": {4 "colors": {
5 "dark": {5 "dark": {
6 "spinner_frames": [6 "spinner_frames": [
@@ -13,9 +13,6 @@
13 "\u28EF",13 "\u28EF",
14 "\u28F7"14 "\u28F7"
15 ],15 ],
16- "spinner_messages": [
17- "working"
18- ],
19 "spinner_interval_ms": 12016 "spinner_interval_ms": 120
20 }17 }
21 }18 }
M examples/themes/vercel.json +0 -14
@@ -62,13 +62,6 @@
6262 "\u25d1",
6363 "\u25d2"
6464 ],
65- "spinner_messages": [
66- "shipping pixels",
67- "deploying thoughts",
68- "warming edge cache",
69- "building preview",
70- "checking redirects"
71- ],
7265 "spinner_interval_ms": 120
7366 },
7467 "light": {
@@ -113,13 +106,6 @@
113106 "\u25d1",
114107 "\u25d2"
115108 ],
116- "spinner_messages": [
117- "shipping pixels",
118- "deploying thoughts",
119- "warming edge cache",
120- "building preview",
121- "checking redirects"
122- ],
123109 "spinner_interval_ms": 120
124110 }
125111 }
@@ -62,13 +62,6 @@
62 "\u25d1",62 "\u25d1",
63 "\u25d2"63 "\u25d2"
64 ],64 ],
65- "spinner_messages": [
66- "shipping pixels",
67- "deploying thoughts",
68- "warming edge cache",
69- "building preview",
70- "checking redirects"
71- ],
72 "spinner_interval_ms": 12065 "spinner_interval_ms": 120
73 },66 },
74 "light": {67 "light": {
@@ -113,13 +106,6 @@
113 "\u25d1",106 "\u25d1",
114 "\u25d2"107 "\u25d2"
115 ],108 ],
116- "spinner_messages": [
117- "shipping pixels",
118- "deploying thoughts",
119- "warming edge cache",
120- "building preview",
121- "checking redirects"
122- ],
123 "spinner_interval_ms": 120109 "spinner_interval_ms": 120
124 }110 }
125 }111 }
M packages/agent/cli.go +1 -0
@@ -1492,6 +1492,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14921492 Name: call.Name,
14931493 Summary: core.BuildPreview(effectiveArgs, 120),
14941494 Content: content.String(),
1495+ Origin: call.Origin,
14951496 })
14961497 if !ok {
14971498 return false, reason, nil
@@ -1492,6 +1492,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1492 Name: call.Name,1492 Name: call.Name,
1493 Summary: core.BuildPreview(effectiveArgs, 120),1493 Summary: core.BuildPreview(effectiveArgs, 120),
1494 Content: content.String(),1494 Content: content.String(),
1495+ Origin: call.Origin,
1495 })1496 })
1496 if !ok {1497 if !ok {
1497 return false, reason, nil1498 return false, reason, nil
A packages/agent/modes/activity.go +175 -0
@@ -0,0 +1,175 @@
1+package modes
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "time"
7+ "unicode"
8+
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+// activityKind describes a user-visible operation. It is deliberately local
13+// to interactive modes: public consumers receive factual core events instead
14+// of rendered UI labels.
15+type activityKind string
16+
17+const (
18+ activityIdle activityKind = "idle"
19+ activityPreparingRequest activityKind = "preparing_request"
20+ activitySendingRequest activityKind = "sending_request"
21+ activityWaitingForResponse activityKind = "waiting_for_response"
22+ activityReceivingResponse activityKind = "receiving_response"
23+ activityPreparingTool activityKind = "preparing_tool"
24+ activityAwaitingConfirmation activityKind = "awaiting_confirmation"
25+ activityRunningTool activityKind = "running_tool"
26+ activitySendingToolResults activityKind = "sending_tool_results"
27+ activityRetryingRequest activityKind = "retrying_request"
28+ activityCompactingHistory activityKind = "compacting_history"
29+ activityCondensingHistory activityKind = "condensing_history"
30+ activityRunningShellCommand activityKind = "running_shell_command"
31+)
32+
33+type activity struct {
34+ kind activityKind
35+ provider string
36+ model string
37+ tool string
38+ attempt int
39+ maxTries int
40+ retryIn time.Duration
41+}
42+
43+func (a activity) label() string {
44+ switch a.kind {
45+ case activityPreparingRequest:
46+ return "Preparing request"
47+ case activitySendingRequest:
48+ return "Sending request to " + activityText(a.provider, "provider")
49+ case activityWaitingForResponse:
50+ return "Waiting for " + activityText(a.model, "model") + " to respond"
51+ case activityReceivingResponse:
52+ return "Receiving response from " + activityText(a.model, "model")
53+ case activityPreparingTool:
54+ return "Preparing tool: " + activityText(a.tool, "tool")
55+ case activityAwaitingConfirmation:
56+ return "Waiting for approval: " + activityText(a.tool, "tool")
57+ case activityRunningTool:
58+ return "Running tool: " + activityText(a.tool, "tool")
59+ case activitySendingToolResults:
60+ return "Sending tool results to " + activityText(a.provider, "provider")
61+ case activityRetryingRequest:
62+ if a.attempt <= 0 || a.maxTries <= 0 {
63+ return "Retrying request"
64+ }
65+ return "Retrying request in " + a.retryIn.Round(time.Millisecond).String() + " (attempt " + strconv.Itoa(a.attempt) + "/" + strconv.Itoa(a.maxTries) + ")"
66+ case activityCompactingHistory:
67+ return "Compacting conversation history"
68+ case activityCondensingHistory:
69+ return "Condensing conversation history"
70+ case activityRunningShellCommand:
71+ return "Running shell command"
72+ default:
73+ return ""
74+ }
75+}
76+
77+func activityText(value, fallback string) string {
78+ value = strings.Map(func(r rune) rune {
79+ if unicode.IsControl(r) {
80+ return -1
81+ }
82+ return r
83+ }, value)
84+ value = strings.TrimSpace(value)
85+ if value == "" {
86+ return fallback
87+ }
88+ return value
89+}
90+
91+// agentActivity reduces agent lifecycle facts into one current activity. It
92+// tracks all pending tool calls so a multi-tool round does not claim results
93+// are being sent while another tool still needs to run.
94+type agentActivity struct {
95+ activity
96+ pendingToolCalls map[string]string
97+ toolOrder []string
98+}
99+
100+func newAgentActivity(provider, model string) agentActivity {
101+ return agentActivity{activity: activity{
102+ kind: activityPreparingRequest,
103+ provider: provider,
104+ model: model,
105+ }}
106+}
107+
108+func (a *agentActivity) apply(ev core.AgentEvent) {
109+ if a == nil {
110+ return
111+ }
112+ switch e := ev.(type) {
113+ case core.EvTurnStart:
114+ a.kind = activityPreparingRequest
115+ a.pendingToolCalls = nil
116+ a.toolOrder = nil
117+ case core.EvRequestStarted:
118+ a.kind = activitySendingRequest
119+ if e.Provider != "" {
120+ a.provider = e.Provider
121+ }
122+ if e.Model != "" {
123+ a.model = e.Model
124+ }
125+ case core.EvAssistantStart:
126+ a.kind = activityWaitingForResponse
127+ case core.EvTextDelta:
128+ a.kind = activityReceivingResponse
129+ case core.EvToolUseStart:
130+ a.kind = activityPreparingTool
131+ a.tool = e.Name
132+ case core.EvToolCall:
133+ if a.pendingToolCalls == nil {
134+ a.pendingToolCalls = make(map[string]string)
135+ }
136+ if _, exists := a.pendingToolCalls[e.ID]; !exists {
137+ a.toolOrder = append(a.toolOrder, e.ID)
138+ }
139+ a.pendingToolCalls[e.ID] = e.Name
140+ a.kind = activityPreparingTool
141+ a.tool = e.Name
142+ case core.EvToolExecutionStarted:
143+ a.kind = activityRunningTool
144+ a.tool = e.Name
145+ case core.EvToolResult:
146+ if _, exists := a.pendingToolCalls[e.ID]; !exists {
147+ return
148+ }
149+ delete(a.pendingToolCalls, e.ID)
150+ if len(a.pendingToolCalls) == 0 {
151+ a.kind = activitySendingToolResults
152+ a.tool = ""
153+ return
154+ }
155+ for _, id := range a.toolOrder {
156+ if name, ok := a.pendingToolCalls[id]; ok {
157+ a.kind = activityPreparingTool
158+ a.tool = name
159+ return
160+ }
161+ }
162+ case core.EvRetryScheduled:
163+ // Agent retries discard the preceding partial assistant message.
164+ // Its proposed tool calls cannot run, so they must not affect the
165+ // next successful attempt's batch accounting.
166+ if e.Scope == core.RetryScopeAgent {
167+ a.pendingToolCalls = nil
168+ a.toolOrder = nil
169+ }
170+ a.kind = activityRetryingRequest
171+ a.attempt = e.Attempt
172+ a.maxTries = e.MaxAttempts
173+ a.retryIn = e.Delay
174+ }
175+}
@@ -0,0 +1,175 @@
1+package modes
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "time"
7+ "unicode"
8+
9+ "github.com/bnema/zut/packages/core"
10+)
11+
12+// activityKind describes a user-visible operation. It is deliberately local
13+// to interactive modes: public consumers receive factual core events instead
14+// of rendered UI labels.
15+type activityKind string
16+
17+const (
18+ activityIdle activityKind = "idle"
19+ activityPreparingRequest activityKind = "preparing_request"
20+ activitySendingRequest activityKind = "sending_request"
21+ activityWaitingForResponse activityKind = "waiting_for_response"
22+ activityReceivingResponse activityKind = "receiving_response"
23+ activityPreparingTool activityKind = "preparing_tool"
24+ activityAwaitingConfirmation activityKind = "awaiting_confirmation"
25+ activityRunningTool activityKind = "running_tool"
26+ activitySendingToolResults activityKind = "sending_tool_results"
27+ activityRetryingRequest activityKind = "retrying_request"
28+ activityCompactingHistory activityKind = "compacting_history"
29+ activityCondensingHistory activityKind = "condensing_history"
30+ activityRunningShellCommand activityKind = "running_shell_command"
31+)
32+
33+type activity struct {
34+ kind activityKind
35+ provider string
36+ model string
37+ tool string
38+ attempt int
39+ maxTries int
40+ retryIn time.Duration
41+}
42+
43+func (a activity) label() string {
44+ switch a.kind {
45+ case activityPreparingRequest:
46+ return "Preparing request"
47+ case activitySendingRequest:
48+ return "Sending request to " + activityText(a.provider, "provider")
49+ case activityWaitingForResponse:
50+ return "Waiting for " + activityText(a.model, "model") + " to respond"
51+ case activityReceivingResponse:
52+ return "Receiving response from " + activityText(a.model, "model")
53+ case activityPreparingTool:
54+ return "Preparing tool: " + activityText(a.tool, "tool")
55+ case activityAwaitingConfirmation:
56+ return "Waiting for approval: " + activityText(a.tool, "tool")
57+ case activityRunningTool:
58+ return "Running tool: " + activityText(a.tool, "tool")
59+ case activitySendingToolResults:
60+ return "Sending tool results to " + activityText(a.provider, "provider")
61+ case activityRetryingRequest:
62+ if a.attempt <= 0 || a.maxTries <= 0 {
63+ return "Retrying request"
64+ }
65+ return "Retrying request in " + a.retryIn.Round(time.Millisecond).String() + " (attempt " + strconv.Itoa(a.attempt) + "/" + strconv.Itoa(a.maxTries) + ")"
66+ case activityCompactingHistory:
67+ return "Compacting conversation history"
68+ case activityCondensingHistory:
69+ return "Condensing conversation history"
70+ case activityRunningShellCommand:
71+ return "Running shell command"
72+ default:
73+ return ""
74+ }
75+}
76+
77+func activityText(value, fallback string) string {
78+ value = strings.Map(func(r rune) rune {
79+ if unicode.IsControl(r) {
80+ return -1
81+ }
82+ return r
83+ }, value)
84+ value = strings.TrimSpace(value)
85+ if value == "" {
86+ return fallback
87+ }
88+ return value
89+}
90+
91+// agentActivity reduces agent lifecycle facts into one current activity. It
92+// tracks all pending tool calls so a multi-tool round does not claim results
93+// are being sent while another tool still needs to run.
94+type agentActivity struct {
95+ activity
96+ pendingToolCalls map[string]string
97+ toolOrder []string
98+}
99+
100+func newAgentActivity(provider, model string) agentActivity {
101+ return agentActivity{activity: activity{
102+ kind: activityPreparingRequest,
103+ provider: provider,
104+ model: model,
105+ }}
106+}
107+
108+func (a *agentActivity) apply(ev core.AgentEvent) {
109+ if a == nil {
110+ return
111+ }
112+ switch e := ev.(type) {
113+ case core.EvTurnStart:
114+ a.kind = activityPreparingRequest
115+ a.pendingToolCalls = nil
116+ a.toolOrder = nil
117+ case core.EvRequestStarted:
118+ a.kind = activitySendingRequest
119+ if e.Provider != "" {
120+ a.provider = e.Provider
121+ }
122+ if e.Model != "" {
123+ a.model = e.Model
124+ }
125+ case core.EvAssistantStart:
126+ a.kind = activityWaitingForResponse
127+ case core.EvTextDelta:
128+ a.kind = activityReceivingResponse
129+ case core.EvToolUseStart:
130+ a.kind = activityPreparingTool
131+ a.tool = e.Name
132+ case core.EvToolCall:
133+ if a.pendingToolCalls == nil {
134+ a.pendingToolCalls = make(map[string]string)
135+ }
136+ if _, exists := a.pendingToolCalls[e.ID]; !exists {
137+ a.toolOrder = append(a.toolOrder, e.ID)
138+ }
139+ a.pendingToolCalls[e.ID] = e.Name
140+ a.kind = activityPreparingTool
141+ a.tool = e.Name
142+ case core.EvToolExecutionStarted:
143+ a.kind = activityRunningTool
144+ a.tool = e.Name
145+ case core.EvToolResult:
146+ if _, exists := a.pendingToolCalls[e.ID]; !exists {
147+ return
148+ }
149+ delete(a.pendingToolCalls, e.ID)
150+ if len(a.pendingToolCalls) == 0 {
151+ a.kind = activitySendingToolResults
152+ a.tool = ""
153+ return
154+ }
155+ for _, id := range a.toolOrder {
156+ if name, ok := a.pendingToolCalls[id]; ok {
157+ a.kind = activityPreparingTool
158+ a.tool = name
159+ return
160+ }
161+ }
162+ case core.EvRetryScheduled:
163+ // Agent retries discard the preceding partial assistant message.
164+ // Its proposed tool calls cannot run, so they must not affect the
165+ // next successful attempt's batch accounting.
166+ if e.Scope == core.RetryScopeAgent {
167+ a.pendingToolCalls = nil
168+ a.toolOrder = nil
169+ }
170+ a.kind = activityRetryingRequest
171+ a.attempt = e.Attempt
172+ a.maxTries = e.MaxAttempts
173+ a.retryIn = e.Delay
174+ }
175+}
A packages/agent/modes/activity_test.go +71 -0
@@ -0,0 +1,71 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+func TestAgentActivityDescribesLifecycle(t *testing.T) {
12+ a := newAgentActivity("anthropic", "claude-test")
13+ check := func(want string) {
14+ t.Helper()
15+ if got := a.label(); got != want {
16+ t.Fatalf("label = %q, want %q", got, want)
17+ }
18+ }
19+
20+ check("Preparing request")
21+ a.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
22+ check("Sending request to anthropic")
23+ a.apply(core.EvAssistantStart{})
24+ check("Waiting for claude-test to respond")
25+ a.apply(core.EvTextDelta{Delta: "hello"})
26+ check("Receiving response from claude-test")
27+ a.apply(core.EvToolUseStart{ID: "one", Name: "read"})
28+ check("Preparing tool: read")
29+ a.apply(core.EvToolCall{ID: "one", Name: "read"})
30+ a.apply(core.EvToolCall{ID: "two", Name: "bash"})
31+ a.apply(core.EvToolExecutionStarted{ID: "one", Name: "read"})
32+ check("Running tool: read")
33+ a.apply(core.EvToolResult{ID: "one", Result: core.ToolResult{}})
34+ check("Preparing tool: bash")
35+ a.apply(core.EvToolExecutionStarted{ID: "two", Name: "bash"})
36+ a.apply(core.EvToolResult{ID: "two", Result: core.ToolResult{}})
37+ check("Sending tool results to anthropic")
38+ a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 2 * time.Second})
39+ check("Retrying request in 2s (attempt 2/4)")
40+}
41+
42+func TestActivityLabelsSanitizeAndFallback(t *testing.T) {
43+ a := activity{kind: activitySendingRequest, provider: " \x1b[31mremote\n"}
44+ if got, want := a.label(), "Sending request to [31mremote"; got != want {
45+ t.Fatalf("sanitized label = %q, want %q", got, want)
46+ }
47+ a = activity{kind: activityRunningTool}
48+ if got, want := a.label(), "Running tool: tool"; got != want {
49+ t.Fatalf("fallback label = %q, want %q", got, want)
50+ }
51+}
52+
53+func TestAgentActivityIgnoresUnknownToolResult(t *testing.T) {
54+ a := newAgentActivity("provider", "model")
55+ a.apply(core.EvToolResult{ID: "unknown", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ignored"}}}})
56+ if got, want := a.label(), "Preparing request"; got != want {
57+ t.Fatalf("label = %q, want %q", got, want)
58+ }
59+}
60+
61+func TestAgentActivityClearsDiscardedToolCallsOnAgentRetry(t *testing.T) {
62+ a := newAgentActivity("provider", "model")
63+ a.apply(core.EvToolCall{ID: "discarded", Name: "stale"})
64+ a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 2, Delay: time.Millisecond})
65+ a.apply(core.EvToolCall{ID: "actual", Name: "read"})
66+ a.apply(core.EvToolResult{ID: "actual", Result: core.ToolResult{}})
67+
68+ if got, want := a.label(), "Sending tool results to provider"; got != want {
69+ t.Fatalf("label = %q, want %q", got, want)
70+ }
71+}
@@ -0,0 +1,71 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+func TestAgentActivityDescribesLifecycle(t *testing.T) {
12+ a := newAgentActivity("anthropic", "claude-test")
13+ check := func(want string) {
14+ t.Helper()
15+ if got := a.label(); got != want {
16+ t.Fatalf("label = %q, want %q", got, want)
17+ }
18+ }
19+
20+ check("Preparing request")
21+ a.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
22+ check("Sending request to anthropic")
23+ a.apply(core.EvAssistantStart{})
24+ check("Waiting for claude-test to respond")
25+ a.apply(core.EvTextDelta{Delta: "hello"})
26+ check("Receiving response from claude-test")
27+ a.apply(core.EvToolUseStart{ID: "one", Name: "read"})
28+ check("Preparing tool: read")
29+ a.apply(core.EvToolCall{ID: "one", Name: "read"})
30+ a.apply(core.EvToolCall{ID: "two", Name: "bash"})
31+ a.apply(core.EvToolExecutionStarted{ID: "one", Name: "read"})
32+ check("Running tool: read")
33+ a.apply(core.EvToolResult{ID: "one", Result: core.ToolResult{}})
34+ check("Preparing tool: bash")
35+ a.apply(core.EvToolExecutionStarted{ID: "two", Name: "bash"})
36+ a.apply(core.EvToolResult{ID: "two", Result: core.ToolResult{}})
37+ check("Sending tool results to anthropic")
38+ a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 2 * time.Second})
39+ check("Retrying request in 2s (attempt 2/4)")
40+}
41+
42+func TestActivityLabelsSanitizeAndFallback(t *testing.T) {
43+ a := activity{kind: activitySendingRequest, provider: " \x1b[31mremote\n"}
44+ if got, want := a.label(), "Sending request to [31mremote"; got != want {
45+ t.Fatalf("sanitized label = %q, want %q", got, want)
46+ }
47+ a = activity{kind: activityRunningTool}
48+ if got, want := a.label(), "Running tool: tool"; got != want {
49+ t.Fatalf("fallback label = %q, want %q", got, want)
50+ }
51+}
52+
53+func TestAgentActivityIgnoresUnknownToolResult(t *testing.T) {
54+ a := newAgentActivity("provider", "model")
55+ a.apply(core.EvToolResult{ID: "unknown", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ignored"}}}})
56+ if got, want := a.label(), "Preparing request"; got != want {
57+ t.Fatalf("label = %q, want %q", got, want)
58+ }
59+}
60+
61+func TestAgentActivityClearsDiscardedToolCallsOnAgentRetry(t *testing.T) {
62+ a := newAgentActivity("provider", "model")
63+ a.apply(core.EvToolCall{ID: "discarded", Name: "stale"})
64+ a.apply(core.EvRetryScheduled{Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 2, Delay: time.Millisecond})
65+ a.apply(core.EvToolCall{ID: "actual", Name: "read"})
66+ a.apply(core.EvToolResult{ID: "actual", Result: core.ToolResult{}})
67+
68+ if got, want := a.label(), "Sending tool results to provider"; got != want {
69+ t.Fatalf("label = %q, want %q", got, want)
70+ }
71+}
M packages/agent/modes/btw_dialog.go +74 -23
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "encoding/json"
56 "fmt"
67 "strings"
78 "sync"
@@ -38,13 +39,13 @@ type btwDialog struct {
3839 compactMode bool
3940 lineInput bool
4041 cancel context.CancelFunc
42+ generation uint64
43+ closeHook func()
4144
42- // spin drives the same braille animation + rotating funny-line
43- // shown in the main status bar. Owned by the dialog so its clock
44- // is independent of the main spinner (so re-opening the dialog
45- // always starts fresh and the message doesn't carry over from a
46- // completed main turn).
47- spin *spinner
45+ // spin drives the same braille animation as the main status bar. Its
46+ // activity label is owned by this dialog under d.mu.
47+ spin *spinner
48+ activity agentActivity
4849
4950 // sideAgent owns an isolated transcript seeded from the main
5051 // conversation. It uses the same tools and runtime hooks as the
@@ -68,6 +69,23 @@ func newBtwDialog() *btwDialog {
6869 return &btwDialog{}
6970 }
7071
72+func btwOrigin(generation uint64) string {
73+ return fmt.Sprintf("btw:%d", generation)
74+}
75+
76+func isBtwOrigin(origin string) bool {
77+ return strings.HasPrefix(origin, "btw:")
78+}
79+
80+func (d *btwDialog) setCloseHook(hook func()) {
81+ if d == nil {
82+ return
83+ }
84+ d.mu.Lock()
85+ d.closeHook = hook
86+ d.mu.Unlock()
87+}
88+
7189 // Active reports whether the dialog is visible and consuming keys.
7290 func (d *btwDialog) Active() bool {
7391 if d == nil {
@@ -92,18 +110,25 @@ func (d *btwDialog) Loading() bool {
92110 return d.active && d.loading
93111 }
94112
95-// SetToolPreview attaches the side-effect-free confirmation preview to the
96-// matching side-chat tool box before execution begins.
97-func (d *btwDialog) SetToolPreview(id, summary, content string) bool {
98- if d == nil || id == "" {
113+// enqueueToolConfirmation attaches the side-effect-free confirmation preview
114+// to the matching side-chat tool box and queues its prompt while d.mu is
115+// held. Keeping both actions together prevents Close from advancing the
116+// generation between the ownership check and the confirmation enqueue.
117+func (d *btwDialog) enqueueToolConfirmation(origin, id, summary, content string, enqueue func()) bool {
118+ if d == nil || id == "" || enqueue == nil {
99119 return false
100120 }
101121 d.mu.Lock()
102122 defer d.mu.Unlock()
123+ if !d.active || origin != btwOrigin(d.generation) {
124+ return false
125+ }
103126 for turnIdx := range d.turns {
104127 if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {
105128 tool.Args = summary
106129 tool.Preview = content
130+ d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name}
131+ enqueue()
107132 return true
108133 }
109134 }
@@ -130,6 +155,7 @@ func (d *btwDialog) ToggleToolExpansion() {
130155 // host redraw loop can pick up the update without polling.
131156 func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {
132157 d.mu.Lock()
158+ d.generation++
133159 d.active = true
134160 d.theme = th
135161 d.compactMode = compactMode
@@ -138,12 +164,13 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
138164 d.turns = nil
139165 d.loading = false
140166 d.cancel = nil
167+ d.activity = agentActivity{}
141168 prompt := th.AccentBar(th.Accent)
142169 if lineInput {
143170 prompt = ""
144171 }
145172 d.editor = tui.NewEditor(prompt)
146- d.sideAgent = newBtwAgent(agent, system, model)
173+ d.sideAgent = newBtwAgent(agent, system, model, btwOrigin(d.generation))
147174 d.cwd = cwd
148175 d.mu.Unlock()
149176
@@ -156,18 +183,24 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
156183 // Close hides the dialog. Cancels any in-flight request.
157184 func (d *btwDialog) Close() {
158185 d.mu.Lock()
186+ d.generation++
159187 d.active = false
160188 d.turns = nil
161189 d.editor = nil
162190 d.loading = false
191+ d.activity = agentActivity{}
163192 cancel := d.cancel
164193 d.cancel = nil
165194 d.sideAgent = nil
166195 d.toolView = nil
196+ closeHook := d.closeHook
167197 d.mu.Unlock()
168198 if cancel != nil {
169199 cancel()
170200 }
201+ if closeHook != nil {
202+ closeHook()
203+ }
171204 }
172205
173206 // HandleKey routes a keypress to the dialog. Returns true if the
@@ -247,15 +280,25 @@ func (d *btwDialog) submit(invalidate func()) {
247280 d.spin.Start()
248281 d.turns = append(d.turns, btwTurn{User: question})
249282 turnIdx := len(d.turns) - 1
283+ generation := d.generation
250284
251285 ctx, cancel := context.WithCancel(context.Background())
252286 d.cancel = cancel
253287 agent := d.sideAgent
288+ providerName := ""
289+ model := ""
290+ if agent != nil {
291+ model = agent.Model
292+ if agent.Client != nil {
293+ providerName = agent.Client.Name()
294+ }
295+ }
296+ d.activity = newAgentActivity(providerName, model)
254297 d.mu.Unlock()
255298
256299 go func() {
257300 err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {
258- d.handleAgentEvent(turnIdx, ev)
301+ d.handleAgentEvent(turnIdx, generation, ev)
259302 if invalidate != nil {
260303 invalidate()
261304 }
@@ -264,21 +307,26 @@ func (d *btwDialog) submit(invalidate func()) {
264307 if err != nil && ctx.Err() == nil {
265308 errMsg = err.Error()
266309 }
267- d.completeTurn(turnIdx, errMsg)
310+ d.completeTurn(turnIdx, generation, errMsg)
268311 if invalidate != nil {
269312 invalidate()
270313 }
271314 }()
272315 }
273316
274-func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
317+func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent {
275318 agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())
276319 agent.MaxSteps = main.MaxSteps
277320 agent.Reasoning = main.Reasoning
278321 agent.Temperature = main.Temperature
279322 agent.FastMode = main.FastModeEnabled()
280323 agent.MaxTokens = main.MaxTokens
281- agent.BeforeToolExecute = main.BeforeToolExecute
324+ if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil {
325+ agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
326+ call.Origin = origin
327+ return beforeToolExecute(call)
328+ }
329+ }
282330 agent.BeforeTurn = main.BeforeTurn
283331 agent.BeforeAssistantMessage = main.BeforeAssistantMessage
284332 agent.MaxRetries = main.MaxRetries
@@ -288,13 +336,14 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
288336 return agent
289337 }
290338
291-func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) {
339+func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) {
292340 d.mu.Lock()
293341 defer d.mu.Unlock()
294- if turnIdx < 0 || turnIdx >= len(d.turns) {
342+ if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) {
295343 return
296344 }
297345 turn := &d.turns[turnIdx]
346+ d.activity.apply(ev)
298347 switch e := ev.(type) {
299348 case core.EvTextDelta:
300349 turn.Assistant += e.Delta
@@ -353,14 +402,15 @@ func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView {
353402 }
354403
355404 // completeTurn records an error, if any, and clears the loading state.
356-func (d *btwDialog) completeTurn(idx int, errMsg string) {
405+func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) {
357406 d.mu.Lock()
358407 defer d.mu.Unlock()
359- if idx < 0 || idx >= len(d.turns) {
408+ if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) {
360409 return
361410 }
362411 d.turns[idx].Err = errMsg
363412 d.loading = false
413+ d.activity = agentActivity{}
364414 d.cancel = nil
365415 }
366416
@@ -408,12 +458,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
408458
409459 if d.loading && d.spin != nil {
410460 out = append(out, "")
411- // Match the main chat busy prefix shape: spinner glyph,
412- // rotating funny-line, elapsed seconds, then a muted hint
413- // that esc cancels.
461+ label := d.activity.label()
462+ if label == "" {
463+ label = "Preparing request"
464+ }
414465 prefix := fmt.Sprintf("%s %s - %s",
415466 th.FGColor(th.Assistant, d.spin.Frame()),
416- th.FGColor(th.Assistant, d.spin.Message()),
467+ th.FGColor(th.Assistant, label),
417468 th.FGColor(th.Muted, d.spin.Elapsed().String()),
418469 )
419470 out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)"))
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "fmt"6 "fmt"
6 "strings"7 "strings"
7 "sync"8 "sync"
@@ -38,13 +39,13 @@ type btwDialog struct {
38 compactMode bool39 compactMode bool
39 lineInput bool40 lineInput bool
40 cancel context.CancelFunc41 cancel context.CancelFunc
42+ generation uint64
43+ closeHook func()
41 44
42- // spin drives the same braille animation + rotating funny-line45+ // spin drives the same braille animation as the main status bar. Its
43- // shown in the main status bar. Owned by the dialog so its clock46+ // activity label is owned by this dialog under d.mu.
44- // is independent of the main spinner (so re-opening the dialog47+ spin *spinner
45- // always starts fresh and the message doesn't carry over from a48+ activity agentActivity
46- // completed main turn).
47- spin *spinner
48 49
49 // sideAgent owns an isolated transcript seeded from the main50 // sideAgent owns an isolated transcript seeded from the main
50 // conversation. It uses the same tools and runtime hooks as the51 // conversation. It uses the same tools and runtime hooks as the
@@ -68,6 +69,23 @@ func newBtwDialog() *btwDialog {
68 return &btwDialog{}69 return &btwDialog{}
69 }70 }
70 71
72+func btwOrigin(generation uint64) string {
73+ return fmt.Sprintf("btw:%d", generation)
74+}
75+
76+func isBtwOrigin(origin string) bool {
77+ return strings.HasPrefix(origin, "btw:")
78+}
79+
80+func (d *btwDialog) setCloseHook(hook func()) {
81+ if d == nil {
82+ return
83+ }
84+ d.mu.Lock()
85+ d.closeHook = hook
86+ d.mu.Unlock()
87+}
88+
71 // Active reports whether the dialog is visible and consuming keys.89 // Active reports whether the dialog is visible and consuming keys.
72 func (d *btwDialog) Active() bool {90 func (d *btwDialog) Active() bool {
73 if d == nil {91 if d == nil {
@@ -92,18 +110,25 @@ func (d *btwDialog) Loading() bool {
92 return d.active && d.loading110 return d.active && d.loading
93 }111 }
94 112
95-// SetToolPreview attaches the side-effect-free confirmation preview to the113+// enqueueToolConfirmation attaches the side-effect-free confirmation preview
96-// matching side-chat tool box before execution begins.114+// to the matching side-chat tool box and queues its prompt while d.mu is
97-func (d *btwDialog) SetToolPreview(id, summary, content string) bool {115+// held. Keeping both actions together prevents Close from advancing the
98- if d == nil || id == "" {116+// generation between the ownership check and the confirmation enqueue.
117+func (d *btwDialog) enqueueToolConfirmation(origin, id, summary, content string, enqueue func()) bool {
118+ if d == nil || id == "" || enqueue == nil {
99 return false119 return false
100 }120 }
101 d.mu.Lock()121 d.mu.Lock()
102 defer d.mu.Unlock()122 defer d.mu.Unlock()
123+ if !d.active || origin != btwOrigin(d.generation) {
124+ return false
125+ }
103 for turnIdx := range d.turns {126 for turnIdx := range d.turns {
104 if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {127 if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {
105 tool.Args = summary128 tool.Args = summary
106 tool.Preview = content129 tool.Preview = content
130+ d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name}
131+ enqueue()
107 return true132 return true
108 }133 }
109 }134 }
@@ -130,6 +155,7 @@ func (d *btwDialog) ToggleToolExpansion() {
130 // host redraw loop can pick up the update without polling.155 // host redraw loop can pick up the update without polling.
131 func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {156 func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {
132 d.mu.Lock()157 d.mu.Lock()
158+ d.generation++
133 d.active = true159 d.active = true
134 d.theme = th160 d.theme = th
135 d.compactMode = compactMode161 d.compactMode = compactMode
@@ -138,12 +164,13 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
138 d.turns = nil164 d.turns = nil
139 d.loading = false165 d.loading = false
140 d.cancel = nil166 d.cancel = nil
167+ d.activity = agentActivity{}
141 prompt := th.AccentBar(th.Accent)168 prompt := th.AccentBar(th.Accent)
142 if lineInput {169 if lineInput {
143 prompt = ""170 prompt = ""
144 }171 }
145 d.editor = tui.NewEditor(prompt)172 d.editor = tui.NewEditor(prompt)
146- d.sideAgent = newBtwAgent(agent, system, model)173+ d.sideAgent = newBtwAgent(agent, system, model, btwOrigin(d.generation))
147 d.cwd = cwd174 d.cwd = cwd
148 d.mu.Unlock()175 d.mu.Unlock()
149 176
@@ -156,18 +183,24 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
156 // Close hides the dialog. Cancels any in-flight request.183 // Close hides the dialog. Cancels any in-flight request.
157 func (d *btwDialog) Close() {184 func (d *btwDialog) Close() {
158 d.mu.Lock()185 d.mu.Lock()
186+ d.generation++
159 d.active = false187 d.active = false
160 d.turns = nil188 d.turns = nil
161 d.editor = nil189 d.editor = nil
162 d.loading = false190 d.loading = false
191+ d.activity = agentActivity{}
163 cancel := d.cancel192 cancel := d.cancel
164 d.cancel = nil193 d.cancel = nil
165 d.sideAgent = nil194 d.sideAgent = nil
166 d.toolView = nil195 d.toolView = nil
196+ closeHook := d.closeHook
167 d.mu.Unlock()197 d.mu.Unlock()
168 if cancel != nil {198 if cancel != nil {
169 cancel()199 cancel()
170 }200 }
201+ if closeHook != nil {
202+ closeHook()
203+ }
171 }204 }
172 205
173 // HandleKey routes a keypress to the dialog. Returns true if the206 // HandleKey routes a keypress to the dialog. Returns true if the
@@ -247,15 +280,25 @@ func (d *btwDialog) submit(invalidate func()) {
247 d.spin.Start()280 d.spin.Start()
248 d.turns = append(d.turns, btwTurn{User: question})281 d.turns = append(d.turns, btwTurn{User: question})
249 turnIdx := len(d.turns) - 1282 turnIdx := len(d.turns) - 1
283+ generation := d.generation
250 284
251 ctx, cancel := context.WithCancel(context.Background())285 ctx, cancel := context.WithCancel(context.Background())
252 d.cancel = cancel286 d.cancel = cancel
253 agent := d.sideAgent287 agent := d.sideAgent
288+ providerName := ""
289+ model := ""
290+ if agent != nil {
291+ model = agent.Model
292+ if agent.Client != nil {
293+ providerName = agent.Client.Name()
294+ }
295+ }
296+ d.activity = newAgentActivity(providerName, model)
254 d.mu.Unlock()297 d.mu.Unlock()
255 298
256 go func() {299 go func() {
257 err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {300 err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {
258- d.handleAgentEvent(turnIdx, ev)301+ d.handleAgentEvent(turnIdx, generation, ev)
259 if invalidate != nil {302 if invalidate != nil {
260 invalidate()303 invalidate()
261 }304 }
@@ -264,21 +307,26 @@ func (d *btwDialog) submit(invalidate func()) {
264 if err != nil && ctx.Err() == nil {307 if err != nil && ctx.Err() == nil {
265 errMsg = err.Error()308 errMsg = err.Error()
266 }309 }
267- d.completeTurn(turnIdx, errMsg)310+ d.completeTurn(turnIdx, generation, errMsg)
268 if invalidate != nil {311 if invalidate != nil {
269 invalidate()312 invalidate()
270 }313 }
271 }()314 }()
272 }315 }
273 316
274-func newBtwAgent(main *core.Agent, system, model string) *core.Agent {317+func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent {
275 agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())318 agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())
276 agent.MaxSteps = main.MaxSteps319 agent.MaxSteps = main.MaxSteps
277 agent.Reasoning = main.Reasoning320 agent.Reasoning = main.Reasoning
278 agent.Temperature = main.Temperature321 agent.Temperature = main.Temperature
279 agent.FastMode = main.FastModeEnabled()322 agent.FastMode = main.FastModeEnabled()
280 agent.MaxTokens = main.MaxTokens323 agent.MaxTokens = main.MaxTokens
281- agent.BeforeToolExecute = main.BeforeToolExecute324+ if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil {
325+ agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
326+ call.Origin = origin
327+ return beforeToolExecute(call)
328+ }
329+ }
282 agent.BeforeTurn = main.BeforeTurn330 agent.BeforeTurn = main.BeforeTurn
283 agent.BeforeAssistantMessage = main.BeforeAssistantMessage331 agent.BeforeAssistantMessage = main.BeforeAssistantMessage
284 agent.MaxRetries = main.MaxRetries332 agent.MaxRetries = main.MaxRetries
@@ -288,13 +336,14 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
288 return agent336 return agent
289 }337 }
290 338
291-func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) {339+func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) {
292 d.mu.Lock()340 d.mu.Lock()
293 defer d.mu.Unlock()341 defer d.mu.Unlock()
294- if turnIdx < 0 || turnIdx >= len(d.turns) {342+ if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) {
295 return343 return
296 }344 }
297 turn := &d.turns[turnIdx]345 turn := &d.turns[turnIdx]
346+ d.activity.apply(ev)
298 switch e := ev.(type) {347 switch e := ev.(type) {
299 case core.EvTextDelta:348 case core.EvTextDelta:
300 turn.Assistant += e.Delta349 turn.Assistant += e.Delta
@@ -353,14 +402,15 @@ func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView {
353 }402 }
354 403
355 // completeTurn records an error, if any, and clears the loading state.404 // completeTurn records an error, if any, and clears the loading state.
356-func (d *btwDialog) completeTurn(idx int, errMsg string) {405+func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) {
357 d.mu.Lock()406 d.mu.Lock()
358 defer d.mu.Unlock()407 defer d.mu.Unlock()
359- if idx < 0 || idx >= len(d.turns) {408+ if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) {
360 return409 return
361 }410 }
362 d.turns[idx].Err = errMsg411 d.turns[idx].Err = errMsg
363 d.loading = false412 d.loading = false
413+ d.activity = agentActivity{}
364 d.cancel = nil414 d.cancel = nil
365 }415 }
366 416
@@ -408,12 +458,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
408 458
409 if d.loading && d.spin != nil {459 if d.loading && d.spin != nil {
410 out = append(out, "")460 out = append(out, "")
411- // Match the main chat busy prefix shape: spinner glyph,461+ label := d.activity.label()
412- // rotating funny-line, elapsed seconds, then a muted hint462+ if label == "" {
413- // that esc cancels.463+ label = "Preparing request"
464+ }
414 prefix := fmt.Sprintf("%s %s - %s",465 prefix := fmt.Sprintf("%s %s - %s",
415 th.FGColor(th.Assistant, d.spin.Frame()),466 th.FGColor(th.Assistant, d.spin.Frame()),
416- th.FGColor(th.Assistant, d.spin.Message()),467+ th.FGColor(th.Assistant, label),
417 th.FGColor(th.Muted, d.spin.Elapsed().String()),468 th.FGColor(th.Muted, d.spin.Elapsed().String()),
418 )469 )
419 out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)"))470 out = append(out, " "+prefix+" "+th.FGColor(th.Muted, "(esc cancels)"))
M packages/agent/modes/btw_dialog_test.go +124 -1
@@ -128,8 +128,10 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
128128 Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},
129129 }})
130130 var policyCalls atomic.Int32
131- main.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
131+ var policyOrigin string
132+ main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
132133 policyCalls.Add(1)
134+ policyOrigin = call.Origin
133135 return true, "", nil
134136 }
135137
@@ -155,6 +157,9 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
155157 if got := policyCalls.Load(); got != 1 {
156158 t.Fatalf("main agent tool-policy calls = %d, want 1", got)
157159 }
160+ if policyOrigin != btwOrigin(1) {
161+ t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1))
162+ }
158163
159164 client.mu.Lock()
160165 requests := append([]provider.Request(nil), client.requests...)
@@ -201,3 +206,121 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
201206 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
202207 }
203208 }
209+
210+func TestBtwActivityRendersFactDrivenLabel(t *testing.T) {
211+ d := &btwDialog{
212+ active: true,
213+ loading: true,
214+ theme: tui.Dark,
215+ toolView: &tui.View{Theme: tui.Dark},
216+ spin: newSpinner(tui.Dark),
217+ activity: newAgentActivity("anthropic", "claude-test"),
218+ }
219+ d.spin.Start()
220+ d.activity.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
221+
222+ rendered := strings.Join(d.Render(tui.Dark, 80), "\n")
223+ if !strings.Contains(rendered, "Sending request to anthropic") {
224+ t.Fatalf("/btw activity = %q, want factual request label", rendered)
225+ }
226+ if strings.Contains(rendered, "reticulating splines") || strings.Contains(rendered, "thinking") {
227+ t.Fatalf("/btw activity retained random spinner wording: %q", rendered)
228+ }
229+}
230+
231+func TestBtwDialogIgnoresCallbacksFromPreviousGeneration(t *testing.T) {
232+ d := &btwDialog{
233+ active: true,
234+ loading: true,
235+ generation: 2,
236+ turns: []btwTurn{{User: "new question"}},
237+ activity: newAgentActivity("provider", "model"),
238+ }
239+
240+ d.handleAgentEvent(0, 1, core.EvTextDelta{Delta: "stale response"})
241+ d.completeTurn(0, 1, "stale error")
242+
243+ d.mu.Lock()
244+ defer d.mu.Unlock()
245+ if d.turns[0].Assistant != "" || d.turns[0].Err != "" {
246+ t.Fatalf("stale callback changed reopened turn: %#v", d.turns[0])
247+ }
248+ if !d.loading {
249+ t.Fatal("stale completion cleared the reopened dialog loading state")
250+ }
251+}
252+
253+func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) {
254+ client := &btwToolTestClient{done: make(chan struct{})}
255+ tool := &btwEchoTool{}
256+ main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
257+ d := newBtwDialog()
258+ i := &Interactive{
259+ btwDialog: d,
260+ confirmDialog: newConfirmDialog(),
261+ dirty: make(chan struct{}, 1),
262+ }
263+ gate := core.NewConfirmGate(i)
264+ mainResp := make(chan core.ConfirmDecision, 1)
265+ i.confirmDialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp})
266+ i.confirmDialog.Blur()
267+ main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
268+ return gate.CheckToolCall(core.ToolCallConfirmation{
269+ ID: call.ID,
270+ Name: call.Name,
271+ Summary: "hello",
272+ Origin: call.Origin,
273+ })
274+ }
275+
276+ d.Open(tui.Dark, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
277+ sideConfirmationFirst := func() bool {
278+ i.confirmDialog.mu.Lock()
279+ defer i.confirmDialog.mu.Unlock()
280+ return len(i.confirmDialog.pending) > 0 && i.confirmDialog.pending[0].toolName == "echo"
281+ }
282+ deadline := time.Now().Add(2 * time.Second)
283+ for !sideConfirmationFirst() && time.Now().Before(deadline) {
284+ time.Sleep(time.Millisecond)
285+ }
286+ if !sideConfirmationFirst() {
287+ t.Fatal("side-agent tool confirmation did not reach the front of the interactive confirmation queue")
288+ }
289+ if !i.confirmDialog.Focused() {
290+ t.Fatal("side-agent confirmation did not focus the prioritized dialog")
291+ }
292+
293+ d.mu.Lock()
294+ if len(d.turns) != 1 || len(d.turns[0].Tools) != 1 {
295+ d.mu.Unlock()
296+ t.Fatalf("side-agent tool state = %#v, want one live tool", d.turns)
297+ }
298+ if got, want := d.turns[0].Tools[0].Args, "hello"; got != want {
299+ d.mu.Unlock()
300+ t.Fatalf("side-agent confirmation summary = %q, want %q", got, want)
301+ }
302+ if got, want := d.activity.label(), "Waiting for approval: echo"; got != want {
303+ d.mu.Unlock()
304+ t.Fatalf("side-agent activity = %q, want %q", got, want)
305+ }
306+ d.mu.Unlock()
307+
308+ i.confirmDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
309+ select {
310+ case <-client.done:
311+ case <-time.After(2 * time.Second):
312+ t.Fatal("side-agent did not continue after confirmation")
313+ }
314+ if got := tool.runs.Load(); got != 1 {
315+ t.Fatalf("tool executions = %d, want 1 after confirmation", got)
316+ }
317+ if i.confirmDialog.Focused() {
318+ t.Fatal("main confirmation regained focus instead of returning input to /btw")
319+ }
320+ i.confirmDialog.CancelAll("test complete")
321+ select {
322+ case <-mainResp:
323+ default:
324+ t.Fatal("main confirmation was not retained after resolving the side-chat call")
325+ }
326+}
@@ -128,8 +128,10 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
128 Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},128 Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},
129 }})129 }})
130 var policyCalls atomic.Int32130 var policyCalls atomic.Int32
131- main.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {131+ var policyOrigin string
132+ main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
132 policyCalls.Add(1)133 policyCalls.Add(1)
134+ policyOrigin = call.Origin
133 return true, "", nil135 return true, "", nil
134 }136 }
135 137
@@ -155,6 +157,9 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
155 if got := policyCalls.Load(); got != 1 {157 if got := policyCalls.Load(); got != 1 {
156 t.Fatalf("main agent tool-policy calls = %d, want 1", got)158 t.Fatalf("main agent tool-policy calls = %d, want 1", got)
157 }159 }
160+ if policyOrigin != btwOrigin(1) {
161+ t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1))
162+ }
158 163
159 client.mu.Lock()164 client.mu.Lock()
160 requests := append([]provider.Request(nil), client.requests...)165 requests := append([]provider.Request(nil), client.requests...)
@@ -201,3 +206,121 @@ func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T)
201 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)206 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
202 }207 }
203 }208 }
209+
210+func TestBtwActivityRendersFactDrivenLabel(t *testing.T) {
211+ d := &btwDialog{
212+ active: true,
213+ loading: true,
214+ theme: tui.Dark,
215+ toolView: &tui.View{Theme: tui.Dark},
216+ spin: newSpinner(tui.Dark),
217+ activity: newAgentActivity("anthropic", "claude-test"),
218+ }
219+ d.spin.Start()
220+ d.activity.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
221+
222+ rendered := strings.Join(d.Render(tui.Dark, 80), "\n")
223+ if !strings.Contains(rendered, "Sending request to anthropic") {
224+ t.Fatalf("/btw activity = %q, want factual request label", rendered)
225+ }
226+ if strings.Contains(rendered, "reticulating splines") || strings.Contains(rendered, "thinking") {
227+ t.Fatalf("/btw activity retained random spinner wording: %q", rendered)
228+ }
229+}
230+
231+func TestBtwDialogIgnoresCallbacksFromPreviousGeneration(t *testing.T) {
232+ d := &btwDialog{
233+ active: true,
234+ loading: true,
235+ generation: 2,
236+ turns: []btwTurn{{User: "new question"}},
237+ activity: newAgentActivity("provider", "model"),
238+ }
239+
240+ d.handleAgentEvent(0, 1, core.EvTextDelta{Delta: "stale response"})
241+ d.completeTurn(0, 1, "stale error")
242+
243+ d.mu.Lock()
244+ defer d.mu.Unlock()
245+ if d.turns[0].Assistant != "" || d.turns[0].Err != "" {
246+ t.Fatalf("stale callback changed reopened turn: %#v", d.turns[0])
247+ }
248+ if !d.loading {
249+ t.Fatal("stale completion cleared the reopened dialog loading state")
250+ }
251+}
252+
253+func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) {
254+ client := &btwToolTestClient{done: make(chan struct{})}
255+ tool := &btwEchoTool{}
256+ main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
257+ d := newBtwDialog()
258+ i := &Interactive{
259+ btwDialog: d,
260+ confirmDialog: newConfirmDialog(),
261+ dirty: make(chan struct{}, 1),
262+ }
263+ gate := core.NewConfirmGate(i)
264+ mainResp := make(chan core.ConfirmDecision, 1)
265+ i.confirmDialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp})
266+ i.confirmDialog.Blur()
267+ main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
268+ return gate.CheckToolCall(core.ToolCallConfirmation{
269+ ID: call.ID,
270+ Name: call.Name,
271+ Summary: "hello",
272+ Origin: call.Origin,
273+ })
274+ }
275+
276+ d.Open(tui.Dark, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
277+ sideConfirmationFirst := func() bool {
278+ i.confirmDialog.mu.Lock()
279+ defer i.confirmDialog.mu.Unlock()
280+ return len(i.confirmDialog.pending) > 0 && i.confirmDialog.pending[0].toolName == "echo"
281+ }
282+ deadline := time.Now().Add(2 * time.Second)
283+ for !sideConfirmationFirst() && time.Now().Before(deadline) {
284+ time.Sleep(time.Millisecond)
285+ }
286+ if !sideConfirmationFirst() {
287+ t.Fatal("side-agent tool confirmation did not reach the front of the interactive confirmation queue")
288+ }
289+ if !i.confirmDialog.Focused() {
290+ t.Fatal("side-agent confirmation did not focus the prioritized dialog")
291+ }
292+
293+ d.mu.Lock()
294+ if len(d.turns) != 1 || len(d.turns[0].Tools) != 1 {
295+ d.mu.Unlock()
296+ t.Fatalf("side-agent tool state = %#v, want one live tool", d.turns)
297+ }
298+ if got, want := d.turns[0].Tools[0].Args, "hello"; got != want {
299+ d.mu.Unlock()
300+ t.Fatalf("side-agent confirmation summary = %q, want %q", got, want)
301+ }
302+ if got, want := d.activity.label(), "Waiting for approval: echo"; got != want {
303+ d.mu.Unlock()
304+ t.Fatalf("side-agent activity = %q, want %q", got, want)
305+ }
306+ d.mu.Unlock()
307+
308+ i.confirmDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
309+ select {
310+ case <-client.done:
311+ case <-time.After(2 * time.Second):
312+ t.Fatal("side-agent did not continue after confirmation")
313+ }
314+ if got := tool.runs.Load(); got != 1 {
315+ t.Fatalf("tool executions = %d, want 1 after confirmation", got)
316+ }
317+ if i.confirmDialog.Focused() {
318+ t.Fatal("main confirmation regained focus instead of returning input to /btw")
319+ }
320+ i.confirmDialog.CancelAll("test complete")
321+ select {
322+ case <-mainResp:
323+ default:
324+ t.Fatal("main confirmation was not retained after resolving the side-chat call")
325+ }
326+}
M packages/agent/modes/confirm_dialog.go +38 -0
@@ -121,6 +121,44 @@ func (d *confirmDialog) CancelAll(reason string) {
121121 }
122122 }
123123
124+// CancelChildConfirmations refuses side-chat confirmations without affecting
125+// queued main-thread calls. Closing /btw must unblock its agent even though
126+// the confirmation UI is no longer visible.
127+func (d *confirmDialog) CancelChildConfirmations(reason string) {
128+ if d == nil {
129+ return
130+ }
131+ d.mu.Lock()
132+ pending := d.pending
133+ remaining := make([]*confirmRequest, 0, len(pending))
134+ cancelled := make([]*confirmRequest, 0, len(pending))
135+ activeCancelled := false
136+ for index, req := range pending {
137+ if req.returnToChild {
138+ cancelled = append(cancelled, req)
139+ activeCancelled = activeCancelled || index == 0
140+ continue
141+ }
142+ remaining = append(remaining, req)
143+ }
144+ d.pending = remaining
145+ if activeCancelled {
146+ d.cursor = 0
147+ d.focused = len(remaining) > 0
148+ if d.focused {
149+ d.activeSince = time.Now()
150+ }
151+ }
152+ d.mu.Unlock()
153+
154+ for _, req := range cancelled {
155+ select {
156+ case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}:
157+ default:
158+ }
159+ }
160+}
161+
124162 // AllowAllPending approves every pending request and drains the
125163 // queue. Used by /yolo so any confirmation dialog already on screen
126164 // resolves immediately as "yes".
@@ -121,6 +121,44 @@ func (d *confirmDialog) CancelAll(reason string) {
121 }121 }
122 }122 }
123 123
124+// CancelChildConfirmations refuses side-chat confirmations without affecting
125+// queued main-thread calls. Closing /btw must unblock its agent even though
126+// the confirmation UI is no longer visible.
127+func (d *confirmDialog) CancelChildConfirmations(reason string) {
128+ if d == nil {
129+ return
130+ }
131+ d.mu.Lock()
132+ pending := d.pending
133+ remaining := make([]*confirmRequest, 0, len(pending))
134+ cancelled := make([]*confirmRequest, 0, len(pending))
135+ activeCancelled := false
136+ for index, req := range pending {
137+ if req.returnToChild {
138+ cancelled = append(cancelled, req)
139+ activeCancelled = activeCancelled || index == 0
140+ continue
141+ }
142+ remaining = append(remaining, req)
143+ }
144+ d.pending = remaining
145+ if activeCancelled {
146+ d.cursor = 0
147+ d.focused = len(remaining) > 0
148+ if d.focused {
149+ d.activeSince = time.Now()
150+ }
151+ }
152+ d.mu.Unlock()
153+
154+ for _, req := range cancelled {
155+ select {
156+ case req.resp <- core.ConfirmDecision{Allow: false, Reason: reason}:
157+ default:
158+ }
159+ }
160+}
161+
124 // AllowAllPending approves every pending request and drains the162 // AllowAllPending approves every pending request and drains the
125 // queue. Used by /yolo so any confirmation dialog already on screen163 // queue. Used by /yolo so any confirmation dialog already on screen
126 // resolves immediately as "yes".164 // resolves immediately as "yes".
M packages/agent/modes/confirm_dialog_test.go +65 -3
@@ -39,6 +39,7 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
3939 Name: "edit",
4040 Summary: "sample.go",
4141 Content: "-old\n+new\n",
42+ Origin: btwOrigin(0),
4243 })
4344 }()
4445
@@ -50,10 +51,10 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
5051 t.Fatal("confirmation dialog did not open")
5152 }
5253 i.mu.Lock()
53- preview := i.toolCalls["call-1"].Preview
54+ mainPreview := i.toolCalls["call-1"].Preview
5455 i.mu.Unlock()
55- if preview != "-old\n+new\n" {
56- t.Fatalf("tool preview = %q", preview)
56+ if mainPreview != "" {
57+ t.Fatalf("main tool preview = %q, want side-chat origin to leave it untouched", mainPreview)
5758 }
5859 btw.mu.Lock()
5960 btwPreview := btw.turns[0].Tools[0].Preview
@@ -79,6 +80,67 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
7980 }
8081 }
8182
83+func TestConfirmToolCallRejectsClosedSideChatGeneration(t *testing.T) {
84+ i := newConfirmationInputInteractive()
85+ i.btwDialog.Open(tui.Dark, i.agent, i.agent.System, i.agent.Model, t.TempDir(), "", false, false, true, nil)
86+ i.btwDialog.mu.Lock()
87+ origin := btwOrigin(i.btwDialog.generation)
88+ i.btwDialog.mu.Unlock()
89+ i.btwDialog.Close()
90+
91+ decision := i.ConfirmToolCall(core.ToolCallConfirmation{
92+ ID: "call-1",
93+ Name: "edit",
94+ Origin: origin,
95+ })
96+ if decision.Allow || decision.Reason != "side-chat tool call canceled" {
97+ t.Fatalf("stale side-chat decision = %+v", decision)
98+ }
99+ if i.confirmDialog.Active() {
100+ t.Fatal("stale side-chat confirmation activated the main dialog")
101+ }
102+}
103+
104+func TestBtwCloseCancelsSideChatConfirmation(t *testing.T) {
105+ i := newConfirmationInputInteractive()
106+ d := i.btwDialog
107+ d.mu.Lock()
108+ d.active = true
109+ d.generation = 1
110+ d.turns = []btwTurn{{Tools: []tui.ToolCallView{{ID: "call-1", Name: "edit"}}}}
111+ d.mu.Unlock()
112+
113+ decision := make(chan core.ConfirmDecision, 1)
114+ go func() {
115+ decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
116+ ID: "call-1",
117+ Name: "edit",
118+ Origin: btwOrigin(1),
119+ })
120+ }()
121+
122+ deadline := time.Now().Add(time.Second)
123+ for !i.confirmDialog.Active() && time.Now().Before(deadline) {
124+ runtime.Gosched()
125+ }
126+ if !i.confirmDialog.Active() {
127+ t.Fatal("side-chat confirmation did not enter the queue")
128+ }
129+ d.Close()
130+
131+ select {
132+ case got := <-decision:
133+ if got.Allow || got.Reason != "side chat closed" {
134+ t.Fatalf("close decision = %+v", got)
135+ }
136+ case <-time.After(time.Second):
137+ t.Fatal("closing /btw did not unblock its confirmation")
138+ }
139+ if i.confirmDialog.Active() {
140+ t.Fatal("side-chat confirmation remained active after /btw closed")
141+ }
142+}
143+
82144 func newConfirmationInputInteractive() *Interactive {
83145 agent := core.NewAgent(nil, "test-model", "test system", nil)
84146 return NewInteractive(InteractiveConfig{
@@ -39,6 +39,7 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
39 Name: "edit",39 Name: "edit",
40 Summary: "sample.go",40 Summary: "sample.go",
41 Content: "-old\n+new\n",41 Content: "-old\n+new\n",
42+ Origin: btwOrigin(0),
42 })43 })
43 }()44 }()
44 45
@@ -50,10 +51,10 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
50 t.Fatal("confirmation dialog did not open")51 t.Fatal("confirmation dialog did not open")
51 }52 }
52 i.mu.Lock()53 i.mu.Lock()
53- preview := i.toolCalls["call-1"].Preview54+ mainPreview := i.toolCalls["call-1"].Preview
54 i.mu.Unlock()55 i.mu.Unlock()
55- if preview != "-old\n+new\n" {56+ if mainPreview != "" {
56- t.Fatalf("tool preview = %q", preview)57+ t.Fatalf("main tool preview = %q, want side-chat origin to leave it untouched", mainPreview)
57 }58 }
58 btw.mu.Lock()59 btw.mu.Lock()
59 btwPreview := btw.turns[0].Tools[0].Preview60 btwPreview := btw.turns[0].Tools[0].Preview
@@ -79,6 +80,67 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
79 }80 }
80 }81 }
81 82
83+func TestConfirmToolCallRejectsClosedSideChatGeneration(t *testing.T) {
84+ i := newConfirmationInputInteractive()
85+ i.btwDialog.Open(tui.Dark, i.agent, i.agent.System, i.agent.Model, t.TempDir(), "", false, false, true, nil)
86+ i.btwDialog.mu.Lock()
87+ origin := btwOrigin(i.btwDialog.generation)
88+ i.btwDialog.mu.Unlock()
89+ i.btwDialog.Close()
90+
91+ decision := i.ConfirmToolCall(core.ToolCallConfirmation{
92+ ID: "call-1",
93+ Name: "edit",
94+ Origin: origin,
95+ })
96+ if decision.Allow || decision.Reason != "side-chat tool call canceled" {
97+ t.Fatalf("stale side-chat decision = %+v", decision)
98+ }
99+ if i.confirmDialog.Active() {
100+ t.Fatal("stale side-chat confirmation activated the main dialog")
101+ }
102+}
103+
104+func TestBtwCloseCancelsSideChatConfirmation(t *testing.T) {
105+ i := newConfirmationInputInteractive()
106+ d := i.btwDialog
107+ d.mu.Lock()
108+ d.active = true
109+ d.generation = 1
110+ d.turns = []btwTurn{{Tools: []tui.ToolCallView{{ID: "call-1", Name: "edit"}}}}
111+ d.mu.Unlock()
112+
113+ decision := make(chan core.ConfirmDecision, 1)
114+ go func() {
115+ decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
116+ ID: "call-1",
117+ Name: "edit",
118+ Origin: btwOrigin(1),
119+ })
120+ }()
121+
122+ deadline := time.Now().Add(time.Second)
123+ for !i.confirmDialog.Active() && time.Now().Before(deadline) {
124+ runtime.Gosched()
125+ }
126+ if !i.confirmDialog.Active() {
127+ t.Fatal("side-chat confirmation did not enter the queue")
128+ }
129+ d.Close()
130+
131+ select {
132+ case got := <-decision:
133+ if got.Allow || got.Reason != "side chat closed" {
134+ t.Fatalf("close decision = %+v", got)
135+ }
136+ case <-time.After(time.Second):
137+ t.Fatal("closing /btw did not unblock its confirmation")
138+ }
139+ if i.confirmDialog.Active() {
140+ t.Fatal("side-chat confirmation remained active after /btw closed")
141+ }
142+}
143+
82 func newConfirmationInputInteractive() *Interactive {144 func newConfirmationInputInteractive() *Interactive {
83 agent := core.NewAgent(nil, "test-model", "test system", nil)145 agent := core.NewAgent(nil, "test-model", "test system", nil)
84 return NewInteractive(InteractiveConfig{146 return NewInteractive(InteractiveConfig{
M packages/agent/modes/interactive.go +41 -20
@@ -524,6 +524,7 @@ type Interactive struct {
524524 cumUsage provider.Usage
525525 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
526526 busy bool
527+ activity agentActivity
527528 pendingIdleWork []func()
528529 dirty chan struct{}
529530 modelRefresh chan modelRefreshResult
@@ -807,6 +808,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
807808 clock: time.Now,
808809 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
809810 }
811+ i.btwDialog.setCloseHook(func() {
812+ i.confirmDialog.CancelChildConfirmations("side chat closed")
813+ })
810814 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
811815 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
812816 if cfg.LlamaCPPConfig != nil {
@@ -1766,16 +1770,17 @@ func (i *Interactive) redraw() {
17661770 suggest = append([]string{""}, suggest...)
17671771 }
17681772
1769- // Busy prefix shown at the far left of the status bar. The
1770- // spinner glyph and its funny-line message share the `zut`
1771- // label colour (Theme.Assistant) so the whole "who's working"
1772- // band reads at a glance. Elapsed time stays muted because it
1773- // drifts every second and shouldn't grab focus.
1773+ // Busy prefix shown at the far left of the status bar. The spinner owns
1774+ // only its frame; the activity label reports the operation in progress.
17741775 var busyPrefix string
17751776 if i.busy {
1777+ label := i.activity.label()
1778+ if label == "" {
1779+ label = "Preparing request"
1780+ }
17761781 busyPrefix = fmt.Sprintf("%s %s %s %s",
17771782 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
1778- i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()),
1783+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
17791784 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
17801785 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
17811786 )
@@ -5393,7 +5398,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
53935398 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
53945399 i.mu.Lock()
53955400 i.modelRefreshing = true
5396- i.statusOK = "refreshing models"
5401+ i.statusOK = "Refreshing models"
53975402 i.statusErr = ""
53985403 i.mu.Unlock()
53995404 go func() {
@@ -5592,7 +5597,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
55925597 if refreshErr != nil {
55935598 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
55945599 i.statusOK = ""
5595- } else if i.statusOK == "refreshing models" {
5600+ } else if i.statusOK == "Refreshing models" {
55965601 i.statusOK = ""
55975602 }
55985603 i.mu.Unlock()
@@ -6418,11 +6423,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
64186423 i.mu.Lock()
64196424 i.busy = true
64206425 i.compacting = true
6426+ i.spin.Start()
6427+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
64216428 if auto {
6422- i.spin.StartFixed("condensing history")
6429+ i.activity.kind = activityCondensingHistory
64236430 i.autoCompacting = true
64246431 } else {
6425- i.spin.StartFixed("compacting")
6432+ i.activity.kind = activityCompactingHistory
64266433 }
64276434 i.cancelTurn = cancel
64286435 i.statusErr = ""
@@ -6618,7 +6625,8 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
66186625 i.cancelTurn = cancel
66196626 i.statusErr = ""
66206627 i.statusOK = ""
6621- i.spin.StartFixed("running shell command")
6628+ i.spin.Start()
6629+ i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
66226630 // Clear stale extension notes the same way a new turn would so the
66236631 // screen doesn't accumulate transient state.
66246632 i.extNotes = nil
@@ -6751,6 +6759,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
67516759 i.mu.Lock()
67526760 i.busy = true
67536761 i.spin.Start()
6762+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
67546763 i.cancelTurn = cancel
67556764 i.statusErr = ""
67566765 i.statusOK = ""
@@ -7126,6 +7135,7 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
71267135 func (i *Interactive) handleEvent(ev core.AgentEvent) {
71277136 i.mu.Lock()
71287137 defer i.mu.Unlock()
7138+ i.activity.apply(ev)
71297139 switch e := ev.(type) {
71307140 case core.EvAssistantStart:
71317141 // Fires at the top of every oneTurn, including follow-up
@@ -7348,7 +7358,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
73487358 return
73497359 }
73507360 i.mu.Lock()
7351- i.statusOK = "reloading extensions..."
7361+ i.statusOK = "Reloading extensions..."
73527362 i.statusErr = ""
73537363 i.mu.Unlock()
73547364 i.invalidate()
@@ -7378,23 +7388,34 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis
73787388 // tool panel, then blocks until the user approves or refuses the call.
73797389 func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
73807390 resp := make(chan core.ConfirmDecision, 1)
7381- returnToChild := false
7391+ if isBtwOrigin(call.Origin) {
7392+ req := &confirmRequest{
7393+ toolName: call.Name,
7394+ preview: call.Summary,
7395+ resp: resp,
7396+ returnToChild: true,
7397+ }
7398+ if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
7399+ i.confirmDialog.Enqueue(req)
7400+ }) {
7401+ return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
7402+ }
7403+ i.invalidate()
7404+ return <-resp
7405+ }
73827406 if call.ID != "" {
73837407 i.mu.Lock()
73847408 if tc, ok := i.toolCalls[call.ID]; ok {
73857409 tc.Args = call.Summary
73867410 tc.Preview = call.Content
7411+ i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
73877412 }
73887413 i.mu.Unlock()
7389- if i.btwDialog != nil && i.btwDialog.Active() {
7390- returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
7391- }
73927414 }
73937415 i.confirmDialog.Enqueue(&confirmRequest{
7394- toolName: call.Name,
7395- preview: call.Summary,
7396- resp: resp,
7397- returnToChild: returnToChild,
7416+ toolName: call.Name,
7417+ preview: call.Summary,
7418+ resp: resp,
73987419 })
73997420 i.invalidate()
74007421 return <-resp
@@ -524,6 +524,7 @@ type Interactive struct {
524 cumUsage provider.Usage524 cumUsage provider.Usage
525 lastCtxInput int // input_tokens of the most recent turn — approximates current context size525 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
526 busy bool526 busy bool
527+ activity agentActivity
527 pendingIdleWork []func()528 pendingIdleWork []func()
528 dirty chan struct{}529 dirty chan struct{}
529 modelRefresh chan modelRefreshResult530 modelRefresh chan modelRefreshResult
@@ -807,6 +808,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
807 clock: time.Now,808 clock: time.Now,
808 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),809 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
809 }810 }
811+ i.btwDialog.setCloseHook(func() {
812+ i.confirmDialog.CancelChildConfirmations("side chat closed")
813+ })
810 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)814 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
811 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)815 i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore)
812 if cfg.LlamaCPPConfig != nil {816 if cfg.LlamaCPPConfig != nil {
@@ -1766,16 +1770,17 @@ func (i *Interactive) redraw() {
1766 suggest = append([]string{""}, suggest...)1770 suggest = append([]string{""}, suggest...)
1767 }1771 }
1768 1772
1769- // Busy prefix shown at the far left of the status bar. The1773+ // Busy prefix shown at the far left of the status bar. The spinner owns
1770- // spinner glyph and its funny-line message share the `zut`1774+ // only its frame; the activity label reports the operation in progress.
1771- // label colour (Theme.Assistant) so the whole "who's working"
1772- // band reads at a glance. Elapsed time stays muted because it
1773- // drifts every second and shouldn't grab focus.
1774 var busyPrefix string1775 var busyPrefix string
1775 if i.busy {1776 if i.busy {
1777+ label := i.activity.label()
1778+ if label == "" {
1779+ label = "Preparing request"
1780+ }
1776 busyPrefix = fmt.Sprintf("%s %s %s %s",1781 busyPrefix = fmt.Sprintf("%s %s %s %s",
1777 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),1782 i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
1778- i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Message()),1783+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
1779 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),1784 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
1780 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),1785 i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
1781 )1786 )
@@ -5393,7 +5398,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5393 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {5398 } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
5394 i.mu.Lock()5399 i.mu.Lock()
5395 i.modelRefreshing = true5400 i.modelRefreshing = true
5396- i.statusOK = "refreshing models"5401+ i.statusOK = "Refreshing models"
5397 i.statusErr = ""5402 i.statusErr = ""
5398 i.mu.Unlock()5403 i.mu.Unlock()
5399 go func() {5404 go func() {
@@ -5592,7 +5597,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
5592 if refreshErr != nil {5597 if refreshErr != nil {
5593 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()5598 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
5594 i.statusOK = ""5599 i.statusOK = ""
5595- } else if i.statusOK == "refreshing models" {5600+ } else if i.statusOK == "Refreshing models" {
5596 i.statusOK = ""5601 i.statusOK = ""
5597 }5602 }
5598 i.mu.Unlock()5603 i.mu.Unlock()
@@ -6418,11 +6423,13 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
6418 i.mu.Lock()6423 i.mu.Lock()
6419 i.busy = true6424 i.busy = true
6420 i.compacting = true6425 i.compacting = true
6426+ i.spin.Start()
6427+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6421 if auto {6428 if auto {
6422- i.spin.StartFixed("condensing history")6429+ i.activity.kind = activityCondensingHistory
6423 i.autoCompacting = true6430 i.autoCompacting = true
6424 } else {6431 } else {
6425- i.spin.StartFixed("compacting")6432+ i.activity.kind = activityCompactingHistory
6426 }6433 }
6427 i.cancelTurn = cancel6434 i.cancelTurn = cancel
6428 i.statusErr = ""6435 i.statusErr = ""
@@ -6618,7 +6625,8 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
6618 i.cancelTurn = cancel6625 i.cancelTurn = cancel
6619 i.statusErr = ""6626 i.statusErr = ""
6620 i.statusOK = ""6627 i.statusOK = ""
6621- i.spin.StartFixed("running shell command")6628+ i.spin.Start()
6629+ i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
6622 // Clear stale extension notes the same way a new turn would so the6630 // Clear stale extension notes the same way a new turn would so the
6623 // screen doesn't accumulate transient state.6631 // screen doesn't accumulate transient state.
6624 i.extNotes = nil6632 i.extNotes = nil
@@ -6751,6 +6759,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
6751 i.mu.Lock()6759 i.mu.Lock()
6752 i.busy = true6760 i.busy = true
6753 i.spin.Start()6761 i.spin.Start()
6762+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
6754 i.cancelTurn = cancel6763 i.cancelTurn = cancel
6755 i.statusErr = ""6764 i.statusErr = ""
6756 i.statusOK = ""6765 i.statusOK = ""
@@ -7126,6 +7135,7 @@ func (i *Interactive) shouldAutoCompactLocked() bool {
7126 func (i *Interactive) handleEvent(ev core.AgentEvent) {7135 func (i *Interactive) handleEvent(ev core.AgentEvent) {
7127 i.mu.Lock()7136 i.mu.Lock()
7128 defer i.mu.Unlock()7137 defer i.mu.Unlock()
7138+ i.activity.apply(ev)
7129 switch e := ev.(type) {7139 switch e := ev.(type) {
7130 case core.EvAssistantStart:7140 case core.EvAssistantStart:
7131 // Fires at the top of every oneTurn, including follow-up7141 // Fires at the top of every oneTurn, including follow-up
@@ -7348,7 +7358,7 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
7348 return7358 return
7349 }7359 }
7350 i.mu.Lock()7360 i.mu.Lock()
7351- i.statusOK = "reloading extensions..."7361+ i.statusOK = "Reloading extensions..."
7352 i.statusErr = ""7362 i.statusErr = ""
7353 i.mu.Unlock()7363 i.mu.Unlock()
7354 i.invalidate()7364 i.invalidate()
@@ -7378,23 +7388,34 @@ func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecis
7378 // tool panel, then blocks until the user approves or refuses the call.7388 // tool panel, then blocks until the user approves or refuses the call.
7379 func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {7389 func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
7380 resp := make(chan core.ConfirmDecision, 1)7390 resp := make(chan core.ConfirmDecision, 1)
7381- returnToChild := false7391+ if isBtwOrigin(call.Origin) {
7392+ req := &confirmRequest{
7393+ toolName: call.Name,
7394+ preview: call.Summary,
7395+ resp: resp,
7396+ returnToChild: true,
7397+ }
7398+ if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
7399+ i.confirmDialog.Enqueue(req)
7400+ }) {
7401+ return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
7402+ }
7403+ i.invalidate()
7404+ return <-resp
7405+ }
7382 if call.ID != "" {7406 if call.ID != "" {
7383 i.mu.Lock()7407 i.mu.Lock()
7384 if tc, ok := i.toolCalls[call.ID]; ok {7408 if tc, ok := i.toolCalls[call.ID]; ok {
7385 tc.Args = call.Summary7409 tc.Args = call.Summary
7386 tc.Preview = call.Content7410 tc.Preview = call.Content
7411+ i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
7387 }7412 }
7388 i.mu.Unlock()7413 i.mu.Unlock()
7389- if i.btwDialog != nil && i.btwDialog.Active() {
7390- returnToChild = i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
7391- }
7392 }7414 }
7393 i.confirmDialog.Enqueue(&confirmRequest{7415 i.confirmDialog.Enqueue(&confirmRequest{
7394- toolName: call.Name,7416+ toolName: call.Name,
7395- preview: call.Summary,7417+ preview: call.Summary,
7396- resp: resp,7418+ resp: resp,
7397- returnToChild: returnToChild,
7398 })7419 })
7399 i.invalidate()7420 i.invalidate()
7400 return <-resp7421 return <-resp
M packages/agent/modes/interactive_model_test.go +1 -1
@@ -105,7 +105,7 @@ func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {
105105 if !i.modelDialog.Active() {
106106 t.Fatal("model picker did not open immediately")
107107 }
108- if i.statusOK == "refreshing models" {
108+ if i.statusOK == "Refreshing models" {
109109 t.Fatal("refresh status shown without llama.cpp router configuration")
110110 }
111111 }
@@ -105,7 +105,7 @@ func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {
105 if !i.modelDialog.Active() {105 if !i.modelDialog.Active() {
106 t.Fatal("model picker did not open immediately")106 t.Fatal("model picker did not open immediately")
107 }107 }
108- if i.statusOK == "refreshing models" {108+ if i.statusOK == "Refreshing models" {
109 t.Fatal("refresh status shown without llama.cpp router configuration")109 t.Fatal("refresh status shown without llama.cpp router configuration")
110 }110 }
111 }111 }
M packages/agent/modes/json.go +14 -0
@@ -41,6 +41,17 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
4141 switch e := ev.(type) {
4242 case core.EvTurnStart:
4343 m["step"] = e.Step
44+ case core.EvRequestStarted:
45+ m["provider"] = e.Provider
46+ m["model"] = e.Model
47+ m["scope"] = string(e.Scope)
48+ m["attempt"] = e.Attempt
49+ m["max_attempts"] = e.MaxAttempts
50+ case core.EvRetryScheduled:
51+ m["scope"] = string(e.Scope)
52+ m["attempt"] = e.Attempt
53+ m["max_attempts"] = e.MaxAttempts
54+ m["delay_ms"] = e.Delay.Milliseconds()
4455 case core.EvUserMessage:
4556 m["content"] = ContentToJSON(e.Message.Content)
4657 m["time"] = e.Message.Time
@@ -63,6 +74,9 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
6374 var args any
6475 _ = json.Unmarshal(e.Args, &args)
6576 m["args"] = args
77+ case core.EvToolExecutionStarted:
78+ m["id"] = e.ID
79+ m["name"] = e.Name
6680 case core.EvToolProgress:
6781 m["id"] = e.ID
6882 m["text"] = e.Text
@@ -41,6 +41,17 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
41 switch e := ev.(type) {41 switch e := ev.(type) {
42 case core.EvTurnStart:42 case core.EvTurnStart:
43 m["step"] = e.Step43 m["step"] = e.Step
44+ case core.EvRequestStarted:
45+ m["provider"] = e.Provider
46+ m["model"] = e.Model
47+ m["scope"] = string(e.Scope)
48+ m["attempt"] = e.Attempt
49+ m["max_attempts"] = e.MaxAttempts
50+ case core.EvRetryScheduled:
51+ m["scope"] = string(e.Scope)
52+ m["attempt"] = e.Attempt
53+ m["max_attempts"] = e.MaxAttempts
54+ m["delay_ms"] = e.Delay.Milliseconds()
44 case core.EvUserMessage:55 case core.EvUserMessage:
45 m["content"] = ContentToJSON(e.Message.Content)56 m["content"] = ContentToJSON(e.Message.Content)
46 m["time"] = e.Message.Time57 m["time"] = e.Message.Time
@@ -63,6 +74,9 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
63 var args any74 var args any
64 _ = json.Unmarshal(e.Args, &args)75 _ = json.Unmarshal(e.Args, &args)
65 m["args"] = args76 m["args"] = args
77+ case core.EvToolExecutionStarted:
78+ m["id"] = e.ID
79+ m["name"] = e.Name
66 case core.EvToolProgress:80 case core.EvToolProgress:
67 m["id"] = e.ID81 m["id"] = e.ID
68 m["text"] = e.Text82 m["text"] = e.Text
A packages/agent/modes/json_activity_test.go +50 -0
@@ -0,0 +1,50 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/core"
8+)
9+
10+func TestEventToJSONActivityLifecycle(t *testing.T) {
11+ request := EventToJSON(core.EvRequestStarted{
12+ Provider: "anthropic",
13+ Model: "claude-test",
14+ Scope: core.RetryScopeProvider,
15+ Attempt: 2,
16+ MaxAttempts: 3,
17+ })
18+ if got, want := request["type"], "request_started"; got != want {
19+ t.Fatalf("type = %q, want %q", got, want)
20+ }
21+ for key, want := range map[string]any{
22+ "provider": "anthropic",
23+ "model": "claude-test",
24+ "scope": "provider",
25+ "attempt": 2,
26+ "max_attempts": 3,
27+ } {
28+ if got := request[key]; got != want {
29+ t.Errorf("request[%q] = %#v, want %#v", key, got, want)
30+ }
31+ }
32+
33+ retry := EventToJSON(core.EvRetryScheduled{
34+ Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 1500 * time.Millisecond,
35+ })
36+ if got, want := retry["delay_ms"], int64(1500); got != want {
37+ t.Fatalf("delay_ms = %#v, want %#v", got, want)
38+ }
39+ if _, exists := retry["delay"]; exists {
40+ t.Fatal("retry event must not serialize a duration")
41+ }
42+
43+ tool := EventToJSON(core.EvToolExecutionStarted{ID: "call-1", Name: "read"})
44+ if got, want := tool["type"], "tool_execution_started"; got != want {
45+ t.Fatalf("type = %q, want %q", got, want)
46+ }
47+ if tool["id"] != "call-1" || tool["name"] != "read" {
48+ t.Fatalf("tool event = %#v", tool)
49+ }
50+}
@@ -0,0 +1,50 @@
1+package modes
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/core"
8+)
9+
10+func TestEventToJSONActivityLifecycle(t *testing.T) {
11+ request := EventToJSON(core.EvRequestStarted{
12+ Provider: "anthropic",
13+ Model: "claude-test",
14+ Scope: core.RetryScopeProvider,
15+ Attempt: 2,
16+ MaxAttempts: 3,
17+ })
18+ if got, want := request["type"], "request_started"; got != want {
19+ t.Fatalf("type = %q, want %q", got, want)
20+ }
21+ for key, want := range map[string]any{
22+ "provider": "anthropic",
23+ "model": "claude-test",
24+ "scope": "provider",
25+ "attempt": 2,
26+ "max_attempts": 3,
27+ } {
28+ if got := request[key]; got != want {
29+ t.Errorf("request[%q] = %#v, want %#v", key, got, want)
30+ }
31+ }
32+
33+ retry := EventToJSON(core.EvRetryScheduled{
34+ Scope: core.RetryScopeAgent, Attempt: 2, MaxAttempts: 4, Delay: 1500 * time.Millisecond,
35+ })
36+ if got, want := retry["delay_ms"], int64(1500); got != want {
37+ t.Fatalf("delay_ms = %#v, want %#v", got, want)
38+ }
39+ if _, exists := retry["delay"]; exists {
40+ t.Fatal("retry event must not serialize a duration")
41+ }
42+
43+ tool := EventToJSON(core.EvToolExecutionStarted{ID: "call-1", Name: "read"})
44+ if got, want := tool["type"], "tool_execution_started"; got != want {
45+ t.Fatalf("type = %q, want %q", got, want)
46+ }
47+ if tool["id"] != "call-1" || tool["name"] != "read" {
48+ t.Fatalf("tool event = %#v", tool)
49+ }
50+}
M packages/agent/modes/llama_dialog.go +1 -1
@@ -178,7 +178,7 @@ func (d *llamaDialog) load(model string) {
178178 d.mu.Lock()
179179 client := d.client
180180 d.mu.Unlock()
181- d.beginProgress("Loading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error {
181+ d.beginProgress("Loading local model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error {
182182 _, err := client.LoadAndWait(ctx, model, update)
183183 return err
184184 })
@@ -178,7 +178,7 @@ func (d *llamaDialog) load(model string) {
178 d.mu.Lock()178 d.mu.Lock()
179 client := d.client179 client := d.client
180 d.mu.Unlock()180 d.mu.Unlock()
181- d.beginProgress("Loading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error {181+ d.beginProgress("Loading local model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error {
182 _, err := client.LoadAndWait(ctx, model, update)182 _, err := client.LoadAndWait(ctx, model, update)
183 return err183 return err
184 })184 })
M packages/agent/modes/session_dialog.go +2 -2
@@ -514,9 +514,9 @@ func (d *sessionDialog) loadingMessage(th tui.Theme) string {
514514 }
515515 idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
516516 }
517- progress := "loading sessions"
517+ progress := "Loading sessions"
518518 if d.loadingTotal > 0 {
519- progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
519+ progress = fmt.Sprintf("Loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
520520 }
521521 return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)")
522522 }
@@ -514,9 +514,9 @@ func (d *sessionDialog) loadingMessage(th tui.Theme) string {
514 }514 }
515 idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)515 idx = int(elapsed/(time.Duration(interval)*time.Millisecond)) % len(frames)
516 }516 }
517- progress := "loading sessions"517+ progress := "Loading sessions"
518 if d.loadingTotal > 0 {518 if d.loadingTotal > 0 {
519- progress = fmt.Sprintf("loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)519+ progress = fmt.Sprintf("Loading sessions (%d/%d)", d.loadingDone, d.loadingTotal)
520 }520 }
521 return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)")521 return th.FGColor(th.Spinner, frames[idx]) + " " + th.FGColor(th.Muted, progress+" (esc cancel)")
522 }522 }
M packages/agent/modes/session_dialog_test.go +2 -2
@@ -52,7 +52,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
5252 t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
5353 }
5454 loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
55- if !strings.Contains(loadingText, "loading sessions") {
55+ if !strings.Contains(loadingText, "Loading sessions") {
5656 t.Fatalf("loading render = %q, want spinner status", loadingText)
5757 }
5858 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
@@ -71,7 +71,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
7171 t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
7272 }
7373 loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
74- if strings.Contains(loadedText, "loading sessions") {
74+ if strings.Contains(loadedText, "Loading sessions") {
7575 t.Fatalf("completed render still shows loading spinner: %q", loadedText)
7676 }
7777 if !strings.Contains(loadedText, "load this session") {
@@ -52,7 +52,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
52 t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())52 t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
53 }53 }
54 loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")54 loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
55- if !strings.Contains(loadingText, "loading sessions") {55+ if !strings.Contains(loadingText, "Loading sessions") {
56 t.Fatalf("loading render = %q, want spinner status", loadingText)56 t.Fatalf("loading render = %q, want spinner status", loadingText)
57 }57 }
58 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})58 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
@@ -71,7 +71,7 @@ func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
71 t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)71 t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
72 }72 }
73 loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")73 loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
74- if strings.Contains(loadedText, "loading sessions") {74+ if strings.Contains(loadedText, "Loading sessions") {
75 t.Fatalf("completed render still shows loading spinner: %q", loadedText)75 t.Fatalf("completed render still shows loading spinner: %q", loadedText)
76 }76 }
77 if !strings.Contains(loadedText, "load this session") {77 if !strings.Contains(loadedText, "load this session") {
M packages/agent/modes/spinner.go +4 -55
@@ -1,26 +1,18 @@
11 package modes
22
33 import (
4- "math/rand"
54 "time"
65
76 "github.com/bnema/zut/packages/tui"
87 )
98
10-// spinner drives the busy animation shown in the status bar while a
11-// turn is streaming. It rotates through a list of playful status
12-// messages and a small frame animation.
9+// spinner drives the busy animation shown while an operation is active. The
10+// operation label is owned by the caller's activity state; the spinner owns
11+// only the visual frame and elapsed time.
1312 type spinner struct {
1413 frames []string
15- messages []string
1614 interval time.Duration
1715 startedAt time.Time
18- msgIdx int
19-
20- // fixedMsg overrides the rotating funnyWorkingLines message when
21- // set. Used for auto-compaction so the spinner clearly says what's
22- // happening instead of cycling jokes.
23- fixedMsg string
2416 }
2517
2618 // newSpinner constructs a fresh spinner.
@@ -35,42 +27,16 @@ func (s *spinner) Configure(th tui.Theme) {
3527 if len(s.frames) == 0 {
3628 s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
3729 }
38- s.messages = append([]string(nil), th.SpinnerMessages...)
39- if len(s.messages) == 0 {
40- s.messages = []string{"thinking"}
41- }
4230 interval := th.SpinnerIntervalMS
4331 if interval <= 0 {
4432 interval = 80
4533 }
4634 s.interval = time.Duration(interval) * time.Millisecond
47- if s.msgIdx >= len(s.messages) {
48- s.msgIdx = 0
49- }
5035 }
5136
52-// Start resets the spinner to the beginning of its animation and
53-// picks a random message that stays fixed for the whole run. A
54-// rotating rollodex of quips during a single turn felt noisy in
55-// practice — you'd see five different phrases for one
56-// long-running response, which implies progress that isn't
57-// actually happening. One stable phrase per turn reads calmer
58-// and the variety across turns (next Start picks another index)
59-// still keeps the set fresh over a session.
37+// Start resets the animation clock for a new operation.
6038 func (s *spinner) Start() {
6139 s.startedAt = time.Now()
62- if len(s.messages) == 0 {
63- s.messages = []string{"thinking"}
64- }
65- s.msgIdx = rand.Intn(len(s.messages))
66- s.fixedMsg = ""
67-}
68-
69-// StartFixed is like Start but pins the status text to msg for the
70-// duration of this spinner run. Cleared by the next Start() call.
71-func (s *spinner) StartFixed(msg string) {
72- s.startedAt = time.Now()
73- s.fixedMsg = msg
7440 }
7541
7642 // Frame returns the current spinner glyph for the running animation.
@@ -90,23 +56,6 @@ func (s *spinner) Frame() string {
9056 return s.frames[idx]
9157 }
9258
93-// Message returns the spinner's status text. One random phrase
94-// per Start call, pinned until the next turn. When the spinner
95-// was started via StartFixed, the pinned message is returned
96-// unchanged.
97-func (s *spinner) Message() string {
98- if s.fixedMsg != "" {
99- return s.fixedMsg
100- }
101- if len(s.messages) == 0 {
102- return "thinking"
103- }
104- if s.msgIdx < 0 || s.msgIdx >= len(s.messages) {
105- s.msgIdx = 0
106- }
107- return s.messages[s.msgIdx]
108-}
109-
11059 // Elapsed returns the wall-clock duration the spinner has been running.
11160 func (s *spinner) Elapsed() time.Duration {
11261 if s.startedAt.IsZero() {
@@ -1,26 +1,18 @@
1 package modes1 package modes
2 2
3 import (3 import (
4- "math/rand"
5 "time"4 "time"
6 5
7 "github.com/bnema/zut/packages/tui"6 "github.com/bnema/zut/packages/tui"
8 )7 )
9 8
10-// spinner drives the busy animation shown in the status bar while a9+// spinner drives the busy animation shown while an operation is active. The
11-// turn is streaming. It rotates through a list of playful status10+// operation label is owned by the caller's activity state; the spinner owns
12-// messages and a small frame animation.11+// only the visual frame and elapsed time.
13 type spinner struct {12 type spinner struct {
14 frames []string13 frames []string
15- messages []string
16 interval time.Duration14 interval time.Duration
17 startedAt time.Time15 startedAt time.Time
18- msgIdx int
19-
20- // fixedMsg overrides the rotating funnyWorkingLines message when
21- // set. Used for auto-compaction so the spinner clearly says what's
22- // happening instead of cycling jokes.
23- fixedMsg string
24 }16 }
25 17
26 // newSpinner constructs a fresh spinner.18 // newSpinner constructs a fresh spinner.
@@ -35,42 +27,16 @@ func (s *spinner) Configure(th tui.Theme) {
35 if len(s.frames) == 0 {27 if len(s.frames) == 0 {
36 s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}28 s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
37 }29 }
38- s.messages = append([]string(nil), th.SpinnerMessages...)
39- if len(s.messages) == 0 {
40- s.messages = []string{"thinking"}
41- }
42 interval := th.SpinnerIntervalMS30 interval := th.SpinnerIntervalMS
43 if interval <= 0 {31 if interval <= 0 {
44 interval = 8032 interval = 80
45 }33 }
46 s.interval = time.Duration(interval) * time.Millisecond34 s.interval = time.Duration(interval) * time.Millisecond
47- if s.msgIdx >= len(s.messages) {
48- s.msgIdx = 0
49- }
50 }35 }
51 36
52-// Start resets the spinner to the beginning of its animation and37+// Start resets the animation clock for a new operation.
53-// picks a random message that stays fixed for the whole run. A
54-// rotating rollodex of quips during a single turn felt noisy in
55-// practice — you'd see five different phrases for one
56-// long-running response, which implies progress that isn't
57-// actually happening. One stable phrase per turn reads calmer
58-// and the variety across turns (next Start picks another index)
59-// still keeps the set fresh over a session.
60 func (s *spinner) Start() {38 func (s *spinner) Start() {
61 s.startedAt = time.Now()39 s.startedAt = time.Now()
62- if len(s.messages) == 0 {
63- s.messages = []string{"thinking"}
64- }
65- s.msgIdx = rand.Intn(len(s.messages))
66- s.fixedMsg = ""
67-}
68-
69-// StartFixed is like Start but pins the status text to msg for the
70-// duration of this spinner run. Cleared by the next Start() call.
71-func (s *spinner) StartFixed(msg string) {
72- s.startedAt = time.Now()
73- s.fixedMsg = msg
74 }40 }
75 41
76 // Frame returns the current spinner glyph for the running animation.42 // Frame returns the current spinner glyph for the running animation.
@@ -90,23 +56,6 @@ func (s *spinner) Frame() string {
90 return s.frames[idx]56 return s.frames[idx]
91 }57 }
92 58
93-// Message returns the spinner's status text. One random phrase
94-// per Start call, pinned until the next turn. When the spinner
95-// was started via StartFixed, the pinned message is returned
96-// unchanged.
97-func (s *spinner) Message() string {
98- if s.fixedMsg != "" {
99- return s.fixedMsg
100- }
101- if len(s.messages) == 0 {
102- return "thinking"
103- }
104- if s.msgIdx < 0 || s.msgIdx >= len(s.messages) {
105- s.msgIdx = 0
106- }
107- return s.messages[s.msgIdx]
108-}
109-
110 // Elapsed returns the wall-clock duration the spinner has been running.59 // Elapsed returns the wall-clock duration the spinner has been running.
111 func (s *spinner) Elapsed() time.Duration {60 func (s *spinner) Elapsed() time.Duration {
112 if s.startedAt.IsZero() {61 if s.startedAt.IsZero() {
M packages/agent/rpc.go +3 -1
@@ -442,7 +442,9 @@ func (s *rpcServer) runCompact(id string) {
442442 defer s.setCancel(nil)
443443
444444 s.writeResponse(id, "compact", map[string]any{"started": true})
445- summary, err := s.agent.Compact(subCtx, 4, nil)
445+ summary, err := s.agent.CompactWithEvents(subCtx, 4, func(ev core.AgentEvent) {
446+ s.writeEvent(modes.EventToJSON(ev))
447+ })
446448 if err != nil {
447449 if !errors.Is(err, context.Canceled) {
448450 s.writeEvent(map[string]any{"type": "error", "message": err.Error()})
@@ -442,7 +442,9 @@ func (s *rpcServer) runCompact(id string) {
442 defer s.setCancel(nil)442 defer s.setCancel(nil)
443 443
444 s.writeResponse(id, "compact", map[string]any{"started": true})444 s.writeResponse(id, "compact", map[string]any{"started": true})
445- summary, err := s.agent.Compact(subCtx, 4, nil)445+ summary, err := s.agent.CompactWithEvents(subCtx, 4, func(ev core.AgentEvent) {
446+ s.writeEvent(modes.EventToJSON(ev))
447+ })
446 if err != nil {448 if err != nil {
447 if !errors.Is(err, context.Canceled) {449 if !errors.Is(err, context.Canceled) {
448 s.writeEvent(map[string]any{"type": "error", "message": err.Error()})450 s.writeEvent(map[string]any{"type": "error", "message": err.Error()})
M packages/agent/sdk/types.go +32 -0
@@ -18,6 +18,15 @@ type Event struct {
1818 Step int `json:"step,omitempty"`
1919 Time string `json:"time,omitempty"`
2020
21+ // request_started / retry_scheduled
22+ Provider string `json:"provider,omitempty"`
23+ Model string `json:"model,omitempty"`
24+ Scope string `json:"scope,omitempty"`
25+ Attempt int `json:"attempt,omitempty"`
26+ MaxAttempts int `json:"max_attempts,omitempty"`
27+ // DelayMS is set for retry_scheduled, including a valid zero delay.
28+ DelayMS *int64 `json:"delay_ms,omitempty"`
29+
2130 // text_delta
2231 Delta string `json:"delta,omitempty"`
2332
@@ -131,16 +140,39 @@ func toEvent(ev core.AgentEvent) Event {
131140 switch e := ev.(type) {
132141 case core.EvTurnStart:
133142 out.Step = e.Step
143+ case core.EvRequestStarted:
144+ out.Provider = e.Provider
145+ out.Model = e.Model
146+ out.Scope = string(e.Scope)
147+ out.Attempt = e.Attempt
148+ out.MaxAttempts = e.MaxAttempts
149+ case core.EvRetryScheduled:
150+ out.Scope = string(e.Scope)
151+ out.Attempt = e.Attempt
152+ out.MaxAttempts = e.MaxAttempts
153+ delayMS := e.Delay.Milliseconds()
154+ out.DelayMS = &delayMS
134155 case core.EvUserMessage:
135156 out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
136157 case core.EvAssistantMessage:
137158 out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
138159 case core.EvTextDelta:
139160 out.Delta = e.Delta
161+ case core.EvToolUseStart:
162+ out.ID = e.ID
163+ out.Name = e.Name
164+ case core.EvToolUseArgs:
165+ out.ID = e.ID
166+ out.Delta = e.Delta
167+ case core.EvToolUseEnd:
168+ out.ID = e.ID
140169 case core.EvToolCall:
141170 out.ID = e.ID
142171 out.Name = e.Name
143172 out.Args = e.Args
173+ case core.EvToolExecutionStarted:
174+ out.ID = e.ID
175+ out.Name = e.Name
144176 case core.EvToolProgress:
145177 out.ID = e.ID
146178 out.Text = e.Text
@@ -18,6 +18,15 @@ type Event struct {
18 Step int `json:"step,omitempty"`18 Step int `json:"step,omitempty"`
19 Time string `json:"time,omitempty"`19 Time string `json:"time,omitempty"`
20 20
21+ // request_started / retry_scheduled
22+ Provider string `json:"provider,omitempty"`
23+ Model string `json:"model,omitempty"`
24+ Scope string `json:"scope,omitempty"`
25+ Attempt int `json:"attempt,omitempty"`
26+ MaxAttempts int `json:"max_attempts,omitempty"`
27+ // DelayMS is set for retry_scheduled, including a valid zero delay.
28+ DelayMS *int64 `json:"delay_ms,omitempty"`
29+
21 // text_delta30 // text_delta
22 Delta string `json:"delta,omitempty"`31 Delta string `json:"delta,omitempty"`
23 32
@@ -131,16 +140,39 @@ func toEvent(ev core.AgentEvent) Event {
131 switch e := ev.(type) {140 switch e := ev.(type) {
132 case core.EvTurnStart:141 case core.EvTurnStart:
133 out.Step = e.Step142 out.Step = e.Step
143+ case core.EvRequestStarted:
144+ out.Provider = e.Provider
145+ out.Model = e.Model
146+ out.Scope = string(e.Scope)
147+ out.Attempt = e.Attempt
148+ out.MaxAttempts = e.MaxAttempts
149+ case core.EvRetryScheduled:
150+ out.Scope = string(e.Scope)
151+ out.Attempt = e.Attempt
152+ out.MaxAttempts = e.MaxAttempts
153+ delayMS := e.Delay.Milliseconds()
154+ out.DelayMS = &delayMS
134 case core.EvUserMessage:155 case core.EvUserMessage:
135 out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}156 out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
136 case core.EvAssistantMessage:157 case core.EvAssistantMessage:
137 out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}158 out.Message = &Message{Role: string(e.Message.Role), Content: convertContent(e.Message.Content)}
138 case core.EvTextDelta:159 case core.EvTextDelta:
139 out.Delta = e.Delta160 out.Delta = e.Delta
161+ case core.EvToolUseStart:
162+ out.ID = e.ID
163+ out.Name = e.Name
164+ case core.EvToolUseArgs:
165+ out.ID = e.ID
166+ out.Delta = e.Delta
167+ case core.EvToolUseEnd:
168+ out.ID = e.ID
140 case core.EvToolCall:169 case core.EvToolCall:
141 out.ID = e.ID170 out.ID = e.ID
142 out.Name = e.Name171 out.Name = e.Name
143 out.Args = e.Args172 out.Args = e.Args
173+ case core.EvToolExecutionStarted:
174+ out.ID = e.ID
175+ out.Name = e.Name
144 case core.EvToolProgress:176 case core.EvToolProgress:
145 out.ID = e.ID177 out.ID = e.ID
146 out.Text = e.Text178 out.Text = e.Text
A packages/agent/sdk/types_test.go +44 -0
@@ -0,0 +1,44 @@
1+package sdk
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/core"
9+)
10+
11+func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) {
12+ retry := toEvent(core.EvRetryScheduled{
13+ Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond,
14+ })
15+ if retry.Type != "retry_scheduled" || retry.Scope != "provider" || retry.Attempt != 2 || retry.MaxAttempts != 3 || retry.DelayMS == nil || *retry.DelayMS != 250 {
16+ t.Fatalf("retry event = %#v", retry)
17+ }
18+
19+ zeroDelay := toEvent(core.EvRetryScheduled{Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3})
20+ encoded, err := json.Marshal(zeroDelay)
21+ if err != nil {
22+ t.Fatalf("marshal zero-delay retry: %v", err)
23+ }
24+ var wire map[string]any
25+ if err := json.Unmarshal(encoded, &wire); err != nil {
26+ t.Fatalf("decode zero-delay retry: %v", err)
27+ }
28+ if got, ok := wire["delay_ms"]; !ok || got != float64(0) {
29+ t.Fatalf("zero-delay retry JSON delay_ms = %#v, want 0", got)
30+ }
31+
32+ toolStart := toEvent(core.EvToolUseStart{ID: "call-1", Name: "read"})
33+ if toolStart.Type != "tool_use_start" || toolStart.ID != "call-1" || toolStart.Name != "read" {
34+ t.Fatalf("tool start = %#v", toolStart)
35+ }
36+ toolArgs := toEvent(core.EvToolUseArgs{ID: "call-1", Delta: `{"path":"a"`})
37+ if toolArgs.Type != "tool_use_args" || toolArgs.ID != "call-1" || toolArgs.Delta == "" {
38+ t.Fatalf("tool args = %#v", toolArgs)
39+ }
40+ toolEnd := toEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"})
41+ if toolEnd.Type != "tool_execution_started" || toolEnd.ID != "call-1" || toolEnd.Name != "read" {
42+ t.Fatalf("tool execution = %#v", toolEnd)
43+ }
44+}
@@ -0,0 +1,44 @@
1+package sdk
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/core"
9+)
10+
11+func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) {
12+ retry := toEvent(core.EvRetryScheduled{
13+ Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond,
14+ })
15+ if retry.Type != "retry_scheduled" || retry.Scope != "provider" || retry.Attempt != 2 || retry.MaxAttempts != 3 || retry.DelayMS == nil || *retry.DelayMS != 250 {
16+ t.Fatalf("retry event = %#v", retry)
17+ }
18+
19+ zeroDelay := toEvent(core.EvRetryScheduled{Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3})
20+ encoded, err := json.Marshal(zeroDelay)
21+ if err != nil {
22+ t.Fatalf("marshal zero-delay retry: %v", err)
23+ }
24+ var wire map[string]any
25+ if err := json.Unmarshal(encoded, &wire); err != nil {
26+ t.Fatalf("decode zero-delay retry: %v", err)
27+ }
28+ if got, ok := wire["delay_ms"]; !ok || got != float64(0) {
29+ t.Fatalf("zero-delay retry JSON delay_ms = %#v, want 0", got)
30+ }
31+
32+ toolStart := toEvent(core.EvToolUseStart{ID: "call-1", Name: "read"})
33+ if toolStart.Type != "tool_use_start" || toolStart.ID != "call-1" || toolStart.Name != "read" {
34+ t.Fatalf("tool start = %#v", toolStart)
35+ }
36+ toolArgs := toEvent(core.EvToolUseArgs{ID: "call-1", Delta: `{"path":"a"`})
37+ if toolArgs.Type != "tool_use_args" || toolArgs.ID != "call-1" || toolArgs.Delta == "" {
38+ t.Fatalf("tool args = %#v", toolArgs)
39+ }
40+ toolEnd := toEvent(core.EvToolExecutionStarted{ID: "call-1", Name: "read"})
41+ if toolEnd.Type != "tool_execution_started" || toolEnd.ID != "call-1" || toolEnd.Name != "read" {
42+ t.Fatalf("tool execution = %#v", toolEnd)
43+ }
44+}
M packages/agent/skills/builtin/write-zut-themes/SKILL.md +5 -7
@@ -88,7 +88,6 @@ Top-level spinner overrides are valid and apply to both modes.
8888 "name": "custom-spinner",
8989 "description": "Only changes the busy spinner.",
9090 "spinner_frames": ["◢", "◣", "◤", "◥"],
91- "spinner_messages": ["working"],
9291 "spinner_interval_ms": 120
9392 }
9493 ```
@@ -103,11 +102,10 @@ settings are used on dark defaults.
103102 ```json
104103 {
105104 "name": "custom-spinner",
106- "description": "An alternative spinner for zut that only displays a single spinner text.",
105+ "description": "An alternative busy-spinner animation for zut.",
107106 "colors": {
108107 "dark": {
109108 "spinner_frames": ["◢", "◣", "◤", "◥"],
110- "spinner_messages": ["working"],
111109 "spinner_interval_ms": 120
112110 }
113111 }
@@ -142,7 +140,6 @@ All fields below are optional.
142140 "selection_bg": 24,
143141 "selection_fg": 231,
144142 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
145- "spinner_messages": ["thinking", "working"],
146143 "spinner_interval_ms": 80,
147144 "syntax_base_style": "monokai",
148145 "syntax": {
@@ -183,7 +180,7 @@ under `colors`, when they should apply to both modes:
183180 "name": "tiny",
184181 "accent": 204,
185182 "colors": {
186- "spinner_messages": ["shipping"]
183+ "spinner_frames": ["◐", "◓", "◑", "◒"]
187184 }
188185 }
189186 ```
@@ -230,16 +227,17 @@ Spinner settings can appear at top level, under `colors`, or under
230227
231228 - `spinner_frames` — list of frame strings. Use single-cell glyphs
232229 when possible so status-bar alignment stays clean.
233-- `spinner_messages` — list of messages; zut chooses one per turn.
234230 - `spinner_interval_ms` — frame interval in milliseconds; must be
235231 positive. Missing/invalid falls back to 80ms.
236232
233+The working-status label belongs to zut and always describes the current
234+operation. The legacy `spinner_messages` field is ignored.
235+
237236 Example:
238237
239238 ```json
240239 {
241240 "spinner_frames": ["◐", "◓", "◑", "◒"],
242- "spinner_messages": ["shipping pixels", "warming edge cache"],
243241 "spinner_interval_ms": 120
244242 }
245243 ```
@@ -88,7 +88,6 @@ Top-level spinner overrides are valid and apply to both modes.
88 "name": "custom-spinner",88 "name": "custom-spinner",
89 "description": "Only changes the busy spinner.",89 "description": "Only changes the busy spinner.",
90 "spinner_frames": ["◢", "◣", "◤", "◥"],90 "spinner_frames": ["◢", "◣", "◤", "◥"],
91- "spinner_messages": ["working"],
92 "spinner_interval_ms": 12091 "spinner_interval_ms": 120
93 }92 }
94 ```93 ```
@@ -103,11 +102,10 @@ settings are used on dark defaults.
103 ```json102 ```json
104 {103 {
105 "name": "custom-spinner",104 "name": "custom-spinner",
106- "description": "An alternative spinner for zut that only displays a single spinner text.",105+ "description": "An alternative busy-spinner animation for zut.",
107 "colors": {106 "colors": {
108 "dark": {107 "dark": {
109 "spinner_frames": ["◢", "◣", "◤", "◥"],108 "spinner_frames": ["◢", "◣", "◤", "◥"],
110- "spinner_messages": ["working"],
111 "spinner_interval_ms": 120109 "spinner_interval_ms": 120
112 }110 }
113 }111 }
@@ -142,7 +140,6 @@ All fields below are optional.
142 "selection_bg": 24,140 "selection_bg": 24,
143 "selection_fg": 231,141 "selection_fg": 231,
144 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],142 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
145- "spinner_messages": ["thinking", "working"],
146 "spinner_interval_ms": 80,143 "spinner_interval_ms": 80,
147 "syntax_base_style": "monokai",144 "syntax_base_style": "monokai",
148 "syntax": {145 "syntax": {
@@ -183,7 +180,7 @@ under `colors`, when they should apply to both modes:
183 "name": "tiny",180 "name": "tiny",
184 "accent": 204,181 "accent": 204,
185 "colors": {182 "colors": {
186- "spinner_messages": ["shipping"]183+ "spinner_frames": ["◐", "◓", "◑", "◒"]
187 }184 }
188 }185 }
189 ```186 ```
@@ -230,16 +227,17 @@ Spinner settings can appear at top level, under `colors`, or under
230 227
231 - `spinner_frames` — list of frame strings. Use single-cell glyphs228 - `spinner_frames` — list of frame strings. Use single-cell glyphs
232 when possible so status-bar alignment stays clean.229 when possible so status-bar alignment stays clean.
233-- `spinner_messages` — list of messages; zut chooses one per turn.
234 - `spinner_interval_ms` — frame interval in milliseconds; must be230 - `spinner_interval_ms` — frame interval in milliseconds; must be
235 positive. Missing/invalid falls back to 80ms.231 positive. Missing/invalid falls back to 80ms.
236 232
233+The working-status label belongs to zut and always describes the current
234+operation. The legacy `spinner_messages` field is ignored.
235+
237 Example:236 Example:
238 237
239 ```json238 ```json
240 {239 {
241 "spinner_frames": ["◐", "◓", "◑", "◒"],240 "spinner_frames": ["◐", "◓", "◑", "◒"],
242- "spinner_messages": ["shipping pixels", "warming edge cache"],
243 "spinner_interval_ms": 120241 "spinner_interval_ms": 120
244 }242 }
245 ```243 ```
M packages/core/agent.go +55 -4
@@ -474,14 +474,25 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
474474 assistantMsg provider.Message
475475 err error
476476 )
477+ maxAttempts := max(a.MaxRetries+1, 1)
477478 for attempt := 0; ; attempt++ {
478- stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext)
479+ stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext, attempt, maxAttempts)
479480 sink(EvTurnEnd{Stop: stop, Err: err})
480481 if err == nil || !a.canRetryError(err, attempt) {
481482 break
482483 }
483484 a.dropLastAssistantMessage()
484- if sleepErr := sleepRetry(ctx, a.retryDelay(attempt)); sleepErr != nil {
485+ delay := a.retryDelay(attempt)
486+ if retryErr := ctx.Err(); retryErr != nil {
487+ return retryErr
488+ }
489+ sink(EvRetryScheduled{
490+ Scope: RetryScopeAgent,
491+ Attempt: attempt + 2,
492+ MaxAttempts: maxAttempts,
493+ Delay: delay,
494+ })
495+ if sleepErr := sleepRetry(ctx, delay); sleepErr != nil {
485496 return sleepErr
486497 }
487498 }
@@ -633,9 +644,36 @@ func (a *Agent) dropLastAssistantMessage() {
633644 }
634645 }
635646
647+// requestLifecycleSink adapts provider-owned pre-stream retries into core
648+// events without letting provider concerns leak into mode renderers.
649+type requestLifecycleSink struct {
650+ sink func(AgentEvent)
651+ provider string
652+ model string
653+}
654+
655+func (s requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) {
656+ s.sink(EvRequestStarted{
657+ Provider: s.provider,
658+ Model: s.model,
659+ Scope: RetryScopeProvider,
660+ Attempt: attempt,
661+ MaxAttempts: maxAttempts,
662+ })
663+}
664+
665+func (s requestLifecycleSink) RetryScheduled(attempt, maxAttempts int, delay time.Duration) {
666+ s.sink(EvRetryScheduled{
667+ Scope: RetryScopeProvider,
668+ Attempt: attempt,
669+ MaxAttempts: maxAttempts,
670+ Delay: delay,
671+ })
672+}
673+
636674 // oneTurn calls the LLM once, forwards events, returns the stop reason
637675 // and the assembled assistant message (already appended to the transcript).
638-func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string) (provider.StopReason, provider.Message, error) {
676+func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string, attempt, maxAttempts int) (provider.StopReason, provider.Message, error) {
639677 fastMode := a.FastModeEnabled()
640678 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
641679 return provider.StopError, provider.Message{}, err
@@ -664,7 +702,19 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
664702 FastMode: fastMode,
665703 MaxTokens: a.MaxTokens,
666704 Temperature: a.Temperature,
667- }
705+ Lifecycle: requestLifecycleSink{
706+ sink: sink,
707+ provider: a.Client.Name(),
708+ model: a.Model,
709+ },
710+ }
711+ sink(EvRequestStarted{
712+ Provider: a.Client.Name(),
713+ Model: a.Model,
714+ Scope: RetryScopeAgent,
715+ Attempt: attempt + 1,
716+ MaxAttempts: maxAttempts,
717+ })
668718 stream, err := a.Client.Stream(ctx, req)
669719 if err != nil {
670720 return provider.StopError, provider.Message{}, err
@@ -852,6 +902,7 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
852902 }
853903 }
854904 }()
905+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
855906 out, err := tool.Execute(ctx, args, func(text string) {
856907 sink(EvToolProgress{ID: tc.ID, Text: text})
857908 })
@@ -474,14 +474,25 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
474 assistantMsg provider.Message474 assistantMsg provider.Message
475 err error475 err error
476 )476 )
477+ maxAttempts := max(a.MaxRetries+1, 1)
477 for attempt := 0; ; attempt++ {478 for attempt := 0; ; attempt++ {
478- stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext)479+ stop, assistantMsg, err = a.oneTurn(ctx, sink, turnContext, attempt, maxAttempts)
479 sink(EvTurnEnd{Stop: stop, Err: err})480 sink(EvTurnEnd{Stop: stop, Err: err})
480 if err == nil || !a.canRetryError(err, attempt) {481 if err == nil || !a.canRetryError(err, attempt) {
481 break482 break
482 }483 }
483 a.dropLastAssistantMessage()484 a.dropLastAssistantMessage()
484- if sleepErr := sleepRetry(ctx, a.retryDelay(attempt)); sleepErr != nil {485+ delay := a.retryDelay(attempt)
486+ if retryErr := ctx.Err(); retryErr != nil {
487+ return retryErr
488+ }
489+ sink(EvRetryScheduled{
490+ Scope: RetryScopeAgent,
491+ Attempt: attempt + 2,
492+ MaxAttempts: maxAttempts,
493+ Delay: delay,
494+ })
495+ if sleepErr := sleepRetry(ctx, delay); sleepErr != nil {
485 return sleepErr496 return sleepErr
486 }497 }
487 }498 }
@@ -633,9 +644,36 @@ func (a *Agent) dropLastAssistantMessage() {
633 }644 }
634 }645 }
635 646
647+// requestLifecycleSink adapts provider-owned pre-stream retries into core
648+// events without letting provider concerns leak into mode renderers.
649+type requestLifecycleSink struct {
650+ sink func(AgentEvent)
651+ provider string
652+ model string
653+}
654+
655+func (s requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) {
656+ s.sink(EvRequestStarted{
657+ Provider: s.provider,
658+ Model: s.model,
659+ Scope: RetryScopeProvider,
660+ Attempt: attempt,
661+ MaxAttempts: maxAttempts,
662+ })
663+}
664+
665+func (s requestLifecycleSink) RetryScheduled(attempt, maxAttempts int, delay time.Duration) {
666+ s.sink(EvRetryScheduled{
667+ Scope: RetryScopeProvider,
668+ Attempt: attempt,
669+ MaxAttempts: maxAttempts,
670+ Delay: delay,
671+ })
672+}
673+
636 // oneTurn calls the LLM once, forwards events, returns the stop reason674 // oneTurn calls the LLM once, forwards events, returns the stop reason
637 // and the assembled assistant message (already appended to the transcript).675 // and the assembled assistant message (already appended to the transcript).
638-func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string) (provider.StopReason, provider.Message, error) {676+func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext string, attempt, maxAttempts int) (provider.StopReason, provider.Message, error) {
639 fastMode := a.FastModeEnabled()677 fastMode := a.FastModeEnabled()
640 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {678 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
641 return provider.StopError, provider.Message{}, err679 return provider.StopError, provider.Message{}, err
@@ -664,7 +702,19 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
664 FastMode: fastMode,702 FastMode: fastMode,
665 MaxTokens: a.MaxTokens,703 MaxTokens: a.MaxTokens,
666 Temperature: a.Temperature,704 Temperature: a.Temperature,
667- }705+ Lifecycle: requestLifecycleSink{
706+ sink: sink,
707+ provider: a.Client.Name(),
708+ model: a.Model,
709+ },
710+ }
711+ sink(EvRequestStarted{
712+ Provider: a.Client.Name(),
713+ Model: a.Model,
714+ Scope: RetryScopeAgent,
715+ Attempt: attempt + 1,
716+ MaxAttempts: maxAttempts,
717+ })
668 stream, err := a.Client.Stream(ctx, req)718 stream, err := a.Client.Stream(ctx, req)
669 if err != nil {719 if err != nil {
670 return provider.StopError, provider.Message{}, err720 return provider.StopError, provider.Message{}, err
@@ -852,6 +902,7 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
852 }902 }
853 }903 }
854 }()904 }()
905+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
855 out, err := tool.Execute(ctx, args, func(text string) {906 out, err := tool.Execute(ctx, args, func(text string) {
856 sink(EvToolProgress{ID: tc.ID, Text: text})907 sink(EvToolProgress{ID: tc.ID, Text: text})
857 })908 })
M packages/core/agent_retry_test.go +66 -0
@@ -20,6 +20,9 @@ type retryFakeClient struct {
2020 func (c *retryFakeClient) Name() string { return "retry-fake" }
2121
2222 func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
23+ if req.Lifecycle != nil {
24+ req.Lifecycle.RequestAttempt(1, 1)
25+ }
2326 call := atomic.AddInt32(&c.calls, 1)
2427 out := make(chan provider.Event, 4)
2528 go func() {
@@ -67,6 +70,35 @@ func TestAgentRetriesOverloadedStreamError(t *testing.T) {
6770 }
6871 }
6972
73+func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {
74+ client := &retryFakeClient{}
75+ a := NewAgent(client, "fake-model", "system", Registry{})
76+ a.RetryBaseDelay = time.Millisecond
77+
78+ var lifecycle []string
79+ if err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
80+ switch e := ev.(type) {
81+ case EvRequestStarted:
82+ lifecycle = append(lifecycle, fmt.Sprintf("request:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
83+ case EvRetryScheduled:
84+ lifecycle = append(lifecycle, fmt.Sprintf("retry:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
85+ }
86+ }); err != nil {
87+ t.Fatalf("Prompt returned %v", err)
88+ }
89+
90+ want := []string{
91+ "request:agent:1/4",
92+ "request:provider:1/1",
93+ "retry:agent:2/4",
94+ "request:agent:2/4",
95+ "request:provider:1/1",
96+ }
97+ if strings.Join(lifecycle, ",") != strings.Join(want, ",") {
98+ t.Fatalf("lifecycle = %v, want %v", lifecycle, want)
99+ }
100+}
101+
70102 // codexRetryFakeClient reproduces a transient OpenAI Codex backend
71103 // failure on the first call and succeeds afterwards.
72104 type codexRetryFakeClient struct {
@@ -288,3 +320,37 @@ func TestAgentPropagatesTemperature(t *testing.T) {
288320 t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
289321 }
290322 }
323+
324+type cancelledRetryClient struct {
325+ cancel context.CancelFunc
326+}
327+
328+func (c *cancelledRetryClient) Name() string { return "cancelled-retry" }
329+
330+func (c *cancelledRetryClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
331+ out := make(chan provider.Event, 1)
332+ c.cancel()
333+ out <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider overloaded")}
334+ close(out)
335+ return out, nil
336+}
337+
338+func TestAgentDoesNotReportRetryAfterCancellation(t *testing.T) {
339+ ctx, cancel := context.WithCancel(context.Background())
340+ client := &cancelledRetryClient{cancel: cancel}
341+ agent := NewAgent(client, "fake-model", "system", Registry{})
342+ agent.MaxRetries = 1
343+
344+ var retries []EvRetryScheduled
345+ err := agent.Prompt(ctx, "hello", nil, func(event AgentEvent) {
346+ if retry, ok := event.(EvRetryScheduled); ok {
347+ retries = append(retries, retry)
348+ }
349+ })
350+ if !errors.Is(err, context.Canceled) {
351+ t.Fatalf("Prompt error = %v, want context.Canceled", err)
352+ }
353+ if len(retries) != 0 {
354+ t.Fatalf("retry events = %#v, want none after cancellation", retries)
355+ }
356+}
@@ -20,6 +20,9 @@ type retryFakeClient struct {
20 func (c *retryFakeClient) Name() string { return "retry-fake" }20 func (c *retryFakeClient) Name() string { return "retry-fake" }
21 21
22 func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {22 func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
23+ if req.Lifecycle != nil {
24+ req.Lifecycle.RequestAttempt(1, 1)
25+ }
23 call := atomic.AddInt32(&c.calls, 1)26 call := atomic.AddInt32(&c.calls, 1)
24 out := make(chan provider.Event, 4)27 out := make(chan provider.Event, 4)
25 go func() {28 go func() {
@@ -67,6 +70,35 @@ func TestAgentRetriesOverloadedStreamError(t *testing.T) {
67 }70 }
68 }71 }
69 72
73+func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {
74+ client := &retryFakeClient{}
75+ a := NewAgent(client, "fake-model", "system", Registry{})
76+ a.RetryBaseDelay = time.Millisecond
77+
78+ var lifecycle []string
79+ if err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
80+ switch e := ev.(type) {
81+ case EvRequestStarted:
82+ lifecycle = append(lifecycle, fmt.Sprintf("request:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
83+ case EvRetryScheduled:
84+ lifecycle = append(lifecycle, fmt.Sprintf("retry:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
85+ }
86+ }); err != nil {
87+ t.Fatalf("Prompt returned %v", err)
88+ }
89+
90+ want := []string{
91+ "request:agent:1/4",
92+ "request:provider:1/1",
93+ "retry:agent:2/4",
94+ "request:agent:2/4",
95+ "request:provider:1/1",
96+ }
97+ if strings.Join(lifecycle, ",") != strings.Join(want, ",") {
98+ t.Fatalf("lifecycle = %v, want %v", lifecycle, want)
99+ }
100+}
101+
70 // codexRetryFakeClient reproduces a transient OpenAI Codex backend102 // codexRetryFakeClient reproduces a transient OpenAI Codex backend
71 // failure on the first call and succeeds afterwards.103 // failure on the first call and succeeds afterwards.
72 type codexRetryFakeClient struct {104 type codexRetryFakeClient struct {
@@ -288,3 +320,37 @@ func TestAgentPropagatesTemperature(t *testing.T) {
288 t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)320 t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
289 }321 }
290 }322 }
323+
324+type cancelledRetryClient struct {
325+ cancel context.CancelFunc
326+}
327+
328+func (c *cancelledRetryClient) Name() string { return "cancelled-retry" }
329+
330+func (c *cancelledRetryClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
331+ out := make(chan provider.Event, 1)
332+ c.cancel()
333+ out <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider overloaded")}
334+ close(out)
335+ return out, nil
336+}
337+
338+func TestAgentDoesNotReportRetryAfterCancellation(t *testing.T) {
339+ ctx, cancel := context.WithCancel(context.Background())
340+ client := &cancelledRetryClient{cancel: cancel}
341+ agent := NewAgent(client, "fake-model", "system", Registry{})
342+ agent.MaxRetries = 1
343+
344+ var retries []EvRetryScheduled
345+ err := agent.Prompt(ctx, "hello", nil, func(event AgentEvent) {
346+ if retry, ok := event.(EvRetryScheduled); ok {
347+ retries = append(retries, retry)
348+ }
349+ })
350+ if !errors.Is(err, context.Canceled) {
351+ t.Fatalf("Prompt error = %v, want context.Canceled", err)
352+ }
353+ if len(retries) != 0 {
354+ t.Fatalf("retry events = %#v, want none after cancellation", retries)
355+ }
356+}
M packages/core/compact.go +36 -5
@@ -18,10 +18,21 @@ import (
1818 // the summary. 0 means summarize everything; a typical useful value is
1919 // 4-8 (last couple of exchanges).
2020 //
21-// The method blocks until the summary request completes. Emitted
22-// events via sink are limited to text deltas from the summary call so
23-// the UI can show progress.
21+// The method blocks until the summary request completes. The optional sink
22+// receives text deltas from the summary call.
2423 func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta string)) (summary string, err error) {
24+ return a.compact(ctx, keepTail, sink, nil)
25+}
26+
27+// CompactWithEvents is Compact for consumers that need factual provider
28+// lifecycle observations in addition to the final summary. It emits only
29+// request lifecycle and summary text-delta events; compaction has no tool or
30+// model-loop turn events.
31+func (a *Agent) CompactWithEvents(ctx context.Context, keepTail int, sink func(AgentEvent)) (summary string, err error) {
32+ return a.compact(ctx, keepTail, nil, sink)
33+}
34+
35+func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta string), eventSink func(AgentEvent)) (summary string, err error) {
2536 a.mu.Lock()
2637 msgs := append([]provider.Message(nil), a.messages...)
2738 a.mu.Unlock()
@@ -65,19 +76,39 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
6576 },
6677 },
6778 }
79+ if eventSink != nil {
80+ req.Lifecycle = requestLifecycleSink{
81+ sink: eventSink,
82+ provider: a.Client.Name(),
83+ model: a.Model,
84+ }
85+ eventSink(EvRequestStarted{
86+ Provider: a.Client.Name(),
87+ Model: a.Model,
88+ Scope: RetryScopeAgent,
89+ Attempt: 1,
90+ MaxAttempts: 1,
91+ })
92+ }
6893
6994 stream, err := a.Client.Stream(ctx, req)
7095 if err != nil {
7196 return "", err
7297 }
98+ if eventSink != nil {
99+ eventSink(EvAssistantStart{})
100+ }
73101
74102 var sb strings.Builder
75103 for ev := range stream {
76104 switch e := ev.(type) {
77105 case provider.EventTextDelta:
78106 sb.WriteString(e.Delta)
79- if sink != nil {
80- sink(e.Delta)
107+ if textSink != nil {
108+ textSink(e.Delta)
109+ }
110+ if eventSink != nil {
111+ eventSink(EvTextDelta{Delta: e.Delta})
81112 }
82113 case provider.EventDone:
83114 if e.Err != nil {
@@ -18,10 +18,21 @@ import (
18 // the summary. 0 means summarize everything; a typical useful value is18 // the summary. 0 means summarize everything; a typical useful value is
19 // 4-8 (last couple of exchanges).19 // 4-8 (last couple of exchanges).
20 //20 //
21-// The method blocks until the summary request completes. Emitted21+// The method blocks until the summary request completes. The optional sink
22-// events via sink are limited to text deltas from the summary call so22+// receives text deltas from the summary call.
23-// the UI can show progress.
24 func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta string)) (summary string, err error) {23 func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta string)) (summary string, err error) {
24+ return a.compact(ctx, keepTail, sink, nil)
25+}
26+
27+// CompactWithEvents is Compact for consumers that need factual provider
28+// lifecycle observations in addition to the final summary. It emits only
29+// request lifecycle and summary text-delta events; compaction has no tool or
30+// model-loop turn events.
31+func (a *Agent) CompactWithEvents(ctx context.Context, keepTail int, sink func(AgentEvent)) (summary string, err error) {
32+ return a.compact(ctx, keepTail, nil, sink)
33+}
34+
35+func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta string), eventSink func(AgentEvent)) (summary string, err error) {
25 a.mu.Lock()36 a.mu.Lock()
26 msgs := append([]provider.Message(nil), a.messages...)37 msgs := append([]provider.Message(nil), a.messages...)
27 a.mu.Unlock()38 a.mu.Unlock()
@@ -65,19 +76,39 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
65 },76 },
66 },77 },
67 }78 }
79+ if eventSink != nil {
80+ req.Lifecycle = requestLifecycleSink{
81+ sink: eventSink,
82+ provider: a.Client.Name(),
83+ model: a.Model,
84+ }
85+ eventSink(EvRequestStarted{
86+ Provider: a.Client.Name(),
87+ Model: a.Model,
88+ Scope: RetryScopeAgent,
89+ Attempt: 1,
90+ MaxAttempts: 1,
91+ })
92+ }
68 93
69 stream, err := a.Client.Stream(ctx, req)94 stream, err := a.Client.Stream(ctx, req)
70 if err != nil {95 if err != nil {
71 return "", err96 return "", err
72 }97 }
98+ if eventSink != nil {
99+ eventSink(EvAssistantStart{})
100+ }
73 101
74 var sb strings.Builder102 var sb strings.Builder
75 for ev := range stream {103 for ev := range stream {
76 switch e := ev.(type) {104 switch e := ev.(type) {
77 case provider.EventTextDelta:105 case provider.EventTextDelta:
78 sb.WriteString(e.Delta)106 sb.WriteString(e.Delta)
79- if sink != nil {107+ if textSink != nil {
80- sink(e.Delta)108+ textSink(e.Delta)
109+ }
110+ if eventSink != nil {
111+ eventSink(EvTextDelta{Delta: e.Delta})
81 }112 }
82 case provider.EventDone:113 case provider.EventDone:
83 if e.Err != nil {114 if e.Err != nil {
A packages/core/compact_lifecycle_test.go +70 -0
@@ -0,0 +1,70 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+type compactLifecycleClient struct {
11+ hadLifecycle bool
12+}
13+
14+func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }
15+
16+func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
17+ c.hadLifecycle = req.Lifecycle != nil
18+ if req.Lifecycle != nil {
19+ req.Lifecycle.RequestAttempt(1, 1)
20+ }
21+ out := make(chan provider.Event, 2)
22+ out <- provider.EventTextDelta{Delta: "summary"}
23+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
24+ Role: provider.RoleAssistant,
25+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
26+ }}
27+ close(out)
28+ return out, nil
29+}
30+
31+func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {
32+ client := &compactLifecycleClient{}
33+ agent := NewAgent(client, "compact-model", "system", Registry{})
34+ agent.SetMessages([]provider.Message{{
35+ Role: provider.RoleUser,
36+ Content: []provider.Content{provider.TextBlock{Text: "source"}},
37+ }})
38+
39+ var events []AgentEvent
40+ summary, err := agent.CompactWithEvents(context.Background(), 0, func(event AgentEvent) {
41+ events = append(events, event)
42+ })
43+ if err != nil {
44+ t.Fatalf("CompactWithEvents: %v", err)
45+ }
46+ if summary != "summary" {
47+ t.Fatalf("summary = %q, want summary", summary)
48+ }
49+ if !client.hadLifecycle {
50+ t.Fatal("compaction request did not receive a lifecycle observer")
51+ }
52+
53+ if len(events) != 4 {
54+ t.Fatalf("events = %#v, want agent request, provider request, assistant start, text delta", events)
55+ }
56+ agentRequest, ok := events[0].(EvRequestStarted)
57+ if !ok || agentRequest.Scope != RetryScopeAgent || agentRequest.Attempt != 1 || agentRequest.MaxAttempts != 1 {
58+ t.Fatalf("first event = %#v, want initial agent request", events[0])
59+ }
60+ providerRequest, ok := events[1].(EvRequestStarted)
61+ if !ok || providerRequest.Scope != RetryScopeProvider || providerRequest.Attempt != 1 || providerRequest.MaxAttempts != 1 {
62+ t.Fatalf("second event = %#v, want initial provider request", events[1])
63+ }
64+ if _, ok := events[2].(EvAssistantStart); !ok {
65+ t.Fatalf("third event = %#v, want assistant start", events[2])
66+ }
67+ if delta, ok := events[3].(EvTextDelta); !ok || delta.Delta != "summary" {
68+ t.Fatalf("fourth event = %#v, want summary text delta", events[3])
69+ }
70+}
@@ -0,0 +1,70 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+type compactLifecycleClient struct {
11+ hadLifecycle bool
12+}
13+
14+func (c *compactLifecycleClient) Name() string { return "compact-lifecycle" }
15+
16+func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
17+ c.hadLifecycle = req.Lifecycle != nil
18+ if req.Lifecycle != nil {
19+ req.Lifecycle.RequestAttempt(1, 1)
20+ }
21+ out := make(chan provider.Event, 2)
22+ out <- provider.EventTextDelta{Delta: "summary"}
23+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
24+ Role: provider.RoleAssistant,
25+ Content: []provider.Content{provider.TextBlock{Text: "summary"}},
26+ }}
27+ close(out)
28+ return out, nil
29+}
30+
31+func TestCompactWithEventsEmitsRequestLifecycle(t *testing.T) {
32+ client := &compactLifecycleClient{}
33+ agent := NewAgent(client, "compact-model", "system", Registry{})
34+ agent.SetMessages([]provider.Message{{
35+ Role: provider.RoleUser,
36+ Content: []provider.Content{provider.TextBlock{Text: "source"}},
37+ }})
38+
39+ var events []AgentEvent
40+ summary, err := agent.CompactWithEvents(context.Background(), 0, func(event AgentEvent) {
41+ events = append(events, event)
42+ })
43+ if err != nil {
44+ t.Fatalf("CompactWithEvents: %v", err)
45+ }
46+ if summary != "summary" {
47+ t.Fatalf("summary = %q, want summary", summary)
48+ }
49+ if !client.hadLifecycle {
50+ t.Fatal("compaction request did not receive a lifecycle observer")
51+ }
52+
53+ if len(events) != 4 {
54+ t.Fatalf("events = %#v, want agent request, provider request, assistant start, text delta", events)
55+ }
56+ agentRequest, ok := events[0].(EvRequestStarted)
57+ if !ok || agentRequest.Scope != RetryScopeAgent || agentRequest.Attempt != 1 || agentRequest.MaxAttempts != 1 {
58+ t.Fatalf("first event = %#v, want initial agent request", events[0])
59+ }
60+ providerRequest, ok := events[1].(EvRequestStarted)
61+ if !ok || providerRequest.Scope != RetryScopeProvider || providerRequest.Attempt != 1 || providerRequest.MaxAttempts != 1 {
62+ t.Fatalf("second event = %#v, want initial provider request", events[1])
63+ }
64+ if _, ok := events[2].(EvAssistantStart); !ok {
65+ t.Fatalf("third event = %#v, want assistant start", events[2])
66+ }
67+ if delta, ok := events[3].(EvTextDelta); !ok || delta.Delta != "summary" {
68+ t.Fatalf("fourth event = %#v, want summary text delta", events[3])
69+ }
70+}
M packages/core/confirm.go +3 -0
@@ -44,6 +44,9 @@ type ToolCallConfirmation struct {
4444 Name string
4545 Summary string
4646 Content string
47+ // Origin routes a confirmation to its owning host view. It is opaque to
48+ // the confirmation gate and never shown to the model.
49+ Origin string
4750 }
4851
4952 // ToolCallConfirmer is optionally implemented by confirmation UIs that can
@@ -44,6 +44,9 @@ type ToolCallConfirmation struct {
44 Name string44 Name string
45 Summary string45 Summary string
46 Content string46 Content string
47+ // Origin routes a confirmation to its owning host view. It is opaque to
48+ // the confirmation gate and never shown to the model.
49+ Origin string
47 }50 }
48 51
49 // ToolCallConfirmer is optionally implemented by confirmation UIs that can52 // ToolCallConfirmer is optionally implemented by confirmation UIs that can
M packages/core/events.go +39 -0
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "encoding/json"
5+ "time"
56
67 "github.com/bnema/zut/packages/provider"
78 )
@@ -29,6 +30,35 @@ type EvAssistantStart struct{}
2930
3031 func (EvAssistantStart) Type() string { return "assistant_start" }
3132
33+// RetryScope identifies the retry layer that issued an activity event.
34+type RetryScope string
35+
36+const (
37+ RetryScopeProvider RetryScope = "provider"
38+ RetryScopeAgent RetryScope = "agent"
39+)
40+
41+// EvRequestStarted reports an outbound provider request attempt.
42+type EvRequestStarted struct {
43+ Provider string
44+ Model string
45+ Scope RetryScope
46+ Attempt int
47+ MaxAttempts int
48+}
49+
50+func (EvRequestStarted) Type() string { return "request_started" }
51+
52+// EvRetryScheduled reports a retry delay before its upcoming attempt.
53+type EvRetryScheduled struct {
54+ Scope RetryScope
55+ Attempt int
56+ MaxAttempts int
57+ Delay time.Duration
58+}
59+
60+func (EvRetryScheduled) Type() string { return "retry_scheduled" }
61+
3262 type EvTextDelta struct {
3363 Delta string
3464 }
@@ -43,6 +73,15 @@ type EvToolCall struct {
4373
4474 func (EvToolCall) Type() string { return "tool_call" }
4575
76+// EvToolExecutionStarted fires after guards and confirmation allow a call,
77+// immediately before the tool starts executing.
78+type EvToolExecutionStarted struct {
79+ ID string
80+ Name string
81+}
82+
83+func (EvToolExecutionStarted) Type() string { return "tool_execution_started" }
84+
4685 // EvToolUseStart fires the moment the provider announces a new
4786 // tool_use block during streaming, before any arg JSON has
4887 // arrived. Gives UIs a hook to pre-render a live "tool is being
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5+ "time"
5 6
6 "github.com/bnema/zut/packages/provider"7 "github.com/bnema/zut/packages/provider"
7 )8 )
@@ -29,6 +30,35 @@ type EvAssistantStart struct{}
29 30
30 func (EvAssistantStart) Type() string { return "assistant_start" }31 func (EvAssistantStart) Type() string { return "assistant_start" }
31 32
33+// RetryScope identifies the retry layer that issued an activity event.
34+type RetryScope string
35+
36+const (
37+ RetryScopeProvider RetryScope = "provider"
38+ RetryScopeAgent RetryScope = "agent"
39+)
40+
41+// EvRequestStarted reports an outbound provider request attempt.
42+type EvRequestStarted struct {
43+ Provider string
44+ Model string
45+ Scope RetryScope
46+ Attempt int
47+ MaxAttempts int
48+}
49+
50+func (EvRequestStarted) Type() string { return "request_started" }
51+
52+// EvRetryScheduled reports a retry delay before its upcoming attempt.
53+type EvRetryScheduled struct {
54+ Scope RetryScope
55+ Attempt int
56+ MaxAttempts int
57+ Delay time.Duration
58+}
59+
60+func (EvRetryScheduled) Type() string { return "retry_scheduled" }
61+
32 type EvTextDelta struct {62 type EvTextDelta struct {
33 Delta string63 Delta string
34 }64 }
@@ -43,6 +73,15 @@ type EvToolCall struct {
43 73
44 func (EvToolCall) Type() string { return "tool_call" }74 func (EvToolCall) Type() string { return "tool_call" }
45 75
76+// EvToolExecutionStarted fires after guards and confirmation allow a call,
77+// immediately before the tool starts executing.
78+type EvToolExecutionStarted struct {
79+ ID string
80+ Name string
81+}
82+
83+func (EvToolExecutionStarted) Type() string { return "tool_execution_started" }
84+
46 // EvToolUseStart fires the moment the provider announces a new85 // EvToolUseStart fires the moment the provider announces a new
47 // tool_use block during streaming, before any arg JSON has86 // tool_use block during streaming, before any arg JSON has
48 // arrived. Gives UIs a hook to pre-render a live "tool is being87 // arrived. Gives UIs a hook to pre-render a live "tool is being
M packages/core/intercept_test.go +20 -0
@@ -78,6 +78,26 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
7878 }
7979 }
8080
81+func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
82+ rec := &recordingTool{}
83+ a := NewAgent(nil, "test", "", Registry{"echo": rec})
84+
85+ var started []EvToolExecutionStarted
86+ res := a.runOneTool(context.Background(), provider.ToolCallBlock{
87+ ID: "T1", Name: "echo", Arguments: json.RawMessage(`{"command":"echo ok"}`),
88+ }, a.ToolsSnapshot(), func(ev AgentEvent) {
89+ if e, ok := ev.(EvToolExecutionStarted); ok {
90+ started = append(started, e)
91+ }
92+ })
93+ if res.IsError {
94+ t.Fatalf("runOneTool error = %#v", res)
95+ }
96+ if len(started) != 1 || started[0].ID != "T1" || started[0].Name != "echo" {
97+ t.Fatalf("execution events = %#v", started)
98+ }
99+}
100+
81101 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
82102 // the interceptor returns an error ToolResult with the reason text.
83103 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
@@ -78,6 +78,26 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
78 }78 }
79 }79 }
80 80
81+func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
82+ rec := &recordingTool{}
83+ a := NewAgent(nil, "test", "", Registry{"echo": rec})
84+
85+ var started []EvToolExecutionStarted
86+ res := a.runOneTool(context.Background(), provider.ToolCallBlock{
87+ ID: "T1", Name: "echo", Arguments: json.RawMessage(`{"command":"echo ok"}`),
88+ }, a.ToolsSnapshot(), func(ev AgentEvent) {
89+ if e, ok := ev.(EvToolExecutionStarted); ok {
90+ started = append(started, e)
91+ }
92+ })
93+ if res.IsError {
94+ t.Fatalf("runOneTool error = %#v", res)
95+ }
96+ if len(started) != 1 || started[0].ID != "T1" || started[0].Name != "echo" {
97+ t.Fatalf("execution events = %#v", started)
98+ }
99+}
100+
81 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from101 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
82 // the interceptor returns an error ToolResult with the reason text.102 // the interceptor returns an error ToolResult with the reason text.
83 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {103 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
M packages/provider/amazon_bedrock.go +6 -0
@@ -600,6 +600,12 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
600600 } else {
601601 return nil, fmt.Errorf("bedrock: no auth configured")
602602 }
603+ if err := ctx.Err(); err != nil {
604+ return nil, err
605+ }
606+ if req.Lifecycle != nil {
607+ req.Lifecycle.RequestAttempt(1, 1)
608+ }
603609 resp, err := c.http.Do(httpReq)
604610 if err != nil {
605611 return nil, fmt.Errorf("bedrock: %w", err)
@@ -600,6 +600,12 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
600 } else {600 } else {
601 return nil, fmt.Errorf("bedrock: no auth configured")601 return nil, fmt.Errorf("bedrock: no auth configured")
602 }602 }
603+ if err := ctx.Err(); err != nil {
604+ return nil, err
605+ }
606+ if req.Lifecycle != nil {
607+ req.Lifecycle.RequestAttempt(1, 1)
608+ }
603 resp, err := c.http.Do(httpReq)609 resp, err := c.http.Do(httpReq)
604 if err != nil {610 if err != nil {
605 return nil, fmt.Errorf("bedrock: %w", err)611 return nil, fmt.Errorf("bedrock: %w", err)
M packages/provider/anthropic.go +1 -1
@@ -578,7 +578,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
578578 return httpReq, nil
579579 }
580580
581- resp, err := doStreamWithRetry(ctx, c.http, newReq)
581+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
582582 if err != nil {
583583 return nil, fmt.Errorf("anthropic: %w", err)
584584 }
@@ -578,7 +578,7 @@ func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event
578 return httpReq, nil578 return httpReq, nil
579 }579 }
580 580
581- resp, err := doStreamWithRetry(ctx, c.http, newReq)581+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
582 if err != nil {582 if err != nil {
583 return nil, fmt.Errorf("anthropic: %w", err)583 return nil, fmt.Errorf("anthropic: %w", err)
584 }584 }
M packages/provider/gemini.go +1 -1
@@ -501,7 +501,7 @@ func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
501501 return httpReq, nil
502502 }
503503
504- resp, err := doStreamWithRetry(ctx, c.http, newReq)
504+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
505505 if err != nil {
506506 return nil, fmt.Errorf("google: %w", err)
507507 }
@@ -501,7 +501,7 @@ func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
501 return httpReq, nil501 return httpReq, nil
502 }502 }
503 503
504- resp, err := doStreamWithRetry(ctx, c.http, newReq)504+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
505 if err != nil {505 if err != nil {
506 return nil, fmt.Errorf("google: %w", err)506 return nil, fmt.Errorf("google: %w", err)
507 }507 }
M packages/provider/openai.go +1 -1
@@ -504,7 +504,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
504504 return httpReq, nil
505505 }
506506
507- resp, err := doStreamWithRetry(ctx, c.http, newReq)
507+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
508508 if err != nil {
509509 return nil, fmt.Errorf("%s: %w", c.Name(), err)
510510 }
@@ -504,7 +504,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
504 return httpReq, nil504 return httpReq, nil
505 }505 }
506 506
507- resp, err := doStreamWithRetry(ctx, c.http, newReq)507+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
508 if err != nil {508 if err != nil {
509 return nil, fmt.Errorf("%s: %w", c.Name(), err)509 return nil, fmt.Errorf("%s: %w", c.Name(), err)
510 }510 }
M packages/provider/openai_codex.go +1 -1
@@ -418,7 +418,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
418418 return httpReq, nil
419419 }
420420
421- resp, err := doStreamWithRetry(ctx, c.http, newReq)
421+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
422422 if err != nil {
423423 return nil, fmt.Errorf("openai-codex: %w", err)
424424 }
@@ -418,7 +418,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
418 return httpReq, nil418 return httpReq, nil
419 }419 }
420 420
421- resp, err := doStreamWithRetry(ctx, c.http, newReq)421+ resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
422 if err != nil {422 if err != nil {
423 return nil, fmt.Errorf("openai-codex: %w", err)423 return nil, fmt.Errorf("openai-codex: %w", err)
424 }424 }
M packages/provider/provider.go +14 -0
@@ -51,6 +51,9 @@ type ToolCallBlock struct {
5151 Name string `json:"name"`
5252 Arguments json.RawMessage `json:"arguments"`
5353 ThoughtSignature string `json:"thought_signature,omitempty"`
54+ // Origin is host-only routing metadata. It is never serialized to a
55+ // provider or stored in the transcript.
56+ Origin string `json:"-"`
5457 }
5558
5659 func (ToolCallBlock) isContent() {}
@@ -245,6 +248,14 @@ type EventDone struct {
245248
246249 func (EventDone) isEvent() {}
247250
251+// RequestLifecycle observes request attempts and retry delays without being
252+// serialized onto a provider wire. Implementations must call RequestAttempt
253+// immediately before an outbound attempt and RetryScheduled before sleeping.
254+type RequestLifecycle interface {
255+ RequestAttempt(attempt, maxAttempts int)
256+ RetryScheduled(attempt, maxAttempts int, delay time.Duration)
257+}
258+
248259 // Request is a single LLM call.
249260 type Request struct {
250261 Model string
@@ -260,6 +271,9 @@ type Request struct {
260271 // FastMode requests OpenAI's fast service tier. Providers that do not
261272 // implement the OpenAI service-tier contract reject the request.
262273 FastMode bool
274+ // Lifecycle receives in-memory request-attempt notifications. It is not
275+ // included in any provider request payload.
276+ Lifecycle RequestLifecycle
263277 }
264278
265279 // OpenAI's current Fast mode is represented as the priority service tier on
@@ -51,6 +51,9 @@ type ToolCallBlock struct {
51 Name string `json:"name"`51 Name string `json:"name"`
52 Arguments json.RawMessage `json:"arguments"`52 Arguments json.RawMessage `json:"arguments"`
53 ThoughtSignature string `json:"thought_signature,omitempty"`53 ThoughtSignature string `json:"thought_signature,omitempty"`
54+ // Origin is host-only routing metadata. It is never serialized to a
55+ // provider or stored in the transcript.
56+ Origin string `json:"-"`
54 }57 }
55 58
56 func (ToolCallBlock) isContent() {}59 func (ToolCallBlock) isContent() {}
@@ -245,6 +248,14 @@ type EventDone struct {
245 248
246 func (EventDone) isEvent() {}249 func (EventDone) isEvent() {}
247 250
251+// RequestLifecycle observes request attempts and retry delays without being
252+// serialized onto a provider wire. Implementations must call RequestAttempt
253+// immediately before an outbound attempt and RetryScheduled before sleeping.
254+type RequestLifecycle interface {
255+ RequestAttempt(attempt, maxAttempts int)
256+ RetryScheduled(attempt, maxAttempts int, delay time.Duration)
257+}
258+
248 // Request is a single LLM call.259 // Request is a single LLM call.
249 type Request struct {260 type Request struct {
250 Model string261 Model string
@@ -260,6 +271,9 @@ type Request struct {
260 // FastMode requests OpenAI's fast service tier. Providers that do not271 // FastMode requests OpenAI's fast service tier. Providers that do not
261 // implement the OpenAI service-tier contract reject the request.272 // implement the OpenAI service-tier contract reject the request.
262 FastMode bool273 FastMode bool
274+ // Lifecycle receives in-memory request-attempt notifications. It is not
275+ // included in any provider request payload.
276+ Lifecycle RequestLifecycle
263 }277 }
264 278
265 // OpenAI's current Fast mode is represented as the priority service tier on279 // OpenAI's current Fast mode is represented as the priority service tier on
M packages/provider/retry.go +17 -1
@@ -63,9 +63,10 @@ func streamRetryBackoff(attempt int) time.Duration {
6363 // If every attempt fails, the last error/response is returned so the
6464 // caller can format a normal error message. On context cancellation
6565 // the loop bails out immediately with ctx.Err().
66-func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) {
66+func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error), lifecycle RequestLifecycle) (*http.Response, error) {
6767 var lastErr error
6868 serverDelay := time.Duration(-1) // <0: use the default fixed backoff
69+ maxAttempts := streamRetryAttempts + 1
6970 for attempt := 0; attempt <= streamRetryAttempts; attempt++ {
7071 if attempt > 0 {
7172 delay := streamRetryBackoff(attempt)
@@ -73,16 +74,31 @@ func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (
7374 delay = serverDelay
7475 }
7576 serverDelay = -1
77+ if err := ctx.Err(); err != nil {
78+ return nil, err
79+ }
80+ if lifecycle != nil {
81+ lifecycle.RetryScheduled(attempt+1, maxAttempts, delay)
82+ }
7683 select {
7784 case <-ctx.Done():
7885 return nil, ctx.Err()
7986 case <-time.After(delay):
8087 }
8188 }
89+ if err := ctx.Err(); err != nil {
90+ return nil, err
91+ }
8292 req, err := newReq()
8393 if err != nil {
8494 return nil, err
8595 }
96+ if err := ctx.Err(); err != nil {
97+ return nil, err
98+ }
99+ if lifecycle != nil {
100+ lifecycle.RequestAttempt(attempt+1, maxAttempts)
101+ }
86102 resp, err := client.Do(req)
87103 if err != nil {
88104 lastErr = err
@@ -63,9 +63,10 @@ func streamRetryBackoff(attempt int) time.Duration {
63 // If every attempt fails, the last error/response is returned so the63 // If every attempt fails, the last error/response is returned so the
64 // caller can format a normal error message. On context cancellation64 // caller can format a normal error message. On context cancellation
65 // the loop bails out immediately with ctx.Err().65 // the loop bails out immediately with ctx.Err().
66-func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error)) (*http.Response, error) {66+func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (*http.Request, error), lifecycle RequestLifecycle) (*http.Response, error) {
67 var lastErr error67 var lastErr error
68 serverDelay := time.Duration(-1) // <0: use the default fixed backoff68 serverDelay := time.Duration(-1) // <0: use the default fixed backoff
69+ maxAttempts := streamRetryAttempts + 1
69 for attempt := 0; attempt <= streamRetryAttempts; attempt++ {70 for attempt := 0; attempt <= streamRetryAttempts; attempt++ {
70 if attempt > 0 {71 if attempt > 0 {
71 delay := streamRetryBackoff(attempt)72 delay := streamRetryBackoff(attempt)
@@ -73,16 +74,31 @@ func doStreamWithRetry(ctx context.Context, client *http.Client, newReq func() (
73 delay = serverDelay74 delay = serverDelay
74 }75 }
75 serverDelay = -176 serverDelay = -1
77+ if err := ctx.Err(); err != nil {
78+ return nil, err
79+ }
80+ if lifecycle != nil {
81+ lifecycle.RetryScheduled(attempt+1, maxAttempts, delay)
82+ }
76 select {83 select {
77 case <-ctx.Done():84 case <-ctx.Done():
78 return nil, ctx.Err()85 return nil, ctx.Err()
79 case <-time.After(delay):86 case <-time.After(delay):
80 }87 }
81 }88 }
89+ if err := ctx.Err(); err != nil {
90+ return nil, err
91+ }
82 req, err := newReq()92 req, err := newReq()
83 if err != nil {93 if err != nil {
84 return nil, err94 return nil, err
85 }95 }
96+ if err := ctx.Err(); err != nil {
97+ return nil, err
98+ }
99+ if lifecycle != nil {
100+ lifecycle.RequestAttempt(attempt+1, maxAttempts)
101+ }
86 resp, err := client.Do(req)102 resp, err := client.Do(req)
87 if err != nil {103 if err != nil {
88 lastErr = err104 lastErr = err
M packages/provider/retry_test.go +119 -1
@@ -2,6 +2,7 @@ package provider
22
33 import (
44 "context"
5+ "errors"
56 "net/http"
67 "net/http/httptest"
78 "strings"
@@ -38,7 +39,8 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) {
3839 defer srv.Close()
3940
4041 c := NewOpenAI("x", srv.URL)
41- evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"})
42+ lifecycle := &recordingRequestLifecycle{}
43+ evs, err := c.Stream(context.Background(), Request{Model: "gpt-5", Lifecycle: lifecycle})
4244 if err != nil {
4345 t.Fatalf("stream returned error: %v", err)
4446 }
@@ -54,6 +56,12 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) {
5456 if got := attempts.Load(); got != 3 {
5557 t.Fatalf("attempts=%d want 3", got)
5658 }
59+ if got := lifecycle.attempts; len(got) != 3 || got[0] != (lifecycleAttempt{attempt: 1, max: 3}) || got[2] != (lifecycleAttempt{attempt: 3, max: 3}) {
60+ t.Fatalf("lifecycle attempts = %#v, want 1 through 3", got)
61+ }
62+ if got := lifecycle.retries; len(got) != 2 || got[0].attempt != 2 || got[1].attempt != 3 {
63+ t.Fatalf("lifecycle retries = %#v, want retries before attempts 2 and 3", got)
64+ }
5765 }
5866
5967 // TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when
@@ -208,6 +216,74 @@ func TestOpenAIStreamRejectsExcessiveRetryAfter(t *testing.T) {
208216 }
209217 }
210218
219+type lifecycleAttempt struct {
220+ attempt int
221+ max int
222+}
223+
224+type lifecycleRetry struct {
225+ attempt int
226+ max int
227+ delay time.Duration
228+}
229+
230+type recordingRequestLifecycle struct {
231+ attempts []lifecycleAttempt
232+ retries []lifecycleRetry
233+}
234+
235+func (r *recordingRequestLifecycle) RequestAttempt(attempt, maxAttempts int) {
236+ r.attempts = append(r.attempts, lifecycleAttempt{attempt: attempt, max: maxAttempts})
237+}
238+
239+func (r *recordingRequestLifecycle) RetryScheduled(attempt, maxAttempts int, delay time.Duration) {
240+ r.retries = append(r.retries, lifecycleRetry{attempt: attempt, max: maxAttempts, delay: delay})
241+}
242+
243+func TestDoStreamWithRetryReportsLifecycle(t *testing.T) {
244+ var attempts atomic.Int32
245+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
246+ if attempts.Add(1) == 1 {
247+ w.WriteHeader(http.StatusServiceUnavailable)
248+ return
249+ }
250+ w.WriteHeader(http.StatusOK)
251+ }))
252+ defer srv.Close()
253+
254+ lifecycle := &recordingRequestLifecycle{}
255+ resp, err := doStreamWithRetry(context.Background(), srv.Client(), func() (*http.Request, error) {
256+ return http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL, nil)
257+ }, lifecycle)
258+ if err != nil {
259+ t.Fatalf("doStreamWithRetry: %v", err)
260+ }
261+ resp.Body.Close()
262+
263+ if got := lifecycle.attempts; len(got) != 2 || got[0] != (lifecycleAttempt{attempt: 1, max: 3}) || got[1] != (lifecycleAttempt{attempt: 2, max: 3}) {
264+ t.Fatalf("attempts = %#v, want attempts 1 and 2 of 3", got)
265+ }
266+ if got := lifecycle.retries; len(got) != 1 || got[0].attempt != 2 || got[0].max != 3 || got[0].delay != 250*time.Millisecond {
267+ t.Fatalf("retries = %#v, want attempt 2 of 3 after 250ms", got)
268+ }
269+}
270+
271+func TestDoStreamWithRetryStopsLifecycleAfterCancellation(t *testing.T) {
272+ ctx, cancel := context.WithCancel(context.Background())
273+ cancel()
274+ lifecycle := &recordingRequestLifecycle{}
275+ _, err := doStreamWithRetry(ctx, http.DefaultClient, func() (*http.Request, error) {
276+ t.Fatal("new request called after cancellation")
277+ return nil, nil
278+ }, lifecycle)
279+ if !errors.Is(err, context.Canceled) {
280+ t.Fatalf("error = %v, want context.Canceled", err)
281+ }
282+ if len(lifecycle.attempts) != 0 || len(lifecycle.retries) != 0 {
283+ t.Fatalf("lifecycle after cancellation = %#v %#v", lifecycle.attempts, lifecycle.retries)
284+ }
285+}
286+
211287 func TestRetryAfterDelay(t *testing.T) {
212288 mk := func(kv ...string) http.Header {
213289 h := http.Header{}
@@ -291,3 +367,45 @@ func TestIsTransientConnectError(t *testing.T) {
291367 type stringErr string
292368
293369 func (s stringErr) Error() string { return string(s) }
370+
371+func TestDoStreamWithRetryDoesNotReportAttemptWhenRequestCreationFails(t *testing.T) {
372+ lifecycle := &recordingRequestLifecycle{}
373+ _, err := doStreamWithRetry(context.Background(), http.DefaultClient, func() (*http.Request, error) {
374+ return nil, errors.New("cannot build request")
375+ }, lifecycle)
376+ if err == nil || !strings.Contains(err.Error(), "cannot build request") {
377+ t.Fatalf("error = %v, want request creation failure", err)
378+ }
379+ if len(lifecycle.attempts) != 0 {
380+ t.Fatalf("request attempts = %#v, want none", lifecycle.attempts)
381+ }
382+}
383+
384+type retryRoundTripper func(*http.Request) (*http.Response, error)
385+
386+func (f retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
387+ return f(req)
388+}
389+
390+func TestDoStreamWithRetryDoesNotReportCancelledRetry(t *testing.T) {
391+ ctx, cancel := context.WithCancel(context.Background())
392+ defer cancel()
393+ client := &http.Client{Transport: retryRoundTripper(func(_ *http.Request) (*http.Response, error) {
394+ cancel()
395+ return &http.Response{
396+ StatusCode: http.StatusServiceUnavailable,
397+ Header: make(http.Header),
398+ Body: http.NoBody,
399+ }, nil
400+ })}
401+ lifecycle := &recordingRequestLifecycle{}
402+ _, err := doStreamWithRetry(ctx, client, func() (*http.Request, error) {
403+ return http.NewRequestWithContext(ctx, http.MethodPost, "https://example.test", nil)
404+ }, lifecycle)
405+ if !errors.Is(err, context.Canceled) {
406+ t.Fatalf("error = %v, want context.Canceled", err)
407+ }
408+ if len(lifecycle.retries) != 0 {
409+ t.Fatalf("retry lifecycle = %#v, want none after cancellation", lifecycle.retries)
410+ }
411+}
@@ -2,6 +2,7 @@ package provider
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "net/http"6 "net/http"
6 "net/http/httptest"7 "net/http/httptest"
7 "strings"8 "strings"
@@ -38,7 +39,8 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) {
38 defer srv.Close()39 defer srv.Close()
39 40
40 c := NewOpenAI("x", srv.URL)41 c := NewOpenAI("x", srv.URL)
41- evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"})42+ lifecycle := &recordingRequestLifecycle{}
43+ evs, err := c.Stream(context.Background(), Request{Model: "gpt-5", Lifecycle: lifecycle})
42 if err != nil {44 if err != nil {
43 t.Fatalf("stream returned error: %v", err)45 t.Fatalf("stream returned error: %v", err)
44 }46 }
@@ -54,6 +56,12 @@ func TestOpenAIStreamRetriesOn503(t *testing.T) {
54 if got := attempts.Load(); got != 3 {56 if got := attempts.Load(); got != 3 {
55 t.Fatalf("attempts=%d want 3", got)57 t.Fatalf("attempts=%d want 3", got)
56 }58 }
59+ if got := lifecycle.attempts; len(got) != 3 || got[0] != (lifecycleAttempt{attempt: 1, max: 3}) || got[2] != (lifecycleAttempt{attempt: 3, max: 3}) {
60+ t.Fatalf("lifecycle attempts = %#v, want 1 through 3", got)
61+ }
62+ if got := lifecycle.retries; len(got) != 2 || got[0].attempt != 2 || got[1].attempt != 3 {
63+ t.Fatalf("lifecycle retries = %#v, want retries before attempts 2 and 3", got)
64+ }
57 }65 }
58 66
59 // TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when67 // TestOpenAIStreamSurfaces503AfterRetriesExhausted verifies that when
@@ -208,6 +216,74 @@ func TestOpenAIStreamRejectsExcessiveRetryAfter(t *testing.T) {
208 }216 }
209 }217 }
210 218
219+type lifecycleAttempt struct {
220+ attempt int
221+ max int
222+}
223+
224+type lifecycleRetry struct {
225+ attempt int
226+ max int
227+ delay time.Duration
228+}
229+
230+type recordingRequestLifecycle struct {
231+ attempts []lifecycleAttempt
232+ retries []lifecycleRetry
233+}
234+
235+func (r *recordingRequestLifecycle) RequestAttempt(attempt, maxAttempts int) {
236+ r.attempts = append(r.attempts, lifecycleAttempt{attempt: attempt, max: maxAttempts})
237+}
238+
239+func (r *recordingRequestLifecycle) RetryScheduled(attempt, maxAttempts int, delay time.Duration) {
240+ r.retries = append(r.retries, lifecycleRetry{attempt: attempt, max: maxAttempts, delay: delay})
241+}
242+
243+func TestDoStreamWithRetryReportsLifecycle(t *testing.T) {
244+ var attempts atomic.Int32
245+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
246+ if attempts.Add(1) == 1 {
247+ w.WriteHeader(http.StatusServiceUnavailable)
248+ return
249+ }
250+ w.WriteHeader(http.StatusOK)
251+ }))
252+ defer srv.Close()
253+
254+ lifecycle := &recordingRequestLifecycle{}
255+ resp, err := doStreamWithRetry(context.Background(), srv.Client(), func() (*http.Request, error) {
256+ return http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL, nil)
257+ }, lifecycle)
258+ if err != nil {
259+ t.Fatalf("doStreamWithRetry: %v", err)
260+ }
261+ resp.Body.Close()
262+
263+ if got := lifecycle.attempts; len(got) != 2 || got[0] != (lifecycleAttempt{attempt: 1, max: 3}) || got[1] != (lifecycleAttempt{attempt: 2, max: 3}) {
264+ t.Fatalf("attempts = %#v, want attempts 1 and 2 of 3", got)
265+ }
266+ if got := lifecycle.retries; len(got) != 1 || got[0].attempt != 2 || got[0].max != 3 || got[0].delay != 250*time.Millisecond {
267+ t.Fatalf("retries = %#v, want attempt 2 of 3 after 250ms", got)
268+ }
269+}
270+
271+func TestDoStreamWithRetryStopsLifecycleAfterCancellation(t *testing.T) {
272+ ctx, cancel := context.WithCancel(context.Background())
273+ cancel()
274+ lifecycle := &recordingRequestLifecycle{}
275+ _, err := doStreamWithRetry(ctx, http.DefaultClient, func() (*http.Request, error) {
276+ t.Fatal("new request called after cancellation")
277+ return nil, nil
278+ }, lifecycle)
279+ if !errors.Is(err, context.Canceled) {
280+ t.Fatalf("error = %v, want context.Canceled", err)
281+ }
282+ if len(lifecycle.attempts) != 0 || len(lifecycle.retries) != 0 {
283+ t.Fatalf("lifecycle after cancellation = %#v %#v", lifecycle.attempts, lifecycle.retries)
284+ }
285+}
286+
211 func TestRetryAfterDelay(t *testing.T) {287 func TestRetryAfterDelay(t *testing.T) {
212 mk := func(kv ...string) http.Header {288 mk := func(kv ...string) http.Header {
213 h := http.Header{}289 h := http.Header{}
@@ -291,3 +367,45 @@ func TestIsTransientConnectError(t *testing.T) {
291 type stringErr string367 type stringErr string
292 368
293 func (s stringErr) Error() string { return string(s) }369 func (s stringErr) Error() string { return string(s) }
370+
371+func TestDoStreamWithRetryDoesNotReportAttemptWhenRequestCreationFails(t *testing.T) {
372+ lifecycle := &recordingRequestLifecycle{}
373+ _, err := doStreamWithRetry(context.Background(), http.DefaultClient, func() (*http.Request, error) {
374+ return nil, errors.New("cannot build request")
375+ }, lifecycle)
376+ if err == nil || !strings.Contains(err.Error(), "cannot build request") {
377+ t.Fatalf("error = %v, want request creation failure", err)
378+ }
379+ if len(lifecycle.attempts) != 0 {
380+ t.Fatalf("request attempts = %#v, want none", lifecycle.attempts)
381+ }
382+}
383+
384+type retryRoundTripper func(*http.Request) (*http.Response, error)
385+
386+func (f retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
387+ return f(req)
388+}
389+
390+func TestDoStreamWithRetryDoesNotReportCancelledRetry(t *testing.T) {
391+ ctx, cancel := context.WithCancel(context.Background())
392+ defer cancel()
393+ client := &http.Client{Transport: retryRoundTripper(func(_ *http.Request) (*http.Response, error) {
394+ cancel()
395+ return &http.Response{
396+ StatusCode: http.StatusServiceUnavailable,
397+ Header: make(http.Header),
398+ Body: http.NoBody,
399+ }, nil
400+ })}
401+ lifecycle := &recordingRequestLifecycle{}
402+ _, err := doStreamWithRetry(ctx, client, func() (*http.Request, error) {
403+ return http.NewRequestWithContext(ctx, http.MethodPost, "https://example.test", nil)
404+ }, lifecycle)
405+ if !errors.Is(err, context.Canceled) {
406+ t.Fatalf("error = %v, want context.Canceled", err)
407+ }
408+ if len(lifecycle.retries) != 0 {
409+ t.Fatalf("retry lifecycle = %#v, want none after cancellation", lifecycle.retries)
410+ }
411+}
M packages/tui/theme.go +1 -32
@@ -73,7 +73,7 @@ type Theme struct {
7373 ToolOut TerminalColor
7474 Error TerminalColor
7575 Warning TerminalColor
76- Spinner TerminalColor // spinner + funny working line
76+ Spinner TerminalColor // spinner frame and activity label
7777 ThinkingMax TerminalColor // status color for the opt-in max reasoning level
7878 SelectionBG TerminalColor // background for highlighted rows
7979 SelectionFG TerminalColor // foreground for highlighted rows
@@ -86,7 +86,6 @@ type Theme struct {
8686 Terminal TerminalProfile
8787
8888 SpinnerFrames []string
89- SpinnerMessages []string
9089 SpinnerIntervalMS int
9190
9291 SyntaxBaseStyle string
@@ -151,34 +150,6 @@ var defaultSpinnerFrames = []string{
151150 "⠓",
152151 }
153152
154-var defaultSpinnerMessages = []string{
155- "thinking",
156- "reticulating splines",
157- "bribing the tokenizer",
158- "asking the rubber duck",
159- "summoning daemons",
160- "consulting the oracle",
161- "herding tokens",
162- "compiling excuses",
163- "poking the model",
164- "negotiating with rate limits",
165- "picking a fight with syntax",
166- "reading between the bits",
167- "tasting the semicolons",
168- "pretending to understand the code",
169- "petting the cache",
170- "drafting clever replies",
171- "warming up the GPU choir",
172- "arguing with a stack trace",
173- "googling the answer (not really)",
174- "rewriting history",
175- "every draft is a stone in the work",
176- "bringing order to the unhewn",
177- "finding the load-bearing measure",
178- "where clarity grows, work grows lighter",
179- "every correction serves the work",
180-}
181-
182153 var Dark = Theme{
183154 FG: Color256(253),
184155 Muted: Color256(244),
@@ -196,7 +167,6 @@ var Dark = Theme{
196167 SelectionBG: Color256(24), // deep blue background
197168 SelectionFG: Color256(231), // near-white foreground
198169 SpinnerFrames: defaultSpinnerFrames,
199- SpinnerMessages: defaultSpinnerMessages,
200170 SpinnerIntervalMS: 80,
201171 SyntaxBaseStyle: "monokai",
202172 Syntax: nordSyntax,
@@ -219,7 +189,6 @@ var Light = Theme{
219189 SelectionBG: Color256(153), // light blue
220190 SelectionFG: Color256(232), // near-black
221191 SpinnerFrames: defaultSpinnerFrames,
222- SpinnerMessages: defaultSpinnerMessages,
223192 SpinnerIntervalMS: 80,
224193 SyntaxBaseStyle: "monokai",
225194 Syntax: nordSyntax,
@@ -73,7 +73,7 @@ type Theme struct {
73 ToolOut TerminalColor73 ToolOut TerminalColor
74 Error TerminalColor74 Error TerminalColor
75 Warning TerminalColor75 Warning TerminalColor
76- Spinner TerminalColor // spinner + funny working line76+ Spinner TerminalColor // spinner frame and activity label
77 ThinkingMax TerminalColor // status color for the opt-in max reasoning level77 ThinkingMax TerminalColor // status color for the opt-in max reasoning level
78 SelectionBG TerminalColor // background for highlighted rows78 SelectionBG TerminalColor // background for highlighted rows
79 SelectionFG TerminalColor // foreground for highlighted rows79 SelectionFG TerminalColor // foreground for highlighted rows
@@ -86,7 +86,6 @@ type Theme struct {
86 Terminal TerminalProfile86 Terminal TerminalProfile
87 87
88 SpinnerFrames []string88 SpinnerFrames []string
89- SpinnerMessages []string
90 SpinnerIntervalMS int89 SpinnerIntervalMS int
91 90
92 SyntaxBaseStyle string91 SyntaxBaseStyle string
@@ -151,34 +150,6 @@ var defaultSpinnerFrames = []string{
151 "⠓",150 "⠓",
152 }151 }
153 152
154-var defaultSpinnerMessages = []string{
155- "thinking",
156- "reticulating splines",
157- "bribing the tokenizer",
158- "asking the rubber duck",
159- "summoning daemons",
160- "consulting the oracle",
161- "herding tokens",
162- "compiling excuses",
163- "poking the model",
164- "negotiating with rate limits",
165- "picking a fight with syntax",
166- "reading between the bits",
167- "tasting the semicolons",
168- "pretending to understand the code",
169- "petting the cache",
170- "drafting clever replies",
171- "warming up the GPU choir",
172- "arguing with a stack trace",
173- "googling the answer (not really)",
174- "rewriting history",
175- "every draft is a stone in the work",
176- "bringing order to the unhewn",
177- "finding the load-bearing measure",
178- "where clarity grows, work grows lighter",
179- "every correction serves the work",
180-}
181-
182 var Dark = Theme{153 var Dark = Theme{
183 FG: Color256(253),154 FG: Color256(253),
184 Muted: Color256(244),155 Muted: Color256(244),
@@ -196,7 +167,6 @@ var Dark = Theme{
196 SelectionBG: Color256(24), // deep blue background167 SelectionBG: Color256(24), // deep blue background
197 SelectionFG: Color256(231), // near-white foreground168 SelectionFG: Color256(231), // near-white foreground
198 SpinnerFrames: defaultSpinnerFrames,169 SpinnerFrames: defaultSpinnerFrames,
199- SpinnerMessages: defaultSpinnerMessages,
200 SpinnerIntervalMS: 80,170 SpinnerIntervalMS: 80,
201 SyntaxBaseStyle: "monokai",171 SyntaxBaseStyle: "monokai",
202 Syntax: nordSyntax,172 Syntax: nordSyntax,
@@ -219,7 +189,6 @@ var Light = Theme{
219 SelectionBG: Color256(153), // light blue189 SelectionBG: Color256(153), // light blue
220 SelectionFG: Color256(232), // near-black190 SelectionFG: Color256(232), // near-black
221 SpinnerFrames: defaultSpinnerFrames,191 SpinnerFrames: defaultSpinnerFrames,
222- SpinnerMessages: defaultSpinnerMessages,
223 SpinnerIntervalMS: 80,192 SpinnerIntervalMS: 80,
224 SyntaxBaseStyle: "monokai",193 SyntaxBaseStyle: "monokai",
225 Syntax: nordSyntax,194 Syntax: nordSyntax,
M packages/tui/theme_loader.go +1 -5
@@ -38,7 +38,7 @@ func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
3838 return err
3939 }
4040 // Allow a tiny theme file with overrides at the top level, e.g.
41- // {"spinner_frames":[".","o"],"spinner_messages":["working"]}.
41+ // {"spinner_frames":[".","o"],"spinner_interval_ms":120}.
4242 // Metadata fields are ignored by ThemeOverrides because they do not
4343 // have matching json tags.
4444 _ = json.Unmarshal(data, &a.Overrides)
@@ -97,7 +97,6 @@ type ThemeOverrides struct {
9797 SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"`
9898 SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"`
9999 SpinnerFrames []string `json:"spinner_frames,omitempty"`
100- SpinnerMessages []string `json:"spinner_messages,omitempty"`
101100 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
102101 SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"`
103102 Syntax SyntaxThemeOverrides `json:"syntax,omitempty"`
@@ -502,9 +501,6 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
502501 if len(o.SpinnerFrames) > 0 {
503502 th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
504503 }
505- if len(o.SpinnerMessages) > 0 {
506- th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...)
507- }
508504 if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
509505 th.SpinnerIntervalMS = *o.SpinnerIntervalMS
510506 }
@@ -38,7 +38,7 @@ func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
38 return err38 return err
39 }39 }
40 // Allow a tiny theme file with overrides at the top level, e.g.40 // Allow a tiny theme file with overrides at the top level, e.g.
41- // {"spinner_frames":[".","o"],"spinner_messages":["working"]}.41+ // {"spinner_frames":[".","o"],"spinner_interval_ms":120}.
42 // Metadata fields are ignored by ThemeOverrides because they do not42 // Metadata fields are ignored by ThemeOverrides because they do not
43 // have matching json tags.43 // have matching json tags.
44 _ = json.Unmarshal(data, &a.Overrides)44 _ = json.Unmarshal(data, &a.Overrides)
@@ -97,7 +97,6 @@ type ThemeOverrides struct {
97 SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"`97 SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"`
98 SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"`98 SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"`
99 SpinnerFrames []string `json:"spinner_frames,omitempty"`99 SpinnerFrames []string `json:"spinner_frames,omitempty"`
100- SpinnerMessages []string `json:"spinner_messages,omitempty"`
101 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`100 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
102 SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"`101 SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"`
103 Syntax SyntaxThemeOverrides `json:"syntax,omitempty"`102 Syntax SyntaxThemeOverrides `json:"syntax,omitempty"`
@@ -502,9 +501,6 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
502 if len(o.SpinnerFrames) > 0 {501 if len(o.SpinnerFrames) > 0 {
503 th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)502 th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
504 }503 }
505- if len(o.SpinnerMessages) > 0 {
506- th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...)
507- }
508 if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {504 if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
509 th.SpinnerIntervalMS = *o.SpinnerIntervalMS505 th.SpinnerIntervalMS = *o.SpinnerIntervalMS
510 }506 }
M packages/tui/theme_loader_test.go +8 -11
@@ -36,8 +36,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
3636 if th.FG != Dark.FG {
3737 t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG)
3838 }
39- if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 {
40- t.Fatal("spinner defaults should be inherited")
39+ if len(th.SpinnerFrames) == 0 {
40+ t.Fatal("spinner frames should be inherited")
4141 }
4242 }
4343
@@ -53,7 +53,7 @@ func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {
5353 }
5454 }
5555
56-func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
56+func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) {
5757 home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`)
5858
5959 th, _, err := LoadThemeFromHome(home, "spinner", Dark)
@@ -63,8 +63,8 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
6363 if got := len(th.SpinnerFrames); got != 2 {
6464 t.Fatalf("spinner frame count = %d, want 2", got)
6565 }
66- if th.SpinnerFrames[1] != "o" || th.SpinnerMessages[0] != "working" || th.SpinnerIntervalMS != 200 {
67- t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS)
66+ if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 {
67+ t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
6868 }
6969 if th.Accent != Dark.Accent {
7070 t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent)
@@ -81,9 +81,6 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
8181 if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" {
8282 t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames)
8383 }
84- if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "working" {
85- t.Fatalf("spinner messages = %#v, want dark fallback message", th.SpinnerMessages)
86- }
8784 if th.SpinnerIntervalMS != 120 {
8885 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
8986 }
@@ -92,7 +89,7 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
9289 }
9390 }
9491
95-func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
92+func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) {
9693 home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`)
9794
9895 th, _, err := LoadThemeFromHome(home, "shared", Light)
@@ -102,8 +99,8 @@ func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
10299 if th.Accent != Color256(204) {
103100 t.Fatalf("accent = %#v, want 204", th.Accent)
104101 }
105- if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" {
106- t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages)
102+ if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS {
103+ t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
107104 }
108105 if th.FG != Light.FG {
109106 t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)
@@ -36,8 +36,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
36 if th.FG != Dark.FG {36 if th.FG != Dark.FG {
37 t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG)37 t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG)
38 }38 }
39- if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 {39+ if len(th.SpinnerFrames) == 0 {
40- t.Fatal("spinner defaults should be inherited")40+ t.Fatal("spinner frames should be inherited")
41 }41 }
42 }42 }
43 43
@@ -53,7 +53,7 @@ func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {
53 }53 }
54 }54 }
55 55
56-func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {56+func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) {
57 home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`)57 home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`)
58 58
59 th, _, err := LoadThemeFromHome(home, "spinner", Dark)59 th, _, err := LoadThemeFromHome(home, "spinner", Dark)
@@ -63,8 +63,8 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
63 if got := len(th.SpinnerFrames); got != 2 {63 if got := len(th.SpinnerFrames); got != 2 {
64 t.Fatalf("spinner frame count = %d, want 2", got)64 t.Fatalf("spinner frame count = %d, want 2", got)
65 }65 }
66- if th.SpinnerFrames[1] != "o" || th.SpinnerMessages[0] != "working" || th.SpinnerIntervalMS != 200 {66+ if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 {
67- t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS)67+ t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
68 }68 }
69 if th.Accent != Dark.Accent {69 if th.Accent != Dark.Accent {
70 t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent)70 t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent)
@@ -81,9 +81,6 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
81 if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" {81 if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" {
82 t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames)82 t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames)
83 }83 }
84- if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "working" {
85- t.Fatalf("spinner messages = %#v, want dark fallback message", th.SpinnerMessages)
86- }
87 if th.SpinnerIntervalMS != 120 {84 if th.SpinnerIntervalMS != 120 {
88 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)85 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
89 }86 }
@@ -92,7 +89,7 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
92 }89 }
93 }90 }
94 91
95-func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {92+func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) {
96 home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`)93 home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`)
97 94
98 th, _, err := LoadThemeFromHome(home, "shared", Light)95 th, _, err := LoadThemeFromHome(home, "shared", Light)
@@ -102,8 +99,8 @@ func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
102 if th.Accent != Color256(204) {99 if th.Accent != Color256(204) {
103 t.Fatalf("accent = %#v, want 204", th.Accent)100 t.Fatalf("accent = %#v, want 204", th.Accent)
104 }101 }
105- if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" {102+ if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS {
106- t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages)103+ t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
107 }104 }
108 if th.FG != Light.FG {105 if th.FG != Light.FG {
109 t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)106 t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)

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