@oh-my-pi/pi-coding-agent 17.3.7 → 17.3.8

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 (109) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/{CHANGELOG-1hmwwt45.md → CHANGELOG-vr9cckb4.md} +60 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/update-cli.d.ts +8 -0
  9. package/dist/types/cli-commands.d.ts +10 -2
  10. package/dist/types/config/settings-schema.d.ts +28 -0
  11. package/dist/types/config/settings.d.ts +9 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  13. package/dist/types/launch/presence.d.ts +4 -1
  14. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  15. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  16. package/dist/types/mnemopi/backend.d.ts +12 -0
  17. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  19. package/dist/types/modes/interactive-mode.d.ts +25 -3
  20. package/dist/types/modes/types.d.ts +10 -0
  21. package/dist/types/session/agent-session.d.ts +3 -0
  22. package/dist/types/session/prewalk.d.ts +4 -0
  23. package/dist/types/session/session-entries.d.ts +0 -1
  24. package/dist/types/session/session-manager.d.ts +12 -0
  25. package/dist/types/session/session-stats.d.ts +13 -1
  26. package/dist/types/session/skill-title-input.d.ts +13 -0
  27. package/dist/types/subprocess/worker-client.d.ts +7 -4
  28. package/dist/types/task/label.d.ts +2 -0
  29. package/dist/types/task/render.d.ts +2 -0
  30. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +6 -4
  32. package/dist/types/tiny/title-protocol.d.ts +1 -0
  33. package/dist/types/tiny/worker.d.ts +27 -0
  34. package/dist/types/tools/bash.d.ts +1 -1
  35. package/dist/types/tools/read-format.d.ts +6 -0
  36. package/dist/types/tools/read-summary.d.ts +7 -1
  37. package/dist/types/utils/block-context.d.ts +14 -0
  38. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  39. package/dist/types/utils/git.d.ts +25 -1
  40. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  41. package/package.json +13 -13
  42. package/src/advisor/advise-tool.ts +5 -3
  43. package/src/cli/auth-broker-cli.ts +36 -1
  44. package/src/cli/profile-bootstrap.ts +2 -6
  45. package/src/cli/update-cli.ts +63 -11
  46. package/src/cli-commands.ts +61 -7
  47. package/src/commands/completions.ts +2 -1
  48. package/src/commit/agentic/index.ts +15 -2
  49. package/src/commit/git/diff.ts +6 -2
  50. package/src/config/model-resolver.ts +52 -6
  51. package/src/config/models-config.ts +2 -2
  52. package/src/config/settings-schema.ts +33 -0
  53. package/src/config/settings.ts +159 -30
  54. package/src/discovery/helpers.ts +45 -2
  55. package/src/discovery/omp-plugins.ts +2 -1
  56. package/src/discovery/opencode.ts +56 -3
  57. package/src/eval/js/process-entry.ts +4 -4
  58. package/src/export/html/tool-views.generated.js +19 -19
  59. package/src/extensibility/extensions/runner.ts +3 -2
  60. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  61. package/src/launch/client.ts +9 -4
  62. package/src/launch/presence.ts +19 -4
  63. package/src/lsp/defaults.json +1 -1
  64. package/src/mcp/manager.ts +41 -20
  65. package/src/mcp/oauth-credentials.ts +38 -0
  66. package/src/mcp/oauth-flow.ts +21 -0
  67. package/src/mcp/tool-bridge.ts +32 -16
  68. package/src/mnemopi/backend.ts +35 -3
  69. package/src/modes/components/model-hub.ts +37 -4
  70. package/src/modes/components/settings-selector.ts +17 -11
  71. package/src/modes/components/tool-execution.ts +97 -29
  72. package/src/modes/components/tree-selector.ts +7 -2
  73. package/src/modes/controllers/event-controller.ts +12 -2
  74. package/src/modes/controllers/input-controller.ts +64 -27
  75. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  76. package/src/modes/interactive-mode.ts +79 -11
  77. package/src/modes/types.ts +11 -0
  78. package/src/prompts/system/memory-extraction-system.md +5 -22
  79. package/src/prompts/system/system-prompt.md +1 -1
  80. package/src/session/agent-session.ts +52 -6
  81. package/src/session/messages.ts +6 -0
  82. package/src/session/prewalk.ts +25 -7
  83. package/src/session/session-entries.ts +0 -1
  84. package/src/session/session-maintenance.ts +10 -1
  85. package/src/session/session-manager.ts +15 -0
  86. package/src/session/session-stats.ts +24 -3
  87. package/src/session/settings-stream-fn.ts +7 -0
  88. package/src/session/skill-title-input.ts +32 -0
  89. package/src/session/turn-recovery.ts +23 -18
  90. package/src/subprocess/worker-client.ts +8 -5
  91. package/src/task/executor.ts +11 -0
  92. package/src/task/index.ts +2 -0
  93. package/src/task/label.ts +14 -1
  94. package/src/task/persisted-revive.ts +13 -0
  95. package/src/task/render.ts +1 -1
  96. package/src/task/structured-subagent.ts +5 -2
  97. package/src/tiny/completion-prompt.ts +16 -0
  98. package/src/tiny/title-client.ts +15 -6
  99. package/src/tiny/title-protocol.ts +8 -1
  100. package/src/tiny/worker.ts +21 -19
  101. package/src/tools/bash.ts +7 -1
  102. package/src/tools/read-format.ts +16 -2
  103. package/src/tools/read-summary.ts +9 -4
  104. package/src/tools/read.ts +306 -72
  105. package/src/utils/block-context.ts +15 -1
  106. package/src/utils/fetch-timeout.ts +33 -0
  107. package/src/utils/git.ts +54 -11
  108. package/src/web/search/providers/browser-page.ts +21 -3
  109. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -14,7 +14,7 @@ import {
14
14
  Text,
15
15
  type TUI,
16
16
  } from "@oh-my-pi/pi-tui";
17
- import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
17
+ import { getProjectDir, isRecord, logger, sanitizeText } from "@oh-my-pi/pi-utils";
18
18
  import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
19
19
  import type { Theme } from "../../modes/theme/theme";
20
20
  import { getThemeEpoch, theme } from "../../modes/theme/theme";
@@ -73,6 +73,21 @@ function isTodoToolDetails(details: unknown): details is TodoToolDetails {
73
73
  );
74
74
  }
75
75
 
76
+ interface ToolImageBlock {
77
+ data?: string;
78
+ mimeType?: string;
79
+ }
80
+
81
+ function imageBlocksFromDetails(details: unknown): ToolImageBlock[] {
82
+ if (!isRecord(details) || !Array.isArray(details.images)) return [];
83
+ return details.images.filter(
84
+ (image): image is ToolImageBlock =>
85
+ isRecord(image) &&
86
+ (image.data === undefined || typeof image.data === "string") &&
87
+ (image.mimeType === undefined || typeof image.mimeType === "string"),
88
+ );
89
+ }
90
+
76
91
  function displaceableToolName(
77
92
  toolName: string,
78
93
  result: { details?: unknown; isError?: boolean },
@@ -269,6 +284,52 @@ export function sharedSpinnerFrame(frameCount: number, now: number = performance
269
284
  return frameCount > 0 ? Math.floor(now / SPINNER_GLYPH_ADVANCE_MS) % frameCount : 0;
270
285
  }
271
286
 
287
+ /** Live tool blocks currently driving a spinner. A single shared ticker (below)
288
+ * advances and repaints every registered block per glyph step, so N concurrent
289
+ * live/streaming blocks — e.g. parallel `task` subagents — cost one 80ms timer
290
+ * and one coalesced render frame per tick instead of N unsynchronized timers
291
+ * each independently waking the render scheduler (issue #8731). */
292
+ const liveSpinnerBlocks = new Set<ToolExecutionComponent>();
293
+ let sharedSpinnerTimer: NodeJS.Timeout | undefined;
294
+
295
+ /** Arm the shared spinner ticker if it is not already running. */
296
+ function ensureSharedSpinnerTicker(): void {
297
+ if (sharedSpinnerTimer) return;
298
+ sharedSpinnerTimer = setInterval(() => {
299
+ const frame = sharedSpinnerFrame(theme.spinnerFrames.length);
300
+ // Deleting the current block mid-iteration (freeze path) is safe on a Set.
301
+ for (const block of liveSpinnerBlocks) block.tickSpinner(frame);
302
+ }, SPINNER_RENDER_INTERVAL_MS);
303
+ }
304
+
305
+ /** Register a live block with the shared ticker, starting it on first use. */
306
+ function registerSpinnerBlock(block: ToolExecutionComponent): void {
307
+ liveSpinnerBlocks.add(block);
308
+ ensureSharedSpinnerTicker();
309
+ }
310
+
311
+ /** Drop a block; stop the ticker once no live block remains. */
312
+ function unregisterSpinnerBlock(block: ToolExecutionComponent): void {
313
+ if (!liveSpinnerBlocks.delete(block)) return;
314
+ if (liveSpinnerBlocks.size === 0 && sharedSpinnerTimer) {
315
+ clearInterval(sharedSpinnerTimer);
316
+ sharedSpinnerTimer = undefined;
317
+ }
318
+ }
319
+
320
+ /** Stop the shared spinner ticker and drop every registered live block.
321
+ * Called on interactive-mode teardown so a stray live block cannot keep the
322
+ * process-wide 80ms interval alive past shutdown (lingering event-loop
323
+ * handles pin the process; cf. `postmortem.quit`). Test files that assert on
324
+ * ticker arming also use this to start from a clean slate. */
325
+ export function stopSharedSpinnerTicker(): void {
326
+ liveSpinnerBlocks.clear();
327
+ if (sharedSpinnerTimer) {
328
+ clearInterval(sharedSpinnerTimer);
329
+ sharedSpinnerTimer = undefined;
330
+ }
331
+ }
332
+
272
333
  // Stable per-instance counter so each tool execution's inline images get a
273
334
  // graphics id that survives child re-creation (the image budget keys off it).
274
335
  let toolExecutionInstanceSeq = 0;
@@ -337,7 +398,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
337
398
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
338
399
  // Spinner animation for partial task results
339
400
  #spinnerFrame?: number;
340
- #spinnerInterval?: NodeJS.Timeout;
401
+ #spinnerActive = false;
341
402
  // Todo write completion strikethrough reveal animation
342
403
  #todoStrikeInterval?: NodeJS.Timeout;
343
404
  // Track if args are still being streamed (for edit/write spinner)
@@ -617,14 +678,18 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
617
678
  }
618
679
 
619
680
  /**
620
- * Get all image blocks from result content and details.images.
621
- * Some tools (like generate_image) store images in details to avoid bloating model context.
681
+ * Get all image blocks from result content and details.
682
+ * Some tools (like generate_image) store images in details to avoid bloating
683
+ * model context. Xdev-dispatched tools preserve those details under
684
+ * details.xdev.inner.
622
685
  */
623
- #getAllImageBlocks(): Array<{ data?: string; mimeType?: string }> {
686
+ #getAllImageBlocks(): ToolImageBlock[] {
624
687
  if (!this.#result) return [];
625
- const contentImages = this.#result.content?.filter((c: any) => c.type === "image") || [];
626
- const detailImages = this.#result.details?.images || [];
627
- return [...contentImages, ...detailImages];
688
+ const contentImages = this.#result.content.filter(block => block.type === "image");
689
+ const details = this.#result.details;
690
+ const detailImages = imageBlocksFromDetails(details);
691
+ const xdevImages = isRecord(details) && isRecord(details.xdev) ? imageBlocksFromDetails(details.xdev.inner) : [];
692
+ return [...contentImages, ...detailImages, ...xdevImages];
628
693
  }
629
694
 
630
695
  /**
@@ -697,27 +762,16 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
697
762
  !isBackgroundAsyncRunning &&
698
763
  (pendingCallConsumesSpinner || partialResultConsumesSpinner);
699
764
  const needsSpinner = isStreamingArgs || isLivePartialTool || this.#displaceableByToolName === "hub";
700
- if (needsSpinner && !this.#spinnerInterval) {
765
+ if (needsSpinner && !this.#spinnerActive) {
701
766
  const frameCount = theme.spinnerFrames.length;
702
767
  const frame = sharedSpinnerFrame(frameCount);
703
768
  this.#spinnerFrame = frame;
704
769
  this.#renderState.spinnerFrame = frame;
705
- this.#spinnerInterval = setInterval(() => {
706
- // If a detached task interval from an older render path is still live,
707
- // stop it the instant the block leaves the repaintable region.
708
- if (this.#maybeFreezeBackgroundTask()) return;
709
- const now = performance.now();
710
- const frameCount = theme.spinnerFrames.length;
711
- this.#spinnerFrame = sharedSpinnerFrame(frameCount, now);
712
- this.#renderState.spinnerFrame = this.#spinnerFrame;
713
- // Component-scoped: a spinner tick only changes this tool block, so
714
- // the TUI reuses every other root subtree instead of walking the
715
- // whole tree (issue #4377).
716
- this.#ui.requestComponentRender(this);
717
- }, SPINNER_RENDER_INTERVAL_MS);
718
- } else if (!needsSpinner && this.#spinnerInterval) {
719
- clearInterval(this.#spinnerInterval);
720
- this.#spinnerInterval = undefined;
770
+ this.#spinnerActive = true;
771
+ registerSpinnerBlock(this);
772
+ } else if (!needsSpinner && this.#spinnerActive) {
773
+ this.#spinnerActive = false;
774
+ unregisterSpinnerBlock(this);
721
775
  // Clear the last drawn frame so a non-live renderCall (e.g. a write whose
722
776
  // args just completed) stops showing a frozen spinner glyph. Skip when a
723
777
  // todo strike owns the frame — it sets its own value right after this.
@@ -728,6 +782,20 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
728
782
  }
729
783
  }
730
784
 
785
+ /**
786
+ * Advance to the shared spinner glyph and repaint just this block. Driven by
787
+ * the single shared spinner ticker (see `registerSpinnerBlock`); the tick is
788
+ * component-scoped so the TUI reuses every other root subtree (issue #4377).
789
+ */
790
+ tickSpinner(frame: number): void {
791
+ // A detached task block that scrolled into native scrollback stops the
792
+ // instant it leaves the repaintable region.
793
+ if (this.#maybeFreezeBackgroundTask()) return;
794
+ this.#spinnerFrame = frame;
795
+ this.#renderState.spinnerFrame = frame;
796
+ this.#ui.requestComponentRender(this);
797
+ }
798
+
731
799
  /**
732
800
  * Freeze a detached (`async.state === "running"`) task block once its rows
733
801
  * become native-scrollback history: the block left the transcript's live
@@ -790,7 +858,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
790
858
  clearInterval(this.#todoStrikeInterval);
791
859
  this.#todoStrikeInterval = undefined;
792
860
  }
793
- if (!this.#spinnerInterval) {
861
+ if (!this.#spinnerActive) {
794
862
  this.#spinnerFrame = undefined;
795
863
  this.#renderState.spinnerFrame = undefined;
796
864
  }
@@ -886,9 +954,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
886
954
  * Stop spinner animation and cleanup resources.
887
955
  */
888
956
  stopAnimation(): void {
889
- if (this.#spinnerInterval) {
890
- clearInterval(this.#spinnerInterval);
891
- this.#spinnerInterval = undefined;
957
+ if (this.#spinnerActive) {
958
+ this.#spinnerActive = false;
959
+ unregisterSpinnerBlock(this);
892
960
  this.#spinnerFrame = undefined;
893
961
  this.#renderState.spinnerFrame = undefined;
894
962
  }
@@ -849,13 +849,18 @@ class TreeList implements Component {
849
849
  this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisibleLines);
850
850
  } else if (matchesSelectPageDown(keyData) || matchesKey(keyData, "right")) {
851
851
  this.#selectedIndex = Math.min(this.#filteredNodes.length - 1, this.#selectedIndex + this.maxVisibleLines);
852
- } else if (matchesKey(keyData, "shift+enter") || matchesKey(keyData, "shift+return")) {
852
+ } else if (
853
+ matchesKey(keyData, "shift+enter") ||
854
+ matchesKey(keyData, "shift+return") ||
855
+ keyData === "\n" || // Shift+Enter delivered as bare LF (iTerm2 legacy mapping) — matches the composer (issue #8821)
856
+ keyData === "\x1b[13;2~" // Shift+Enter legacy CSI ~ form — also accepted by the composer (editor.ts:1466)
857
+ ) {
853
858
  // Summarize-and-switch: fork with a branch summary without the extra prompt.
854
859
  const selected = this.#filteredNodes[this.#selectedIndex];
855
860
  if (selected && this.onSelect) {
856
861
  this.onSelect(selected.node.entry.id, { summarize: true });
857
862
  }
858
- } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
863
+ } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
859
864
  const selected = this.#filteredNodes[this.#selectedIndex];
860
865
  if (selected && this.onSelect) {
861
866
  this.onSelect(selected.node.entry.id, { summarize: false });
@@ -24,7 +24,7 @@ import { getSymbolTheme, theme } from "../../modes/theme/theme";
24
24
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
25
25
  import idleRecapPrompt from "../../prompts/system/recap-user.md" with { type: "text" };
26
26
  import type { AgentSessionEvent } from "../../session/agent-session";
27
- import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages";
27
+ import { isSilentAbort, isUserInvokedSkillPrompt, readQueueChipText, resolveAbortLabel } from "../../session/messages";
28
28
  import { type ApprovalMode, resolveApproval } from "../../tools/approval";
29
29
  import { previewLine, TRUNCATE_LENGTHS } from "../../tools/render-utils";
30
30
  import { PROPOSE_DEVICE_NAME, writeDeviceDispatch } from "../../tools/resolve";
@@ -778,7 +778,17 @@ export class EventController {
778
778
  }
779
779
  this.#renderedCustomMessages.add(signature);
780
780
  this.#resetReadGroup();
781
- this.ctx.addMessageToChat(event.message);
781
+ if (
782
+ event.message.role === "custom" &&
783
+ this.ctx.optimisticSkillMessagePending &&
784
+ isUserInvokedSkillPrompt(event.message)
785
+ ) {
786
+ // The optimistic `/skill:` row painted at submit time (issue #8895):
787
+ // swap it for the canonical message instead of appending a duplicate.
788
+ this.ctx.reconcileOptimisticSkillMessage(event.message);
789
+ } else {
790
+ this.ctx.addMessageToChat(event.message);
791
+ }
782
792
  // Queued custom-message chips are derived from the agent queue; refresh the
783
793
  // pending bar when the queued custom is consumed so the chip disappears
784
794
  // immediately.
@@ -17,7 +17,7 @@ import { expandEmoticons } from "../../modes/emoji-autocomplete";
17
17
  import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
18
18
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
19
19
  import { parseQueueShorthand, splitQueuedMessages } from "../../modes/queue-input";
20
- import { invokeSkillCommandFromText, isKnownSkillCommand } from "../../modes/skill-command";
20
+ import { buildSkillCommandPrompt, isKnownSkillCommand } from "../../modes/skill-command";
21
21
  import type { InteractiveModeContext } from "../../modes/types";
22
22
  import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
23
23
  import { USER_INTERRUPT_LABEL } from "../../session/messages";
@@ -173,6 +173,11 @@ export class InputController {
173
173
  },
174
174
  ) {}
175
175
 
176
+ /** Session-level title starts (user `/skill:` via promptCustomMessage) reuse this UI. */
177
+ notifyTitleGenerationStart(): void {
178
+ this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
179
+ }
180
+
176
181
  #enhancedPaste?: EnhancedPasteController;
177
182
  #focusedLeftTapListenerInstalled = false;
178
183
  #focusedPasteListenerInstalled = false;
@@ -1168,21 +1173,42 @@ export class InputController {
1168
1173
  };
1169
1174
 
1170
1175
  this.ctx.editor.clearDraft(text);
1176
+ let optimistic = false;
1171
1177
  try {
1172
- const handled = await invokeSkillCommandFromText(this.ctx, text, streamingBehavior, {
1173
- images: draftImages,
1174
- propagateErrors: true,
1175
- });
1176
- if (!handled) {
1178
+ // Build the user-attributed skill message once so the optimistic
1179
+ // transcript row and the dispatched message share content.
1180
+ const built = await buildSkillCommandPrompt(this.ctx, text, streamingBehavior, draftImages);
1181
+ if (!built) {
1177
1182
  restoreDraft();
1178
1183
  return false;
1179
1184
  }
1185
+ // Paint the row before the awaited dispatch so a slow preflight (memory
1186
+ // recall, before_agent_start hooks, auto-thinking, pre-prompt compaction)
1187
+ // does not leave the submission invisible (issue #8895). A streaming
1188
+ // submission queues instead and surfaces its chip, so only paint when the
1189
+ // turn will run fresh.
1190
+ optimistic = !this.ctx.session.isStreaming;
1191
+ if (optimistic) {
1192
+ // Mirror the message promptCustomMessage will build for the turn so the
1193
+ // canonical message_start reconciles this row rather than duplicating it.
1194
+ this.ctx.renderOptimisticSkillMessage(
1195
+ { role: "custom", ...built.message, timestamp: Date.now() },
1196
+ { imageLinks: draftImageLinks },
1197
+ );
1198
+ }
1199
+ await this.ctx.session.promptCustomMessage(built.message, built.options);
1180
1200
  return true;
1181
1201
  } catch (error) {
1202
+ if (optimistic) this.ctx.clearOptimisticSkillMessage();
1182
1203
  restoreDraft();
1183
1204
  this.ctx.showError(error instanceof Error ? error.message : String(error));
1184
1205
  return true;
1185
1206
  } finally {
1207
+ if (optimistic && this.ctx.optimisticSkillMessagePending) {
1208
+ // Dispatch resolved without a canonical skill message_start (aborted
1209
+ // preflight, or a streaming-race requeue): drop the pending row.
1210
+ this.ctx.clearOptimisticSkillMessage();
1211
+ }
1186
1212
  if (this.ctx.session.isStreaming) {
1187
1213
  this.ctx.updatePendingMessagesDisplay();
1188
1214
  this.ctx.ui.requestRender();
@@ -1637,6 +1663,38 @@ export class InputController {
1637
1663
  const focusedNow = this.ctx.ui.getFocused();
1638
1664
  const promptTarget =
1639
1665
  focusedNow && focusedNow !== this.ctx.editor && hasPasteText(focusedNow) ? focusedNow : null;
1666
+ // #8769: On macOS, Finder `Cmd+C` on an image file puts BOTH a
1667
+ // `public.file-url` representation and a generated 1024x1024
1668
+ // file-icon bitmap on the pasteboard. `arboard::get_image()`
1669
+ // succeeds with the icon, so probing the image representation first
1670
+ // would attach the generic Finder icon instead of the copied
1671
+ // screenshot — a vision model then sees a white `PNG` document
1672
+ // icon. Probe the file URLs before the bitmap and let any that
1673
+ // resolve to a supported image file win over the icon: the
1674
+ // authoritative file bytes are what the user copied.
1675
+ //
1676
+ // #3506: this branch also recovers file-url-only pasteboards
1677
+ // (Finder selections, certain screenshot tools) where
1678
+ // `arboard::get_image()` returns `ContentNotAvailable` and
1679
+ // `pbpaste` is empty. Every image-shaped path routes through
1680
+ // {@link handleImagePathPaste}, matching the bracketed-paste
1681
+ // handler in `CustomEditor.handleInput`; multi-image Finder
1682
+ // selections must not silently drop after the first attach.
1683
+ // `readMacFileUrls` returns an empty list off Darwin, so on every
1684
+ // other platform this is a no-op and the bitmap read below still
1685
+ // runs first.
1686
+ const fileUrls = promptTarget ? [] : ((await this.clipboard.readMacFileUrls?.()) ?? []);
1687
+ let attachedFromFileUrls = false;
1688
+ for (const url of fileUrls) {
1689
+ const candidate = extractImagePathFromText(url);
1690
+ if (!candidate) continue;
1691
+ await this.handleImagePathPaste(candidate);
1692
+ attachedFromFileUrls = true;
1693
+ }
1694
+ if (attachedFromFileUrls) return true;
1695
+ // No usable image-file URL (pure bitmap pasteboard: screenshots,
1696
+ // browser copies, or a non-image Finder selection). Fall to the
1697
+ // image representation.
1640
1698
  const image = await this.clipboard.readImage();
1641
1699
  if (image) {
1642
1700
  if (promptTarget) {
@@ -1652,27 +1710,6 @@ export class InputController {
1652
1710
  `Unsupported clipboard image format: ${image.mimeType}`,
1653
1711
  );
1654
1712
  }
1655
- // #3506: macOS Finder `Cmd+C` puts only a `public.file-url`
1656
- // representation on the pasteboard. `pbpaste` (the backing call
1657
- // for `readText` on Darwin) only surfaces plain text / RTF / EPS,
1658
- // so it returns empty for file-url-only pasteboards — the smart
1659
- // text fallback below would dead-end with "Clipboard is empty".
1660
- // Reach the file URL directly via AppleScript and route every
1661
- // image-shaped path through {@link handleImagePathPaste}, matching
1662
- // the bracketed-paste handler in `CustomEditor.handleInput` which
1663
- // iterates every extracted image path. Multi-image Finder
1664
- // selections must not silently drop after the first attach.
1665
- // `readMacFileUrls` returns an empty list off Darwin, so the
1666
- // check is free on every other platform.
1667
- const fileUrls = promptTarget ? [] : ((await this.clipboard.readMacFileUrls?.()) ?? []);
1668
- let attachedFromFileUrls = false;
1669
- for (const url of fileUrls) {
1670
- const candidate = extractImagePathFromText(url);
1671
- if (!candidate) continue;
1672
- await this.handleImagePathPaste(candidate);
1673
- attachedFromFileUrls = true;
1674
- }
1675
- if (attachedFromFileUrls) return true;
1676
1713
  // Smart paste (#1628): no image on the clipboard — fall back to
1677
1714
  // pasting its text so the same chord covers both payload kinds.
1678
1715
  // Hosts that pre-empt the terminal's own paste (VS Code's
@@ -1198,11 +1198,20 @@ export class MCPCommandController {
1198
1198
  connectionError = error as Error;
1199
1199
  }
1200
1200
 
1201
- // Server connected fine without auth — reauth is not needed. A tool-level
1202
- // challenge overrides this: servers may allow the anonymous handshake yet
1203
- // protect individual tool calls with `_meta["mcp/www_authenticate"]`.
1201
+ // Server connected fine without auth. A tool-level challenge overrides
1202
+ // this: servers may allow the anonymous handshake yet protect individual
1203
+ // tool calls with `_meta["mcp/www_authenticate"]`. Even without such a
1204
+ // challenge, a clean `initialize` is only weak evidence — per the MCP
1205
+ // spec a server MAY permit unauthenticated `initialize` while requiring a
1206
+ // bearer token for `tools/call`. The user explicitly asked to reauth, so
1207
+ // honor it when the server advertises OAuth discovery metadata; only
1208
+ // refuse when there is genuinely no OAuth endpoint to acquire.
1204
1209
  if (connectionSucceeded && !authChallenge) {
1205
- throw new Error("Server connection succeeded without OAuth; reauthorization is not required.");
1210
+ const discovered = "url" in config && config.url ? await discoverOAuthEndpoints(config.url) : null;
1211
+ if (!discovered) {
1212
+ throw new Error("Server connection succeeded without OAuth; reauthorization is not required.");
1213
+ }
1214
+ return discovered;
1206
1215
  }
1207
1216
 
1208
1217
  // Tool calls can carry richer RFC 6750/RFC 9728 hints than the original
@@ -117,7 +117,8 @@ import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES, buildTuiBuiltinSlashCommands } fr
117
117
  import { formatDuration } from "../slash-commands/helpers/format";
118
118
  import { STTController, type SttState } from "../stt";
119
119
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
120
- import { formatTaskId } from "../task/render";
120
+ import { labelEchoesHandle } from "../task/label";
121
+ import { agentTypeBadge, formatTaskId } from "../task/render";
121
122
  import type { ConfiguredThinkingLevel } from "../thinking";
122
123
  import { tinyTitleClient } from "../tiny/title-client";
123
124
  import type { LspStartupServerInfo } from "../tools";
@@ -165,7 +166,7 @@ import type { HookInputComponent } from "./components/hook-input";
165
166
  import type { HookSelectorComponent, HookSelectorSlider } from "./components/hook-selector";
166
167
  import { type PlanReviewAnnotationState, PlanReviewOverlay } from "./components/plan-review-overlay";
167
168
  import { StatusLineComponent } from "./components/status-line";
168
- import type { ToolExecutionHandle } from "./components/tool-execution";
169
+ import { stopSharedSpinnerTicker, type ToolExecutionHandle } from "./components/tool-execution";
169
170
  import { TranscriptContainer } from "./components/transcript-container";
170
171
  import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome";
171
172
  import { BtwController } from "./controllers/btw-controller";
@@ -452,8 +453,8 @@ const SUBAGENT_OBSERVER_UI_COALESCE_MS = 100;
452
453
 
453
454
  /**
454
455
  * Build the anchored subagent HUD block: a bold accent "Subagents" header plus
455
- * a bounded set of running-agent rows in the same `Id: description` shape the
456
- * inline task rows use (muted task preview when no description was given).
456
+ * a bounded set of running-agent rows in the same `Id ⟨role⟩: description` shape
457
+ * the inline task rows use (muted task preview when no description was given).
457
458
  * Layout mirrors the Todos HUD exactly: unindented header, then
458
459
  * `renderTreeList` rows (dim connectors) shifted right by one space.
459
460
  * Only detached background spawns are listed: a sync task call blocks the
@@ -476,16 +477,23 @@ export function renderSubagentHudLines(sessions: ObservableSession[], columns: n
476
477
  expanded: true,
477
478
  renderItem: session => {
478
479
  const displayId = formatTaskId(session.id);
479
- let line = `${dot} ${theme.fg("accent", theme.bold(displayId))}`;
480
+ const role = session.agent ?? session.progress?.agent;
481
+ const badge = agentTypeBadge(role, theme);
482
+ let line = `${dot} ${theme.fg("accent", theme.bold(displayId))}${badge}`;
480
483
  const description = session.description?.trim() || session.progress?.description?.trim();
481
- if (description) {
482
- const budget = Math.max(TRUNCATE_LENGTHS.SHORT, columns - visibleWidth(displayId) - 10);
483
- line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(description), budget))}`;
484
+ const distinctDescription =
485
+ description && !labelEchoesHandle(session.id, description) ? description : undefined;
486
+ if (distinctDescription) {
487
+ const budget = Math.max(
488
+ TRUNCATE_LENGTHS.SHORT,
489
+ columns - visibleWidth(displayId) - visibleWidth(Bun.stripANSI(badge)) - 10,
490
+ );
491
+ line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(distinctDescription), budget))}`;
484
492
  } else {
485
493
  // No spawn description: fall back to a muted task preview, same as
486
494
  // the inline task rows when a row has no label.
487
495
  const taskPreview = session.progress?.task?.trim();
488
- if (taskPreview) {
496
+ if (taskPreview && !labelEchoesHandle(session.id, taskPreview)) {
489
497
  line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(taskPreview), TRUNCATE_LENGTHS.SHORT))}`;
490
498
  }
491
499
  }
@@ -604,6 +612,10 @@ export class InteractiveMode implements InteractiveModeContext {
604
612
  #pendingSubmissionDispose: (() => void) | undefined;
605
613
  #pendingSubmissionPreservesDraft = false;
606
614
  #optimisticUserMessageComponents: Component[] = [];
615
+ #optimisticSkillMessageComponents: Component[] = [];
616
+ /** True while an optimistically-rendered `/skill:` row awaits its canonical
617
+ * `message_start`. Read by the event controller to reconcile the row. */
618
+ optimisticSkillMessagePending = false;
607
619
  lastSigintTime = 0;
608
620
  lastEscapeTime = 0;
609
621
  lastLeftTapTime = 0;
@@ -895,6 +907,9 @@ export class InteractiveMode implements InteractiveModeContext {
895
907
  this.#selectorController = new SelectorController(this);
896
908
  this.#focusController = new SessionFocusController(this);
897
909
  this.#inputController = new InputController(this);
910
+ this.session.setTitleGenerationStart?.(() => {
911
+ this.#inputController.notifyTitleGenerationStart();
912
+ });
898
913
  this.#observerRegistry = new SessionObserverRegistry();
899
914
  }
900
915
 
@@ -1678,6 +1693,51 @@ export class InteractiveMode implements InteractiveModeContext {
1678
1693
  this.addMessageToChat(message, options);
1679
1694
  }
1680
1695
 
1696
+ /**
1697
+ * Optimistically render a user-invoked `/skill:` row before its awaited
1698
+ * dispatch so a slow preflight (memory recall, `before_agent_start` hooks,
1699
+ * auto-thinking classification, pre-prompt compaction) does not leave the
1700
+ * submission invisible — normal prompts paint their row via
1701
+ * {@link startPendingSubmission} the same way (issue #8895). The canonical
1702
+ * skill `message_start` swaps this row in place via
1703
+ * {@link reconcileOptimisticSkillMessage}; a failed or bailed dispatch drops
1704
+ * it via {@link clearOptimisticSkillMessage}.
1705
+ */
1706
+ renderOptimisticSkillMessage(
1707
+ message: AgentMessage,
1708
+ options?: { imageLinks?: readonly (string | undefined)[] },
1709
+ ): void {
1710
+ this.clearOptimisticSkillMessage();
1711
+ this.optimisticSkillMessagePending = true;
1712
+ this.#optimisticSkillMessageComponents = this.#captureAddedChatComponents(() => {
1713
+ this.addMessageToChat(message, options);
1714
+ });
1715
+ this.ensureLoadingAnimation();
1716
+ this.ui.requestRender();
1717
+ }
1718
+
1719
+ /** Replace the optimistic `/skill:` row with the canonical message emitted by
1720
+ * the session, mirroring {@link replaceOptimisticUserMessage} for skills. */
1721
+ reconcileOptimisticSkillMessage(message: AgentMessage): void {
1722
+ this.optimisticSkillMessagePending = false;
1723
+ for (const component of this.#optimisticSkillMessageComponents) {
1724
+ this.chatContainer.removeChild(component);
1725
+ }
1726
+ this.#optimisticSkillMessageComponents = [];
1727
+ this.addMessageToChat(message);
1728
+ }
1729
+
1730
+ /** Drop the optimistic `/skill:` row when dispatch fails or bails before the
1731
+ * message reaches the agent (aborted preflight, streaming-race requeue). */
1732
+ clearOptimisticSkillMessage(): void {
1733
+ this.optimisticSkillMessagePending = false;
1734
+ if (this.#optimisticSkillMessageComponents.length === 0) return;
1735
+ for (const component of this.#optimisticSkillMessageComponents) {
1736
+ this.chatContainer.removeChild(component);
1737
+ }
1738
+ this.#optimisticSkillMessageComponents = [];
1739
+ }
1740
+
1681
1741
  startPendingSubmission(
1682
1742
  input: {
1683
1743
  text: string;
@@ -4149,6 +4209,9 @@ export class InteractiveMode implements InteractiveModeContext {
4149
4209
  this.#stopLoadingAnimation(false);
4150
4210
  }
4151
4211
  this.#cleanupMicAnimation();
4212
+ // Stop the shared tool-spinner ticker: a live block missed by per-component
4213
+ // stopAnimation would otherwise keep an 80ms interval pinning the process.
4214
+ stopSharedSpinnerTicker();
4152
4215
  this.#liveCommandController.dispose();
4153
4216
  this.#cancelTodoAutoClearTimer();
4154
4217
  this.#cancelObserverUiSyncTimer();
@@ -4239,10 +4302,15 @@ export class InteractiveMode implements InteractiveModeContext {
4239
4302
  popTerminalTitle();
4240
4303
  this.stop();
4241
4304
 
4242
- // Print resumption hint if this is a persisted session
4305
+ // Print resumption hint only if the session was actually materialized to
4306
+ // durable storage. Persistence is lazy — a session that exits before its
4307
+ // first assistant message (or dies early to an auth error, a mid-flight
4308
+ // Ctrl+C, or a launch-then-quit) never wrote its JSONL, so the path is
4309
+ // allocated but the file does not exist and `--resume <id>` would fail
4310
+ // (issue #8860).
4243
4311
  const sessionId = this.sessionManager.getSessionId();
4244
4312
  const sessionFile = this.sessionManager.getSessionFile();
4245
- if (sessionId && sessionFile) {
4313
+ if (sessionId && sessionFile && this.sessionManager.isSessionOnDisk()) {
4246
4314
  process.stderr.write(`\n${chalk.dim(`Resume this session with ${APP_NAME} --resume ${sessionId}`)}\n`);
4247
4315
  }
4248
4316
 
@@ -313,6 +313,17 @@ export interface InteractiveModeContext {
313
313
  message: AgentMessage,
314
314
  options?: { imageLinks?: readonly (string | undefined)[] },
315
315
  ): void;
316
+ /** True while an optimistically-rendered `/skill:` row awaits its canonical `message_start`. */
317
+ optimisticSkillMessagePending: boolean;
318
+ /** Optimistically renders a user-invoked `/skill:` row before its awaited dispatch (issue #8895). */
319
+ renderOptimisticSkillMessage(
320
+ message: AgentMessage,
321
+ options?: { imageLinks?: readonly (string | undefined)[] },
322
+ ): void;
323
+ /** Swaps the optimistic `/skill:` row for the canonical message emitted by the session. */
324
+ reconcileOptimisticSkillMessage(message: AgentMessage): void;
325
+ /** Drops the optimistic `/skill:` row when dispatch fails or bails before reaching the agent. */
326
+ clearOptimisticSkillMessage(): void;
316
327
  isKnownSlashCommand(text: string): boolean;
317
328
  addMessageToChat(
318
329
  message: AgentMessage,
@@ -1,26 +1,9 @@
1
- Extract durable, long-term memory items from the user message below.
1
+ You are a precise long-term memory extractor.
2
2
 
3
- Output ONE item per line as a short plain-text statement: no JSON, no bullets, no numbering, no field labels.
4
- Capture only persistent, reusable information:
5
- - facts (name, role, employer, config, ports, versions, numbers)
6
- - explicit instructions to the assistant
7
- - stable preferences
8
- - dated events or deadlines
3
+ Extract only persistent information explicitly stated in the user message: stable facts, explicit instructions to the assistant, stable preferences, dates, deadlines, paths, ports, and versions.
9
4
 
10
- Keep names, numbers, versions, and dates exact, in the message's original language. When a value is updated, output only the latest value. Ignore greetings, acknowledgements, small talk, weather, and one-off remarks.
11
- If nothing qualifies, output exactly: NO_FACTS
5
+ Never infer, explain, invent, or copy information from another message. Ignore greetings, acknowledgements, weather, and one-off plans. When a value is corrected, output only the latest value.
12
6
 
13
- Example
14
- Message: My name is Sam, I work at Globex, and I always use 2-space indents.
15
- Items:
16
- name is Sam
17
- works at Globex
18
- prefers 2-space indents
7
+ Preserve names, numbers, paths, versions, dates, and the original language exactly. Output one short plain-text fact per line with no bullets, numbering, labels, JSON, or commentary.
19
8
 
20
- Example
21
- Message: lol nice weather today, might grab a coffee later
22
- Items:
23
- NO_FACTS
24
-
25
- Message: {text}
26
- Items:
9
+ If nothing qualifies, output exactly NO_FACTS.
@@ -96,7 +96,7 @@ Write JSON args as `content` to `xd://<tool>` via `{{toolRefs.write}}`. Invalid
96
96
 
97
97
  {{#has tools "think"}}
98
98
  § Scratchpad
99
- `{{toolRefs.think}}`: private scratchpad; not shown to user.
99
+ `{{toolRefs.think}}`: private scratchpad; not shown to user. MUST use for planning; other tools become callable when it completes.
100
100
  {{/has}}
101
101
 
102
102
  § Tool Policy