@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.
- package/CHANGELOG.md +62 -0
- package/dist/cli.js +587 -499
- package/dist/types/advisor/__tests__/advisor.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +58 -0
- package/dist/types/advisor/index.d.ts +3 -0
- package/dist/types/advisor/runtime.d.ts +52 -0
- package/dist/types/advisor/watchdog.d.ts +5 -0
- package/dist/types/config/model-roles.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +75 -5
- package/dist/types/eval/js/context-manager.d.ts +15 -0
- package/dist/types/modes/components/advisor-message.d.ts +9 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +4 -1
- package/dist/types/modes/types.d.ts +9 -1
- package/dist/types/sdk.d.ts +3 -3
- package/dist/types/session/agent-session.d.ts +71 -2
- package/dist/types/session/session-history-format.d.ts +4 -0
- package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
- package/dist/types/session/yield-queue.d.ts +2 -0
- package/dist/types/stt/asr-client.d.ts +1 -1
- package/dist/types/tiny/title-client.d.ts +1 -1
- package/dist/types/tools/job.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/report-tool-issue.d.ts +0 -1
- package/dist/types/tts/tts-client.d.ts +1 -1
- package/dist/types/utils/thinking-display.d.ts +1 -17
- package/package.json +13 -13
- package/src/advisor/__tests__/advisor.test.ts +586 -0
- package/src/advisor/advise-tool.ts +87 -0
- package/src/advisor/index.ts +3 -0
- package/src/advisor/runtime.ts +248 -0
- package/src/advisor/watchdog.ts +83 -0
- package/src/cli.ts +25 -12
- package/src/config/model-registry.ts +6 -2
- package/src/config/model-roles.ts +13 -1
- package/src/config/settings-schema.ts +67 -5
- package/src/eval/__tests__/agent-bridge.test.ts +106 -46
- package/src/eval/__tests__/js-context-manager.test.ts +12 -2
- package/src/eval/js/context-manager.ts +40 -3
- package/src/eval/js/worker-entry.ts +7 -0
- package/src/export/html/template.js +18 -22
- package/src/internal-urls/docs-index.generated.ts +8 -5
- package/src/main.ts +19 -5
- package/src/modes/acp/acp-agent.ts +2 -2
- package/src/modes/acp/acp-event-mapper.ts +2 -2
- package/src/modes/components/advisor-message.ts +99 -0
- package/src/modes/components/agent-hub.ts +38 -7
- package/src/modes/components/assistant-message.ts +110 -15
- package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
- package/src/modes/components/snapcompact-shape-preview.ts +2 -2
- package/src/modes/components/status-line/segments.ts +20 -7
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/controllers/command-controller.ts +69 -2
- package/src/modes/controllers/event-controller.ts +3 -3
- package/src/modes/controllers/input-controller.ts +7 -1
- package/src/modes/controllers/streaming-reveal.ts +4 -4
- package/src/modes/interactive-mode.ts +14 -2
- package/src/modes/types.ts +9 -1
- package/src/modes/utils/ui-helpers.ts +12 -3
- package/src/prompts/advisor/advise-tool.md +1 -0
- package/src/prompts/advisor/system.md +31 -0
- package/src/prompts/agents/oracle.md +0 -1
- package/src/prompts/agents/reviewer.md +0 -1
- package/src/prompts/system/unexpected-stop-classifier.md +17 -0
- package/src/prompts/system/unexpected-stop-retry.md +4 -0
- package/src/sdk.ts +52 -13
- package/src/session/agent-session.ts +722 -21
- package/src/session/session-dump-format.ts +15 -142
- package/src/session/session-history-format.ts +30 -11
- package/src/session/unexpected-stop-classifier.ts +129 -0
- package/src/session/yield-queue.ts +5 -1
- package/src/slash-commands/builtin-registry.ts +102 -4
- package/src/stt/asr-client.ts +1 -1
- package/src/system-prompt.ts +1 -1
- package/src/tiny/title-client.ts +1 -1
- package/src/tools/browser/tab-supervisor.ts +1 -1
- package/src/tools/browser/tab-worker-entry.ts +12 -4
- package/src/tools/job.ts +1 -0
- package/src/tools/path-utils.ts +33 -2
- package/src/tools/report-tool-issue.ts +2 -7
- package/src/tts/tts-client.ts +1 -1
- package/src/utils/thinking-display.ts +8 -34
- 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
|
-
|
|
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,
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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" && !
|
|
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 {
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
1032
|
-
(content.type === "thinking" &&
|
|
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 {
|
|
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
|
|
311
|
+
parts.push(canonicalizeMessage(content.text) ? "T1" : "T0");
|
|
236
312
|
} else if (content.type === "thinking") {
|
|
237
|
-
|
|
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" &&
|
|
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
|
|
378
|
+
if (item.blockType === "text" && content.type === "text") {
|
|
296
379
|
newText = content.text.trim();
|
|
297
|
-
} else if (item.blockType === "thinking" && content
|
|
298
|
-
newText =
|
|
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
|
|
333
|
-
(!this.hideThinkingBlock && c.type === "thinking" &&
|
|
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
|
|
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" &&
|
|
349
|
-
const thinkingText =
|
|
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(
|
|
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
|
-
[
|
|
3
|
+
[Tool Call]: read(path="src/select-list.ts:140-180")
|
|
4
4
|
|
|
5
|
-
[Tool
|
|
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
|
|
41
|
+
/** Sample transcript with `[Tool Result]:` bodies wrapped in dim-ink toggles. */
|
|
42
42
|
const PREVIEW_TEXT = sampleDoc
|
|
43
43
|
.trim()
|
|
44
|
-
.replace(/\[Tool
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
tail += `${theme.sep.dot}${thinkingText}`;
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
}
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
|
|
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
|
|
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
|
|
711
|
+
if (text && canonicalizeMessage(text)) return true;
|
|
711
712
|
}
|
|
712
713
|
}
|
|
713
714
|
}
|