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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli.js +147 -122
  3. package/dist/types/config/settings-schema.d.ts +31 -0
  4. package/dist/types/eval/js/context-manager.d.ts +15 -0
  5. package/dist/types/modes/interactive-mode.d.ts +1 -0
  6. package/dist/types/modes/types.d.ts +6 -0
  7. package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
  8. package/dist/types/stt/asr-client.d.ts +1 -1
  9. package/dist/types/tiny/title-client.d.ts +1 -1
  10. package/dist/types/tools/job.d.ts +1 -0
  11. package/dist/types/tts/tts-client.d.ts +1 -1
  12. package/dist/types/utils/thinking-display.d.ts +1 -17
  13. package/package.json +12 -12
  14. package/src/cli.ts +25 -12
  15. package/src/config/model-registry.ts +6 -2
  16. package/src/config/settings-schema.ts +25 -0
  17. package/src/eval/__tests__/agent-bridge.test.ts +106 -46
  18. package/src/eval/__tests__/js-context-manager.test.ts +12 -2
  19. package/src/eval/js/context-manager.ts +40 -3
  20. package/src/eval/js/worker-entry.ts +7 -0
  21. package/src/export/html/template.js +18 -22
  22. package/src/internal-urls/docs-index.generated.ts +5 -3
  23. package/src/main.ts +15 -5
  24. package/src/modes/acp/acp-agent.ts +2 -2
  25. package/src/modes/acp/acp-event-mapper.ts +2 -2
  26. package/src/modes/components/agent-hub.ts +31 -7
  27. package/src/modes/components/assistant-message.ts +24 -15
  28. package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
  29. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  30. package/src/modes/components/tree-selector.ts +3 -2
  31. package/src/modes/controllers/event-controller.ts +3 -3
  32. package/src/modes/controllers/input-controller.ts +7 -1
  33. package/src/modes/controllers/streaming-reveal.ts +4 -4
  34. package/src/modes/interactive-mode.ts +2 -0
  35. package/src/modes/types.ts +6 -0
  36. package/src/modes/utils/ui-helpers.ts +3 -3
  37. package/src/prompts/agents/oracle.md +0 -1
  38. package/src/prompts/agents/reviewer.md +0 -1
  39. package/src/prompts/system/unexpected-stop-classifier.md +17 -0
  40. package/src/prompts/system/unexpected-stop-retry.md +4 -0
  41. package/src/session/agent-session.ts +164 -10
  42. package/src/session/session-dump-format.ts +8 -19
  43. package/src/session/unexpected-stop-classifier.ts +129 -0
  44. package/src/stt/asr-client.ts +1 -1
  45. package/src/tiny/title-client.ts +1 -1
  46. package/src/tools/browser/tab-supervisor.ts +1 -1
  47. package/src/tools/browser/tab-worker-entry.ts +12 -4
  48. package/src/tools/job.ts +1 -0
  49. package/src/tts/tts-client.ts +1 -1
  50. package/src/utils/thinking-display.ts +8 -34
package/src/main.ts CHANGED
@@ -274,7 +274,19 @@ export async function submitInteractiveInput(
274
274
 
275
275
  try {
276
276
  using _keepalive = new EventLoopKeepalive();
277
- const streamingBehavior = session.isStreaming ? ("followUp" as const) : undefined;
277
+ // Honor the submission's queue intent, defaulting to followUp. Reading
278
+ // `session.isStreaming` to decide queue-vs-fresh is NOT atomic with the
279
+ // eventual `agent.prompt()` call inside `session.prompt()`: a background turn
280
+ // (queued-message drain, idle compaction, goal/loop continuation timer) can
281
+ // flip the agent busy in the gap, and a bare prompt() would then throw
282
+ // AgentBusyError straight to an error toast even though the UI shows no
283
+ // "Working…". Passing a behavior unconditionally is a no-op when the session
284
+ // is genuinely idle (a fresh turn runs and the option is ignored) and queues
285
+ // the message instead of erroring when a turn is already underway. Normal
286
+ // user Enter carries "steer" (interrupt, matching the streaming-branch Enter);
287
+ // background/continuation submits omit it and fall back to "followUp". The
288
+ // synthetic branch below opts out by design.
289
+ const streamingBehavior = input.streamingBehavior ?? ("followUp" as const);
278
290
  // Continue shortcuts submit an already-started synthetic developer prompt with
279
291
  // no optimistic user message.
280
292
  if (!input.started && !mode.markPendingSubmissionStarted(input)) {
@@ -287,9 +299,7 @@ export async function submitInteractiveInput(
287
299
  display: input.display ?? false,
288
300
  attribution: "agent" as const,
289
301
  };
290
- await (streamingBehavior
291
- ? session.promptCustomMessage(message, { streamingBehavior })
292
- : session.promptCustomMessage(message));
302
+ await session.promptCustomMessage(message, { streamingBehavior });
293
303
  } else if (input.synthetic) {
294
304
  // Synthetic continue shortcuts are hidden developer prompts. The streaming
295
305
  // queue (#queueUserMessage) only carries user-attributed messages, so we do
@@ -299,7 +309,7 @@ export async function submitInteractiveInput(
299
309
  // its role.
300
310
  await session.prompt(input.text, { synthetic: true, expandPromptTemplates: false });
301
311
  } else {
302
- await session.prompt(input.text, { images: input.images, ...(streamingBehavior && { streamingBehavior }) });
312
+ await session.prompt(input.text, { images: input.images, streamingBehavior });
303
313
  }
304
314
  } catch (error: unknown) {
305
315
  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) {
@@ -41,7 +41,7 @@ import type { SessionMessageEntry } from "../../session/session-entries";
41
41
  import { parseSessionEntries } from "../../session/session-loader";
42
42
  import { createIrcMessageCard } from "../../tools/irc";
43
43
  import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
44
- import { hasVisibleThinking } from "../../utils/thinking-display";
44
+ import { canonicalizeMessage } from "../../utils/thinking-display";
45
45
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
46
46
  import { getEditorTheme, theme } from "../theme/theme";
47
47
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
@@ -193,6 +193,8 @@ export class AgentHubOverlayComponent extends Container {
193
193
  #rows: AgentRef[] = [];
194
194
  #selectedRow = 0;
195
195
  #notice: string | undefined;
196
+ /** Captured row order from the first refresh; keeps the hub stable while open. */
197
+ #rowOrder: Map<string, number> | undefined;
196
198
 
197
199
  // Chat state
198
200
  #chatAgentId: string | undefined;
@@ -349,10 +351,32 @@ export class AgentHubOverlayComponent extends Container {
349
351
 
350
352
  #refreshRows(): void {
351
353
  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);
354
+ const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID);
355
+
356
+ if (!this.#rowOrder) {
357
+ // First refresh (usually the constructor): order by status, then recency.
358
+ this.#rows = refs.sort(
359
+ (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity,
360
+ );
361
+ this.#rowOrder = new Map(this.#rows.map((ref, i) => [ref.id, i]));
362
+ } else {
363
+ // After the hub is open, freeze the relative order so keyboard selection
364
+ // does not jump around as agents heartbeat or update activity. New agents
365
+ // are appended at the end and then stay put.
366
+ this.#rows = refs.sort((a, b) => {
367
+ const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
368
+ if (statusDiff !== 0) return statusDiff;
369
+ const aOrder = this.#rowOrder!.get(a.id) ?? Number.MAX_SAFE_INTEGER;
370
+ const bOrder = this.#rowOrder!.get(b.id) ?? Number.MAX_SAFE_INTEGER;
371
+ return aOrder - bOrder;
372
+ });
373
+ for (const ref of this.#rows) {
374
+ if (!this.#rowOrder.has(ref.id)) {
375
+ this.#rowOrder.set(ref.id, this.#rowOrder.size);
376
+ }
377
+ }
378
+ }
379
+
356
380
  const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1;
357
381
  this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1));
358
382
  }
@@ -1028,8 +1052,8 @@ export class AgentHubOverlayComponent extends Container {
1028
1052
 
1029
1053
  const hasVisibleAssistantContent = message.content.some(
1030
1054
  content =>
1031
- (content.type === "text" && content.text.trim().length > 0) ||
1032
- (content.type === "thinking" && hasVisibleThinking(content)),
1055
+ (content.type === "text" && canonicalizeMessage(content.text)) ||
1056
+ (content.type === "thinking" && canonicalizeMessage(content.thinking)),
1033
1057
  );
1034
1058
  if (hasVisibleAssistantContent) {
1035
1059
  // New visible turn content closes the current read run (mirrors rebuild).
@@ -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.
@@ -232,9 +232,10 @@ export class AssistantMessageComponent extends Container {
232
232
  const parts: string[] = [`htb:${this.hideThinkingBlock ? 1 : 0}`];
233
233
  for (const content of message.content) {
234
234
  if (content.type === "text") {
235
- parts.push(content.text.trim() ? "T1" : "T0");
235
+ parts.push(canonicalizeMessage(content.text) ? "T1" : "T0");
236
236
  } else if (content.type === "thinking") {
237
- if (!hasVisibleThinking(content)) parts.push("K0");
237
+ const canon = canonicalizeMessage(content.thinking);
238
+ if (!canon) parts.push("K0");
238
239
  else if (this.hideThinkingBlock) parts.push("KH");
239
240
  else parts.push("KV");
240
241
  } else {
@@ -267,7 +268,8 @@ export class AssistantMessageComponent extends Container {
267
268
  for (const item of this.#fastPathItems) {
268
269
  if (item.blockType === "thinking") {
269
270
  const content = message.content[item.contentIndex];
270
- if (content?.type === "thinking" && getVisibleThinkingText(content) !== item.lastText) return false;
271
+ if (content?.type === "thinking" && canonicalizeMessage(content.thinking) !== item.lastText)
272
+ return false;
271
273
  }
272
274
  }
273
275
  }
@@ -291,13 +293,17 @@ export class AssistantMessageComponent extends Container {
291
293
  for (const item of this.#fastPathItems) {
292
294
  item.md.transientRenderCache = transient;
293
295
  const content = message.content[item.contentIndex];
296
+ if (!content) {
297
+ this.#fastPathKey = undefined;
298
+ this.#fastPathItems = undefined;
299
+ return false;
300
+ }
294
301
  let newText: string;
295
- if (item.blockType === "text" && content?.type === "text") {
302
+ if (item.blockType === "text" && content.type === "text") {
296
303
  newText = content.text.trim();
297
- } else if (item.blockType === "thinking" && content?.type === "thinking") {
298
- newText = getVisibleThinkingText(content);
304
+ } else if (item.blockType === "thinking" && content.type === "thinking") {
305
+ newText = canonicalizeMessage(content.thinking);
299
306
  } else {
300
- // Block at this index is gone or changed type (index shift) — fail closed.
301
307
  this.#fastPathKey = undefined;
302
308
  this.#fastPathItems = undefined;
303
309
  return false;
@@ -329,24 +335,23 @@ export class AssistantMessageComponent extends Container {
329
335
 
330
336
  const hasVisibleContent = message.content.some(
331
337
  c =>
332
- (c.type === "text" && c.text.trim()) ||
333
- (!this.hideThinkingBlock && c.type === "thinking" && hasVisibleThinking(c)),
338
+ (c.type === "text" && canonicalizeMessage(c.text)) ||
339
+ (!this.hideThinkingBlock && c.type === "thinking" && canonicalizeMessage(c.thinking)),
334
340
  );
335
341
 
336
342
  // Render content in order
337
343
  let thinkingIndex = 0;
338
344
  for (let i = 0; i < message.content.length; i++) {
339
345
  const content = message.content[i];
340
- if (content.type === "text" && content.text.trim()) {
341
- // Assistant text messages with no background - trim the text
346
+ if (content.type === "text" && canonicalizeMessage(content.text)) {
342
347
  // Set paddingY=0 to avoid extra spacing before tool executions
343
348
  const trimmed = content.text.trim();
344
349
  const md = new Markdown(trimmed, 1, 0, getMarkdownTheme());
345
350
  md.transientRenderCache = this.#lastUpdateTransient;
346
351
  this.#contentContainer.addChild(md);
347
352
  captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
348
- } else if (content.type === "thinking" && hasVisibleThinking(content)) {
349
- const thinkingText = getVisibleThinkingText(content);
353
+ } else if (content.type === "thinking" && canonicalizeMessage(content.thinking)) {
354
+ const thinkingText = canonicalizeMessage(content.thinking);
350
355
  if (this.hideThinkingBlock) {
351
356
  thinkingIndex += 1;
352
357
  continue;
@@ -355,7 +360,11 @@ export class AssistantMessageComponent extends Container {
355
360
  // This avoids a superfluous blank line before separately-rendered tool execution blocks.
356
361
  const hasVisibleContentAfter = message.content
357
362
  .slice(i + 1)
358
- .some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && hasVisibleThinking(c)));
363
+ .some(
364
+ c =>
365
+ (c.type === "text" && canonicalizeMessage(c.text)) ||
366
+ (c.type === "thinking" && canonicalizeMessage(c.thinking)),
367
+ );
359
368
 
360
369
  // Thinking traces in thinkingText color, italic
361
370
  const md = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
@@ -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" }
@@ -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
  }
@@ -22,7 +22,7 @@ import type { AgentSessionEvent } from "../../session/agent-session";
22
22
  import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages";
23
23
  import type { ResolveToolDetails } from "../../tools/resolve";
24
24
  import { vocalizer } from "../../tts/vocalizer";
25
- import { hasVisibleThinking } from "../../utils/thinking-display";
25
+ import { canonicalizeMessage } from "../../utils/thinking-display";
26
26
  import { interruptHint } from "../shared";
27
27
  import { StreamingRevealController } from "./streaming-reveal";
28
28
  import { ToolArgsRevealController } from "./tool-args-reveal";
@@ -480,8 +480,8 @@ export class EventController {
480
480
 
481
481
  const visibleBlockCount = this.ctx.streamingMessage.content.filter(
482
482
  content =>
483
- (content.type === "text" && content.text.trim().length > 0) ||
484
- (content.type === "thinking" && hasVisibleThinking(content)),
483
+ (content.type === "text" && canonicalizeMessage(content.text)) ||
484
+ (content.type === "thinking" && canonicalizeMessage(content.thinking)),
485
485
  ).length;
486
486
  if (visibleBlockCount > this.#lastVisibleBlockCount) {
487
487
  this.#resetReadGroup();
@@ -689,11 +689,17 @@ export class InputController {
689
689
  this.ctx.pendingImages = [];
690
690
  this.ctx.pendingImageLinks = [];
691
691
 
692
- // Render user message immediately, then let session events catch up
692
+ // Render user message immediately, then let session events catch up.
693
+ // Tag the submission as "steer": this is a normal Enter the controller
694
+ // believed was idle, but a background turn can start in the gap before
695
+ // `submitInteractiveInput` dispatches it. Steering matches the
696
+ // streaming-branch Enter (above) and keeps the message from throwing
697
+ // AgentBusyError on that race.
693
698
  const submission = this.ctx.startPendingSubmission({
694
699
  text,
695
700
  images,
696
701
  imageLinks: inputImageLinks,
702
+ streamingBehavior: "steer",
697
703
  });
698
704
 
699
705
  this.ctx.onInputCallback(submission);
@@ -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 { hasVisibleThinking } from "../../utils/thinking-display";
4
+ import { canonicalizeMessage } 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;
@@ -88,7 +88,7 @@ export function visibleUnits(message: AssistantMessage, hideThinking: boolean):
88
88
  for (const block of message.content) {
89
89
  if (block.type === "text") {
90
90
  total += countGraphemes(block.text);
91
- } else if (block.type === "thinking" && !hideThinking && hasVisibleThinking(block)) {
91
+ } else if (block.type === "thinking" && !hideThinking && canonicalizeMessage(block.thinking)) {
92
92
  total += countGraphemes(block.thinking);
93
93
  }
94
94
  }
@@ -129,7 +129,7 @@ export function buildDisplayMessage(
129
129
  const units = countOf(i, block.text);
130
130
  content.push(revealTextBlock(block, remaining, units));
131
131
  remaining = Math.max(0, remaining - units);
132
- } else if (block.type === "thinking" && !hideThinking && hasVisibleThinking(block)) {
132
+ } else if (block.type === "thinking" && !hideThinking && canonicalizeMessage(block.thinking)) {
133
133
  const units = countOf(i, block.thinking);
134
134
  content.push(revealThinkingBlock(block, remaining, units));
135
135
  remaining = Math.max(0, remaining - units);
@@ -231,7 +231,7 @@ export class StreamingRevealController {
231
231
  const block = message.content[i]!;
232
232
  if (block.type === "text") {
233
233
  total += this.#unitCounter.count(i, block.text);
234
- } else if (block.type === "thinking" && !this.#hideThinkingBlock && hasVisibleThinking(block)) {
234
+ } else if (block.type === "thinking" && !this.#hideThinkingBlock && canonicalizeMessage(block.thinking)) {
235
235
  total += this.#unitCounter.count(i, block.thinking);
236
236
  }
237
237
  }
@@ -1123,6 +1123,7 @@ export class InteractiveMode implements InteractiveModeContext {
1123
1123
  imageLinks?: (string | undefined)[];
1124
1124
  customType?: string;
1125
1125
  display?: boolean;
1126
+ streamingBehavior?: "steer" | "followUp";
1126
1127
  }): SubmittedUserInput {
1127
1128
  const submission: SubmittedUserInput = {
1128
1129
  text: input.text,
@@ -1130,6 +1131,7 @@ export class InteractiveMode implements InteractiveModeContext {
1130
1131
  imageLinks: input.imageLinks,
1131
1132
  customType: input.customType,
1132
1133
  display: input.display,
1134
+ streamingBehavior: input.streamingBehavior,
1133
1135
  cancelled: false,
1134
1136
  started: false,
1135
1137
  };
@@ -54,6 +54,11 @@ export type SubmittedUserInput = {
54
54
  * turn. Used by the `c`/`.` continue shortcut. */
55
55
  synthetic?: boolean;
56
56
  display?: boolean;
57
+ /** Queue intent if the session is (or becomes) busy when this submission is
58
+ * dispatched: "steer" (interrupt the active turn) or "followUp" (process after
59
+ * it). Normal user Enter carries "steer" to match the streaming-branch Enter;
60
+ * background/continuation submits omit it and default to "followUp". */
61
+ streamingBehavior?: "steer" | "followUp";
57
62
  cancelled: boolean;
58
63
  started: boolean;
59
64
  };
@@ -222,6 +227,7 @@ export interface InteractiveModeContext {
222
227
  imageLinks?: (string | undefined)[];
223
228
  customType?: string;
224
229
  display?: boolean;
230
+ streamingBehavior?: "steer" | "followUp";
225
231
  }): SubmittedUserInput;
226
232
  cancelPendingSubmission(): boolean;
227
233
  markPendingSubmissionStarted(input: SubmittedUserInput): boolean;
@@ -45,7 +45,7 @@ import {
45
45
  import type { SessionContext } from "../../session/session-context";
46
46
  import { createIrcMessageCard } from "../../tools/irc";
47
47
  import { formatBytes, formatDuration } from "../../tools/render-utils";
48
- import { hasVisibleThinking } from "../../utils/thinking-display";
48
+ import { canonicalizeMessage } from "../../utils/thinking-display";
49
49
 
50
50
  type TextBlock = { type: "text"; text: string };
51
51
  interface RenderInitialMessagesOptions {
@@ -399,8 +399,8 @@ export class UiHelpers {
399
399
  const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined;
400
400
  const hasVisibleAssistantContent = message.content.some(
401
401
  content =>
402
- (content.type === "text" && content.text.trim().length > 0) ||
403
- (content.type === "thinking" && hasVisibleThinking(content)),
402
+ (content.type === "text" && canonicalizeMessage(content.text)) ||
403
+ (content.type === "thinking" && canonicalizeMessage(content.thinking)),
404
404
  );
405
405
  if (hasVisibleAssistantContent) {
406
406
  // Rebuild reconstructs immutable history; seal (not finalize) so the
@@ -4,7 +4,6 @@ description: Wise senior engineer to consult or delegate work to — debugging,
4
4
  spawns: explore
5
5
  model: pi/slow
6
6
  thinking-level: xhigh
7
- blocking: true
8
7
  ---
9
8
 
10
9
  You are the wise guy on the team — a senior engineer with deep judgment that other agents consult when they are stuck, uncertain, or need a second opinion. You also take direct delegation: if the caller hands you work, you do it, including reads, writes, edits, and running commands.
@@ -5,7 +5,6 @@ tools: read, search, find, bash, lsp, web_search, ast_grep, report_finding
5
5
  spawns: explore
6
6
  model: pi/slow
7
7
  thinking-level: high
8
- blocking: true
9
8
  output:
10
9
  properties:
11
10
  overall_correctness:
@@ -0,0 +1,17 @@
1
+ You are checking whether an assistant message is an unexpected stop. A message is an unexpected stop if the assistant says it will take an action, continue working, or call a tool, but then ends without actually doing so.
2
+
3
+ Examples of unexpected stops:
4
+ - "I should do the same for the JS eval worker. Doing that now."
5
+ - "Let me run the tests next."
6
+ - "I'll fix that now."
7
+ - "Should I do that for you?"
8
+
9
+ Not an unexpected stop:
10
+ - "I've completed the task."
11
+ - "Is there anything else I can help with?"
12
+ - "The fix is done and tests pass."
13
+
14
+ Message:
15
+ {{message}}
16
+
17
+ Answer with a single word: YES if this is an unexpected stop, NO otherwise.
@@ -0,0 +1,4 @@
1
+ <system-injection>
2
+ You said you would continue with a tool call or action but stopped. Continue now.
3
+ Attempt #{{retryCount}}/{{maxRetries}}
4
+ </system-injection>