@oh-my-pi/pi-coding-agent 14.0.4 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/package.json +11 -8
  3. package/src/async/index.ts +1 -0
  4. package/src/async/support.ts +5 -0
  5. package/src/cli/list-models.ts +96 -57
  6. package/src/commit/model-selection.ts +16 -13
  7. package/src/config/model-equivalence.ts +674 -0
  8. package/src/config/model-registry.ts +182 -13
  9. package/src/config/model-resolver.ts +203 -74
  10. package/src/config/settings-schema.ts +23 -0
  11. package/src/config/settings.ts +9 -2
  12. package/src/dap/session.ts +31 -39
  13. package/src/debug/log-formatting.ts +2 -2
  14. package/src/edit/modes/chunk.ts +8 -3
  15. package/src/export/html/template.css +82 -0
  16. package/src/export/html/template.generated.ts +1 -1
  17. package/src/export/html/template.js +612 -97
  18. package/src/internal-urls/docs-index.generated.ts +1 -1
  19. package/src/internal-urls/jobs-protocol.ts +2 -1
  20. package/src/lsp/client.ts +5 -3
  21. package/src/lsp/index.ts +4 -9
  22. package/src/lsp/utils.ts +26 -0
  23. package/src/main.ts +6 -1
  24. package/src/memories/index.ts +7 -6
  25. package/src/modes/components/diff.ts +1 -1
  26. package/src/modes/components/model-selector.ts +221 -64
  27. package/src/modes/controllers/command-controller.ts +18 -0
  28. package/src/modes/controllers/event-controller.ts +438 -426
  29. package/src/modes/controllers/selector-controller.ts +13 -5
  30. package/src/modes/theme/mermaid-cache.ts +5 -7
  31. package/src/priority.json +8 -0
  32. package/src/prompts/agents/designer.md +1 -2
  33. package/src/prompts/system/system-prompt.md +5 -1
  34. package/src/prompts/tools/bash.md +15 -0
  35. package/src/prompts/tools/cancel-job.md +1 -1
  36. package/src/prompts/tools/chunk-edit.md +39 -40
  37. package/src/prompts/tools/read-chunk.md +13 -1
  38. package/src/prompts/tools/read.md +9 -0
  39. package/src/prompts/tools/write.md +1 -0
  40. package/src/sdk.ts +7 -4
  41. package/src/session/agent-session.ts +33 -6
  42. package/src/session/compaction/compaction.ts +1 -1
  43. package/src/task/executor.ts +5 -1
  44. package/src/tools/await-tool.ts +2 -1
  45. package/src/tools/bash.ts +221 -56
  46. package/src/tools/browser.ts +84 -21
  47. package/src/tools/cancel-job.ts +2 -1
  48. package/src/tools/fetch.ts +1 -1
  49. package/src/tools/find.ts +40 -94
  50. package/src/tools/gemini-image.ts +1 -0
  51. package/src/tools/inspect-image.ts +1 -1
  52. package/src/tools/read.ts +218 -1
  53. package/src/tools/render-utils.ts +1 -1
  54. package/src/tools/sqlite-reader.ts +623 -0
  55. package/src/tools/write.ts +187 -1
  56. package/src/utils/commit-message-generator.ts +1 -0
  57. package/src/utils/git.ts +24 -1
  58. package/src/utils/image-resize.ts +73 -37
  59. package/src/utils/title-generator.ts +1 -1
  60. package/src/web/scrapers/types.ts +50 -32
  61. package/src/web/search/providers/codex.ts +21 -2
@@ -13,6 +13,12 @@ import type { AgentSessionEvent } from "../../session/agent-session";
13
13
  import { calculatePromptTokens } from "../../session/compaction/compaction";
14
14
  import type { ExitPlanModeDetails } from "../../tools";
15
15
 
16
+ type AgentSessionEventKind = AgentSessionEvent["type"];
17
+
18
+ type AgentSessionEventHandlers = {
19
+ [E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
20
+ };
21
+
16
22
  export class EventController {
17
23
  #lastReadGroup: ReadToolGroupComponent | undefined = undefined;
18
24
  #lastThinkingCount = 0;
@@ -23,7 +29,31 @@ export class EventController {
23
29
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
24
30
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
25
31
  #idleCompactionTimer?: NodeJS.Timeout;
26
- constructor(private ctx: InteractiveModeContext) {}
32
+ #handlers: AgentSessionEventHandlers;
33
+
34
+ constructor(private ctx: InteractiveModeContext) {
35
+ this.#handlers = {
36
+ agent_start: e => this.#handleAgentStart(e),
37
+ agent_end: e => this.#handleAgentEnd(e),
38
+ turn_start: async () => {},
39
+ turn_end: async () => {},
40
+ message_start: e => this.#handleMessageStart(e),
41
+ message_update: e => this.#handleMessageUpdate(e),
42
+ message_end: e => this.#handleMessageEnd(e),
43
+ tool_execution_start: e => this.#handleToolExecutionStart(e),
44
+ tool_execution_update: e => this.#handleToolExecutionUpdate(e),
45
+ tool_execution_end: e => this.#handleToolExecutionEnd(e),
46
+ auto_compaction_start: e => this.#handleAutoCompactionStart(e),
47
+ auto_compaction_end: e => this.#handleAutoCompactionEnd(e),
48
+ auto_retry_start: e => this.#handleAutoRetryStart(e),
49
+ auto_retry_end: e => this.#handleAutoRetryEnd(e),
50
+ retry_fallback_applied: e => this.#handleRetryFallbackApplied(e),
51
+ retry_fallback_succeeded: e => this.#handleRetryFallbackSucceeded(e),
52
+ ttsr_triggered: e => this.#handleTtsrTriggered(e),
53
+ todo_reminder: e => this.#handleTodoReminder(e),
54
+ todo_auto_clear: e => this.#handleTodoAutoClear(e),
55
+ } satisfies AgentSessionEventHandlers;
56
+ }
27
57
 
28
58
  dispose(): void {
29
59
  this.#cancelIdleCompaction();
@@ -98,203 +128,114 @@ export class EventController {
98
128
  this.ctx.statusLine.invalidate();
99
129
  this.ctx.updateEditorTopBorder();
100
130
 
101
- switch (event.type) {
102
- case "agent_start":
103
- this.#lastIntent = undefined;
104
- this.#readToolCallArgs.clear();
105
- this.#readToolCallAssistantComponents.clear();
106
- this.#lastAssistantComponent = undefined;
107
- if (this.ctx.retryEscapeHandler) {
108
- this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
109
- this.ctx.retryEscapeHandler = undefined;
110
- }
111
- if (this.ctx.retryLoader) {
112
- this.ctx.retryLoader.stop();
113
- this.ctx.retryLoader = undefined;
114
- this.ctx.statusContainer.clear();
115
- }
116
- this.#cancelIdleCompaction();
117
- this.ctx.ensureLoadingAnimation();
118
- this.ctx.ui.requestRender();
119
- break;
120
-
121
- case "message_start":
122
- if (event.message.role === "hookMessage" || event.message.role === "custom") {
123
- const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
124
- if (this.#renderedCustomMessages.has(signature)) {
125
- break;
126
- }
127
- this.#renderedCustomMessages.add(signature);
128
- this.#resetReadGroup();
129
- this.ctx.addMessageToChat(event.message);
130
- this.ctx.ui.requestRender();
131
- } else if (event.message.role === "user") {
132
- const textContent = this.ctx.getUserMessageText(event.message);
133
- const imageCount =
134
- typeof event.message.content === "string"
135
- ? 0
136
- : event.message.content.filter(content => content.type === "image").length;
137
- const signature = `${textContent}\u0000${imageCount}`;
131
+ const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
132
+ await run(event);
133
+ }
138
134
 
139
- this.#resetReadGroup();
140
- if (this.ctx.optimisticUserMessageSignature !== signature) {
141
- this.ctx.addMessageToChat(event.message);
142
- }
143
- this.ctx.optimisticUserMessageSignature = undefined;
135
+ async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
136
+ this.#lastIntent = undefined;
137
+ this.#readToolCallArgs.clear();
138
+ this.#readToolCallAssistantComponents.clear();
139
+ this.#lastAssistantComponent = undefined;
140
+ if (this.ctx.retryEscapeHandler) {
141
+ this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
142
+ this.ctx.retryEscapeHandler = undefined;
143
+ }
144
+ if (this.ctx.retryLoader) {
145
+ this.ctx.retryLoader.stop();
146
+ this.ctx.retryLoader = undefined;
147
+ this.ctx.statusContainer.clear();
148
+ }
149
+ this.#cancelIdleCompaction();
150
+ this.ctx.ensureLoadingAnimation();
151
+ this.ctx.ui.requestRender();
152
+ }
144
153
 
145
- if (!event.message.synthetic) {
146
- this.ctx.editor.setText("");
147
- this.ctx.updatePendingMessagesDisplay();
148
- }
149
- this.ctx.ui.requestRender();
150
- } else if (event.message.role === "fileMention") {
151
- this.#resetReadGroup();
152
- this.ctx.addMessageToChat(event.message);
153
- this.ctx.ui.requestRender();
154
- } else if (event.message.role === "assistant") {
155
- this.#lastThinkingCount = 0;
156
- this.#resetReadGroup();
157
- this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock);
158
- this.ctx.streamingMessage = event.message;
159
- this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
160
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
161
- this.ctx.ui.requestRender();
162
- }
163
- break;
164
-
165
- case "message_update":
166
- if (this.ctx.streamingComponent && event.message.role === "assistant") {
167
- this.ctx.streamingMessage = event.message;
168
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
169
-
170
- const thinkingCount = this.ctx.streamingMessage.content.filter(
171
- content => content.type === "thinking" && content.thinking.trim(),
172
- ).length;
173
- if (thinkingCount > this.#lastThinkingCount) {
174
- this.#resetReadGroup();
175
- this.#lastThinkingCount = thinkingCount;
176
- }
154
+ async #handleMessageStart(event: Extract<AgentSessionEvent, { type: "message_start" }>): Promise<void> {
155
+ if (event.message.role === "hookMessage" || event.message.role === "custom") {
156
+ const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
157
+ if (this.#renderedCustomMessages.has(signature)) {
158
+ return;
159
+ }
160
+ this.#renderedCustomMessages.add(signature);
161
+ this.#resetReadGroup();
162
+ this.ctx.addMessageToChat(event.message);
163
+ this.ctx.ui.requestRender();
164
+ } else if (event.message.role === "user") {
165
+ const textContent = this.ctx.getUserMessageText(event.message);
166
+ const imageCount =
167
+ typeof event.message.content === "string"
168
+ ? 0
169
+ : event.message.content.filter(content => content.type === "image").length;
170
+ const signature = `${textContent}\u0000${imageCount}`;
171
+
172
+ this.#resetReadGroup();
173
+ if (this.ctx.optimisticUserMessageSignature !== signature) {
174
+ this.ctx.addMessageToChat(event.message);
175
+ }
176
+ this.ctx.optimisticUserMessageSignature = undefined;
177
177
 
178
- for (const content of this.ctx.streamingMessage.content) {
179
- if (content.type !== "toolCall") continue;
180
- if (content.name === "read") {
181
- this.#trackReadToolCall(content.id, content.arguments);
182
- const component = this.ctx.pendingTools.get(content.id);
183
- if (component) {
184
- component.updateArgs(content.arguments, content.id);
185
- } else {
186
- const group = this.#getReadGroup();
187
- group.updateArgs(content.arguments, content.id);
188
- this.ctx.pendingTools.set(content.id, group);
189
- }
190
- continue;
191
- }
192
-
193
- // Preserve the raw partial JSON for renderers that need to surface fields before the JSON object closes.
194
- // Bash uses this to show inline env assignments during streaming instead of popping them in at completion.
195
- const renderArgs =
196
- "partialJson" in content
197
- ? { ...content.arguments, __partialJson: content.partialJson }
198
- : content.arguments;
199
- if (!this.ctx.pendingTools.has(content.id)) {
200
- this.#resetReadGroup();
201
- this.ctx.chatContainer.addChild(new Text("", 0, 0));
202
- const tool = this.ctx.session.getToolByName(content.name);
203
- const component = new ToolExecutionComponent(
204
- content.name,
205
- renderArgs,
206
- {
207
- showImages: settings.get("terminal.showImages"),
208
- editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
209
- editAllowFuzzy: settings.get("edit.fuzzyMatch"),
210
- },
211
- tool,
212
- this.ctx.ui,
213
- this.ctx.sessionManager.getCwd(),
214
- );
215
- component.setExpanded(this.ctx.toolOutputExpanded);
216
- this.ctx.chatContainer.addChild(component);
217
- this.ctx.pendingTools.set(content.id, component);
218
- } else {
219
- const component = this.ctx.pendingTools.get(content.id);
220
- if (component) {
221
- component.updateArgs(renderArgs, content.id);
222
- }
223
- }
224
- }
178
+ if (!event.message.synthetic) {
179
+ this.ctx.editor.setText("");
180
+ this.ctx.updatePendingMessagesDisplay();
181
+ }
182
+ this.ctx.ui.requestRender();
183
+ } else if (event.message.role === "fileMention") {
184
+ this.#resetReadGroup();
185
+ this.ctx.addMessageToChat(event.message);
186
+ this.ctx.ui.requestRender();
187
+ } else if (event.message.role === "assistant") {
188
+ this.#lastThinkingCount = 0;
189
+ this.#resetReadGroup();
190
+ this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock);
191
+ this.ctx.streamingMessage = event.message;
192
+ this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
193
+ this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
194
+ this.ctx.ui.requestRender();
195
+ }
196
+ }
225
197
 
226
- // Update working message with intent from streamed tool arguments
227
- for (const content of this.ctx.streamingMessage.content) {
228
- if (content.type !== "toolCall") continue;
229
- const args = content.arguments;
230
- if (!args || typeof args !== "object" || !(INTENT_FIELD in args)) continue;
231
- this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
232
- }
198
+ async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
199
+ if (this.ctx.streamingComponent && event.message.role === "assistant") {
200
+ this.ctx.streamingMessage = event.message;
201
+ this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
202
+
203
+ const thinkingCount = this.ctx.streamingMessage.content.filter(
204
+ content => content.type === "thinking" && content.thinking.trim(),
205
+ ).length;
206
+ if (thinkingCount > this.#lastThinkingCount) {
207
+ this.#resetReadGroup();
208
+ this.#lastThinkingCount = thinkingCount;
209
+ }
233
210
 
234
- this.ctx.ui.requestRender();
235
- }
236
- break;
237
-
238
- case "message_end":
239
- if (event.message.role === "user") break;
240
- if (this.ctx.streamingComponent && event.message.role === "assistant") {
241
- this.ctx.streamingMessage = event.message;
242
- let errorMessage: string | undefined;
243
- if (this.ctx.streamingMessage.stopReason === "aborted" && !this.ctx.session.isTtsrAbortPending) {
244
- const retryAttempt = this.ctx.session.retryAttempt;
245
- errorMessage =
246
- retryAttempt > 0
247
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
248
- : "Operation aborted";
249
- this.ctx.streamingMessage.errorMessage = errorMessage;
250
- }
251
- if (this.ctx.session.isTtsrAbortPending && this.ctx.streamingMessage.stopReason === "aborted") {
252
- const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
253
- this.ctx.streamingComponent.updateContent(msgWithoutAbort);
211
+ for (const content of this.ctx.streamingMessage.content) {
212
+ if (content.type !== "toolCall") continue;
213
+ if (content.name === "read") {
214
+ this.#trackReadToolCall(content.id, content.arguments);
215
+ const component = this.ctx.pendingTools.get(content.id);
216
+ if (component) {
217
+ component.updateArgs(content.arguments, content.id);
254
218
  } else {
255
- this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
219
+ const group = this.#getReadGroup();
220
+ group.updateArgs(content.arguments, content.id);
221
+ this.ctx.pendingTools.set(content.id, group);
256
222
  }
257
-
258
- if (
259
- this.ctx.streamingMessage.stopReason !== "aborted" &&
260
- this.ctx.streamingMessage.stopReason !== "error"
261
- ) {
262
- for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
263
- component.setArgsComplete(toolCallId);
264
- }
265
- }
266
- this.#lastAssistantComponent = this.ctx.streamingComponent;
267
- this.#lastAssistantComponent.setUsageInfo(event.message.usage);
268
- this.ctx.streamingComponent = undefined;
269
- this.ctx.streamingMessage = undefined;
270
- this.ctx.statusLine.invalidate();
271
- this.ctx.updateEditorTopBorder();
223
+ continue;
272
224
  }
273
- this.ctx.ui.requestRender();
274
- break;
275
-
276
- case "tool_execution_start": {
277
- this.#updateWorkingMessageFromIntent(event.intent);
278
- if (!this.ctx.pendingTools.has(event.toolCallId)) {
279
- if (event.toolName === "read") {
280
- this.#trackReadToolCall(event.toolCallId, event.args);
281
- const component = this.ctx.pendingTools.get(event.toolCallId);
282
- if (component) {
283
- component.updateArgs(event.args, event.toolCallId);
284
- } else {
285
- const group = this.#getReadGroup();
286
- group.updateArgs(event.args, event.toolCallId);
287
- this.ctx.pendingTools.set(event.toolCallId, group);
288
- }
289
- this.ctx.ui.requestRender();
290
- break;
291
- }
292
225
 
226
+ // Preserve the raw partial JSON for renderers that need to surface fields before the JSON object closes.
227
+ // Bash uses this to show inline env assignments during streaming instead of popping them in at completion.
228
+ const renderArgs =
229
+ "partialJson" in content
230
+ ? { ...content.arguments, __partialJson: content.partialJson }
231
+ : content.arguments;
232
+ if (!this.ctx.pendingTools.has(content.id)) {
293
233
  this.#resetReadGroup();
294
- const tool = this.ctx.session.getToolByName(event.toolName);
234
+ this.ctx.chatContainer.addChild(new Text("", 0, 0));
235
+ const tool = this.ctx.session.getToolByName(content.name);
295
236
  const component = new ToolExecutionComponent(
296
- event.toolName,
297
- event.args,
237
+ content.name,
238
+ renderArgs,
298
239
  {
299
240
  showImages: settings.get("terminal.showImages"),
300
241
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
@@ -306,274 +247,345 @@ export class EventController {
306
247
  );
307
248
  component.setExpanded(this.ctx.toolOutputExpanded);
308
249
  this.ctx.chatContainer.addChild(component);
309
- this.ctx.pendingTools.set(event.toolCallId, component);
310
- this.ctx.ui.requestRender();
250
+ this.ctx.pendingTools.set(content.id, component);
251
+ } else {
252
+ const component = this.ctx.pendingTools.get(content.id);
253
+ if (component) {
254
+ component.updateArgs(renderArgs, content.id);
255
+ }
311
256
  }
312
- break;
313
257
  }
314
258
 
315
- case "tool_execution_update": {
316
- const component = this.ctx.pendingTools.get(event.toolCallId);
317
- if (component) {
318
- const asyncState = (event.partialResult.details as { async?: { state?: string } } | undefined)?.async
319
- ?.state;
320
- const isFinalAsyncState = asyncState === "completed" || asyncState === "failed";
321
- component.updateResult(
322
- { ...event.partialResult, isError: asyncState === "failed" },
323
- !isFinalAsyncState,
324
- event.toolCallId,
325
- );
326
- if (isFinalAsyncState) {
327
- this.ctx.pendingTools.delete(event.toolCallId);
328
- this.#backgroundToolCallIds.delete(event.toolCallId);
329
- }
330
- this.ctx.ui.requestRender();
331
- }
332
- break;
259
+ // Update working message with intent from streamed tool arguments
260
+ for (const content of this.ctx.streamingMessage.content) {
261
+ if (content.type !== "toolCall") continue;
262
+ const args = content.arguments;
263
+ if (!args || typeof args !== "object" || !(INTENT_FIELD in args)) continue;
264
+ this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
333
265
  }
334
266
 
335
- case "tool_execution_end": {
336
- if (event.toolName === "read") {
337
- if (this.#inlineReadToolImages(event.toolCallId, event.result)) {
338
- const component = this.ctx.pendingTools.get(event.toolCallId);
339
- if (component) {
340
- component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
341
- this.ctx.pendingTools.delete(event.toolCallId);
342
- }
343
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
344
- if (asyncState === "running") {
345
- this.#backgroundToolCallIds.add(event.toolCallId);
346
- } else {
347
- this.#backgroundToolCallIds.delete(event.toolCallId);
348
- this.#clearReadToolCall(event.toolCallId);
349
- }
350
- this.ctx.ui.requestRender();
351
- } else {
352
- let component = this.ctx.pendingTools.get(event.toolCallId);
353
- if (!component) {
354
- const group = this.#getReadGroup();
355
- const args = this.#readToolCallArgs.get(event.toolCallId);
356
- if (args) {
357
- group.updateArgs(args, event.toolCallId);
358
- }
359
- component = group;
360
- this.ctx.pendingTools.set(event.toolCallId, group);
361
- }
362
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
363
- const isBackgroundRunning = asyncState === "running";
364
- component.updateResult(
365
- { ...event.result, isError: event.isError },
366
- isBackgroundRunning,
367
- event.toolCallId,
368
- );
369
- if (isBackgroundRunning) {
370
- this.#backgroundToolCallIds.add(event.toolCallId);
371
- } else {
372
- this.ctx.pendingTools.delete(event.toolCallId);
373
- this.#backgroundToolCallIds.delete(event.toolCallId);
374
- this.#clearReadToolCall(event.toolCallId);
375
- }
376
- this.ctx.ui.requestRender();
377
- }
378
- } else {
379
- const component = this.ctx.pendingTools.get(event.toolCallId);
380
- if (component) {
381
- const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
382
- const isBackgroundRunning = asyncState === "running";
383
- component.updateResult(
384
- { ...event.result, isError: event.isError },
385
- isBackgroundRunning,
386
- event.toolCallId,
387
- );
388
- if (isBackgroundRunning) {
389
- this.#backgroundToolCallIds.add(event.toolCallId);
390
- } else {
391
- this.ctx.pendingTools.delete(event.toolCallId);
392
- this.#backgroundToolCallIds.delete(event.toolCallId);
393
- }
394
- this.ctx.ui.requestRender();
395
- }
396
- }
397
- // Update todo display when todo_write tool completes
398
- if (event.toolName === "todo_write" && !event.isError) {
399
- const details = event.result.details as { phases?: TodoPhase[] } | undefined;
400
- if (details?.phases) {
401
- this.ctx.setTodos(details.phases);
402
- }
403
- } else if (event.toolName === "todo_write" && event.isError) {
404
- const textContent = event.result.content.find(
405
- (content: { type: string; text?: string }) => content.type === "text",
406
- )?.text;
407
- this.ctx.showWarning(
408
- `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
409
- );
410
- }
411
- if (event.toolName === "exit_plan_mode" && !event.isError) {
412
- const details = event.result.details as ExitPlanModeDetails | undefined;
413
- if (details) {
414
- await this.ctx.handleExitPlanModeTool(details);
415
- }
416
- }
417
- break;
267
+ this.ctx.ui.requestRender();
268
+ }
269
+ }
270
+
271
+ async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
272
+ if (event.message.role === "user") return;
273
+ if (this.ctx.streamingComponent && event.message.role === "assistant") {
274
+ this.ctx.streamingMessage = event.message;
275
+ let errorMessage: string | undefined;
276
+ if (this.ctx.streamingMessage.stopReason === "aborted" && !this.ctx.session.isTtsrAbortPending) {
277
+ const retryAttempt = this.ctx.session.retryAttempt;
278
+ errorMessage =
279
+ retryAttempt > 0
280
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
281
+ : "Operation aborted";
282
+ this.ctx.streamingMessage.errorMessage = errorMessage;
283
+ }
284
+ if (this.ctx.session.isTtsrAbortPending && this.ctx.streamingMessage.stopReason === "aborted") {
285
+ const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
286
+ this.ctx.streamingComponent.updateContent(msgWithoutAbort);
287
+ } else {
288
+ this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
418
289
  }
419
290
 
420
- case "agent_end":
421
- if (this.ctx.loadingAnimation) {
422
- this.ctx.loadingAnimation.stop();
423
- this.ctx.loadingAnimation = undefined;
424
- this.ctx.statusContainer.clear();
425
- }
426
- if (this.ctx.streamingComponent) {
427
- this.ctx.chatContainer.removeChild(this.ctx.streamingComponent);
428
- this.ctx.streamingComponent = undefined;
429
- this.ctx.streamingMessage = undefined;
291
+ if (this.ctx.streamingMessage.stopReason !== "aborted" && this.ctx.streamingMessage.stopReason !== "error") {
292
+ for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
293
+ component.setArgsComplete(toolCallId);
430
294
  }
431
- await this.ctx.flushPendingModelSwitch();
432
- for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
433
- if (!this.#backgroundToolCallIds.has(toolCallId)) {
434
- this.ctx.pendingTools.delete(toolCallId);
435
- }
436
- }
437
- this.#backgroundToolCallIds = new Set(
438
- Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
439
- );
440
- this.#readToolCallArgs.clear();
441
- this.#readToolCallAssistantComponents.clear();
442
- this.#lastAssistantComponent = undefined;
443
- this.ctx.ui.requestRender();
444
- this.#scheduleIdleCompaction();
445
- this.sendCompletionNotification();
446
- break;
447
-
448
- case "auto_compaction_start": {
449
- this.#cancelIdleCompaction();
450
- this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
451
- this.ctx.editor.onEscape = () => {
452
- this.ctx.session.abortCompaction();
453
- };
454
- this.ctx.statusContainer.clear();
455
- const reasonText =
456
- event.reason === "overflow" ? "Context overflow detected, " : event.reason === "idle" ? "Idle " : "";
457
- const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
458
- this.ctx.autoCompactionLoader = new Loader(
459
- this.ctx.ui,
460
- spinner => theme.fg("accent", spinner),
461
- text => theme.fg("muted", text),
462
- `${reasonText}${actionLabel}… (esc to cancel)`,
463
- getSymbolTheme().spinnerFrames,
464
- );
465
- this.ctx.statusContainer.addChild(this.ctx.autoCompactionLoader);
466
- this.ctx.ui.requestRender();
467
- break;
468
295
  }
296
+ this.#lastAssistantComponent = this.ctx.streamingComponent;
297
+ this.#lastAssistantComponent.setUsageInfo(event.message.usage);
298
+ this.ctx.streamingComponent = undefined;
299
+ this.ctx.streamingMessage = undefined;
300
+ this.ctx.statusLine.invalidate();
301
+ this.ctx.updateEditorTopBorder();
302
+ }
303
+ this.ctx.ui.requestRender();
304
+ }
469
305
 
470
- case "auto_compaction_end": {
471
- this.#cancelIdleCompaction();
472
- if (this.ctx.autoCompactionEscapeHandler) {
473
- this.ctx.editor.onEscape = this.ctx.autoCompactionEscapeHandler;
474
- this.ctx.autoCompactionEscapeHandler = undefined;
475
- }
476
- if (this.ctx.autoCompactionLoader) {
477
- this.ctx.autoCompactionLoader.stop();
478
- this.ctx.autoCompactionLoader = undefined;
479
- this.ctx.statusContainer.clear();
480
- }
481
- const isHandoffAction = event.action === "handoff";
482
- if (event.aborted) {
483
- this.ctx.showStatus(
484
- isHandoffAction ? "Auto-handoff cancelled" : "Auto context-full maintenance cancelled",
485
- );
486
- } else if (event.result) {
487
- this.ctx.rebuildChatFromMessages();
488
- this.ctx.statusLine.invalidate();
489
- this.ctx.updateEditorTopBorder();
490
- } else if (event.errorMessage) {
491
- this.ctx.showWarning(event.errorMessage);
492
- } else if (isHandoffAction) {
493
- this.ctx.chatContainer.clear();
494
- this.ctx.rebuildChatFromMessages();
495
- this.ctx.statusLine.invalidate();
496
- this.ctx.updateEditorTopBorder();
497
- await this.ctx.reloadTodos();
498
- this.ctx.showStatus("Auto-handoff completed");
499
- } else if (event.skipped) {
500
- // Benign skip: no model selected, no candidate models available, or nothing
501
- // to compact yet. Not a failure — suppress the warning.
306
+ async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
307
+ this.#updateWorkingMessageFromIntent(event.intent);
308
+ if (!this.ctx.pendingTools.has(event.toolCallId)) {
309
+ if (event.toolName === "read") {
310
+ this.#trackReadToolCall(event.toolCallId, event.args);
311
+ const component = this.ctx.pendingTools.get(event.toolCallId);
312
+ if (component) {
313
+ component.updateArgs(event.args, event.toolCallId);
502
314
  } else {
503
- this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance");
315
+ const group = this.#getReadGroup();
316
+ group.updateArgs(event.args, event.toolCallId);
317
+ this.ctx.pendingTools.set(event.toolCallId, group);
504
318
  }
505
- await this.ctx.flushCompactionQueue({ willRetry: event.willRetry });
506
319
  this.ctx.ui.requestRender();
507
- break;
320
+ return;
508
321
  }
509
322
 
510
- case "auto_retry_start": {
511
- this.ctx.retryEscapeHandler = this.ctx.editor.onEscape;
512
- this.ctx.editor.onEscape = () => {
513
- this.ctx.session.abortRetry();
514
- };
515
- this.ctx.statusContainer.clear();
516
- const delaySeconds = Math.round(event.delayMs / 1000);
517
- this.ctx.retryLoader = new Loader(
518
- this.ctx.ui,
519
- spinner => theme.fg("warning", spinner),
520
- text => theme.fg("muted", text),
521
- `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s… (esc to cancel)`,
522
- getSymbolTheme().spinnerFrames,
523
- );
524
- this.ctx.statusContainer.addChild(this.ctx.retryLoader);
525
- this.ctx.ui.requestRender();
526
- break;
323
+ this.#resetReadGroup();
324
+ const tool = this.ctx.session.getToolByName(event.toolName);
325
+ const component = new ToolExecutionComponent(
326
+ event.toolName,
327
+ event.args,
328
+ {
329
+ showImages: settings.get("terminal.showImages"),
330
+ editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
331
+ editAllowFuzzy: settings.get("edit.fuzzyMatch"),
332
+ },
333
+ tool,
334
+ this.ctx.ui,
335
+ this.ctx.sessionManager.getCwd(),
336
+ );
337
+ component.setExpanded(this.ctx.toolOutputExpanded);
338
+ this.ctx.chatContainer.addChild(component);
339
+ this.ctx.pendingTools.set(event.toolCallId, component);
340
+ this.ctx.ui.requestRender();
341
+ }
342
+ }
343
+
344
+ async #handleToolExecutionUpdate(
345
+ event: Extract<AgentSessionEvent, { type: "tool_execution_update" }>,
346
+ ): Promise<void> {
347
+ const component = this.ctx.pendingTools.get(event.toolCallId);
348
+ if (component) {
349
+ const asyncState = (event.partialResult.details as { async?: { state?: string } } | undefined)?.async?.state;
350
+ const isFinalAsyncState = asyncState === "completed" || asyncState === "failed";
351
+ component.updateResult(
352
+ { ...event.partialResult, isError: asyncState === "failed" },
353
+ !isFinalAsyncState,
354
+ event.toolCallId,
355
+ );
356
+ if (isFinalAsyncState) {
357
+ this.ctx.pendingTools.delete(event.toolCallId);
358
+ this.#backgroundToolCallIds.delete(event.toolCallId);
527
359
  }
360
+ this.ctx.ui.requestRender();
361
+ }
362
+ }
528
363
 
529
- case "auto_retry_end": {
530
- if (this.ctx.retryEscapeHandler) {
531
- this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
532
- this.ctx.retryEscapeHandler = undefined;
364
+ async #handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): Promise<void> {
365
+ if (event.toolName === "read") {
366
+ if (this.#inlineReadToolImages(event.toolCallId, event.result)) {
367
+ const component = this.ctx.pendingTools.get(event.toolCallId);
368
+ if (component) {
369
+ component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
370
+ this.ctx.pendingTools.delete(event.toolCallId);
533
371
  }
534
- if (this.ctx.retryLoader) {
535
- this.ctx.retryLoader.stop();
536
- this.ctx.retryLoader = undefined;
537
- this.ctx.statusContainer.clear();
372
+ const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
373
+ if (asyncState === "running") {
374
+ this.#backgroundToolCallIds.add(event.toolCallId);
375
+ } else {
376
+ this.#backgroundToolCallIds.delete(event.toolCallId);
377
+ this.#clearReadToolCall(event.toolCallId);
538
378
  }
539
- if (!event.success) {
540
- this.ctx.showError(
541
- `Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`,
542
- );
379
+ this.ctx.ui.requestRender();
380
+ } else {
381
+ let component = this.ctx.pendingTools.get(event.toolCallId);
382
+ if (!component) {
383
+ const group = this.#getReadGroup();
384
+ const args = this.#readToolCallArgs.get(event.toolCallId);
385
+ if (args) {
386
+ group.updateArgs(args, event.toolCallId);
387
+ }
388
+ component = group;
389
+ this.ctx.pendingTools.set(event.toolCallId, group);
390
+ }
391
+ const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
392
+ const isBackgroundRunning = asyncState === "running";
393
+ component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId);
394
+ if (isBackgroundRunning) {
395
+ this.#backgroundToolCallIds.add(event.toolCallId);
396
+ } else {
397
+ this.ctx.pendingTools.delete(event.toolCallId);
398
+ this.#backgroundToolCallIds.delete(event.toolCallId);
399
+ this.#clearReadToolCall(event.toolCallId);
543
400
  }
544
401
  this.ctx.ui.requestRender();
545
- break;
546
402
  }
547
-
548
- case "retry_fallback_applied": {
549
- this.ctx.showWarning(`Fallback: ${event.from} -> ${event.to}`);
550
- break;
403
+ } else {
404
+ const component = this.ctx.pendingTools.get(event.toolCallId);
405
+ if (component) {
406
+ const asyncState = (event.result.details as { async?: { state?: string } } | undefined)?.async?.state;
407
+ const isBackgroundRunning = asyncState === "running";
408
+ component.updateResult({ ...event.result, isError: event.isError }, isBackgroundRunning, event.toolCallId);
409
+ if (isBackgroundRunning) {
410
+ this.#backgroundToolCallIds.add(event.toolCallId);
411
+ } else {
412
+ this.ctx.pendingTools.delete(event.toolCallId);
413
+ this.#backgroundToolCallIds.delete(event.toolCallId);
414
+ }
415
+ this.ctx.ui.requestRender();
551
416
  }
552
-
553
- case "retry_fallback_succeeded": {
554
- this.ctx.showStatus(`Fallback succeeded on ${event.model}`);
555
- break;
417
+ }
418
+ // Update todo display when todo_write tool completes
419
+ if (event.toolName === "todo_write" && !event.isError) {
420
+ const details = event.result.details as { phases?: TodoPhase[] } | undefined;
421
+ if (details?.phases) {
422
+ this.ctx.setTodos(details.phases);
556
423
  }
557
-
558
- case "ttsr_triggered": {
559
- const component = new TtsrNotificationComponent(event.rules);
560
- component.setExpanded(this.ctx.toolOutputExpanded);
561
- this.ctx.chatContainer.addChild(component);
562
- this.ctx.ui.requestRender();
563
- break;
424
+ } else if (event.toolName === "todo_write" && event.isError) {
425
+ const textContent = event.result.content.find(
426
+ (content: { type: string; text?: string }) => content.type === "text",
427
+ )?.text;
428
+ this.ctx.showWarning(
429
+ `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
430
+ );
431
+ }
432
+ if (event.toolName === "exit_plan_mode" && !event.isError) {
433
+ const details = event.result.details as ExitPlanModeDetails | undefined;
434
+ if (details) {
435
+ await this.ctx.handleExitPlanModeTool(details);
564
436
  }
437
+ }
438
+ }
565
439
 
566
- case "todo_reminder": {
567
- const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
568
- this.ctx.chatContainer.addChild(component);
569
- this.ctx.ui.requestRender();
570
- break;
440
+ async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
441
+ if (this.ctx.loadingAnimation) {
442
+ this.ctx.loadingAnimation.stop();
443
+ this.ctx.loadingAnimation = undefined;
444
+ this.ctx.statusContainer.clear();
445
+ }
446
+ if (this.ctx.streamingComponent) {
447
+ this.ctx.chatContainer.removeChild(this.ctx.streamingComponent);
448
+ this.ctx.streamingComponent = undefined;
449
+ this.ctx.streamingMessage = undefined;
450
+ }
451
+ await this.ctx.flushPendingModelSwitch();
452
+ for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
453
+ if (!this.#backgroundToolCallIds.has(toolCallId)) {
454
+ this.ctx.pendingTools.delete(toolCallId);
571
455
  }
456
+ }
457
+ this.#backgroundToolCallIds = new Set(
458
+ Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
459
+ );
460
+ this.#readToolCallArgs.clear();
461
+ this.#readToolCallAssistantComponents.clear();
462
+ this.#lastAssistantComponent = undefined;
463
+ this.ctx.ui.requestRender();
464
+ this.#scheduleIdleCompaction();
465
+ this.sendCompletionNotification();
466
+ }
572
467
 
573
- case "todo_auto_clear":
574
- await this.ctx.reloadTodos();
575
- break;
468
+ async #handleAutoCompactionStart(
469
+ event: Extract<AgentSessionEvent, { type: "auto_compaction_start" }>,
470
+ ): Promise<void> {
471
+ this.#cancelIdleCompaction();
472
+ this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
473
+ this.ctx.editor.onEscape = () => {
474
+ this.ctx.session.abortCompaction();
475
+ };
476
+ this.ctx.statusContainer.clear();
477
+ const reasonText =
478
+ event.reason === "overflow" ? "Context overflow detected, " : event.reason === "idle" ? "Idle " : "";
479
+ const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
480
+ this.ctx.autoCompactionLoader = new Loader(
481
+ this.ctx.ui,
482
+ spinner => theme.fg("accent", spinner),
483
+ text => theme.fg("muted", text),
484
+ `${reasonText}${actionLabel}… (esc to cancel)`,
485
+ getSymbolTheme().spinnerFrames,
486
+ );
487
+ this.ctx.statusContainer.addChild(this.ctx.autoCompactionLoader);
488
+ this.ctx.ui.requestRender();
489
+ }
490
+
491
+ async #handleAutoCompactionEnd(event: Extract<AgentSessionEvent, { type: "auto_compaction_end" }>): Promise<void> {
492
+ this.#cancelIdleCompaction();
493
+ if (this.ctx.autoCompactionEscapeHandler) {
494
+ this.ctx.editor.onEscape = this.ctx.autoCompactionEscapeHandler;
495
+ this.ctx.autoCompactionEscapeHandler = undefined;
576
496
  }
497
+ if (this.ctx.autoCompactionLoader) {
498
+ this.ctx.autoCompactionLoader.stop();
499
+ this.ctx.autoCompactionLoader = undefined;
500
+ this.ctx.statusContainer.clear();
501
+ }
502
+ const isHandoffAction = event.action === "handoff";
503
+ if (event.aborted) {
504
+ this.ctx.showStatus(isHandoffAction ? "Auto-handoff cancelled" : "Auto context-full maintenance cancelled");
505
+ } else if (event.result) {
506
+ this.ctx.rebuildChatFromMessages();
507
+ this.ctx.statusLine.invalidate();
508
+ this.ctx.updateEditorTopBorder();
509
+ } else if (event.errorMessage) {
510
+ this.ctx.showWarning(event.errorMessage);
511
+ } else if (isHandoffAction) {
512
+ this.ctx.chatContainer.clear();
513
+ this.ctx.rebuildChatFromMessages();
514
+ this.ctx.statusLine.invalidate();
515
+ this.ctx.updateEditorTopBorder();
516
+ await this.ctx.reloadTodos();
517
+ this.ctx.showStatus("Auto-handoff completed");
518
+ } else if (event.skipped) {
519
+ // Benign skip: no model selected, no candidate models available, or nothing
520
+ // to compact yet. Not a failure — suppress the warning.
521
+ } else {
522
+ this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance");
523
+ }
524
+ await this.ctx.flushCompactionQueue({ willRetry: event.willRetry });
525
+ this.ctx.ui.requestRender();
526
+ }
527
+
528
+ async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
529
+ this.ctx.retryEscapeHandler = this.ctx.editor.onEscape;
530
+ this.ctx.editor.onEscape = () => {
531
+ this.ctx.session.abortRetry();
532
+ };
533
+ this.ctx.statusContainer.clear();
534
+ const delaySeconds = Math.round(event.delayMs / 1000);
535
+ this.ctx.retryLoader = new Loader(
536
+ this.ctx.ui,
537
+ spinner => theme.fg("warning", spinner),
538
+ text => theme.fg("muted", text),
539
+ `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s… (esc to cancel)`,
540
+ getSymbolTheme().spinnerFrames,
541
+ );
542
+ this.ctx.statusContainer.addChild(this.ctx.retryLoader);
543
+ this.ctx.ui.requestRender();
544
+ }
545
+
546
+ async #handleAutoRetryEnd(event: Extract<AgentSessionEvent, { type: "auto_retry_end" }>): Promise<void> {
547
+ if (this.ctx.retryEscapeHandler) {
548
+ this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
549
+ this.ctx.retryEscapeHandler = undefined;
550
+ }
551
+ if (this.ctx.retryLoader) {
552
+ this.ctx.retryLoader.stop();
553
+ this.ctx.retryLoader = undefined;
554
+ this.ctx.statusContainer.clear();
555
+ }
556
+ if (!event.success) {
557
+ this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
558
+ }
559
+ this.ctx.ui.requestRender();
560
+ }
561
+
562
+ async #handleRetryFallbackApplied(
563
+ event: Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>,
564
+ ): Promise<void> {
565
+ this.ctx.showWarning(`Fallback: ${event.from} -> ${event.to}`);
566
+ }
567
+
568
+ async #handleRetryFallbackSucceeded(
569
+ event: Extract<AgentSessionEvent, { type: "retry_fallback_succeeded" }>,
570
+ ): Promise<void> {
571
+ this.ctx.showStatus(`Fallback succeeded on ${event.model}`);
572
+ }
573
+
574
+ async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
575
+ const component = new TtsrNotificationComponent(event.rules);
576
+ component.setExpanded(this.ctx.toolOutputExpanded);
577
+ this.ctx.chatContainer.addChild(component);
578
+ this.ctx.ui.requestRender();
579
+ }
580
+
581
+ async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
582
+ const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
583
+ this.ctx.chatContainer.addChild(component);
584
+ this.ctx.ui.requestRender();
585
+ }
586
+
587
+ async #handleTodoAutoClear(_event: Extract<AgentSessionEvent, { type: "todo_auto_clear" }>): Promise<void> {
588
+ await this.ctx.reloadTodos();
577
589
  }
578
590
 
579
591
  #cancelIdleCompaction(): void {