@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3

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 (187) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli.js +3621 -3579
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/runtime.d.ts +15 -1
  8. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  9. package/dist/types/advisor/watchdog.d.ts +20 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/config/model-roles.d.ts +1 -1
  12. package/dist/types/config/settings-schema.d.ts +113 -12
  13. package/dist/types/debug/log-viewer.d.ts +1 -0
  14. package/dist/types/debug/raw-sse.d.ts +1 -0
  15. package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/edit/index.d.ts +18 -0
  18. package/dist/types/edit/streaming.d.ts +30 -0
  19. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  20. package/dist/types/extensibility/shared-events.d.ts +1 -0
  21. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  22. package/dist/types/extensibility/utils.d.ts +12 -0
  23. package/dist/types/mcp/oauth-discovery.d.ts +0 -11
  24. package/dist/types/mcp/transports/index.d.ts +1 -0
  25. package/dist/types/mcp/transports/sse.d.ts +20 -0
  26. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  27. package/dist/types/modes/components/index.d.ts +1 -0
  28. package/dist/types/modes/components/model-selector.d.ts +9 -1
  29. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  30. package/dist/types/modes/components/status-line/component.d.ts +30 -3
  31. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/interactive-mode.d.ts +10 -4
  34. package/dist/types/modes/skill-command.d.ts +32 -0
  35. package/dist/types/modes/types.d.ts +7 -2
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +84 -12
  38. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  39. package/dist/types/session/messages.d.ts +32 -7
  40. package/dist/types/session/messages.test.d.ts +1 -0
  41. package/dist/types/session/session-entries.d.ts +31 -3
  42. package/dist/types/session/session-history-format.d.ts +6 -0
  43. package/dist/types/session/session-loader.d.ts +9 -1
  44. package/dist/types/session/session-manager.d.ts +6 -4
  45. package/dist/types/session/session-storage.d.ts +11 -0
  46. package/dist/types/session/session-title-slot.d.ts +19 -0
  47. package/dist/types/session/settings-stream-fn.d.ts +21 -0
  48. package/dist/types/session/turn-persistence.d.ts +88 -0
  49. package/dist/types/ssh/connection-manager.d.ts +47 -0
  50. package/dist/types/ssh/utils.d.ts +16 -0
  51. package/dist/types/task/executor.d.ts +3 -16
  52. package/dist/types/task/render.d.ts +0 -5
  53. package/dist/types/task/renderer.d.ts +13 -0
  54. package/dist/types/task/types.d.ts +16 -0
  55. package/dist/types/task/yield-assembly.d.ts +28 -0
  56. package/dist/types/tiny/text.d.ts +8 -0
  57. package/dist/types/tools/render-utils.d.ts +2 -0
  58. package/dist/types/tools/review.d.ts +6 -4
  59. package/dist/types/tools/ssh.d.ts +1 -1
  60. package/dist/types/tools/todo.d.ts +6 -0
  61. package/dist/types/tools/yield.d.ts +8 -3
  62. package/dist/types/utils/thinking-display.d.ts +4 -0
  63. package/package.json +12 -12
  64. package/src/advisor/__tests__/advisor.test.ts +438 -10
  65. package/src/advisor/__tests__/config.test.ts +173 -0
  66. package/src/advisor/advise-tool.ts +11 -6
  67. package/src/advisor/config.ts +256 -0
  68. package/src/advisor/index.ts +1 -0
  69. package/src/advisor/runtime.ts +77 -4
  70. package/src/advisor/transcript-recorder.ts +25 -2
  71. package/src/advisor/watchdog.ts +57 -31
  72. package/src/auto-thinking/classifier.ts +2 -2
  73. package/src/autoresearch/index.ts +7 -2
  74. package/src/cli/gc-cli.ts +17 -10
  75. package/src/collab/guest.ts +43 -7
  76. package/src/config/model-registry.ts +80 -18
  77. package/src/config/model-resolver.ts +5 -1
  78. package/src/config/model-roles.ts +3 -3
  79. package/src/config/settings-schema.ts +107 -8
  80. package/src/debug/index.ts +32 -7
  81. package/src/debug/log-viewer.ts +111 -53
  82. package/src/debug/raw-sse.ts +68 -48
  83. package/src/discovery/codex.ts +13 -5
  84. package/src/discovery/omp-extension-roots.ts +38 -13
  85. package/src/edit/hashline/diff.ts +57 -4
  86. package/src/edit/index.ts +21 -0
  87. package/src/edit/streaming.ts +170 -0
  88. package/src/eval/js/shared/local-module-loader.ts +23 -1
  89. package/src/export/html/template.js +13 -7
  90. package/src/extensibility/custom-tools/types.ts +1 -0
  91. package/src/extensibility/extensions/loader.ts +5 -3
  92. package/src/extensibility/extensions/wrapper.ts +9 -3
  93. package/src/extensibility/hooks/loader.ts +3 -3
  94. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  95. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
  96. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
  97. package/src/extensibility/plugins/manager.ts +76 -5
  98. package/src/extensibility/shared-events.ts +1 -0
  99. package/src/extensibility/tool-event-input.ts +23 -0
  100. package/src/extensibility/utils.ts +74 -0
  101. package/src/internal-urls/docs-index.generated.txt +1 -1
  102. package/src/mcp/client.ts +3 -1
  103. package/src/mcp/manager.ts +12 -5
  104. package/src/mcp/oauth-discovery.ts +5 -29
  105. package/src/mcp/transports/http.ts +3 -1
  106. package/src/mcp/transports/index.ts +1 -0
  107. package/src/mcp/transports/sse.ts +377 -0
  108. package/src/memories/index.ts +15 -6
  109. package/src/mnemopi/backend.ts +2 -2
  110. package/src/modes/acp/acp-agent.ts +1 -1
  111. package/src/modes/components/advisor-config.ts +555 -0
  112. package/src/modes/components/advisor-message.ts +9 -2
  113. package/src/modes/components/agent-hub.ts +9 -4
  114. package/src/modes/components/assistant-message.ts +5 -5
  115. package/src/modes/components/index.ts +2 -0
  116. package/src/modes/components/model-selector.ts +79 -48
  117. package/src/modes/components/settings-selector.ts +1 -0
  118. package/src/modes/components/status-line/component.ts +145 -14
  119. package/src/modes/components/status-line/segments.ts +47 -22
  120. package/src/modes/components/status-line/types.ts +13 -1
  121. package/src/modes/components/tool-execution.ts +47 -6
  122. package/src/modes/controllers/command-controller.ts +23 -2
  123. package/src/modes/controllers/event-controller.ts +114 -11
  124. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  125. package/src/modes/controllers/input-controller.ts +61 -61
  126. package/src/modes/controllers/selector-controller.ts +100 -9
  127. package/src/modes/interactive-mode.ts +65 -10
  128. package/src/modes/print-mode.ts +1 -1
  129. package/src/modes/skill-command.ts +116 -0
  130. package/src/modes/types.ts +7 -2
  131. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  132. package/src/modes/utils/ui-helpers.ts +46 -27
  133. package/src/prompts/agents/reviewer.md +11 -10
  134. package/src/prompts/review-custom-request.md +1 -2
  135. package/src/prompts/review-request.md +1 -2
  136. package/src/prompts/system/interrupted-thinking.md +7 -0
  137. package/src/prompts/system/recap-user.md +9 -0
  138. package/src/prompts/system/subagent-system-prompt.md +8 -5
  139. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  140. package/src/prompts/system/system-prompt.md +0 -1
  141. package/src/prompts/tools/irc.md +2 -2
  142. package/src/prompts/tools/read.md +2 -2
  143. package/src/sdk.ts +40 -50
  144. package/src/session/agent-session.ts +1139 -600
  145. package/src/session/indexed-session-storage.ts +86 -13
  146. package/src/session/messages.test.ts +125 -0
  147. package/src/session/messages.ts +192 -21
  148. package/src/session/redis-session-storage.ts +49 -2
  149. package/src/session/session-entries.ts +39 -2
  150. package/src/session/session-history-format.ts +29 -2
  151. package/src/session/session-listing.ts +54 -24
  152. package/src/session/session-loader.ts +66 -3
  153. package/src/session/session-manager.ts +113 -19
  154. package/src/session/session-persistence.ts +96 -3
  155. package/src/session/session-storage.ts +36 -0
  156. package/src/session/session-title-slot.ts +141 -0
  157. package/src/session/settings-stream-fn.ts +49 -0
  158. package/src/session/sql-session-storage.ts +71 -11
  159. package/src/session/turn-persistence.ts +142 -0
  160. package/src/session/unexpected-stop-classifier.ts +2 -2
  161. package/src/slash-commands/builtin-registry.ts +16 -3
  162. package/src/slash-commands/helpers/mcp.ts +2 -1
  163. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  164. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  165. package/src/ssh/connection-manager.ts +139 -12
  166. package/src/ssh/file-transfer.ts +23 -18
  167. package/src/ssh/ssh-executor.ts +2 -13
  168. package/src/ssh/utils.ts +19 -0
  169. package/src/task/executor.ts +21 -23
  170. package/src/task/render.ts +162 -20
  171. package/src/task/renderer.ts +14 -0
  172. package/src/task/types.ts +17 -0
  173. package/src/task/yield-assembly.ts +207 -0
  174. package/src/tiny/models.ts +8 -6
  175. package/src/tiny/text.ts +37 -7
  176. package/src/tools/ask.ts +55 -4
  177. package/src/tools/image-gen.ts +2 -1
  178. package/src/tools/render-utils.ts +2 -0
  179. package/src/tools/renderers.ts +8 -2
  180. package/src/tools/review.ts +17 -7
  181. package/src/tools/ssh.ts +8 -4
  182. package/src/tools/todo.ts +17 -1
  183. package/src/tools/tts.ts +2 -1
  184. package/src/tools/yield.ts +140 -31
  185. package/src/utils/thinking-display.ts +15 -0
  186. package/src/utils/title-generator.ts +1 -1
  187. package/src/web/search/providers/tavily.ts +36 -19
@@ -132,6 +132,14 @@ function rawTextInputFromPartialJson(partialJson: unknown): string | undefined {
132
132
  return partialJson;
133
133
  }
134
134
 
135
+ /** Read the streamed raw-JSON buffer a tool block stashes on its args, narrowed
136
+ * rather than cast: a missing or non-string `__partialJson` yields `undefined`. */
137
+ function partialJsonOf(args: unknown): string | undefined {
138
+ if (args == null || typeof args !== "object" || !("__partialJson" in args)) return undefined;
139
+ const value = args.__partialJson;
140
+ return typeof value === "string" ? value : undefined;
141
+ }
142
+
135
143
  function getArgsWithStreamedTextInput(args: unknown): unknown {
136
144
  if (args == null || typeof args !== "object") return args;
137
145
  const record = args as Record<string, unknown>;
@@ -242,6 +250,10 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
242
250
  #editDiffLastArgsKey?: string;
243
251
  // Latest in-flight streaming diff recompute, captured so it can be awaited.
244
252
  #editDiffInFlight?: Promise<void>;
253
+ /** Set when newer args arrived while a preview compute was in flight; the
254
+ * drain loop re-runs once the current compute settles, so a slow diff
255
+ * coalesces streamed ticks instead of being aborted by each one. */
256
+ #editDiffDirty = false;
245
257
  // Cached converted images for Kitty protocol (which requires PNG), keyed by index
246
258
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
247
259
  // Spinner animation for partial task results
@@ -323,7 +335,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
323
335
  this.setIgnoreTight(true);
324
336
 
325
337
  this.#updateDisplay();
326
- this.#editDiffInFlight = this.#runPreviewDiff();
338
+ this.#schedulePreviewDiff();
327
339
  }
328
340
 
329
341
  updateArgs(args: any, _toolCallId?: string): void {
@@ -335,7 +347,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
335
347
  this.#args = args;
336
348
  this.#displayInputVersion++;
337
349
  this.#updateSpinnerAnimation();
338
- this.#editDiffInFlight = this.#runPreviewDiff();
350
+ this.#schedulePreviewDiff();
339
351
  this.#updateDisplay();
340
352
  }
341
353
 
@@ -346,7 +358,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
346
358
  setArgsComplete(_toolCallId?: string): void {
347
359
  this.#argsComplete = true;
348
360
  this.#updateSpinnerAnimation();
349
- this.#editDiffInFlight = this.#runPreviewDiff();
361
+ this.#schedulePreviewDiff();
350
362
  }
351
363
 
352
364
  /**
@@ -360,7 +372,32 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
360
372
  await this.#editDiffInFlight;
361
373
  }
362
374
 
363
- async #runPreviewDiff(): Promise<void> {
375
+ /**
376
+ * Schedule a streaming diff preview recompute, coalescing bursts of
377
+ * `updateArgs` into one compute at a time: run the current compute to
378
+ * completion and re-run only after it settles when newer args arrived, never
379
+ * cancelling an in-flight compute on a fresh tick. The reveal controller pushes
380
+ * args ~30fps and a whole-file hashline/large-file diff can outlast a frame, so
381
+ * cancel-per-tick would starve every compute and no preview would land until
382
+ * args complete. Coalescing lets each diff land, so the preview tracks the
383
+ * stream at the rate the diffs can sustain.
384
+ */
385
+ #schedulePreviewDiff(): void {
386
+ this.#editDiffDirty = true;
387
+ if (this.#editDiffInFlight) return;
388
+ this.#editDiffInFlight = this.#drainPreviewDiff().finally(() => {
389
+ this.#editDiffInFlight = undefined;
390
+ });
391
+ }
392
+
393
+ async #drainPreviewDiff(): Promise<void> {
394
+ while (this.#editDiffDirty) {
395
+ this.#editDiffDirty = false;
396
+ await this.#computePreviewDiff();
397
+ }
398
+ }
399
+
400
+ async #computePreviewDiff(): Promise<void> {
364
401
  const editMode = this.#editMode;
365
402
  if (!editMode) return;
366
403
  const strategy = EDIT_MODE_STRATEGIES[editMode];
@@ -370,7 +407,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
370
407
  if (args == null || typeof args !== "object") return;
371
408
 
372
409
  const previewArgs = getArgsWithStreamedTextInput(args);
373
- const partialJson = (previewArgs as { __partialJson?: string }).__partialJson;
410
+ const partialJson = partialJsonOf(previewArgs);
374
411
  let effectiveArgs: unknown;
375
412
  try {
376
413
  effectiveArgs = strategy.extractCompleteEdits(previewArgs, partialJson);
@@ -400,7 +437,8 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
400
437
  if (argsKey === this.#editDiffLastArgsKey) return;
401
438
  this.#editDiffLastArgsKey = argsKey;
402
439
 
403
- this.#editDiffAbort?.abort();
440
+ // Single-flight (the drain loop never overlaps computes), so this controller
441
+ // only ever cancels the live compute on teardown via `stopAnimation`.
404
442
  const controller = new AbortController();
405
443
  this.#editDiffAbort = controller;
406
444
 
@@ -732,6 +770,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
732
770
  this.#stopTodoStrikeAnimation();
733
771
  this.#editDiffAbort?.abort();
734
772
  this.#editDiffAbort = undefined;
773
+ // Drop any queued rerun so the drain loop exits instead of recomputing a
774
+ // preview for a torn-down block after its in-flight compute is aborted.
775
+ this.#editDiffDirty = false;
735
776
  }
736
777
 
737
778
  setExpanded(expanded: boolean): void {
@@ -360,6 +360,27 @@ export class CommandController {
360
360
  ]);
361
361
  return;
362
362
  }
363
+ if (stats.advisors.length > 1) {
364
+ let info = `${theme.bold("Advisor Status")} (${stats.advisors.length} advisors)\n`;
365
+ for (const a of stats.advisors) {
366
+ const ctx =
367
+ a.contextWindow > 0
368
+ ? `${a.contextTokens.toLocaleString()} / ${a.contextWindow.toLocaleString()} (${Math.round((a.contextTokens / a.contextWindow) * 100)}%)`
369
+ : `${a.contextTokens.toLocaleString()}`;
370
+ info += `\n${theme.bold(a.name)}\n`;
371
+ info += `${theme.fg("dim", "Model:")} ${a.model.provider}/${a.model.id}\n`;
372
+ info += `${theme.fg("dim", "Context:")} ${ctx}\n`;
373
+ info += `${theme.fg("dim", "Messages:")} ${a.messages.total.toLocaleString()}\n`;
374
+ info += `${theme.fg("dim", "Spend:")} ${a.tokens.input.toLocaleString()} in / ${a.tokens.output.toLocaleString()} out`;
375
+ if (a.cost > 0) info += `, $${a.cost.toFixed(4)}`;
376
+ info += "\n";
377
+ }
378
+ info += `\n${theme.bold("Totals")}\n`;
379
+ info += `${theme.fg("dim", "Tokens:")} ${stats.tokens.total.toLocaleString()}\n`;
380
+ if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`;
381
+ this.ctx.present([new Spacer(1), new Text(info, 1, 0)]);
382
+ return;
383
+ }
363
384
  const model = stats.model!;
364
385
  let info = `${theme.bold("Advisor Status")}\n\n`;
365
386
  info += `${theme.bold("Provider")}\n`;
@@ -846,7 +867,7 @@ export class CommandController {
846
867
  setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
847
868
 
848
869
  this.ctx.statusLine.invalidate();
849
- this.ctx.statusLine.setSessionStartTime(Date.now());
870
+ this.ctx.statusLine.resetActiveTime();
850
871
  this.ctx.updateEditorTopBorder();
851
872
  this.ctx.updateEditorBorderColor();
852
873
  this.ctx.chatContainer.clear();
@@ -1018,7 +1039,7 @@ export class CommandController {
1018
1039
  this.ctx.streamingMessage = undefined;
1019
1040
  this.ctx.pendingTools.clear();
1020
1041
  this.ctx.statusLine.invalidate();
1021
- this.ctx.statusLine.setSessionStartTime(Date.now());
1042
+ this.ctx.statusLine.resetActiveTime();
1022
1043
  this.ctx.updateEditorTopBorder();
1023
1044
  this.ctx.updateEditorBorderColor();
1024
1045
  await this.ctx.reloadTodos();
@@ -1,6 +1,8 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
- import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
2
+ import * as AIError from "@oh-my-pi/pi-ai/error";
3
+ import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
3
4
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
5
+ import { logger, prompt } from "@oh-my-pi/pi-utils";
4
6
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
5
7
  import { extractTextContent } from "../../commit/utils";
6
8
  import { settings } from "../../config/settings";
@@ -19,9 +21,12 @@ import { createUsageRowBlock } from "../../modes/components/usage-row";
19
21
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
20
22
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
21
23
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
24
+ import idleRecapPrompt from "../../prompts/system/recap-user.md" with { type: "text" };
22
25
  import type { AgentSessionEvent } from "../../session/agent-session";
23
26
  import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages";
27
+ import { previewLine, TRUNCATE_LENGTHS } from "../../tools/render-utils";
24
28
  import type { ResolveToolDetails } from "../../tools/resolve";
29
+ import { nextActionableTask } from "../../tools/todo";
25
30
  import { vocalizer } from "../../tts/vocalizer";
26
31
  import { canonicalizeMessage } from "../../utils/thinking-display";
27
32
  import { interruptHint } from "../shared";
@@ -42,6 +47,8 @@ const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
42
47
  * oldest live-region card retires as soon as a new one would exceed the cap.
43
48
  */
44
49
  const MAX_LIVE_IRC_CARDS = 4;
50
+ const IDLE_RECAP_MIN_SECONDS = 1;
51
+ const IDLE_RECAP_MAX_SECONDS = 3600;
45
52
 
46
53
  type AgentSessionEventHandlers = {
47
54
  [E in AgentSessionEventKind]: (event: Extract<AgentSessionEvent, { type: E }>) => Promise<void>;
@@ -68,6 +75,10 @@ export class EventController {
68
75
  // #handleMessageEnd / #handleAgentStart).
69
76
  #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
70
77
  #idleCompactionTimer?: NodeJS.Timeout;
78
+ #idleRecapTimer?: NodeJS.Timeout;
79
+ // In-flight ephemeral recap turn; aborted by #cancelIdleRecap when any
80
+ // activity (new turn, compaction, editor draft) supersedes the idle recap.
81
+ #idleRecapAbort?: AbortController;
71
82
  #ircExpiryTimers = new Map<string, NodeJS.Timeout>();
72
83
  // Insertion-ordered IRC cards not yet retired; values are the transcript
73
84
  // components each card contributed (see #retireIrcCard for the guard).
@@ -156,6 +167,7 @@ export class EventController {
156
167
  this.#streamingReveal.stop();
157
168
  this.#toolArgsReveal.stop();
158
169
  this.#cancelIdleCompaction();
170
+ this.#cancelIdleRecap();
159
171
  this.#setTerminalProgress(false);
160
172
  for (const timer of this.#ircExpiryTimers.values()) {
161
173
  clearTimeout(timer);
@@ -247,6 +259,7 @@ export class EventController {
247
259
  this.#lastAssistantComponent = undefined;
248
260
  this.#pinnedErrorComponent = undefined;
249
261
  this.#cancelIdleCompaction();
262
+ this.#cancelIdleRecap();
250
263
  for (const timer of this.#ircExpiryTimers.values()) {
251
264
  clearTimeout(timer);
252
265
  }
@@ -301,6 +314,8 @@ export class EventController {
301
314
  this.ctx.statusContainer.clear();
302
315
  }
303
316
  this.#cancelIdleCompaction();
317
+ this.#cancelIdleRecap();
318
+ this.ctx.statusLine.markActivityStart();
304
319
  this.#setTerminalProgress(true);
305
320
  this.ctx.ensureLoadingAnimation();
306
321
  this.ctx.ui.requestRender();
@@ -553,6 +568,11 @@ export class EventController {
553
568
  this.#ensureWorkingLoaderWhileStreaming();
554
569
  this.#vocalizeDelta(event);
555
570
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
571
+ const unlockedThinkingVisibility = this.ctx.noteDisplayableThinkingContent(event.message);
572
+ if (unlockedThinkingVisibility) {
573
+ this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.effectiveHideThinkingBlock);
574
+ this.#streamingReveal.resyncVisibility();
575
+ }
556
576
  this.ctx.streamingMessage = event.message;
557
577
  this.#streamingReveal.setTarget(this.ctx.streamingMessage);
558
578
 
@@ -609,9 +629,9 @@ export class EventController {
609
629
  // reveal (write/edit/bash previews grow smoothly when a slow provider
610
630
  // delivers large batches); once it closes, the final args render
611
631
  // as-is — mirroring how assistant text snaps at message_end.
612
- const partialJson = "partialJson" in content ? content.partialJson : undefined;
613
632
  let renderArgs: Record<string, unknown>;
614
- if (typeof partialJson === "string") {
633
+ const partialJson = getStreamingPartialJson(content);
634
+ if (partialJson) {
615
635
  renderArgs = this.#toolArgsReveal.setTarget(
616
636
  content.id,
617
637
  partialJson,
@@ -680,6 +700,12 @@ export class EventController {
680
700
 
681
701
  async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
682
702
  if (event.message.role === "user") return;
703
+ const unlockedThinkingVisibility =
704
+ event.message.role === "assistant" && this.ctx.noteDisplayableThinkingContent(event.message);
705
+ if (unlockedThinkingVisibility && this.ctx.streamingComponent) {
706
+ this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.effectiveHideThinkingBlock);
707
+ this.#streamingReveal.resyncVisibility();
708
+ }
683
709
  if (event.message.role === "assistant" && settings.get("speech.enabled")) {
684
710
  if (event.message.stopReason === "aborted") {
685
711
  // Esc / Ctrl+C / interrupt: stop speaking now and drop the trailing partial.
@@ -697,7 +723,7 @@ export class EventController {
697
723
  this.#toolArgsReveal.flushAll();
698
724
  let errorMessage: string | undefined;
699
725
  const aborted = this.ctx.streamingMessage.stopReason === "aborted";
700
- const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage);
726
+ const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage);
701
727
  const ttsrSilenced = aborted && this.ctx.viewSession.isTtsrAbortPending;
702
728
  if (aborted && !silentlyAborted && !ttsrSilenced) {
703
729
  // Resolve the operator-facing label: a user-interrupt (Esc) abort
@@ -707,7 +733,7 @@ export class EventController {
707
733
  // AgentSession.#handleAgentEvent already stamped SILENT_ABORT_MARKER for
708
734
  // the plan-compact transition before this controller ran, so reaching
709
735
  // this branch implies the abort was NOT a silent internal transition.
710
- errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.viewSession.retryAttempt);
736
+ errorMessage = resolveAbortLabel(this.ctx.streamingMessage, this.ctx.viewSession.retryAttempt);
711
737
  this.ctx.streamingMessage.errorMessage = errorMessage;
712
738
  }
713
739
  if (silentlyAborted || ttsrSilenced) {
@@ -759,11 +785,7 @@ export class EventController {
759
785
  // above the editor so it survives transcript scroll. Cleared at the next
760
786
  // turn's agent_start. Suppress the transcript's inline `Error: …` line for
761
787
  // the same message while pinned so the error isn't rendered twice.
762
- if (
763
- event.message.stopReason === "error" &&
764
- event.message.errorMessage &&
765
- !isSilentAbort(event.message.errorMessage)
766
- ) {
788
+ if (event.message.stopReason === "error" && event.message.errorMessage && !isSilentAbort(event.message)) {
767
789
  this.#lastAssistantComponent?.setErrorPinned(true);
768
790
  this.#pinnedErrorComponent = this.#lastAssistantComponent;
769
791
  this.ctx.showPinnedError(event.message.errorMessage);
@@ -970,6 +992,7 @@ export class EventController {
970
992
 
971
993
  async #finishAgentEnd(): Promise<void> {
972
994
  this.#setTerminalProgress(false);
995
+ this.ctx.statusLine.markActivityEnd();
973
996
  this.#streamingReveal.stop();
974
997
  this.#toolArgsReveal.flushAll();
975
998
  if (this.ctx.loadingAnimation) {
@@ -1011,6 +1034,7 @@ export class EventController {
1011
1034
  this.#lastAssistantComponent = undefined;
1012
1035
  this.ctx.ui.requestRender();
1013
1036
  this.#scheduleIdleCompaction();
1037
+ this.#scheduleIdleRecap();
1014
1038
  this.sendCompletionNotification();
1015
1039
  }
1016
1040
 
@@ -1056,6 +1080,7 @@ export class EventController {
1056
1080
  event: Extract<AgentSessionEvent, { type: "auto_compaction_start" }>,
1057
1081
  ): Promise<void> {
1058
1082
  this.#cancelIdleCompaction();
1083
+ this.#cancelIdleRecap();
1059
1084
  this.#setTerminalProgress(true);
1060
1085
  this.#stopWorkingLoader();
1061
1086
  this.ctx.statusContainer.clear();
@@ -1088,6 +1113,7 @@ export class EventController {
1088
1113
 
1089
1114
  async #handleAutoCompactionEnd(event: Extract<AgentSessionEvent, { type: "auto_compaction_end" }>): Promise<void> {
1090
1115
  this.#cancelIdleCompaction();
1116
+ this.#cancelIdleRecap();
1091
1117
  this.#setTerminalProgress(false);
1092
1118
  if (this.ctx.autoCompactionLoader) {
1093
1119
  this.ctx.autoCompactionLoader.stop();
@@ -1157,7 +1183,7 @@ export class EventController {
1157
1183
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1158
1184
  this.#stopWorkingLoader();
1159
1185
  this.ctx.statusContainer.clear();
1160
- if (event.errorMessage?.includes(THINKING_LOOP_ERROR_MARKER)) {
1186
+ if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
1161
1187
  // The retry path drops the failed assistant from runtime context. Do not
1162
1188
  // restore its inline Error row; just unpin the fixed-region banner so the
1163
1189
  // retry UI is the visible state.
@@ -1240,6 +1266,17 @@ export class EventController {
1240
1266
  }
1241
1267
  }
1242
1268
 
1269
+ #cancelIdleRecap(): void {
1270
+ if (this.#idleRecapTimer) {
1271
+ clearTimeout(this.#idleRecapTimer);
1272
+ this.#idleRecapTimer = undefined;
1273
+ }
1274
+ if (this.#idleRecapAbort) {
1275
+ this.#idleRecapAbort.abort();
1276
+ this.#idleRecapAbort = undefined;
1277
+ }
1278
+ }
1279
+
1243
1280
  #scheduleIdleCompaction(): void {
1244
1281
  this.#cancelIdleCompaction();
1245
1282
  // Don't schedule idle work while context maintenance is already running; the
@@ -1270,6 +1307,72 @@ export class EventController {
1270
1307
  this.#idleCompactionTimer.unref?.();
1271
1308
  }
1272
1309
 
1310
+ #scheduleIdleRecap(): void {
1311
+ this.#cancelIdleRecap();
1312
+ if (this.ctx.viewSession.isCompacting) return;
1313
+
1314
+ const recapSettings = settings.getGroup("recap");
1315
+ if (!recapSettings.enabled) return;
1316
+ if (this.ctx.editor.getText().trim()) return;
1317
+
1318
+ const timeoutMs =
1319
+ Math.max(IDLE_RECAP_MIN_SECONDS, Math.min(IDLE_RECAP_MAX_SECONDS, recapSettings.idleSeconds)) * 1000;
1320
+ this.#idleRecapTimer = setTimeout(() => {
1321
+ this.#idleRecapTimer = undefined;
1322
+ void this.#runIdleRecap();
1323
+ }, timeoutMs);
1324
+ this.#idleRecapTimer.unref?.();
1325
+ }
1326
+
1327
+ /**
1328
+ * Generate the idle recap with an ephemeral side-channel turn over the
1329
+ * current conversation (same pipeline as `/btw`) and surface it as a status
1330
+ * line. Live goal/title and the active todo task are passed as anchoring
1331
+ * hints because the snapshot only carries conversation history, not the
1332
+ * controller's todo/goal state. The request is abortable: any activity
1333
+ * cancels it via #cancelIdleRecap, and idle conditions are re-checked after
1334
+ * the reply lands so a stale recap never paints over fresh work.
1335
+ */
1336
+ async #runIdleRecap(): Promise<void> {
1337
+ if (!this.#idleConditionsHold()) return;
1338
+ if (!this.ctx.viewSession.model) return;
1339
+ if (this.ctx.viewSession.messages.length === 0) return;
1340
+
1341
+ const promptText = prompt.render(idleRecapPrompt, {
1342
+ goal: this.#idleRecapGoalText() ?? "",
1343
+ task: nextActionableTask(this.ctx.todoPhases)?.content ?? "",
1344
+ });
1345
+
1346
+ const abort = new AbortController();
1347
+ this.#idleRecapAbort = abort;
1348
+ try {
1349
+ const { replyText } = await this.ctx.viewSession.runEphemeralTurn({ promptText, signal: abort.signal });
1350
+ if (this.#idleRecapAbort !== abort || abort.signal.aborted || !this.#idleConditionsHold()) return;
1351
+ const recap = previewLine(replyText, TRUNCATE_LENGTHS.RECAP);
1352
+ if (!recap) return;
1353
+ this.ctx.showStatus(theme.fg("dim", theme.italic(`※ recap: ${recap}`)), { dim: false });
1354
+ } catch (error) {
1355
+ if (!abort.signal.aborted) logger.debug("Idle recap turn failed", { error: String(error) });
1356
+ } finally {
1357
+ if (this.#idleRecapAbort === abort) this.#idleRecapAbort = undefined;
1358
+ }
1359
+ }
1360
+
1361
+ /** Idle gate shared by the recap timer fire and its post-reply re-check. */
1362
+ #idleConditionsHold(): boolean {
1363
+ if (this.ctx.viewSession.isStreaming) return false;
1364
+ if (this.ctx.viewSession.isCompacting) return false;
1365
+ if (this.ctx.editor.getText().trim()) return false;
1366
+ return true;
1367
+ }
1368
+
1369
+ #idleRecapGoalText(): string | undefined {
1370
+ const goal = this.ctx.viewSession.getGoalModeState?.()?.goal.objective.trim();
1371
+ if (goal) return goal;
1372
+ const title = this.ctx.sessionManager.getSessionName()?.trim();
1373
+ return title || undefined;
1374
+ }
1375
+
1273
1376
  #currentContextTokens(): number {
1274
1377
  return this.ctx.viewSession.getContextUsage()?.tokens ?? 0;
1275
1378
  }
@@ -170,7 +170,7 @@ export class ExtensionUiController {
170
170
 
171
171
  // Reset and update status line
172
172
  this.ctx.statusLine.invalidate();
173
- this.ctx.statusLine.setSessionStartTime(Date.now());
173
+ this.ctx.statusLine.resetActiveTime();
174
174
  this.ctx.updateEditorTopBorder();
175
175
  this.ctx.ui.requestRender();
176
176
 
@@ -13,9 +13,10 @@ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-
13
13
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
14
14
  import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
15
15
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
16
+ import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command";
16
17
  import type { InteractiveModeContext } from "../../modes/types";
17
18
  import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
18
- import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails, USER_INTERRUPT_LABEL } from "../../session/messages";
19
+ import { USER_INTERRUPT_LABEL } from "../../session/messages";
19
20
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
20
21
  import { isTinyTitleLocalModelKey } from "../../tiny/models";
21
22
  import { isLowSignalTitleInput } from "../../tiny/text";
@@ -715,11 +716,18 @@ export class InputController {
715
716
  }
716
717
 
717
718
  // Handle skill commands (/skill:name [args]). Enter ⇒ steer (matches the
718
- // free-text Enter semantics applied a few lines below at the streaming
719
- // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
720
- // same helper with `"followUp"`.
721
- if (text && (await this.#invokeSkillCommand(text, "steer"))) {
722
- return;
719
+ // free-text Enter semantics below); Ctrl+Enter routes through `handleFollowUp`.
720
+ // During compaction, queue immediately so bash/python/loop-mode branches do
721
+ // not consume the skill before the compaction-resume path re-parses it.
722
+ if (text && isKnownSkillCommand(this.ctx, text)) {
723
+ if (this.ctx.session.isCompacting) {
724
+ const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
725
+ this.ctx.queueCompactionMessage(text, "steer", images);
726
+ return;
727
+ }
728
+ if (await this.#invokeSkillCommand(text, "steer", inputImages, inputImageLinks)) {
729
+ return;
730
+ }
723
731
  }
724
732
 
725
733
  // Handle bash command (! for normal, !! for excluded from context)
@@ -1074,59 +1082,52 @@ export class InputController {
1074
1082
 
1075
1083
  /**
1076
1084
  * Dispatch a `/skill:<name> [args]` invocation through `promptCustomMessage`
1077
- * using the supplied `streamingBehavior`. Returns true if the text was a
1078
- * recognised skill command and was dispatched. A failure to load the skill
1079
- * file is surfaced via `showError` but still returns true the editor was
1080
- * already cleared on the success path, so falling through to plain-text
1081
- * handling at that point would double-submit. Returns false when the text
1082
- * isn't a `/skill:` prefix or the command name isn't a registered skill,
1083
- * so the caller can fall through to plain-text handling (this branch
1084
- * leaves the editor state untouched). `streamingBehavior` is only consulted
1085
- * while the agent is streaming; the idle path of `promptCustomMessage`
1086
- * ignores it.
1085
+ * using the supplied `streamingBehavior`. Returns false when the text is not
1086
+ * a registered skill command and leaves the editor state untouched. Registered
1087
+ * skills consume the full composer draft (text plus pending images) before
1088
+ * dispatch; if dispatch rejects, the draft is restored so the user can retry.
1087
1089
  */
1088
- async #invokeSkillCommand(text: string, streamingBehavior: "steer" | "followUp"): Promise<boolean> {
1089
- if (!text.startsWith("/skill:")) return false;
1090
- const spaceIndex = text.indexOf(" ");
1091
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
1092
- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
1093
- const skillPath = this.ctx.skillCommands?.get(commandName);
1094
- if (!skillPath) return false;
1095
- this.ctx.editor.addToHistory(text);
1096
- this.ctx.editor.setText("");
1090
+ async #invokeSkillCommand(
1091
+ text: string,
1092
+ streamingBehavior: "steer" | "followUp",
1093
+ images?: ImageContent[],
1094
+ imageLinks?: (string | undefined)[],
1095
+ ): Promise<boolean> {
1096
+ if (!isKnownSkillCommand(this.ctx, text)) return false;
1097
+ const draftImages = images && images.length > 0 ? [...images] : undefined;
1098
+ const draftImageLinks = draftImages && imageLinks && imageLinks.length > 0 ? [...imageLinks] : undefined;
1099
+ const restoreDraft = () => {
1100
+ this.ctx.editor.setText(text);
1101
+ if (draftImages && draftImages.length > 0) {
1102
+ this.ctx.editor.pendingImages = [...draftImages];
1103
+ this.ctx.editor.pendingImageLinks = draftImageLinks
1104
+ ? [...draftImageLinks]
1105
+ : draftImages.map(() => undefined);
1106
+ this.ctx.editor.imageLinks = this.ctx.editor.pendingImageLinks;
1107
+ }
1108
+ };
1109
+
1110
+ this.ctx.editor.clearDraft(text);
1097
1111
  try {
1098
- const content = await Bun.file(skillPath).text();
1099
- const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
1100
- const metaLines = [`Skill: ${skillPath}`];
1101
- if (args) {
1102
- metaLines.push(`User: ${args}`);
1112
+ const handled = await invokeSkillCommandFromText(this.ctx, text, streamingBehavior, {
1113
+ images: draftImages,
1114
+ propagateErrors: true,
1115
+ });
1116
+ if (!handled) {
1117
+ restoreDraft();
1118
+ return false;
1103
1119
  }
1104
- const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
1105
- const skillName = commandName.slice("skill:".length);
1106
- const details: SkillPromptDetails = {
1107
- name: skillName || commandName,
1108
- path: skillPath,
1109
- args: args || undefined,
1110
- lineCount: body ? body.split("\n").length : 0,
1111
- };
1112
- await this.ctx.session.promptCustomMessage(
1113
- {
1114
- customType: SKILL_PROMPT_MESSAGE_TYPE,
1115
- content: message,
1116
- display: true,
1117
- details,
1118
- attribution: "user",
1119
- },
1120
- { streamingBehavior, queueChipText: text },
1121
- );
1120
+ return true;
1121
+ } catch (error) {
1122
+ restoreDraft();
1123
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1124
+ return true;
1125
+ } finally {
1122
1126
  if (this.ctx.session.isStreaming) {
1123
1127
  this.ctx.updatePendingMessagesDisplay();
1124
1128
  this.ctx.ui.requestRender();
1125
1129
  }
1126
- } catch (err) {
1127
- this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
1128
1130
  }
1129
- return true;
1130
1131
  }
1131
1132
 
1132
1133
  async handleRetry(): Promise<void> {
@@ -1159,9 +1160,8 @@ export class InputController {
1159
1160
  // Compaction first: while compacting, free text gets queued via
1160
1161
  // `queueCompactionMessage`, and `/skill:*` rides the same queue so a
1161
1162
  // skill typed during compaction is not lost or short-circuited through
1162
- // `promptCustomMessage`. The skill text is queued verbatim; whether
1163
- // the queued entry is later re-parsed into a skill invocation is a
1164
- // separate concern owned by the compaction-resume path.
1163
+ // `promptCustomMessage`. The compaction-resume path re-parses the
1164
+ // queued text into a user-attributed skill invocation before delivery.
1165
1165
  if (this.ctx.session.isCompacting) {
1166
1166
  const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
1167
1167
  this.ctx.queueCompactionMessage(text, "followUp", images);
@@ -1187,7 +1187,7 @@ export class InputController {
1187
1187
  // Skill commands invoke through the custom-message path regardless of
1188
1188
  // which keybinding submitted them. Enter routes them as `steer`;
1189
1189
  // Ctrl+Enter (this handler) routes them as `followUp`.
1190
- if (text && (await this.#invokeSkillCommand(text, "followUp"))) {
1190
+ if (text && (await this.#invokeSkillCommand(text, "followUp", images, imageLinks))) {
1191
1191
  return;
1192
1192
  }
1193
1193
 
@@ -1739,14 +1739,14 @@ export class InputController {
1739
1739
  }
1740
1740
 
1741
1741
  toggleThinkingBlockVisibility(): void {
1742
- // When thinking is "off", thinking blocks are always hidden (some
1743
- // providers return them regardless). The toggle is meaningless in
1744
- // that state inform the user instead of silently flipping the
1745
- // persisted value. When thinking is on, the toggle works normally
1746
- // even if blocks are already hidden (user may want to show them).
1742
+ // When thinking is "off" and the session has not produced reasoning
1743
+ // content, thinking blocks stay auto-hidden; the toggle would only corrupt
1744
+ // the persisted preference. OpenAI-compatible servers can stream reasoning
1745
+ // without advertising model support, so observed thinking content unlocks
1746
+ // the display toggle.
1747
1747
  const thinkingOff =
1748
1748
  ((this.ctx.viewSession ?? this.ctx.session)?.thinkingLevel ?? ThinkingLevel.Off) === ThinkingLevel.Off;
1749
- if (thinkingOff) {
1749
+ if (thinkingOff && !this.ctx.hasDisplayableThinkingContent) {
1750
1750
  this.ctx.showStatus("Thinking is off — enable thinking to show blocks");
1751
1751
  return;
1752
1752
  }