@oh-my-pi/pi-coding-agent 16.1.6 → 16.1.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 (97) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/cli.js +3160 -3139
  3. package/dist/types/cli/session-picker.d.ts +0 -1
  4. package/dist/types/cli/tiny-models-cli.d.ts +2 -0
  5. package/dist/types/collab/protocol.d.ts +20 -1
  6. package/dist/types/config/models-config-schema.d.ts +10 -0
  7. package/dist/types/config/models-config.d.ts +6 -0
  8. package/dist/types/config/settings-schema.d.ts +57 -6
  9. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +5 -1
  10. package/dist/types/internal-urls/filesystem-resource.d.ts +9 -0
  11. package/dist/types/mcp/loader.d.ts +3 -2
  12. package/dist/types/mcp/manager.d.ts +4 -3
  13. package/dist/types/mcp/startup-events.d.ts +21 -4
  14. package/dist/types/mnemopi/config.d.ts +1 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +1 -0
  16. package/dist/types/modes/components/agent-transcript-viewer.d.ts +1 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  18. package/dist/types/modes/components/btw-panel.d.ts +2 -0
  19. package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -0
  20. package/dist/types/modes/components/session-selector.d.ts +0 -2
  21. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/controllers/streaming-reveal.d.ts +3 -2
  24. package/dist/types/modes/interactive-mode.d.ts +3 -0
  25. package/dist/types/modes/types.d.ts +3 -0
  26. package/dist/types/session/agent-session.d.ts +5 -3
  27. package/dist/types/session/session-history-format.d.ts +25 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +1 -1
  29. package/dist/types/slash-commands/builtin-registry.d.ts +7 -4
  30. package/dist/types/slash-commands/types.d.ts +2 -0
  31. package/dist/types/system-prompt.d.ts +1 -0
  32. package/dist/types/tiny/models.d.ts +11 -7
  33. package/dist/types/tools/todo.d.ts +1 -0
  34. package/dist/types/utils/thinking-display.d.ts +2 -0
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +104 -0
  37. package/src/advisor/runtime.ts +38 -2
  38. package/src/cli/session-picker.ts +1 -2
  39. package/src/cli/tiny-models-cli.ts +7 -2
  40. package/src/collab/guest.ts +172 -20
  41. package/src/collab/host.ts +47 -5
  42. package/src/collab/protocol.ts +16 -1
  43. package/src/config/models-config-schema.ts +1 -0
  44. package/src/config/settings-schema.ts +59 -5
  45. package/src/edit/renderer.ts +8 -12
  46. package/src/extensibility/legacy-pi-ai-shim.ts +16 -4
  47. package/src/internal-urls/docs-index.generated.txt +1 -1
  48. package/src/internal-urls/filesystem-resource.ts +34 -0
  49. package/src/internal-urls/local-protocol.ts +7 -1
  50. package/src/internal-urls/memory-protocol.ts +5 -1
  51. package/src/internal-urls/skill-protocol.ts +20 -4
  52. package/src/internal-urls/vault-protocol.ts +5 -2
  53. package/src/main.ts +8 -8
  54. package/src/mcp/loader.ts +4 -3
  55. package/src/mcp/manager.ts +35 -15
  56. package/src/mcp/startup-events.ts +106 -11
  57. package/src/mnemopi/config.ts +2 -0
  58. package/src/mnemopi/state.ts +3 -1
  59. package/src/modes/components/agent-hub.ts +4 -0
  60. package/src/modes/components/agent-transcript-viewer.ts +2 -0
  61. package/src/modes/components/assistant-message.ts +217 -18
  62. package/src/modes/components/btw-panel.ts +15 -3
  63. package/src/modes/components/chat-transcript-builder.ts +8 -2
  64. package/src/modes/components/model-selector.ts +72 -9
  65. package/src/modes/components/omfg-panel.ts +1 -1
  66. package/src/modes/components/session-selector.ts +4 -9
  67. package/src/modes/components/snapcompact-shape-preview.ts +1 -1
  68. package/src/modes/components/tool-execution.ts +10 -2
  69. package/src/modes/controllers/btw-controller.ts +32 -7
  70. package/src/modes/controllers/event-controller.ts +24 -4
  71. package/src/modes/controllers/input-controller.ts +43 -21
  72. package/src/modes/controllers/selector-controller.ts +23 -13
  73. package/src/modes/controllers/streaming-reveal.ts +78 -20
  74. package/src/modes/controllers/todo-command-controller.ts +9 -10
  75. package/src/modes/interactive-mode.ts +83 -8
  76. package/src/modes/types.ts +3 -0
  77. package/src/modes/utils/ui-helpers.ts +1 -0
  78. package/src/prompts/advisor/system.md +1 -1
  79. package/src/prompts/system/side-channel-no-tools.md +3 -0
  80. package/src/prompts/system/system-prompt.md +164 -156
  81. package/src/sdk.ts +12 -8
  82. package/src/session/agent-session.ts +349 -84
  83. package/src/session/history-storage.ts +15 -16
  84. package/src/session/session-history-format.ts +41 -1
  85. package/src/session/snapcompact-inline.ts +9 -6
  86. package/src/slash-commands/builtin-registry.ts +147 -21
  87. package/src/slash-commands/helpers/todo.ts +12 -6
  88. package/src/slash-commands/types.ts +2 -0
  89. package/src/system-prompt.ts +6 -11
  90. package/src/tiny/models.ts +7 -3
  91. package/src/tiny/title-client.ts +8 -2
  92. package/src/tiny/worker.ts +1 -0
  93. package/src/tools/search.ts +10 -1
  94. package/src/tools/sqlite-reader.ts +59 -3
  95. package/src/tools/todo.ts +6 -0
  96. package/src/tools/write.ts +4 -6
  97. package/src/utils/thinking-display.ts +78 -0
@@ -31,7 +31,13 @@ import {
31
31
  JSON_TREE_SCALAR_LEN_EXPANDED,
32
32
  renderJsonTreeLines,
33
33
  } from "../../tools/json-tree";
34
- import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
+ import {
35
+ formatExpandHint,
36
+ formatStatusIcon,
37
+ replaceTabs,
38
+ resolveImageOptions,
39
+ truncateToWidth,
40
+ } from "../../tools/render-utils";
35
41
  import { toolRenderers } from "../../tools/renderers";
36
42
  import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
37
43
  import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
@@ -865,9 +871,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
865
871
  this.#multiFileBoxes.push(pendingSpacer);
866
872
  this.addChild(pendingSpacer);
867
873
  const pendingBox = new Box(0, 0);
874
+ const spinner =
875
+ this.#spinnerFrame !== undefined ? formatStatusIcon("running", theme, this.#spinnerFrame) : "";
868
876
  const pendingText = renderStatusLine(
869
877
  {
870
- icon: "pending",
878
+ iconOverride: spinner,
871
879
  title: "Edit",
872
880
  description: theme.fg("dim", `${remaining} more file${remaining > 1 ? "s" : ""} pending…`),
873
881
  },
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import { prompt } from "@oh-my-pi/pi-utils";
3
3
  import btwUserPrompt from "../../prompts/system/btw-user.md" with { type: "text" };
4
+ import { copyToClipboard } from "../../utils/clipboard";
4
5
  import { BtwPanelComponent } from "../components/btw-panel";
5
6
  import type { InteractiveModeContext } from "../types";
6
7
 
@@ -39,6 +40,8 @@ export class BtwController {
39
40
  #lastAssistantMessage: AssistantMessage | undefined;
40
41
  #lastLeafId: string | null | undefined;
41
42
  #branchInFlight = false;
43
+ #lastCopyText: string | undefined;
44
+ #copyInFlight = false;
42
45
 
43
46
  constructor(private readonly ctx: InteractiveModeContext) {}
44
47
 
@@ -58,6 +61,27 @@ export class BtwController {
58
61
  );
59
62
  }
60
63
 
64
+ canCopy(): boolean {
65
+ return (
66
+ !this.#copyInFlight && this.#activeRequest?.component.isCopyable() === true && this.#lastCopyText !== undefined
67
+ );
68
+ }
69
+
70
+ async handleCopy(): Promise<boolean> {
71
+ if (!this.canCopy() || this.#lastCopyText === undefined) return false;
72
+ this.#copyInFlight = true;
73
+ try {
74
+ await copyToClipboard(this.#lastCopyText);
75
+ this.ctx.showStatus("Copied /btw answer to clipboard");
76
+ return true;
77
+ } catch (error) {
78
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
79
+ return true;
80
+ } finally {
81
+ this.#copyInFlight = false;
82
+ }
83
+ }
84
+
61
85
  async handleBranch(): Promise<boolean> {
62
86
  if (!this.canBranch() || !this.#lastQuestion || !this.#lastAssistantMessage) return false;
63
87
  this.#branchInFlight = true;
@@ -123,17 +147,17 @@ export class BtwController {
123
147
  if (!this.#isActiveRequest(request)) {
124
148
  return;
125
149
  }
126
- if (replyText) {
127
- request.component.setAnswer(replyText);
128
- }
150
+ request.component.setAnswer(replyText);
129
151
  request.component.markComplete();
130
- if (request.component.isBranchable()) {
152
+ const copyText = request.component.getCopyText();
153
+ if (copyText !== undefined) {
131
154
  this.#lastQuestion = request.question;
132
155
  this.#lastReplyText = replyText;
156
+ this.#lastCopyText = copyText;
133
157
  this.#lastAssistantMessage = assistantMessageWithReplyText(assistantMessage, replyText);
134
158
  this.#lastLeafId = request.leafId;
135
159
  } else {
136
- this.#clearBranchState();
160
+ this.#clearCompletedState();
137
161
  }
138
162
  } catch (error) {
139
163
  if (!this.#isActiveRequest(request)) {
@@ -151,7 +175,7 @@ export class BtwController {
151
175
  const request = this.#activeRequest;
152
176
  if (!request) return;
153
177
  this.#activeRequest = undefined;
154
- this.#clearBranchState();
178
+ this.#clearCompletedState();
155
179
  if (options.abort) {
156
180
  request.abortController.abort();
157
181
  }
@@ -160,10 +184,11 @@ export class BtwController {
160
184
  this.ctx.ui.requestRender();
161
185
  }
162
186
 
163
- #clearBranchState(): void {
187
+ #clearCompletedState(): void {
164
188
  this.#lastQuestion = undefined;
165
189
  this.#lastReplyText = undefined;
166
190
  this.#lastAssistantMessage = undefined;
191
+ this.#lastCopyText = undefined;
167
192
  this.#lastLeafId = undefined;
168
193
  }
169
194
 
@@ -87,6 +87,7 @@ export class EventController {
87
87
  this.#streamingReveal = new StreamingRevealController({
88
88
  getSmoothStreaming: () => this.ctx.settings.get("display.smoothStreaming"),
89
89
  getHideThinkingBlock: () => this.ctx.hideThinkingBlock,
90
+ getProseOnlyThinking: () => this.ctx.proseOnlyThinking,
90
91
  requestRender: () => this.ctx.ui.requestRender(),
91
92
  });
92
93
  this.#toolArgsReveal = new ToolArgsRevealController({
@@ -333,6 +334,7 @@ export class EventController {
333
334
  () => this.ctx.ui.requestRender(),
334
335
  this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
335
336
  this.ctx.ui.imageBudget,
337
+ this.ctx.proseOnlyThinking,
336
338
  );
337
339
  this.ctx.streamingMessage = event.message;
338
340
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -920,6 +922,17 @@ export class EventController {
920
922
  }
921
923
  }
922
924
 
925
+ /**
926
+ * Trailing Esc hint for live maintenance loaders. While a subagent is
927
+ * focused, Esc returns to main instead of cancelling its maintenance
928
+ * (#2819), so the loader drops the hint entirely rather than advertise a
929
+ * cancel that no longer happens. Includes the leading space so the focused
930
+ * label carries no dangling whitespace.
931
+ */
932
+ #maintenanceEscHint(): string {
933
+ return this.ctx.focusedAgentId ? "" : " (esc to cancel)";
934
+ }
935
+
923
936
  async #handleAutoCompactionStart(
924
937
  event: Extract<AgentSessionEvent, { type: "auto_compaction_start" }>,
925
938
  ): Promise<void> {
@@ -939,12 +952,14 @@ export class EventController {
939
952
  ? "Auto-handoff"
940
953
  : event.action === "shake"
941
954
  ? "Auto-shake"
942
- : "Auto context-full maintenance";
955
+ : event.action === "snapcompact"
956
+ ? "Auto-snapcompact"
957
+ : "Auto context-full maintenance";
943
958
  this.ctx.autoCompactionLoader = new Loader(
944
959
  this.ctx.ui,
945
960
  spinner => theme.fg("accent", spinner),
946
961
  text => theme.fg("muted", text),
947
- `${reasonText}${actionLabel}(esc to cancel)`,
962
+ `${reasonText}${actionLabel}…${this.#maintenanceEscHint()}`,
948
963
  getSymbolTheme().spinnerFrames,
949
964
  );
950
965
  this.ctx.statusContainer.addChild(this.ctx.autoCompactionLoader);
@@ -960,13 +975,16 @@ export class EventController {
960
975
  }
961
976
  const isHandoffAction = event.action === "handoff";
962
977
  const isShakeAction = event.action === "shake";
978
+ const isSnapcompactAction = event.action === "snapcompact";
963
979
  if (event.aborted) {
964
980
  this.ctx.showStatus(
965
981
  isHandoffAction
966
982
  ? "Auto-handoff cancelled"
967
983
  : isShakeAction
968
984
  ? "Auto-shake cancelled"
969
- : "Auto context-full maintenance cancelled",
985
+ : isSnapcompactAction
986
+ ? "Auto-snapcompact cancelled"
987
+ : "Auto context-full maintenance cancelled",
970
988
  );
971
989
  } else if (isShakeAction) {
972
990
  // Shake produces no CompactionResult; rebuild on success, suppress benign skips.
@@ -1005,6 +1023,8 @@ export class EventController {
1005
1023
  } else if (event.skipped) {
1006
1024
  // Benign skip: no model selected, no candidate models available, or nothing
1007
1025
  // to compact yet. Not a failure — suppress the warning.
1026
+ } else if (isSnapcompactAction) {
1027
+ this.ctx.showWarning("Auto-snapcompact maintenance failed; continuing without maintenance");
1008
1028
  } else {
1009
1029
  this.ctx.showWarning("Auto context-full maintenance failed; continuing without maintenance");
1010
1030
  }
@@ -1027,7 +1047,7 @@ export class EventController {
1027
1047
  this.ctx.ui,
1028
1048
  spinner => theme.fg("warning", spinner),
1029
1049
  text => theme.fg("muted", text),
1030
- `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s(esc to cancel)`,
1050
+ `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s…${this.#maintenanceEscHint()}`,
1031
1051
  getSymbolTheme().spinnerFrames,
1032
1052
  );
1033
1053
  this.ctx.statusContainer.addChild(this.ctx.retryLoader);
@@ -99,6 +99,7 @@ export class InputController {
99
99
  #enhancedPaste?: EnhancedPasteController;
100
100
  #focusedLeftTapListenerInstalled = false;
101
101
  #btwBranchListenerInstalled = false;
102
+ #btwCopyListenerInstalled = false;
102
103
  // Tap counter for the double-← gesture; reset whenever a quiet gap
103
104
  // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
104
105
  // #detectLeftDoubleTap.
@@ -169,11 +170,23 @@ export class InputController {
169
170
  this.ctx.ui.addInputListener(data => {
170
171
  if (!matchesKey(data, "b")) return undefined;
171
172
  if (!this.ctx.canBranchBtw()) return undefined;
173
+ if (this.ctx.ui.getFocused() !== this.ctx.editor) return undefined;
172
174
  if (this.ctx.editor.getText().trim()) return undefined;
173
175
  void this.ctx.handleBtwBranchKey();
174
176
  return { consume: true };
175
177
  });
176
178
  }
179
+ if (!this.#btwCopyListenerInstalled) {
180
+ this.#btwCopyListenerInstalled = true;
181
+ this.ctx.ui.addInputListener(data => {
182
+ if (!matchesKey(data, "c")) return undefined;
183
+ if (!this.ctx.canCopyBtw()) return undefined;
184
+ if (this.ctx.ui.getFocused() !== this.ctx.editor) return undefined;
185
+ if (this.ctx.editor.getText().trim()) return undefined;
186
+ void this.ctx.handleBtwCopyKey();
187
+ return { consume: true };
188
+ });
189
+ }
177
190
  this.ctx.editor.onEscape = () => {
178
191
  // Active context maintenance owns Esc: auto/manual compaction,
179
192
  // handoff generation, and auto-retry backoff all advertise
@@ -182,27 +195,37 @@ export class InputController {
182
195
  // to clobber the single saved-handler slot (auto-compaction start
183
196
  // → /compact → auto end → manual finally), leaving Esc wired to a
184
197
  // stale no-op closure until restart.
185
- const viewSession = this.ctx.viewSession;
186
- let aborted = false;
187
- if (viewSession.isCompacting) {
188
- try {
189
- viewSession.abortCompaction();
190
- } catch {}
191
- aborted = true;
192
- }
193
- if (viewSession.isGeneratingHandoff) {
194
- try {
195
- viewSession.abortHandoff();
196
- } catch {}
197
- aborted = true;
198
- }
199
- if (viewSession.isRetrying) {
200
- try {
201
- viewSession.abortRetry();
202
- } catch {}
203
- aborted = true;
198
+ //
199
+ // While a subagent is focused, Esc honors the advertised view action
200
+ // ("Esc returns to main") instead of cancelling maintenance —
201
+ // accidentally killing a focused subagent's compaction on the way out
202
+ // was #2819. The auto-maintenance loaders relabel their hint to match
203
+ // (see EventController). Main-session maintenance still owns Esc and
204
+ // stays cancellable from the main view (focused submit gates /compact
205
+ // and handoff, so manual maintenance is main-only anyway).
206
+ if (!this.ctx.focusedAgentId) {
207
+ const viewSession = this.ctx.viewSession;
208
+ let aborted = false;
209
+ if (viewSession.isCompacting) {
210
+ try {
211
+ viewSession.abortCompaction();
212
+ } catch {}
213
+ aborted = true;
214
+ }
215
+ if (viewSession.isGeneratingHandoff) {
216
+ try {
217
+ viewSession.abortHandoff();
218
+ } catch {}
219
+ aborted = true;
220
+ }
221
+ if (viewSession.isRetrying) {
222
+ try {
223
+ viewSession.abortRetry();
224
+ } catch {}
225
+ aborted = true;
226
+ }
227
+ if (aborted) return;
204
228
  }
205
- if (aborted) return;
206
229
 
207
230
  if (this.ctx.loopModeEnabled) {
208
231
  this.ctx.pauseLoop();
@@ -1524,7 +1547,6 @@ export class InputController {
1524
1547
  toggleThinkingBlockVisibility(): void {
1525
1548
  this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock;
1526
1549
  this.ctx.settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
1527
- this.ctx.session.agent.hideThinkingSummary = this.ctx.hideThinkingBlock;
1528
1550
 
1529
1551
  for (const child of this.ctx.chatContainer.children) {
1530
1552
  if (child instanceof AssistantMessageComponent) {
@@ -71,6 +71,10 @@ import { buildCopyTargets } from "../utils/copy-targets";
71
71
 
72
72
  const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
73
73
 
74
+ export function formatTemporaryModelStatus(modelLabel: string, roleSelectorHint = "Alt+M"): string {
75
+ return `Temporary model selection is session-only: ${modelLabel}. Use ${roleSelectorHint} or /model for role models (default/smol/plan/task/slow/custom roles).`;
76
+ }
77
+
74
78
  export class SelectorController {
75
79
  constructor(private ctx: InteractiveModeContext) {}
76
80
 
@@ -314,9 +318,8 @@ export class SelectorController {
314
318
  }
315
319
  }
316
320
  break;
317
- case "hideThinking":
321
+ case "hideThinkingBlock":
318
322
  this.ctx.hideThinkingBlock = value as boolean;
319
- this.ctx.session.agent.hideThinkingSummary = value as boolean;
320
323
  for (const child of this.ctx.chatContainer.children) {
321
324
  if (child instanceof AssistantMessageComponent) {
322
325
  child.setHideThinkingBlock(value as boolean);
@@ -327,6 +330,18 @@ export class SelectorController {
327
330
  // InputController.toggleThinkingBlockVisibility).
328
331
  this.ctx.ui.resetDisplay();
329
332
  break;
333
+ case "proseOnlyThinking":
334
+ this.ctx.proseOnlyThinking = value as boolean;
335
+ for (const child of this.ctx.chatContainer.children) {
336
+ if (child instanceof AssistantMessageComponent) {
337
+ child.setProseOnlyThinking(value as boolean);
338
+ }
339
+ }
340
+ this.ctx.ui.resetDisplay();
341
+ break;
342
+ case "omitThinking":
343
+ this.ctx.session.agent.hideThinkingSummary = value as boolean;
344
+ break;
330
345
  case "display.cacheMissMarker":
331
346
  // Rebuild re-runs the usage-based detection under the new setting so
332
347
  // markers appear/disappear; full reset retires any already committed
@@ -483,7 +498,8 @@ export class SelectorController {
483
498
  }
484
499
  this.ctx.statusLine.invalidate();
485
500
  this.ctx.updateEditorBorderColor();
486
- this.ctx.showStatus(`Temporary model: ${selector ?? model.id}`);
501
+ const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
502
+ this.ctx.showStatus(formatTemporaryModelStatus(selector ?? model.id, roleSelectorHint));
487
503
  done();
488
504
  this.ctx.ui.requestRender();
489
505
  } else if (role === "default") {
@@ -817,14 +833,9 @@ export class SelectorController {
817
833
  this.ctx.sessionManager.getCwd(),
818
834
  this.ctx.sessionManager.getSessionDir(),
819
835
  );
820
- // Current folder has no sessions: preload the global list so the picker
821
- // can open straight into all-projects scope instead of dead-ending.
822
- let allSessions: SessionInfo[] | undefined;
823
- let startInAllScope = false;
824
- if (sessions.length === 0) {
825
- allSessions = await SessionManager.listAll();
826
- startInAllScope = allSessions.length > 0;
827
- }
836
+ // Always open in current-folder scope; the empty-state hint in SessionList
837
+ // invites the user to Tab into all-projects rather than silently surfacing
838
+ // every project's history when the cwd has nothing to resume. See #3099.
828
839
  const historyStorage = this.ctx.historyStorage;
829
840
  const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined;
830
841
  this.showSelector(done => {
@@ -858,8 +869,6 @@ export class SelectorController {
858
869
  },
859
870
  historyMatcher,
860
871
  loadAllSessions: () => SessionManager.listAll(),
861
- allSessions,
862
- startInAllScope,
863
872
  getTerminalRows: () => this.ctx.ui.terminal.rows,
864
873
  },
865
874
  );
@@ -1264,6 +1273,7 @@ export class SelectorController {
1264
1273
  getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type),
1265
1274
  cwd: this.ctx.sessionManager.getCwd(),
1266
1275
  hideThinkingBlock: () => this.ctx.hideThinkingBlock,
1276
+ proseOnlyThinking: () => this.ctx.proseOnlyThinking,
1267
1277
  focusAgent: id => this.ctx.focusAgentSession(id),
1268
1278
  sessionFile: this.ctx.sessionManager.getSessionFile() ?? null,
1269
1279
  });
@@ -1,7 +1,7 @@
1
1
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import { getSegmenter } from "@oh-my-pi/pi-tui";
3
3
  import { LRUCache } from "lru-cache/raw";
4
- import { canonicalizeMessage } from "../../utils/thinking-display";
4
+ import { formatThinkingForDisplay, hasDisplayableThinking } from "../../utils/thinking-display";
5
5
  import type { AssistantMessageComponent } from "../components/assistant-message";
6
6
 
7
7
  export const STREAMING_REVEAL_FRAME_MS = 1000 / 30;
@@ -9,11 +9,13 @@ export const MIN_STEP = 3;
9
9
  export const CATCHUP_FRAMES = 8;
10
10
 
11
11
  type AssistantContentBlock = AssistantMessage["content"][number];
12
+ type DisplayThinkingContentBlock = Extract<AssistantContentBlock, { type: "thinking" }> & { rawThinking?: string };
12
13
  type StreamingRevealComponent = Pick<AssistantMessageComponent, "updateContent">;
13
14
 
14
15
  type StreamingRevealControllerOptions = {
15
16
  getSmoothStreaming(): boolean;
16
17
  getHideThinkingBlock(): boolean;
18
+ getProseOnlyThinking(): boolean;
17
19
  requestRender(): void;
18
20
  };
19
21
 
@@ -83,13 +85,16 @@ function sliceGraphemes(text: string, units: number): string {
83
85
  return text;
84
86
  }
85
87
 
86
- export function visibleUnits(message: AssistantMessage, hideThinking: boolean): number {
88
+ export function visibleUnits(message: AssistantMessage, hideThinking: boolean, proseOnly = true): number {
87
89
  let total = 0;
88
90
  for (const block of message.content) {
89
91
  if (block.type === "text") {
90
92
  total += countGraphemes(block.text);
91
- } else if (block.type === "thinking" && !hideThinking && canonicalizeMessage(block.thinking)) {
92
- total += countGraphemes(block.thinking);
93
+ } else if (block.type === "thinking" && !hideThinking) {
94
+ const formatted = formatThinkingForDisplay(block.thinking, proseOnly);
95
+ if (hasDisplayableThinking(block.thinking, formatted)) {
96
+ total += countGraphemes(formatted);
97
+ }
93
98
  }
94
99
  }
95
100
  return total;
@@ -119,6 +124,7 @@ export function buildDisplayMessage(
119
124
  target: AssistantMessage,
120
125
  revealed: number,
121
126
  hideThinking: boolean,
127
+ proseOnly = true,
122
128
  countOf: (index: number, text: string) => number = (_index, text) => countGraphemes(text),
123
129
  ): AssistantMessage {
124
130
  let remaining = Math.max(0, Math.floor(revealed));
@@ -129,10 +135,20 @@ export function buildDisplayMessage(
129
135
  const units = countOf(i, block.text);
130
136
  content.push(revealTextBlock(block, remaining, units));
131
137
  remaining = Math.max(0, remaining - units);
132
- } else if (block.type === "thinking" && !hideThinking && canonicalizeMessage(block.thinking)) {
133
- const units = countOf(i, block.thinking);
134
- content.push(revealThinkingBlock(block, remaining, units));
135
- remaining = Math.max(0, remaining - units);
138
+ } else if (block.type === "thinking" && !hideThinking) {
139
+ const formatted = formatThinkingForDisplay(block.thinking, proseOnly);
140
+ if (hasDisplayableThinking(block.thinking, formatted)) {
141
+ const units = countOf(i, formatted);
142
+ const displayBlock: DisplayThinkingContentBlock = {
143
+ ...block,
144
+ thinking: formatted,
145
+ rawThinking: block.thinking,
146
+ };
147
+ content.push(revealThinkingBlock(displayBlock, remaining, units));
148
+ remaining = Math.max(0, remaining - units);
149
+ } else {
150
+ content.push(block);
151
+ }
136
152
  } else {
137
153
  content.push(block);
138
154
  }
@@ -147,12 +163,14 @@ export function nextStep(backlog: number): number {
147
163
  export class StreamingRevealController {
148
164
  readonly #getSmoothStreaming: () => boolean;
149
165
  readonly #getHideThinkingBlock: () => boolean;
166
+ readonly #getProseOnlyThinking: () => boolean;
150
167
  readonly #requestRender: () => void;
151
168
  #target: AssistantMessage | undefined;
152
169
  #component: StreamingRevealComponent | undefined;
153
170
  #timer: NodeJS.Timeout | undefined;
154
171
  #revealed = 0;
155
172
  #hideThinkingBlock = false;
173
+ #proseOnlyThinking = true;
156
174
  #smoothStreaming = true;
157
175
  readonly #unitCounter = new BlockUnitCounter();
158
176
  readonly #countOf = (index: number, text: string): number => this.#unitCounter.count(index, text);
@@ -160,6 +178,7 @@ export class StreamingRevealController {
160
178
  constructor(options: StreamingRevealControllerOptions) {
161
179
  this.#getSmoothStreaming = options.getSmoothStreaming;
162
180
  this.#getHideThinkingBlock = options.getHideThinkingBlock;
181
+ this.#getProseOnlyThinking = options.getProseOnlyThinking;
163
182
  this.#requestRender = options.requestRender;
164
183
  }
165
184
 
@@ -169,9 +188,14 @@ export class StreamingRevealController {
169
188
  this.#target = message;
170
189
  this.#revealed = 0;
171
190
  this.#hideThinkingBlock = this.#getHideThinkingBlock();
191
+ this.#proseOnlyThinking = this.#getProseOnlyThinking();
172
192
  this.#smoothStreaming = this.#getSmoothStreaming();
173
193
  if (!this.#smoothStreaming) {
174
- component.updateContent(message, { transient: true });
194
+ const total = this.#visibleUnits(message);
195
+ component.updateContent(
196
+ buildDisplayMessage(message, total, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
197
+ { transient: true },
198
+ );
175
199
  return;
176
200
  }
177
201
  const total = this.#visibleUnits(message);
@@ -179,9 +203,18 @@ export class StreamingRevealController {
179
203
  // A tool call is a transcript-order boundary: finish any leading
180
204
  // assistant text before EventController renders the separate tool card.
181
205
  this.#revealed = total;
182
- component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
183
- transient: true,
184
- });
206
+ component.updateContent(
207
+ buildDisplayMessage(
208
+ message,
209
+ this.#revealed,
210
+ this.#hideThinkingBlock,
211
+ this.#proseOnlyThinking,
212
+ this.#countOf,
213
+ ),
214
+ {
215
+ transient: true,
216
+ },
217
+ );
185
218
  return;
186
219
  }
187
220
  this.#renderCurrent();
@@ -190,9 +223,16 @@ export class StreamingRevealController {
190
223
 
191
224
  setTarget(message: AssistantMessage): void {
192
225
  this.#target = message;
226
+ this.#hideThinkingBlock = this.#getHideThinkingBlock();
227
+ this.#proseOnlyThinking = this.#getProseOnlyThinking();
228
+ this.#smoothStreaming = this.#getSmoothStreaming();
193
229
  if (!this.#component) return;
194
230
  if (!this.#smoothStreaming) {
195
- this.#component.updateContent(message, { transient: true });
231
+ const total = this.#visibleUnits(message);
232
+ this.#component.updateContent(
233
+ buildDisplayMessage(message, total, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
234
+ { transient: true },
235
+ );
196
236
  return;
197
237
  }
198
238
  const total = this.#visibleUnits(message);
@@ -202,7 +242,13 @@ export class StreamingRevealController {
202
242
  this.#revealed = total;
203
243
  this.#stopTimer();
204
244
  this.#component.updateContent(
205
- buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf),
245
+ buildDisplayMessage(
246
+ message,
247
+ this.#revealed,
248
+ this.#hideThinkingBlock,
249
+ this.#proseOnlyThinking,
250
+ this.#countOf,
251
+ ),
206
252
  {
207
253
  transient: true,
208
254
  },
@@ -231,8 +277,11 @@ export class StreamingRevealController {
231
277
  const block = message.content[i]!;
232
278
  if (block.type === "text") {
233
279
  total += this.#unitCounter.count(i, block.text);
234
- } else if (block.type === "thinking" && !this.#hideThinkingBlock && canonicalizeMessage(block.thinking)) {
235
- total += this.#unitCounter.count(i, block.thinking);
280
+ } else if (block.type === "thinking" && !this.#hideThinkingBlock) {
281
+ const formatted = formatThinkingForDisplay(block.thinking, this.#proseOnlyThinking);
282
+ if (hasDisplayableThinking(block.thinking, formatted)) {
283
+ total += this.#unitCounter.count(i, formatted);
284
+ }
236
285
  }
237
286
  }
238
287
  return total;
@@ -244,7 +293,13 @@ export class StreamingRevealController {
244
293
  // smooth reveal has temporarily caught up to the current target. The
245
294
  // message_end handler performs the only stable non-transient render.
246
295
  this.#component.updateContent(
247
- buildDisplayMessage(this.#target, this.#revealed, this.#hideThinkingBlock, this.#countOf),
296
+ buildDisplayMessage(
297
+ this.#target,
298
+ this.#revealed,
299
+ this.#hideThinkingBlock,
300
+ this.#proseOnlyThinking,
301
+ this.#countOf,
302
+ ),
248
303
  { transient: true },
249
304
  );
250
305
  }
@@ -284,9 +339,12 @@ export class StreamingRevealController {
284
339
  return;
285
340
  }
286
341
  this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
287
- component.updateContent(buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
288
- transient: true,
289
- });
342
+ component.updateContent(
343
+ buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
344
+ {
345
+ transient: true,
346
+ },
347
+ );
290
348
  this.#requestRender();
291
349
  if (this.#revealed >= total) {
292
350
  this.#stopTimer();
@@ -1,10 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
- import { resolveToCwd } from "../../tools/path-utils";
3
2
  import {
4
3
  applyOpsToPhases,
5
4
  getLatestTodoPhasesFromEntries,
6
5
  markdownToPhases,
7
6
  phasesToMarkdown,
7
+ resolveTodoMarkdownPath,
8
8
  type TodoItem,
9
9
  type TodoPhase,
10
10
  USER_TODO_EDIT_CUSTOM_TYPE,
@@ -18,8 +18,8 @@ const USAGE = [
18
18
  " /todo Show current todos",
19
19
  " /todo edit Open todos in $EDITOR",
20
20
  " /todo copy Copy todos as Markdown to clipboard",
21
- " /todo export <path> Write todos as Markdown to <path>",
22
- " /todo import <path> Replace todos from Markdown at <path>",
21
+ " /todo export [<path>] Write todos to file (default: TODO.md)",
22
+ " /todo import [<path>] Replace todos from file (default: TODO.md)",
23
23
  " /todo append [<phase>] <task...> Append a task; phase fuzzy-matched or auto-created",
24
24
  " /todo start <task> Mark task in_progress (fuzzy content match)",
25
25
  " /todo done [<task|phase>] Mark task/phase/all completed",
@@ -214,9 +214,7 @@ export class TodoCommandController {
214
214
  }
215
215
 
216
216
  #resolveTodoPath(rest: string): string {
217
- const trimmed = rest.trim();
218
- const raw = trimmed || "TODO.md";
219
- return resolveToCwd(raw, this.ctx.sessionManager.getCwd());
217
+ return resolveTodoMarkdownPath(rest, this.ctx.sessionManager.getCwd());
220
218
  }
221
219
 
222
220
  async #exportToFile(rest: string): Promise<void> {
@@ -225,22 +223,23 @@ export class TodoCommandController {
225
223
  this.ctx.showWarning("No todos to export.");
226
224
  return;
227
225
  }
228
- const target = this.#resolveTodoPath(rest);
229
226
  try {
227
+ const target = this.#resolveTodoPath(rest);
230
228
  await fs.writeFile(target, phasesToMarkdown(phases), "utf8");
231
229
  this.ctx.showStatus(`Wrote todos to ${target}`);
232
230
  } catch (error) {
233
- this.ctx.showError(`Failed to write ${target}: ${error instanceof Error ? error.message : String(error)}`);
231
+ this.ctx.showError(`Failed to write todos: ${error instanceof Error ? error.message : String(error)}`);
234
232
  }
235
233
  }
236
234
 
237
235
  async #importFromFile(rest: string): Promise<void> {
238
- const source = this.#resolveTodoPath(rest);
236
+ let source = "";
239
237
  let content: string;
240
238
  try {
239
+ source = this.#resolveTodoPath(rest);
241
240
  content = await fs.readFile(source, "utf8");
242
241
  } catch (error) {
243
- this.ctx.showError(`Failed to read ${source}: ${error instanceof Error ? error.message : String(error)}`);
242
+ this.ctx.showError(`Failed to read todos: ${error instanceof Error ? error.message : String(error)}`);
244
243
  return;
245
244
  }
246
245
  const { phases, errors } = markdownToPhases(content);