@oh-my-pi/pi-coding-agent 15.13.2 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +587 -499
  3. package/dist/types/advisor/__tests__/advisor.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +58 -0
  5. package/dist/types/advisor/index.d.ts +3 -0
  6. package/dist/types/advisor/runtime.d.ts +52 -0
  7. package/dist/types/advisor/watchdog.d.ts +5 -0
  8. package/dist/types/config/model-roles.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +75 -5
  10. package/dist/types/eval/js/context-manager.d.ts +15 -0
  11. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  13. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  14. package/dist/types/modes/interactive-mode.d.ts +4 -1
  15. package/dist/types/modes/types.d.ts +9 -1
  16. package/dist/types/sdk.d.ts +3 -3
  17. package/dist/types/session/agent-session.d.ts +71 -2
  18. package/dist/types/session/session-history-format.d.ts +4 -0
  19. package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
  20. package/dist/types/session/yield-queue.d.ts +2 -0
  21. package/dist/types/stt/asr-client.d.ts +1 -1
  22. package/dist/types/tiny/title-client.d.ts +1 -1
  23. package/dist/types/tools/job.d.ts +1 -0
  24. package/dist/types/tools/path-utils.d.ts +1 -0
  25. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  26. package/dist/types/tts/tts-client.d.ts +1 -1
  27. package/dist/types/utils/thinking-display.d.ts +1 -17
  28. package/package.json +13 -13
  29. package/src/advisor/__tests__/advisor.test.ts +586 -0
  30. package/src/advisor/advise-tool.ts +87 -0
  31. package/src/advisor/index.ts +3 -0
  32. package/src/advisor/runtime.ts +248 -0
  33. package/src/advisor/watchdog.ts +83 -0
  34. package/src/cli.ts +25 -12
  35. package/src/config/model-registry.ts +6 -2
  36. package/src/config/model-roles.ts +13 -1
  37. package/src/config/settings-schema.ts +67 -5
  38. package/src/eval/__tests__/agent-bridge.test.ts +106 -46
  39. package/src/eval/__tests__/js-context-manager.test.ts +12 -2
  40. package/src/eval/js/context-manager.ts +40 -3
  41. package/src/eval/js/worker-entry.ts +7 -0
  42. package/src/export/html/template.js +18 -22
  43. package/src/internal-urls/docs-index.generated.ts +8 -5
  44. package/src/main.ts +19 -5
  45. package/src/modes/acp/acp-agent.ts +2 -2
  46. package/src/modes/acp/acp-event-mapper.ts +2 -2
  47. package/src/modes/components/advisor-message.ts +99 -0
  48. package/src/modes/components/agent-hub.ts +38 -7
  49. package/src/modes/components/assistant-message.ts +110 -15
  50. package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
  51. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  52. package/src/modes/components/status-line/segments.ts +20 -7
  53. package/src/modes/components/tree-selector.ts +3 -2
  54. package/src/modes/controllers/command-controller.ts +69 -2
  55. package/src/modes/controllers/event-controller.ts +3 -3
  56. package/src/modes/controllers/input-controller.ts +7 -1
  57. package/src/modes/controllers/streaming-reveal.ts +4 -4
  58. package/src/modes/interactive-mode.ts +14 -2
  59. package/src/modes/types.ts +9 -1
  60. package/src/modes/utils/ui-helpers.ts +12 -3
  61. package/src/prompts/advisor/advise-tool.md +1 -0
  62. package/src/prompts/advisor/system.md +31 -0
  63. package/src/prompts/agents/oracle.md +0 -1
  64. package/src/prompts/agents/reviewer.md +0 -1
  65. package/src/prompts/system/unexpected-stop-classifier.md +17 -0
  66. package/src/prompts/system/unexpected-stop-retry.md +4 -0
  67. package/src/sdk.ts +52 -13
  68. package/src/session/agent-session.ts +722 -21
  69. package/src/session/session-dump-format.ts +15 -142
  70. package/src/session/session-history-format.ts +30 -11
  71. package/src/session/unexpected-stop-classifier.ts +129 -0
  72. package/src/session/yield-queue.ts +5 -1
  73. package/src/slash-commands/builtin-registry.ts +102 -4
  74. package/src/stt/asr-client.ts +1 -1
  75. package/src/system-prompt.ts +1 -1
  76. package/src/tiny/title-client.ts +1 -1
  77. package/src/tools/browser/tab-supervisor.ts +1 -1
  78. package/src/tools/browser/tab-worker-entry.ts +12 -4
  79. package/src/tools/job.ts +1 -0
  80. package/src/tools/path-utils.ts +33 -2
  81. package/src/tools/report-tool-issue.ts +2 -7
  82. package/src/tts/tts-client.ts +1 -1
  83. package/src/utils/thinking-display.ts +8 -34
  84. package/src/web/scrapers/docs-rs.ts +2 -3
package/src/main.ts CHANGED
@@ -140,6 +140,10 @@ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
140
140
  // memory should opt in explicitly through their own settings layer.
141
141
  "memory.backend",
142
142
  "memories.enabled",
143
+ // Advisor is interactive-session assistance. Protocol hosts opt in explicitly
144
+ // instead of inheriting a user's globally-enabled local preference.
145
+ "advisor.enabled",
146
+ "advisor.subagents",
143
147
  ];
144
148
 
145
149
  const RPC_BACKGROUND_DEFAULTED_SETTING_PATHS: SettingPath[] = [
@@ -274,7 +278,19 @@ export async function submitInteractiveInput(
274
278
 
275
279
  try {
276
280
  using _keepalive = new EventLoopKeepalive();
277
- const streamingBehavior = session.isStreaming ? ("followUp" as const) : undefined;
281
+ // Honor the submission's queue intent, defaulting to followUp. Reading
282
+ // `session.isStreaming` to decide queue-vs-fresh is NOT atomic with the
283
+ // eventual `agent.prompt()` call inside `session.prompt()`: a background turn
284
+ // (queued-message drain, idle compaction, goal/loop continuation timer) can
285
+ // flip the agent busy in the gap, and a bare prompt() would then throw
286
+ // AgentBusyError straight to an error toast even though the UI shows no
287
+ // "Working…". Passing a behavior unconditionally is a no-op when the session
288
+ // is genuinely idle (a fresh turn runs and the option is ignored) and queues
289
+ // the message instead of erroring when a turn is already underway. Normal
290
+ // user Enter carries "steer" (interrupt, matching the streaming-branch Enter);
291
+ // background/continuation submits omit it and fall back to "followUp". The
292
+ // synthetic branch below opts out by design.
293
+ const streamingBehavior = input.streamingBehavior ?? ("followUp" as const);
278
294
  // Continue shortcuts submit an already-started synthetic developer prompt with
279
295
  // no optimistic user message.
280
296
  if (!input.started && !mode.markPendingSubmissionStarted(input)) {
@@ -287,9 +303,7 @@ export async function submitInteractiveInput(
287
303
  display: input.display ?? false,
288
304
  attribution: "agent" as const,
289
305
  };
290
- await (streamingBehavior
291
- ? session.promptCustomMessage(message, { streamingBehavior })
292
- : session.promptCustomMessage(message));
306
+ await session.promptCustomMessage(message, { streamingBehavior });
293
307
  } else if (input.synthetic) {
294
308
  // Synthetic continue shortcuts are hidden developer prompts. The streaming
295
309
  // queue (#queueUserMessage) only carries user-attributed messages, so we do
@@ -299,7 +313,7 @@ export async function submitInteractiveInput(
299
313
  // its role.
300
314
  await session.prompt(input.text, { synthetic: true, expandPromptTemplates: false });
301
315
  } else {
302
- await session.prompt(input.text, { images: input.images, ...(streamingBehavior && { streamingBehavior }) });
316
+ await session.prompt(input.text, { images: input.images, streamingBehavior });
303
317
  }
304
318
  } catch (error: unknown) {
305
319
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
@@ -72,7 +72,7 @@ import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
72
72
  import { normalizeLocalScheme } from "../../tools/path-utils";
73
73
  import { runResolveInvocation } from "../../tools/resolve";
74
74
  import { ToolError } from "../../tools/tool-errors";
75
- import { getVisibleThinkingText } from "../../utils/thinking-display";
75
+ import { canonicalizeMessage } from "../../utils/thinking-display";
76
76
  import { createAcpClientBridge } from "./acp-client-bridge";
77
77
  import {
78
78
  buildToolCallStartUpdate,
@@ -1907,7 +1907,7 @@ export class AcpAgent implements Agent {
1907
1907
  continue;
1908
1908
  }
1909
1909
  if (item.type === "thinking" && "thinking" in item && typeof item.thinking === "string") {
1910
- const thinking = getVisibleThinkingText(item);
1910
+ const thinking = canonicalizeMessage(item.thinking);
1911
1911
  if (thinking.length === 0) continue;
1912
1912
  notifications.push({
1913
1913
  sessionId,
@@ -9,7 +9,7 @@ import type {
9
9
  import type { AgentSessionEvent } from "../../session/agent-session";
10
10
  import { resolveToCwd } from "../../tools/path-utils";
11
11
  import type { TodoStatus } from "../../tools/todo";
12
- import { hasVisibleThinking } from "../../utils/thinking-display";
12
+ import { canonicalizeMessage } from "../../utils/thinking-display";
13
13
 
14
14
  interface MessageProgress {
15
15
  textEmitted: boolean;
@@ -259,7 +259,7 @@ function mapAssistantMessageUpdate(
259
259
  break;
260
260
  case "thinking_delta": {
261
261
  const block = event.assistantMessageEvent.partial?.content?.[event.assistantMessageEvent.contentIndex];
262
- if (block?.type === "thinking" && !hasVisibleThinking(block)) return [];
262
+ if (block?.type === "thinking" && !canonicalizeMessage(block.thinking)) return [];
263
263
  sessionUpdate = "agent_thought_chunk";
264
264
  text = event.assistantMessageEvent.delta;
265
265
  if (text.length > 0 && progress) {
@@ -0,0 +1,99 @@
1
+ import { type Component, visibleWidth } from "@oh-my-pi/pi-tui";
2
+ import type { AdvisorMessageDetails, AdvisorSeverity } from "../../advisor";
3
+ import {
4
+ createCachedComponent,
5
+ formatBadge,
6
+ replaceTabs,
7
+ type ToolUIColor,
8
+ wrapTextWithAnsi,
9
+ } from "../../tools/render-utils";
10
+ import { Ellipsis, renderStatusLine, truncateToWidth } from "../../tui";
11
+ import type { Theme } from "../theme/theme";
12
+
13
+ const COLLAPSED_NOTES = 3;
14
+ const NOTE_LINE_WIDTH = 110;
15
+
16
+ function wrapVarying(text: string, w1: number, w2: number): string[] {
17
+ if (text.length === 0) return [];
18
+ const firstWrap = wrapTextWithAnsi(text, w1);
19
+ if (firstWrap.length <= 1) {
20
+ return firstWrap;
21
+ }
22
+ const firstLine = firstWrap[0];
23
+ const idx = text.indexOf(firstLine);
24
+ if (idx === -1) {
25
+ return wrapTextWithAnsi(text, w2);
26
+ }
27
+ const remainder = text.slice(idx + firstLine.length).trimStart();
28
+ const restWrap = wrapTextWithAnsi(remainder, w2);
29
+ return [firstLine, ...restWrap];
30
+ }
31
+
32
+ function severityColor(severity: AdvisorSeverity | undefined): ToolUIColor {
33
+ switch (severity) {
34
+ case "blocker":
35
+ return "error";
36
+ case "concern":
37
+ return "warning";
38
+ default:
39
+ return "muted";
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Display-only transcript card for advisor notes injected into the primary
45
+ * session. Mirrors the IRC card's glyph + quote-border conventions so passive
46
+ * advice reads as a distinct, non-interrupting aside rather than a user turn.
47
+ */
48
+ export function createAdvisorMessageCard(
49
+ details: AdvisorMessageDetails | undefined,
50
+ getExpanded: () => boolean,
51
+ uiTheme: Theme,
52
+ ): Component {
53
+ const notes = details?.notes ?? [];
54
+ const blockers = notes.filter(note => note.severity === "blocker").length;
55
+ const meta: string[] = [`${notes.length} ${notes.length === 1 ? "note" : "notes"}`];
56
+ if (blockers > 0) meta.push(uiTheme.fg("error", `${blockers} blocker${blockers === 1 ? "" : "s"}`));
57
+
58
+ return createCachedComponent(
59
+ getExpanded,
60
+ (width, expanded) => {
61
+ const glyph = uiTheme.styledSymbol("status.info", "accent");
62
+ const lines = [renderStatusLine({ iconOverride: glyph, title: "Advisor", meta }, uiTheme)];
63
+ const quote = uiTheme.fg("dim", uiTheme.md.quoteBorder);
64
+ const shown = expanded ? notes : notes.slice(0, COLLAPSED_NOTES);
65
+ for (const entry of shown) {
66
+ const badge = entry.severity
67
+ ? `${formatBadge(entry.severity, severityColor(entry.severity), uiTheme)} `
68
+ : "";
69
+ const quotePrefix = ` ${quote} `;
70
+ const quoteWidth = visibleWidth(quotePrefix);
71
+ const badgeWidth = visibleWidth(badge);
72
+ const w1 = Math.max(10, Math.min(NOTE_LINE_WIDTH, width) - quoteWidth - badgeWidth);
73
+ const w2 = Math.max(10, Math.min(NOTE_LINE_WIDTH, width) - quoteWidth);
74
+
75
+ const paragraphs = entry.note.split("\n").filter(p => p.trim());
76
+ const bodyLines: string[] = [];
77
+ for (let i = 0; i < paragraphs.length; i++) {
78
+ const p = paragraphs[i];
79
+ if (i === 0) {
80
+ bodyLines.push(...wrapVarying(p, w1, w2));
81
+ } else {
82
+ bodyLines.push(...wrapTextWithAnsi(p, w2));
83
+ }
84
+ }
85
+
86
+ bodyLines.forEach((line, index) => {
87
+ const prefix = index === 0 ? badge : "";
88
+ lines.push(` ${quote} ${prefix}${uiTheme.fg("toolOutput", replaceTabs(line))}`);
89
+ });
90
+ }
91
+ const hidden = notes.length - shown.length;
92
+ if (hidden > 0) {
93
+ lines.push(` ${quote} ${uiTheme.fg("dim", `… +${hidden} more ${hidden === 1 ? "note" : "notes"}`)}`);
94
+ }
95
+ return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
96
+ },
97
+ { paddingX: 1 },
98
+ );
99
+ }
@@ -19,6 +19,7 @@ import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Usage } from "@oh-my-pi/pi-ai";
20
20
  import { Container, Editor, matchesKey, ScrollView, Text, type TUI } from "@oh-my-pi/pi-tui";
21
21
  import { formatAge, formatBytes, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils";
22
+ import type { AdvisorMessageDetails } from "../../advisor";
22
23
  import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
23
24
  import type { KeyId } from "../../config/keybindings";
24
25
  import { settings } from "../../config/settings";
@@ -41,10 +42,11 @@ import type { SessionMessageEntry } from "../../session/session-entries";
41
42
  import { parseSessionEntries } from "../../session/session-loader";
42
43
  import { createIrcMessageCard } from "../../tools/irc";
43
44
  import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
44
- import { hasVisibleThinking } from "../../utils/thinking-display";
45
+ import { canonicalizeMessage } from "../../utils/thinking-display";
45
46
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
46
47
  import { getEditorTheme, theme } from "../theme/theme";
47
48
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
49
+ import { createAdvisorMessageCard } from "./advisor-message";
48
50
  import { AssistantMessageComponent } from "./assistant-message";
49
51
  import { createBackgroundTanDispatchBlock } from "./background-tan-message";
50
52
  import { BashExecutionComponent } from "./bash-execution";
@@ -193,6 +195,8 @@ export class AgentHubOverlayComponent extends Container {
193
195
  #rows: AgentRef[] = [];
194
196
  #selectedRow = 0;
195
197
  #notice: string | undefined;
198
+ /** Captured row order from the first refresh; keeps the hub stable while open. */
199
+ #rowOrder: Map<string, number> | undefined;
196
200
 
197
201
  // Chat state
198
202
  #chatAgentId: string | undefined;
@@ -349,10 +353,32 @@ export class AgentHubOverlayComponent extends Container {
349
353
 
350
354
  #refreshRows(): void {
351
355
  const selectedId = this.#rows[this.#selectedRow]?.id;
352
- this.#rows = this.#registry
353
- .list()
354
- .filter(ref => ref.id !== MAIN_AGENT_ID)
355
- .sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity);
356
+ const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID);
357
+
358
+ if (!this.#rowOrder) {
359
+ // First refresh (usually the constructor): order by status, then recency.
360
+ this.#rows = refs.sort(
361
+ (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity,
362
+ );
363
+ this.#rowOrder = new Map(this.#rows.map((ref, i) => [ref.id, i]));
364
+ } else {
365
+ // After the hub is open, freeze the relative order so keyboard selection
366
+ // does not jump around as agents heartbeat or update activity. New agents
367
+ // are appended at the end and then stay put.
368
+ this.#rows = refs.sort((a, b) => {
369
+ const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
370
+ if (statusDiff !== 0) return statusDiff;
371
+ const aOrder = this.#rowOrder!.get(a.id) ?? Number.MAX_SAFE_INTEGER;
372
+ const bOrder = this.#rowOrder!.get(b.id) ?? Number.MAX_SAFE_INTEGER;
373
+ return aOrder - bOrder;
374
+ });
375
+ for (const ref of this.#rows) {
376
+ if (!this.#rowOrder.has(ref.id)) {
377
+ this.#rowOrder.set(ref.id, this.#rowOrder.size);
378
+ }
379
+ }
380
+ }
381
+
356
382
  const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1;
357
383
  this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1));
358
384
  }
@@ -1028,8 +1054,8 @@ export class AgentHubOverlayComponent extends Container {
1028
1054
 
1029
1055
  const hasVisibleAssistantContent = message.content.some(
1030
1056
  content =>
1031
- (content.type === "text" && content.text.trim().length > 0) ||
1032
- (content.type === "thinking" && hasVisibleThinking(content)),
1057
+ (content.type === "text" && canonicalizeMessage(content.text)) ||
1058
+ (content.type === "thinking" && canonicalizeMessage(content.thinking)),
1033
1059
  );
1034
1060
  if (hasVisibleAssistantContent) {
1035
1061
  // New visible turn content closes the current read run (mirrors rebuild).
@@ -1217,6 +1243,11 @@ export class AgentHubOverlayComponent extends Container {
1217
1243
  this.#chatLog.addChild(card);
1218
1244
  return;
1219
1245
  }
1246
+ if (message.customType === "advisor") {
1247
+ const details = (message as CustomMessage<AdvisorMessageDetails>).details;
1248
+ this.#chatLog.addChild(createAdvisorMessageCard(details, () => this.#chatExpanded, theme));
1249
+ return;
1250
+ }
1220
1251
  if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
1221
1252
  this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
1222
1253
  return;
@@ -4,7 +4,7 @@ import type { AssistantThinkingRenderer } from "../../extensibility/extensions/t
4
4
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
5
5
  import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages";
6
6
  import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
7
- import { getVisibleThinkingText, hasVisibleThinking } from "../../utils/thinking-display";
7
+ import { canonicalizeMessage } from "../../utils/thinking-display";
8
8
 
9
9
  /**
10
10
  * Max lines of a turn-ending provider error rendered inline in the transcript.
@@ -15,6 +15,15 @@ import { getVisibleThinkingText, hasVisibleThinking } from "../../utils/thinking
15
15
  */
16
16
  const MAX_TRANSCRIPT_ERROR_LINES = 8;
17
17
 
18
+ /**
19
+ * Frames for the streaming "thinking" pulse rendered in place of a hidden
20
+ * thinking block while the model is still producing it. A single fixed-width
21
+ * glyph that rises ▁▃▄▃ so the indicator animates without shifting the line.
22
+ * Advanced every {@link THINKING_DOTS_FRAME_MS}.
23
+ */
24
+ const THINKING_DOTS_FRAMES = ["▁", "▃", "▄", "▃"] as const;
25
+ const THINKING_DOTS_FRAME_MS = 320;
26
+
18
27
  /**
19
28
  * Component that renders a complete assistant message
20
29
  */
@@ -50,6 +59,11 @@ export class AssistantMessageComponent extends Container {
50
59
  #fastPathItems:
51
60
  | Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
52
61
  | undefined;
62
+ /** Live "thinking" pulse shown in place of a hidden thinking block while it
63
+ * streams; undefined when not animating. Driven by {@link #thinkingDotsTimer}. */
64
+ #thinkingDots: Text | undefined;
65
+ #thinkingDotsTimer: NodeJS.Timeout | undefined;
66
+ #thinkingDotsFrame = 0;
53
67
 
54
68
  constructor(
55
69
  message?: AssistantMessage,
@@ -87,6 +101,60 @@ export class AssistantMessageComponent extends Container {
87
101
  this.hideThinkingBlock = hide;
88
102
  }
89
103
 
104
+ override dispose(): void {
105
+ this.#stopThinkingAnimation();
106
+ super.dispose();
107
+ }
108
+
109
+ /**
110
+ * Whether to render the animated "thinking" pulse in place of the suppressed
111
+ * reasoning: only while this block is still streaming (not yet finalized — the
112
+ * in-flight message always carries `stopReason: "stop"`, so finalization is the
113
+ * only reliable live signal), thinking is hidden, no tool call has started, and
114
+ * the active tail block is a thinking block (the model is reasoning right now).
115
+ * Once text starts, a tool call streams, or the block is sealed, the pulse ends.
116
+ */
117
+ #shouldAnimateThinking(message: AssistantMessage): boolean {
118
+ if (!this.hideThinkingBlock || this.#transcriptBlockFinalized) return false;
119
+ let tail: "text" | "thinking" | undefined;
120
+ for (const content of message.content) {
121
+ if (content.type === "toolCall") return false;
122
+ if (content.type === "text" && canonicalizeMessage(content.text)) tail = "text";
123
+ else if (content.type === "thinking" && canonicalizeMessage(content.thinking)) tail = "thinking";
124
+ }
125
+ return tail === "thinking";
126
+ }
127
+
128
+ #thinkingDotsLabel(): string {
129
+ const glyph = THINKING_DOTS_FRAMES[this.#thinkingDotsFrame % THINKING_DOTS_FRAMES.length] ?? "…";
130
+ return theme.fg("thinkingText", glyph);
131
+ }
132
+
133
+ #startThinkingAnimation(): void {
134
+ if (this.#thinkingDotsTimer) return;
135
+ this.#thinkingDotsTimer = setInterval(() => this.#advanceThinkingDots(), THINKING_DOTS_FRAME_MS);
136
+ this.#thinkingDotsTimer.unref?.();
137
+ }
138
+
139
+ #advanceThinkingDots(): void {
140
+ if (!this.#thinkingDots) {
141
+ this.#stopThinkingAnimation();
142
+ return;
143
+ }
144
+ this.#thinkingDotsFrame = (this.#thinkingDotsFrame + 1) % THINKING_DOTS_FRAMES.length;
145
+ if (this.#thinkingDots.setText(this.#thinkingDotsLabel())) {
146
+ this.onImageUpdate?.();
147
+ }
148
+ }
149
+
150
+ #stopThinkingAnimation(): void {
151
+ if (this.#thinkingDotsTimer) {
152
+ clearInterval(this.#thinkingDotsTimer);
153
+ this.#thinkingDotsTimer = undefined;
154
+ }
155
+ this.#thinkingDotsFrame = 0;
156
+ }
157
+
90
158
  /**
91
159
  * Toggle suppression of the inline `Error: …` line while the same error is
92
160
  * pinned in the banner above the editor. Re-renders so the change is visible.
@@ -109,6 +177,14 @@ export class AssistantMessageComponent extends Container {
109
177
 
110
178
  markTranscriptBlockFinalized(): void {
111
179
  this.#transcriptBlockFinalized = true;
180
+ this.#stopThinkingAnimation();
181
+ // If the live pulse was on screen when the block sealed, drop the fast path
182
+ // and rebuild so the placeholder is removed — finalized blocks never animate.
183
+ if (this.#thinkingDots) {
184
+ this.#fastPathKey = undefined;
185
+ this.#fastPathItems = undefined;
186
+ if (this.#lastMessage) this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
187
+ }
112
188
  }
113
189
 
114
190
  /**
@@ -232,9 +308,10 @@ export class AssistantMessageComponent extends Container {
232
308
  const parts: string[] = [`htb:${this.hideThinkingBlock ? 1 : 0}`];
233
309
  for (const content of message.content) {
234
310
  if (content.type === "text") {
235
- parts.push(content.text.trim() ? "T1" : "T0");
311
+ parts.push(canonicalizeMessage(content.text) ? "T1" : "T0");
236
312
  } else if (content.type === "thinking") {
237
- if (!hasVisibleThinking(content)) parts.push("K0");
313
+ const canon = canonicalizeMessage(content.thinking);
314
+ if (!canon) parts.push("K0");
238
315
  else if (this.hideThinkingBlock) parts.push("KH");
239
316
  else parts.push("KV");
240
317
  } else {
@@ -267,7 +344,8 @@ export class AssistantMessageComponent extends Container {
267
344
  for (const item of this.#fastPathItems) {
268
345
  if (item.blockType === "thinking") {
269
346
  const content = message.content[item.contentIndex];
270
- if (content?.type === "thinking" && getVisibleThinkingText(content) !== item.lastText) return false;
347
+ if (content?.type === "thinking" && canonicalizeMessage(content.thinking) !== item.lastText)
348
+ return false;
271
349
  }
272
350
  }
273
351
  }
@@ -291,13 +369,17 @@ export class AssistantMessageComponent extends Container {
291
369
  for (const item of this.#fastPathItems) {
292
370
  item.md.transientRenderCache = transient;
293
371
  const content = message.content[item.contentIndex];
372
+ if (!content) {
373
+ this.#fastPathKey = undefined;
374
+ this.#fastPathItems = undefined;
375
+ return false;
376
+ }
294
377
  let newText: string;
295
- if (item.blockType === "text" && content?.type === "text") {
378
+ if (item.blockType === "text" && content.type === "text") {
296
379
  newText = content.text.trim();
297
- } else if (item.blockType === "thinking" && content?.type === "thinking") {
298
- newText = getVisibleThinkingText(content);
380
+ } else if (item.blockType === "thinking" && content.type === "thinking") {
381
+ newText = canonicalizeMessage(content.thinking);
299
382
  } else {
300
- // Block at this index is gone or changed type (index shift) — fail closed.
301
383
  this.#fastPathKey = undefined;
302
384
  this.#fastPathItems = undefined;
303
385
  return false;
@@ -320,6 +402,7 @@ export class AssistantMessageComponent extends Container {
320
402
 
321
403
  // Clear content container
322
404
  this.#contentContainer.clear();
405
+ this.#thinkingDots = undefined;
323
406
 
324
407
  // Determine if we should capture Markdown instances for next fast path
325
408
  const shouldCapture = this.#canFastPath(message);
@@ -329,24 +412,23 @@ export class AssistantMessageComponent extends Container {
329
412
 
330
413
  const hasVisibleContent = message.content.some(
331
414
  c =>
332
- (c.type === "text" && c.text.trim()) ||
333
- (!this.hideThinkingBlock && c.type === "thinking" && hasVisibleThinking(c)),
415
+ (c.type === "text" && canonicalizeMessage(c.text)) ||
416
+ (!this.hideThinkingBlock && c.type === "thinking" && canonicalizeMessage(c.thinking)),
334
417
  );
335
418
 
336
419
  // Render content in order
337
420
  let thinkingIndex = 0;
338
421
  for (let i = 0; i < message.content.length; i++) {
339
422
  const content = message.content[i];
340
- if (content.type === "text" && content.text.trim()) {
341
- // Assistant text messages with no background - trim the text
423
+ if (content.type === "text" && canonicalizeMessage(content.text)) {
342
424
  // Set paddingY=0 to avoid extra spacing before tool executions
343
425
  const trimmed = content.text.trim();
344
426
  const md = new Markdown(trimmed, 1, 0, getMarkdownTheme());
345
427
  md.transientRenderCache = this.#lastUpdateTransient;
346
428
  this.#contentContainer.addChild(md);
347
429
  captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
348
- } else if (content.type === "thinking" && hasVisibleThinking(content)) {
349
- const thinkingText = getVisibleThinkingText(content);
430
+ } else if (content.type === "thinking" && canonicalizeMessage(content.thinking)) {
431
+ const thinkingText = canonicalizeMessage(content.thinking);
350
432
  if (this.hideThinkingBlock) {
351
433
  thinkingIndex += 1;
352
434
  continue;
@@ -355,7 +437,11 @@ export class AssistantMessageComponent extends Container {
355
437
  // This avoids a superfluous blank line before separately-rendered tool execution blocks.
356
438
  const hasVisibleContentAfter = message.content
357
439
  .slice(i + 1)
358
- .some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && hasVisibleThinking(c)));
440
+ .some(
441
+ c =>
442
+ (c.type === "text" && canonicalizeMessage(c.text)) ||
443
+ (c.type === "thinking" && canonicalizeMessage(c.thinking)),
444
+ );
359
445
 
360
446
  // Thinking traces in thinkingText color, italic
361
447
  const md = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
@@ -373,6 +459,15 @@ export class AssistantMessageComponent extends Container {
373
459
  }
374
460
  }
375
461
 
462
+ if (this.#shouldAnimateThinking(message)) {
463
+ if (hasVisibleContent) this.#contentContainer.addChild(new Spacer(1));
464
+ this.#thinkingDots = new Text(this.#thinkingDotsLabel(), 1, 0);
465
+ this.#contentContainer.addChild(this.#thinkingDots);
466
+ this.#startThinkingAnimation();
467
+ } else {
468
+ this.#stopThinkingAnimation();
469
+ }
470
+
376
471
  this.#renderToolImages();
377
472
  // Check if aborted - show after partial content
378
473
  // But only if there are no tool calls (tool execution components will show the error)
@@ -1,8 +1,8 @@
1
1
  [User]: Fix the settings overlay crash. Wheeling past the last row throws.
2
2
 
3
- [Assistant tool calls]: read(path="src/select-list.ts:140-180")
3
+ [Tool Call]: read(path="src/select-list.ts:140-180")
4
4
 
5
- [Tool result]: 162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
5
+ [Tool Result]: 162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
6
6
 
7
7
  [Assistant]: Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
8
8
 
@@ -38,10 +38,10 @@ const ZOOM_SCALE = 4;
38
38
  const MAX_IMAGE_COLS = 28;
39
39
  const MAX_IMAGE_ROWS = 14;
40
40
 
41
- /** Sample transcript with `[Tool result]:` bodies wrapped in dim-ink toggles. */
41
+ /** Sample transcript with `[Tool Result]:` bodies wrapped in dim-ink toggles. */
42
42
  const PREVIEW_TEXT = sampleDoc
43
43
  .trim()
44
- .replace(/\[Tool result\]: ([^[]*)/g, (_match, body: string) => `[Tool result]: ${DIM_ON}${body}${DIM_OFF}`);
44
+ .replace(/\[Tool Result\]: ([^[]*)/g, (_match, body: string) => `[Tool Result]: ${DIM_ON}${body}${DIM_OFF}`);
45
45
 
46
46
  type PreviewEntry =
47
47
  | { state: "rendering" }
@@ -86,32 +86,45 @@ const modelSegment: StatusLineSegment = {
86
86
  modelName = modelName.slice(7);
87
87
  }
88
88
 
89
- let content = withIcon(theme.icon.model, modelName);
90
-
89
+ // Fast-mode icon and thinking-level suffix trail the model name and are
90
+ // colored together with it as `statusLineModel`. The advisor "++" badge
91
+ // sits between the name and that tail in `accent`, so it reads as a
92
+ // distinct marker. theme.fg resets only the fg, so the spans are
93
+ // concatenated (not nested) to keep each color intact.
94
+ let tail = "";
91
95
  if (ctx.session.isFastModeActive() && theme.icon.fast) {
92
- content += ` ${theme.icon.fast}`;
96
+ tail += ` ${theme.icon.fast}`;
93
97
  }
94
98
 
95
- // Add thinking level with dot separator
96
99
  if (opts.showThinkingLevel !== false && state.model?.thinking) {
97
100
  if (ctx.session.isAutoThinking) {
98
101
  // Pending (no turn classified yet / classifying) shows a symbol-theme
99
102
  // question-box marker; once resolved it shows `<level>`.
100
103
  const resolved = ctx.session.autoResolvedThinkingLevel();
101
104
  const resolvedText = resolved ? (theme.thinking[resolved as keyof typeof theme.thinking] ?? resolved) : "";
102
- content += `${theme.sep.dot}${resolved ? resolvedText : `${theme.thinking.autoPending} auto`}`;
105
+ tail += `${theme.sep.dot}${resolved ? resolvedText : `${theme.thinking.autoPending} auto`}`;
103
106
  } else {
104
107
  const level = state.thinkingLevel ?? ThinkingLevel.Off;
105
108
  if (level !== ThinkingLevel.Off) {
106
109
  const thinkingText = theme.thinking[level as keyof typeof theme.thinking];
107
110
  if (thinkingText) {
108
- content += `${theme.sep.dot}${thinkingText}`;
111
+ tail += `${theme.sep.dot}${thinkingText}`;
109
112
  }
110
113
  }
111
114
  }
112
115
  }
113
116
 
114
- return { content: theme.fg("statusLineModel", content), visible: true };
117
+ // `statusLineModel` is aliased to `accent` in many themes, so the badge
118
+ // uses `success` to stay visibly distinct from the model name color.
119
+ let content = theme.fg("statusLineModel", withIcon(theme.icon.model, modelName));
120
+ if (ctx.session.isAdvisorActive()) {
121
+ content += theme.fg("success", "++");
122
+ }
123
+ if (tail) {
124
+ content += theme.fg("statusLineModel", tail);
125
+ }
126
+
127
+ return { content, visible: true };
115
128
  },
116
129
  };
117
130
 
@@ -18,6 +18,7 @@ import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../m
18
18
  import type { SessionTreeNode } from "../../session/session-entries";
19
19
  import { shortenPath } from "../../tools/render-utils";
20
20
  import { toPathList } from "../../tools/search";
21
+ import { canonicalizeMessage } from "../../utils/thinking-display";
21
22
  import { DynamicBorder } from "./dynamic-border";
22
23
 
23
24
  /** Gutter info: position (displayIndent where connector was) and whether to show │ */
@@ -702,12 +703,12 @@ class TreeList implements Component {
702
703
  }
703
704
 
704
705
  #hasTextContent(content: unknown): boolean {
705
- if (typeof content === "string") return content.trim().length > 0;
706
+ if (typeof content === "string") return Boolean(canonicalizeMessage(content));
706
707
  if (Array.isArray(content)) {
707
708
  for (const c of content) {
708
709
  if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
709
710
  const text = (c as { text?: string }).text;
710
- if (text && text.trim().length > 0) return true;
711
+ if (text && canonicalizeMessage(text)) return true;
711
712
  }
712
713
  }
713
714
  }