@oh-my-pi/pi-coding-agent 15.13.1 → 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 (109) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/cli.js +1057 -289
  3. package/dist/types/config/model-registry.d.ts +1 -0
  4. package/dist/types/config/models-config-schema.d.ts +3 -0
  5. package/dist/types/config/models-config.d.ts +3 -0
  6. package/dist/types/config/settings-schema.d.ts +97 -0
  7. package/dist/types/edit/hashline/block-resolver.d.ts +1 -1
  8. package/dist/types/edit/index.d.ts +2 -0
  9. package/dist/types/eval/js/context-manager.d.ts +15 -0
  10. package/dist/types/modes/components/welcome.d.ts +1 -0
  11. package/dist/types/modes/controllers/input-controller.d.ts +4 -4
  12. package/dist/types/modes/interactive-mode.d.ts +1 -0
  13. package/dist/types/modes/rpc/rpc-types.d.ts +2 -1
  14. package/dist/types/modes/types.d.ts +6 -0
  15. package/dist/types/sdk.d.ts +3 -0
  16. package/dist/types/session/session-dump-format.d.ts +2 -1
  17. package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
  18. package/dist/types/stt/asr-client.d.ts +1 -1
  19. package/dist/types/system-prompt.d.ts +11 -0
  20. package/dist/types/tiny/title-client.d.ts +1 -1
  21. package/dist/types/tools/ask.d.ts +2 -0
  22. package/dist/types/tools/ast-edit.d.ts +2 -0
  23. package/dist/types/tools/ast-grep.d.ts +2 -0
  24. package/dist/types/tools/browser.d.ts +2 -0
  25. package/dist/types/tools/debug.d.ts +2 -0
  26. package/dist/types/tools/eval.d.ts +2 -0
  27. package/dist/types/tools/find.d.ts +2 -0
  28. package/dist/types/tools/inspect-image.d.ts +2 -1
  29. package/dist/types/tools/irc.d.ts +2 -0
  30. package/dist/types/tools/job.d.ts +1 -0
  31. package/dist/types/tools/ssh.d.ts +2 -0
  32. package/dist/types/tools/todo.d.ts +2 -0
  33. package/dist/types/tts/tts-client.d.ts +1 -1
  34. package/dist/types/tui/tree-list.d.ts +1 -0
  35. package/dist/types/utils/thinking-display.d.ts +1 -17
  36. package/package.json +12 -12
  37. package/src/cli.ts +25 -12
  38. package/src/config/model-registry.ts +16 -2
  39. package/src/config/models-config-schema.ts +2 -0
  40. package/src/config/models-config.ts +1 -0
  41. package/src/config/settings-schema.ts +78 -0
  42. package/src/edit/hashline/block-resolver.ts +1 -1
  43. package/src/edit/hashline/execute.ts +1 -6
  44. package/src/edit/index.ts +48 -0
  45. package/src/eval/__tests__/agent-bridge.test.ts +106 -46
  46. package/src/eval/__tests__/js-context-manager.test.ts +53 -3
  47. package/src/eval/js/context-manager.ts +132 -29
  48. package/src/eval/js/worker-core.ts +1 -1
  49. package/src/eval/js/worker-entry.ts +7 -0
  50. package/src/export/html/template.js +18 -22
  51. package/src/internal-urls/docs-index.generated.ts +12 -3
  52. package/src/main.ts +15 -5
  53. package/src/modes/acp/acp-agent.ts +2 -2
  54. package/src/modes/acp/acp-event-mapper.ts +2 -2
  55. package/src/modes/components/agent-hub.ts +31 -7
  56. package/src/modes/components/assistant-message.ts +24 -15
  57. package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
  58. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  59. package/src/modes/components/tree-selector.ts +3 -2
  60. package/src/modes/components/welcome.ts +14 -4
  61. package/src/modes/controllers/event-controller.ts +3 -3
  62. package/src/modes/controllers/input-controller.ts +28 -39
  63. package/src/modes/controllers/streaming-reveal.ts +4 -4
  64. package/src/modes/interactive-mode.ts +2 -0
  65. package/src/modes/rpc/rpc-mode.ts +1 -0
  66. package/src/modes/rpc/rpc-types.ts +2 -2
  67. package/src/modes/types.ts +6 -0
  68. package/src/modes/utils/ui-helpers.ts +3 -3
  69. package/src/prompts/agents/oracle.md +0 -1
  70. package/src/prompts/agents/reviewer.md +0 -1
  71. package/src/prompts/system/system-prompt.md +17 -21
  72. package/src/prompts/system/unexpected-stop-classifier.md +17 -0
  73. package/src/prompts/system/unexpected-stop-retry.md +4 -0
  74. package/src/prompts/tools/ask.md +0 -8
  75. package/src/prompts/tools/ast-edit.md +0 -15
  76. package/src/prompts/tools/ast-grep.md +0 -13
  77. package/src/prompts/tools/browser.md +0 -21
  78. package/src/prompts/tools/debug.md +0 -13
  79. package/src/prompts/tools/eval.md +0 -9
  80. package/src/prompts/tools/find.md +0 -13
  81. package/src/prompts/tools/inspect-image.md +0 -9
  82. package/src/prompts/tools/irc.md +0 -15
  83. package/src/prompts/tools/patch.md +0 -13
  84. package/src/prompts/tools/ssh.md +0 -9
  85. package/src/prompts/tools/todo.md +1 -19
  86. package/src/sdk.ts +19 -0
  87. package/src/session/agent-session.ts +289 -29
  88. package/src/session/session-dump-format.ts +17 -49
  89. package/src/session/unexpected-stop-classifier.ts +129 -0
  90. package/src/stt/asr-client.ts +1 -1
  91. package/src/system-prompt.ts +31 -0
  92. package/src/tiny/title-client.ts +1 -1
  93. package/src/tools/ask.ts +41 -0
  94. package/src/tools/ast-edit.ts +46 -0
  95. package/src/tools/ast-grep.ts +24 -0
  96. package/src/tools/browser/tab-supervisor.ts +1 -1
  97. package/src/tools/browser/tab-worker-entry.ts +12 -4
  98. package/src/tools/browser.ts +52 -0
  99. package/src/tools/debug.ts +17 -0
  100. package/src/tools/eval.ts +20 -1
  101. package/src/tools/find.ts +24 -0
  102. package/src/tools/inspect-image.ts +27 -1
  103. package/src/tools/irc.ts +41 -0
  104. package/src/tools/job.ts +1 -0
  105. package/src/tools/ssh.ts +16 -0
  106. package/src/tools/todo.ts +82 -3
  107. package/src/tts/tts-client.ts +1 -1
  108. package/src/tui/tree-list.ts +68 -19
  109. 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
  }
@@ -70,8 +70,7 @@ export interface LspServerInfo {
70
70
  export class WelcomeComponent implements Component {
71
71
  #animStart: number | null = null;
72
72
  #animTimer: ReturnType<typeof setInterval> | null = null;
73
- /** Tip chosen once per instance so re-renders (intro, LSP updates) don't shuffle it. */
74
- readonly #tip: string | undefined = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : undefined;
73
+ #selectedTip: string | undefined;
75
74
  // Render cache: the welcome box is the first transcript-area component, so
76
75
  // returning a stable array reference keeps the whole frame prefix stable.
77
76
  // Bypassed while the intro animation runs (every frame differs).
@@ -85,6 +84,16 @@ export class WelcomeComponent implements Component {
85
84
  private recentSessions: RecentSession[] = [],
86
85
  private lspServers: LspServerInfo[] = [],
87
86
  ) {}
87
+ get tip(): string | undefined {
88
+ if (this.#selectedTip === undefined) {
89
+ if (theme.getSymbolPreset() === "unicode" && Math.random() < 0.1) {
90
+ this.#selectedTip = "Please use nerdfont 😭.";
91
+ } else {
92
+ this.#selectedTip = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : "";
93
+ }
94
+ }
95
+ return this.#selectedTip || undefined;
96
+ }
88
97
 
89
98
  invalidate(): void {
90
99
  this.#cachedWidth = -1;
@@ -316,8 +325,9 @@ export class WelcomeComponent implements Component {
316
325
  * when no tip is available or the box is too narrow to be useful.
317
326
  */
318
327
  #renderTip(boxWidth: number): string[] {
319
- if (!this.#tip) return [];
320
- return renderWelcomeTip(this.#tip, boxWidth);
328
+ const tip = this.tip;
329
+ if (!tip) return [];
330
+ return renderWelcomeTip(tip, boxWidth);
321
331
  }
322
332
 
323
333
  /** Center text within a given width */
@@ -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();
@@ -44,26 +44,9 @@ function hasPasteText(value: unknown): value is PasteTarget {
44
44
  return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
45
45
  }
46
46
 
47
- /** Wrap pasted text in a fenced code block, using a backtick fence longer than any run of
48
- * backticks already in the content so an embedded fence cannot terminate the block early. */
49
- function wrapPasteInCodeBlock(content: string): string {
50
- let longestRun = 0;
51
- let run = 0;
52
- for (let i = 0; i < content.length; i++) {
53
- if (content.charCodeAt(i) === 96 /* backtick */) {
54
- run++;
55
- if (run > longestRun) longestRun = run;
56
- } else {
57
- run = 0;
58
- }
59
- }
60
- const fence = "`".repeat(Math.max(3, longestRun + 1));
61
- return `${fence}\n${content}\n${fence}`;
62
- }
63
-
64
- /** Wrap pasted text in `<pasted_text>` tags so the model treats it as one quoted block. */
65
- function wrapPasteInXml(content: string): string {
66
- return `<pasted_text>\n${content}\n</pasted_text>`;
47
+ /** Wrap pasted text in `<attachment>` tags so the model treats it as one quoted block. */
48
+ function wrapPasteInAttachmentBlock(content: string): string {
49
+ return `<attachment>\n${content}\n</attachment>`;
67
50
  }
68
51
 
69
52
  const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
@@ -99,8 +82,8 @@ export class InputController {
99
82
  // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
100
83
  // #detectLeftDoubleTap.
101
84
  #leftTapCount = 0;
102
- // Sequential index for `local://attachment-N` references created by the large-paste "attach as
103
- // file" action. Seeded from 0 and bumped past any existing attachment files in #attachPasteAsFile.
85
+ // Sequential index for `local://attachment-N` references created by the large-paste local-file
86
+ // action. Seeded from 0 and bumped past any existing attachment files in #attachPasteAsFile.
104
87
  #attachmentCounter = 0;
105
88
 
106
89
  #showTinyTitleDownloadProgress(modelKey: string): void {
@@ -706,11 +689,17 @@ export class InputController {
706
689
  this.ctx.pendingImages = [];
707
690
  this.ctx.pendingImageLinks = [];
708
691
 
709
- // 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.
710
698
  const submission = this.ctx.startPendingSubmission({
711
699
  text,
712
700
  images,
713
701
  imageLinks: inputImageLinks,
702
+ streamingBehavior: "steer",
714
703
  });
715
704
 
716
705
  this.ctx.onInputCallback(submission);
@@ -1282,24 +1271,24 @@ export class InputController {
1282
1271
  }
1283
1272
 
1284
1273
  /**
1285
- * Present the large-paste menu and apply the chosen action: wrap in a code block or in XML tags
1286
- * (both collapse to a `[Paste]` marker that expands on submit), or save the text to a file and
1287
- * reference its path so the agent can `read` it on demand. Cancelling (Esc) falls back to the
1288
- * default inline paste marker, so the pasted content is never lost.
1274
+ * Present the large-paste menu and apply the chosen action: wrap in `<attachment>` tags (collapsed
1275
+ * to a `[Paste]` marker that expands on submit), save the text to a file and reference its path so
1276
+ * the agent can `read` it on demand, or paste inline. Cancelling (Esc) falls back to the default
1277
+ * inline paste marker, so the pasted content is never lost.
1289
1278
  */
1290
1279
  async presentLargePasteMenu(text: string, lineCount: number): Promise<void> {
1291
- const CODE_BLOCK = "Wrap in a code block";
1292
- const XML = "Wrap in XML tags";
1293
- const FILE = "Attach as a file";
1280
+ const WRAPPED_BLOCK = "Attach as a wrapped block";
1281
+ const LOCAL_FILE = "Attach as local file";
1282
+ const INLINE = "Paste inline";
1294
1283
 
1295
1284
  let choice: string | undefined;
1296
1285
  try {
1297
1286
  choice = await this.ctx.showHookSelector(
1298
1287
  `Pasted ${lineCount} lines`,
1299
1288
  [
1300
- { label: CODE_BLOCK, description: "Fence the text in a ``` block, collapsed to a marker" },
1301
- { label: XML, description: "Wrap the text in <pasted_text> tags, collapsed to a marker" },
1302
- { label: FILE, description: "Save the text to a file and reference its path" },
1289
+ { label: WRAPPED_BLOCK, description: "Wrap the text in <attachment> tags, collapsed to a marker" },
1290
+ { label: LOCAL_FILE, description: "Save the text to a local://attachment file" },
1291
+ { label: INLINE, description: "Collapse the text to an inline paste marker" },
1303
1292
  ],
1304
1293
  { helpText: "Esc to paste inline" },
1305
1294
  );
@@ -1309,15 +1298,15 @@ export class InputController {
1309
1298
  }
1310
1299
 
1311
1300
  switch (choice) {
1312
- case CODE_BLOCK:
1313
- this.ctx.editor.insertPaste(wrapPasteInCodeBlock(text));
1314
- break;
1315
- case XML:
1316
- this.ctx.editor.insertPaste(wrapPasteInXml(text));
1301
+ case WRAPPED_BLOCK:
1302
+ this.ctx.editor.insertPaste(wrapPasteInAttachmentBlock(text));
1317
1303
  break;
1318
- case FILE:
1304
+ case LOCAL_FILE:
1319
1305
  await this.#attachPasteAsFile(text, lineCount);
1320
1306
  break;
1307
+ case INLINE:
1308
+ this.ctx.editor.insertPaste(text);
1309
+ break;
1321
1310
  default:
1322
1311
  // Esc / cancel: keep the original behavior — collapse to an inline paste marker.
1323
1312
  this.ctx.editor.insertPaste(text);
@@ -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
  };
@@ -796,6 +796,7 @@ export async function runRpcMode(
796
796
  name: tool.name,
797
797
  description: tool.description,
798
798
  parameters: isZodSchema(tool.parameters) ? zodToWireSchema(tool.parameters) : tool.parameters,
799
+ examples: tool.examples,
799
800
  })),
800
801
  contextUsage: session.getContextUsage(),
801
802
  };
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
8
  import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
9
- import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai";
9
+ import type { Effort, ImageContent, Model, ToolExample } from "@oh-my-pi/pi-ai";
10
10
  import type { BashResult } from "../../exec/bash-executor";
11
11
  import type { ContextUsage } from "../../extensibility/extensions/types";
12
12
  import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
@@ -107,7 +107,7 @@ export interface RpcSessionState {
107
107
  todoPhases: TodoPhase[];
108
108
  /** For session dump / export (plain-text parity with /dump). */
109
109
  systemPrompt?: string[];
110
- dumpTools?: Array<{ name: string; description: string; parameters: unknown }>;
110
+ dumpTools?: Array<{ name: string; description: string; parameters: unknown; examples?: readonly ToolExample[] }>;
111
111
  /** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */
112
112
  contextUsage?: ContextUsage;
113
113
  }
@@ -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: