@oh-my-pi/pi-coding-agent 15.9.5 → 15.9.67

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 (98) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/config/keybindings.d.ts +4 -1
  3. package/dist/types/config/settings-schema.d.ts +11 -1
  4. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  5. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  6. package/dist/types/eval/backend.d.ts +6 -6
  7. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  8. package/dist/types/eval/idle-timeout.d.ts +16 -14
  9. package/dist/types/eval/js/executor.d.ts +3 -3
  10. package/dist/types/eval/py/executor.d.ts +2 -2
  11. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  13. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  14. package/dist/types/modes/components/model-selector.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +1 -1
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  20. package/dist/types/tools/eval-render.d.ts +8 -0
  21. package/dist/types/tools/render-utils.d.ts +25 -0
  22. package/dist/types/tui/code-cell.d.ts +6 -0
  23. package/dist/types/tui/output-block.d.ts +11 -0
  24. package/package.json +9 -9
  25. package/src/autoresearch/dashboard.ts +11 -21
  26. package/src/cli/claude-trace-cli.ts +13 -1
  27. package/src/config/keybindings.ts +58 -1
  28. package/src/config/settings-schema.ts +11 -1
  29. package/src/debug/raw-sse.ts +18 -4
  30. package/src/edit/file-snapshot-store.ts +1 -1
  31. package/src/edit/index.ts +1 -1
  32. package/src/edit/renderer.ts +7 -7
  33. package/src/edit/streaming.ts +1 -1
  34. package/src/eval/__tests__/agent-bridge.test.ts +28 -27
  35. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  36. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  37. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  38. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  39. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  40. package/src/eval/agent-bridge.ts +4 -5
  41. package/src/eval/backend.ts +6 -6
  42. package/src/eval/bridge-timeout.ts +44 -0
  43. package/src/eval/idle-timeout.ts +33 -15
  44. package/src/eval/js/executor.ts +10 -10
  45. package/src/eval/llm-bridge.ts +4 -5
  46. package/src/eval/py/executor.ts +6 -6
  47. package/src/eval/py/kernel.ts +11 -1
  48. package/src/eval/py/spawn-options.ts +126 -0
  49. package/src/export/ttsr.ts +9 -0
  50. package/src/extensibility/extensions/runner.ts +2 -0
  51. package/src/internal-urls/docs-index.generated.ts +6 -5
  52. package/src/lsp/client.ts +80 -2
  53. package/src/lsp/index.ts +38 -4
  54. package/src/lsp/render.ts +3 -3
  55. package/src/main.ts +1 -1
  56. package/src/modes/components/agent-dashboard.ts +13 -4
  57. package/src/modes/components/assistant-message.ts +22 -1
  58. package/src/modes/components/copy-selector.ts +249 -0
  59. package/src/modes/components/extensions/extension-list.ts +17 -8
  60. package/src/modes/components/history-search.ts +19 -11
  61. package/src/modes/components/model-selector.ts +125 -29
  62. package/src/modes/components/oauth-selector.ts +28 -12
  63. package/src/modes/components/session-observer-overlay.ts +13 -15
  64. package/src/modes/components/session-selector.ts +24 -13
  65. package/src/modes/components/tool-execution.ts +27 -13
  66. package/src/modes/components/tree-selector.ts +19 -7
  67. package/src/modes/components/user-message-selector.ts +25 -14
  68. package/src/modes/controllers/command-controller.ts +0 -116
  69. package/src/modes/controllers/event-controller.ts +26 -10
  70. package/src/modes/controllers/selector-controller.ts +38 -1
  71. package/src/modes/interactive-mode.ts +4 -4
  72. package/src/modes/theme/theme.ts +46 -10
  73. package/src/modes/types.ts +1 -1
  74. package/src/modes/utils/copy-targets.ts +254 -0
  75. package/src/prompts/tools/ast-edit.md +1 -1
  76. package/src/prompts/tools/ast-grep.md +1 -1
  77. package/src/prompts/tools/read.md +1 -1
  78. package/src/prompts/tools/search.md +1 -1
  79. package/src/session/agent-session.ts +6 -2
  80. package/src/slash-commands/builtin-registry.ts +3 -11
  81. package/src/task/render.ts +38 -11
  82. package/src/tools/bash.ts +18 -8
  83. package/src/tools/browser/render.ts +5 -4
  84. package/src/tools/debug.ts +3 -3
  85. package/src/tools/eval-render.ts +24 -9
  86. package/src/tools/eval.ts +14 -19
  87. package/src/tools/fetch.ts +5 -5
  88. package/src/tools/read.ts +7 -7
  89. package/src/tools/render-utils.ts +46 -0
  90. package/src/tools/ssh.ts +21 -8
  91. package/src/tools/write.ts +17 -8
  92. package/src/tui/code-cell.ts +19 -4
  93. package/src/tui/output-block.ts +14 -0
  94. package/src/web/search/render.ts +3 -3
  95. package/dist/types/eval/heartbeat.d.ts +0 -45
  96. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  97. package/src/eval/heartbeat.ts +0 -74
  98. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
@@ -0,0 +1,254 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import type { ToolCall } from "@oh-my-pi/pi-ai";
3
+
4
+ /** A fenced code block extracted from assistant markdown. */
5
+ export interface CodeBlock {
6
+ /** Info string after the opening fence (language id), trimmed. */
7
+ lang: string;
8
+ /** Block body with the trailing newline stripped. */
9
+ code: string;
10
+ }
11
+
12
+ /** A runnable command found in the transcript. */
13
+ export interface LastCommand {
14
+ kind: "bash" | "eval";
15
+ code: string;
16
+ /** Highlight language: "bash" for bash, "python"/"javascript" for eval. */
17
+ language: string;
18
+ }
19
+
20
+ /**
21
+ * A node in the `/copy` picker tree. Leaves carry `content` (placed on the
22
+ * clipboard) plus `copyMessage` (the status shown afterwards); groups carry
23
+ * `children` to drill into.
24
+ */
25
+ export interface CopyTarget {
26
+ /** Stable identifier (e.g. "msg:1", "msg:1:code:0", "msg:1:all", "cmd:1"). */
27
+ id: string;
28
+ label: string;
29
+ /** Dim annotation: line/block counts, language, or tool name. */
30
+ hint?: string;
31
+ /** Full text rendered in the preview pane. */
32
+ preview: string;
33
+ /** Highlight language for code/command previews (undefined = plain/markdown). */
34
+ language?: string;
35
+ /** Leaf: text copied to the clipboard. */
36
+ content?: string;
37
+ /** Leaf: status message shown after copying. */
38
+ copyMessage?: string;
39
+ /** Group: nested targets to drill into. */
40
+ children?: CopyTarget[];
41
+ }
42
+
43
+ /** Minimal session surface needed to assemble copy targets (eases testing). */
44
+ export interface CopySource {
45
+ readonly messages: readonly AgentMessage[];
46
+ getLastVisibleHandoffText(): string | undefined;
47
+ }
48
+
49
+ /** Cap on how many recent assistant messages the picker lists. */
50
+ const MAX_MESSAGES = 50;
51
+
52
+ const CODE_BLOCK_RE = /^```([^\n]*)\n([\s\S]*?)^```/gm;
53
+
54
+ /** Extract fenced code blocks from assistant markdown, in document order. */
55
+ export function extractCodeBlocks(text: string): CodeBlock[] {
56
+ const blocks: CodeBlock[] = [];
57
+ for (const match of text.matchAll(CODE_BLOCK_RE)) {
58
+ blocks.push({ lang: match[1].trim(), code: match[2].replace(/\n$/, "") });
59
+ }
60
+ return blocks;
61
+ }
62
+
63
+ function extractEvalCode(args: unknown): { code: string; language: string } | undefined {
64
+ if (!args || typeof args !== "object") return undefined;
65
+ const cells = (args as { cells?: unknown }).cells;
66
+ if (!Array.isArray(cells)) return undefined;
67
+
68
+ const codeBlocks: string[] = [];
69
+ let language = "python";
70
+ let languageResolved = false;
71
+ for (const cell of cells) {
72
+ if (!cell || typeof cell !== "object") continue;
73
+ const code = (cell as { code?: unknown }).code;
74
+ if (typeof code !== "string" || code.length === 0) continue;
75
+ codeBlocks.push(code);
76
+ if (!languageResolved) {
77
+ language = (cell as { language?: unknown }).language === "js" ? "javascript" : "python";
78
+ languageResolved = true;
79
+ }
80
+ }
81
+
82
+ return codeBlocks.length > 0 ? { code: codeBlocks.join("\n\n"), language } : undefined;
83
+ }
84
+
85
+ function commandFromToolCall(tc: ToolCall): LastCommand | undefined {
86
+ if (tc.name === "bash" && typeof tc.arguments.command === "string") {
87
+ return { kind: "bash", code: tc.arguments.command, language: "bash" };
88
+ }
89
+ if (tc.name === "eval") {
90
+ const evalResult = extractEvalCode(tc.arguments);
91
+ if (evalResult) return { kind: "eval", code: evalResult.code, language: evalResult.language };
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /** Walk the transcript backwards for the most recent bash command or eval code. */
97
+ export function extractLastCommand(messages: readonly AgentMessage[]): LastCommand | undefined {
98
+ for (let i = messages.length - 1; i >= 0; i--) {
99
+ const msg = messages[i];
100
+ if (msg.role !== "assistant") continue;
101
+ const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
102
+ for (let j = toolCalls.length - 1; j >= 0; j--) {
103
+ const command = commandFromToolCall(toolCalls[j]!);
104
+ if (command) return command;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /** Concatenated visible text of an assistant message, or undefined when empty. */
111
+ function assistantText(msg: AgentMessage): string | undefined {
112
+ if (msg.role !== "assistant") return undefined;
113
+ let text = "";
114
+ for (const content of msg.content) {
115
+ if (content.type === "text") text += content.text;
116
+ }
117
+ return text.trim() || undefined;
118
+ }
119
+
120
+ function pluralLines(text: string): string {
121
+ const count = text.length === 0 ? 0 : text.split("\n").length;
122
+ return `${count} line${count === 1 ? "" : "s"}`;
123
+ }
124
+
125
+ function blockHint(block: CodeBlock): string {
126
+ const lines = pluralLines(block.code);
127
+ return block.lang ? `${block.lang} · ${lines}` : lines;
128
+ }
129
+
130
+ /** First non-empty line, whitespace-collapsed, used as a message label. */
131
+ function firstLine(text: string): string {
132
+ for (const line of text.split("\n")) {
133
+ const trimmed = line.trim();
134
+ if (trimmed) return trimmed.replace(/\s+/g, " ");
135
+ }
136
+ return text.trim().replace(/\s+/g, " ");
137
+ }
138
+
139
+ /** Build the target node for one assistant message: a leaf when it has no code
140
+ * blocks, otherwise a group exposing the full message, each block, and "all". */
141
+ function messageTarget(text: string, rank: number): CopyTarget {
142
+ const id = `msg:${rank}`;
143
+ const label = firstLine(text);
144
+ const blocks = extractCodeBlocks(text);
145
+ const hint = blocks.length > 0 ? `${pluralLines(text)} · ${blocks.length} code` : pluralLines(text);
146
+ const messageCopy = rank === 1 ? "Copied last message to clipboard" : "Copied message to clipboard";
147
+
148
+ if (blocks.length === 0) {
149
+ return { id, label, hint, preview: text, content: text, copyMessage: messageCopy };
150
+ }
151
+
152
+ // The message node itself copies the full message; its code blocks are
153
+ // child copy targets you can expand into.
154
+ const children: CopyTarget[] = blocks.map((block, j) => ({
155
+ id: `${id}:code:${j}`,
156
+ label: `Block ${j + 1}`,
157
+ hint: blockHint(block),
158
+ preview: block.code,
159
+ language: block.lang || undefined,
160
+ content: block.code,
161
+ copyMessage: `Copied code block ${j + 1} to clipboard`,
162
+ }));
163
+ if (blocks.length > 1) {
164
+ const combined = blocks.map(b => b.code).join("\n\n");
165
+ children.push({
166
+ id: `${id}:all`,
167
+ label: `All ${blocks.length} blocks`,
168
+ hint: pluralLines(combined),
169
+ preview: combined,
170
+ content: combined,
171
+ copyMessage: `Copied ${blocks.length} code blocks to clipboard`,
172
+ });
173
+ }
174
+
175
+ return { id, label, hint, preview: text, content: text, copyMessage: messageCopy, children };
176
+ }
177
+
178
+ function commandTitle(command: LastCommand): string {
179
+ return command.kind === "bash" ? "Bash command" : "Eval code";
180
+ }
181
+
182
+ function commandTarget(command: LastCommand, rank: number): CopyTarget {
183
+ const title = commandTitle(command);
184
+ return {
185
+ id: `cmd:${rank}`,
186
+ label: firstLine(command.code) || title,
187
+ hint: `${command.kind} · ${pluralLines(command.code)}`,
188
+ preview: command.code,
189
+ language: command.language,
190
+ content: command.code,
191
+ copyMessage: `Copied ${command.kind === "bash" ? "bash command" : "eval code"} to clipboard`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Assemble the unified `/copy` target tree: recent assistant messages
197
+ * (most recent first, each drillable into its code blocks), runnable command
198
+ * targets interleaved after the assistant message that issued them, and a
199
+ * fresh-handoff fallback when no assistant message exists yet.
200
+ */
201
+ export function buildCopyTargets(source: CopySource): CopyTarget[] {
202
+ const targets: CopyTarget[] = [];
203
+ const pendingCommands: LastCommand[] = [];
204
+ let messageRank = 0;
205
+ let commandRank = 0;
206
+
207
+ const appendCommands = (commands: readonly LastCommand[]) => {
208
+ for (const command of commands) {
209
+ commandRank += 1;
210
+ targets.push(commandTarget(command, commandRank));
211
+ }
212
+ };
213
+
214
+ for (let i = source.messages.length - 1; i >= 0 && messageRank < MAX_MESSAGES; i--) {
215
+ const msg = source.messages[i];
216
+ if (msg.role !== "assistant") continue;
217
+
218
+ const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
219
+ const commands: LastCommand[] = [];
220
+ for (let j = toolCalls.length - 1; j >= 0; j--) {
221
+ const command = commandFromToolCall(toolCalls[j]!);
222
+ if (command) commands.push(command);
223
+ }
224
+
225
+ const text = assistantText(msg);
226
+ if (!text) {
227
+ pendingCommands.push(...commands);
228
+ continue;
229
+ }
230
+
231
+ messageRank += 1;
232
+ targets.push(messageTarget(text, messageRank));
233
+ appendCommands(pendingCommands);
234
+ appendCommands(commands);
235
+ pendingCommands.length = 0;
236
+ }
237
+
238
+ if (messageRank === 0) {
239
+ const handoff = source.getLastVisibleHandoffText();
240
+ if (handoff) {
241
+ targets.unshift({
242
+ id: "handoff",
243
+ label: "Handoff context",
244
+ hint: pluralLines(handoff),
245
+ preview: handoff,
246
+ content: handoff,
247
+ copyMessage: "Copied handoff context to clipboard",
248
+ });
249
+ }
250
+ appendCommands(pendingCommands);
251
+ }
252
+
253
+ return targets;
254
+ }
@@ -14,7 +14,7 @@ Performs structural AST-aware rewrites via native ast-grep.
14
14
  </instruction>
15
15
 
16
16
  <output>
17
- - Replacement summary, per-file replacement counts, and change diffs as src/foo.ts#0a`, `-12:before`, `+12:after` lines in hashline mode
17
+ - Replacement summary, per-file replacement counts, and change diffs as `[src/foo.ts#1A2B]`, `-12:before`, `+12:after` lines in hashline mode
18
18
  - Parse issues when files cannot be processed
19
19
  </output>
20
20
 
@@ -18,7 +18,7 @@ Performs structural code search using AST matching via native ast-grep.
18
18
 
19
19
  <output>
20
20
  - Grouped matches with file path, byte range, line/column ranges, metavariable captures
21
- - Match lines are numbered under a file snapshot tag header in hashline mode: src/foo.ts#0a`, `*42:content` for the matched line, ` 43:content` for context
21
+ - Match lines are numbered under a file snapshot tag header in hashline mode: `[src/foo.ts#1A2B]`, `*42:content` for the matched line, ` 43:content` for context
22
22
  - Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present
23
23
  </output>
24
24
 
@@ -28,7 +28,7 @@ Append `:<sel>` to `path`. The bare path falls back to the default mode.
28
28
 
29
29
  - Reading a directory path returns a depth-limited dirent listing.
30
30
  {{#if IS_HL_MODE}}
31
- - Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: src/foo.ts#0a` then `41:def alpha():`. Copy the PATH#TAG` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
31
+ - Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy the `[PATH#TAG]` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
32
32
  {{else}}
33
33
  {{#if IS_LINE_NUMBER_MODE}}
34
34
  - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`.
@@ -9,7 +9,7 @@ Searches files using powerful regex matching.
9
9
 
10
10
  <output>
11
11
  {{#if IS_HL_MODE}}
12
- - Text output emits a file snapshot tag header per matched file plus numbered lines: src/login.ts#1f`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
12
+ - Text output emits a file snapshot tag header per matched file plus numbered lines: `[src/login.ts#1A2B]`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
13
13
  {{else}}
14
14
  {{#if IS_LINE_NUMBER_MODE}}
15
15
  - Text output is line-number-prefixed
@@ -91,6 +91,7 @@ import {
91
91
  extractRetryHint,
92
92
  getAgentDbPath,
93
93
  getInstallId,
94
+ isBunTestRuntime,
94
95
  isEnoent,
95
96
  isUnexpectedSocketCloseMessage,
96
97
  logger,
@@ -1036,6 +1037,7 @@ export class AgentSession {
1036
1037
 
1037
1038
  #acquirePowerAssertion(): void {
1038
1039
  if (process.platform !== "darwin") return;
1040
+ if (isBunTestRuntime()) return;
1039
1041
  if (this.#powerAssertion) return;
1040
1042
  const idle = this.settings.get("power.preventIdleSleep");
1041
1043
  const system = this.settings.get("power.preventSystemSleep");
@@ -8136,8 +8138,10 @@ export class AgentSession {
8136
8138
 
8137
8139
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
8138
8140
  if (!switchedCredential && currentSelector) {
8139
- this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8140
- switchedModel = await this.#tryRetryModelFallback(currentSelector);
8141
+ if (retrySettings.modelFallback) {
8142
+ this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8143
+ switchedModel = await this.#tryRetryModelFallback(currentSelector);
8144
+ }
8141
8145
  if (switchedModel) {
8142
8146
  delayMs = 0;
8143
8147
  } else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
@@ -392,17 +392,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
392
392
  },
393
393
  {
394
394
  name: "copy",
395
- description: "Copy last agent message to clipboard",
396
- subcommands: [
397
- { name: "last", description: "Copy full last agent message" },
398
- { name: "code", description: "Copy last code block" },
399
- { name: "all", description: "Copy all code blocks from last message" },
400
- { name: "cmd", description: "Copy last bash/python command" },
401
- ],
402
- allowArgs: true,
403
- handleTui: async (command, runtime) => {
404
- const sub = command.args.trim().toLowerCase() || undefined;
405
- await runtime.ctx.handleCopyCommand(sub);
395
+ description: "Pick text or code from the conversation to copy",
396
+ handleTui: (_command, runtime) => {
397
+ runtime.ctx.showCopySelector();
406
398
  runtime.ctx.editor.setText("");
407
399
  },
408
400
  },
@@ -13,6 +13,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
14
14
  import type { Theme } from "../modes/theme/theme";
15
15
  import {
16
+ capPreviewLines,
16
17
  formatBadge,
17
18
  formatDuration,
18
19
  formatMoreItems,
@@ -117,6 +118,26 @@ function normalizeReportFindings(value: unknown): ReportFindingDetails[] {
117
118
  return findings;
118
119
  }
119
120
 
121
+ /**
122
+ * Normalize the `yield` slot of `extractedToolData` into an array of
123
+ * yield-detail records. The subprocess executor always populates this slot as
124
+ * `unknown[]` (see `executor.ts` `extractData` handler), but the renderer
125
+ * MUST also tolerate a stray single object — optional chaining short-circuits
126
+ * on `null`/`undefined` only, so calling `.map` on a plain object would throw
127
+ * `TypeError: completeData?.map is not a function` and crash the TUI.
128
+ * A single object is wrapped as a 1-element array so the review verdict still
129
+ * renders; non-object primitives drop out.
130
+ */
131
+ function normalizeYieldData(value: unknown): Array<{ data: unknown }> {
132
+ if (Array.isArray(value)) {
133
+ return value.filter((item): item is { data: unknown } => item !== null && typeof item === "object");
134
+ }
135
+ if (value !== null && typeof value === "object") {
136
+ return [value as { data: unknown }];
137
+ }
138
+ return [];
139
+ }
140
+
120
141
  function formatJsonScalar(value: unknown, _theme: Theme): string {
121
142
  if (value === null) return "null";
122
143
  if (typeof value === "string") {
@@ -541,10 +562,11 @@ export function renderCall(
541
562
 
542
563
  if (hasContext) {
543
564
  lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
544
- for (const line of context.split("\n")) {
565
+ const contextLines = context.split("\n").map(line => {
545
566
  const content = line ? theme.fg("muted", replaceTabs(line)) : "";
546
- lines.push(` ${vertical} ${content}`);
547
- }
567
+ return ` ${vertical} ${content}`;
568
+ });
569
+ lines.push(...capPreviewLines(contextLines, theme, { expanded: options.expanded, prefix: ` ${vertical} ` }));
548
570
  }
549
571
 
550
572
  // `Tasks` is the last child unless the isolation flag follows it.
@@ -671,12 +693,12 @@ function renderAgentProgress(
671
693
  if (progress.extractedToolData) {
672
694
  // For completed tasks, check for review verdict from yield tool
673
695
  if (progress.status === "completed") {
674
- const completeData = progress.extractedToolData.yield as Array<{ data: unknown }> | undefined;
696
+ const completeData = normalizeYieldData(progress.extractedToolData.yield);
675
697
  const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding);
676
698
  const reviewData = completeData
677
- ?.map(c => c.data as SubmitReviewDetails)
699
+ .map(c => c.data as SubmitReviewDetails)
678
700
  .filter(d => d && typeof d === "object" && "overall_correctness" in d);
679
- if (reviewData && reviewData.length > 0) {
701
+ if (reviewData.length > 0) {
680
702
  const summary = reviewData[reviewData.length - 1];
681
703
  const findings = reportFindingData;
682
704
  lines.push(...renderReviewResult(summary, findings, continuePrefix, expanded, theme));
@@ -912,16 +934,21 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
912
934
  );
913
935
  }
914
936
  // Check for review result (yield with review schema + report_finding)
915
- const completeData = result.extractedToolData?.yield as Array<{ data: unknown }> | undefined;
937
+ // Check for review result (yield with review schema + report_finding).
938
+ // `normalizeYieldData` guards against a stray non-array `yield` slot —
939
+ // optional chaining on `.map` only short-circuits on null/undefined and
940
+ // would otherwise crash the renderer with `TypeError: completeData?.map
941
+ // is not a function` when the slot is a plain object (see issue #1987).
942
+ const completeData = normalizeYieldData(result.extractedToolData?.yield);
916
943
  const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
917
944
 
918
945
  // Extract review verdict from yield tool's data field if it matches SubmitReviewDetails
919
946
  const reviewData = completeData
920
- ?.map(c => c.data as SubmitReviewDetails)
947
+ .map(c => c.data as SubmitReviewDetails)
921
948
  .filter(d => d && typeof d === "object" && "overall_correctness" in d);
922
- const submitReviewData = reviewData && reviewData.length > 0 ? reviewData : undefined;
949
+ const submitReviewData = reviewData.length > 0 ? reviewData : undefined;
923
950
 
924
- if (submitReviewData && submitReviewData.length > 0) {
951
+ if (submitReviewData) {
925
952
  // Use combined review renderer
926
953
  const summary = submitReviewData[submitReviewData.length - 1];
927
954
  const findings = reportFindingData;
@@ -929,7 +956,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
929
956
  return lines;
930
957
  }
931
958
  if (reportFindingData.length > 0) {
932
- const hasCompleteData = completeData && completeData.length > 0;
959
+ const hasCompleteData = completeData.length > 0;
933
960
  const message = hasCompleteData
934
961
  ? "Review verdict missing expected fields"
935
962
  : "Review incomplete (yield not called)";
package/src/tools/bash.ts CHANGED
@@ -20,7 +20,7 @@ import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
20
20
  import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
21
21
  import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
22
22
  import { renderStatusLine } from "../tui";
23
- import { CachedOutputBlock } from "../tui/output-block";
23
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
24
24
  import { getSixelLineMask } from "../utils/sixel";
25
25
  import type { ToolSession } from ".";
26
26
  import { truncateForPrompt } from "./approval";
@@ -31,7 +31,7 @@ import { canUseInteractiveBashPty } from "./bash-pty-selection";
31
31
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
32
32
  import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
33
33
  import { resolveToCwd } from "./path-utils";
34
- import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
34
+ import { capPreviewLines, formatToolWorkingDirectory, replaceTabs } from "./render-utils";
35
35
  import { ToolAbortError, ToolError } from "./tool-errors";
36
36
  import { toolResult } from "./tool-result";
37
37
  import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
@@ -1083,16 +1083,22 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1083
1083
  const cmdLines = formatBashCommandLines(renderArgs, uiTheme);
1084
1084
  const header = renderStatusLine({ icon: "pending", title }, uiTheme);
1085
1085
  const outputBlock = new CachedOutputBlock();
1086
- return {
1086
+ return markFramedBlockComponent({
1087
1087
  render: (width: number): string[] =>
1088
1088
  outputBlock.render(
1089
- { header, state: "pending", sections: [{ lines: cmdLines }], width, animate: true },
1089
+ {
1090
+ header,
1091
+ state: "pending",
1092
+ sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }],
1093
+ width,
1094
+ animate: true,
1095
+ },
1090
1096
  uiTheme,
1091
1097
  ),
1092
1098
  invalidate: () => {
1093
1099
  outputBlock.invalidate();
1094
1100
  },
1095
- };
1101
+ });
1096
1102
  },
1097
1103
 
1098
1104
  renderResult(
@@ -1114,7 +1120,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1114
1120
  const details = result.details;
1115
1121
  const outputBlock = new CachedOutputBlock();
1116
1122
 
1117
- return {
1123
+ return markFramedBlockComponent({
1118
1124
  render: (width: number): string[] => {
1119
1125
  // REACTIVE: read mutable options at render time
1120
1126
  const { renderContext } = options;
@@ -1201,7 +1207,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1201
1207
  header,
1202
1208
  state: options.isPartial ? "pending" : isError ? "error" : "success",
1203
1209
  sections: [
1204
- { lines: cmdLines ?? [] },
1210
+ {
1211
+ lines: options.isPartial
1212
+ ? capPreviewLines(cmdLines ?? [], uiTheme, { expanded })
1213
+ : (cmdLines ?? []),
1214
+ },
1205
1215
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
1206
1216
  ],
1207
1217
  width,
@@ -1213,7 +1223,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1213
1223
  invalidate: () => {
1214
1224
  outputBlock.invalidate();
1215
1225
  },
1216
- };
1226
+ });
1217
1227
  },
1218
1228
  mergeCallAndResult: true,
1219
1229
  inline: true,
@@ -9,7 +9,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../../modes/theme/theme";
12
- import { Hasher, renderCodeCell, renderStatusLine } from "../../tui";
12
+ import { Hasher, isFramedBlockComponent, markFramedBlockComponent, renderCodeCell, renderStatusLine } from "../../tui";
13
13
  import type { BrowserToolDetails } from "../browser";
14
14
  import { formatStyledTruncationWarning, stripOutputNotice } from "../output-meta";
15
15
  import { replaceTabs, shortenPath } from "../render-utils";
@@ -65,13 +65,14 @@ function dropTrailingBlankLines(text: string): string {
65
65
 
66
66
  function appendLine(component: Component, line: string | undefined): Component {
67
67
  if (!line) return component;
68
- return {
68
+ const wrapped = {
69
69
  render: (width: number): string[] => {
70
70
  const base = component.render(width);
71
71
  return [...base, line];
72
72
  },
73
73
  invalidate: () => component.invalidate?.(),
74
74
  };
75
+ return isFramedBlockComponent(component) ? markFramedBlockComponent(wrapped) : wrapped;
75
76
  }
76
77
 
77
78
  function renderRunCell(
@@ -93,7 +94,7 @@ function renderRunCell(
93
94
  const title = titleParts.join(" · ");
94
95
 
95
96
  let cached: { key: bigint; width: number; lines: string[] } | undefined;
96
- return {
97
+ return markFramedBlockComponent({
97
98
  render: (width: number): string[] => {
98
99
  const expanded = options.renderContext?.expanded ?? options.expanded;
99
100
  const previewLines = options.renderContext?.previewLines ?? BROWSER_DEFAULT_PREVIEW_LINES;
@@ -131,7 +132,7 @@ function renderRunCell(
131
132
  invalidate: () => {
132
133
  cached = undefined;
133
134
  },
134
- };
135
+ });
135
136
  }
136
137
 
137
138
  function renderOpenOrCloseLine(
@@ -36,7 +36,7 @@ import {
36
36
  import type { Theme } from "../modes/theme/theme";
37
37
  import debugDescription from "../prompts/tools/debug.md" with { type: "text" };
38
38
  import { renderStatusLine } from "../tui";
39
- import { CachedOutputBlock } from "../tui/output-block";
39
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
40
40
  import type { ToolSession } from ".";
41
41
  import { truncateForPrompt } from "./approval";
42
42
  import type { OutputMeta } from "./output-meta";
@@ -581,7 +581,7 @@ export const debugToolRenderer = {
581
581
  args?: DebugRenderArgs,
582
582
  ): Component {
583
583
  const outputBlock = new CachedOutputBlock();
584
- return {
584
+ return markFramedBlockComponent({
585
585
  render(width: number): string[] {
586
586
  const action = (args?.action ?? result.details?.action ?? "debug").replaceAll("_", " ");
587
587
  const status = options.isPartial ? "running" : result.isError ? "error" : "success";
@@ -620,7 +620,7 @@ export const debugToolRenderer = {
620
620
  invalidate() {
621
621
  outputBlock.invalidate();
622
622
  },
623
- };
623
+ });
624
624
  },
625
625
  mergeCallAndResult: true,
626
626
  inline: true,
@@ -18,7 +18,7 @@ import { formatContextUsage } from "../modes/components/status-line/context-thre
18
18
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
19
19
  import { shimmerEnabled } from "../modes/theme/shimmer";
20
20
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
21
- import { borderShimmerTick, renderCodeCell } from "../tui";
21
+ import { borderShimmerTick, markFramedBlockComponent, renderCodeCell } from "../tui";
22
22
  import {
23
23
  JSON_TREE_MAX_DEPTH_COLLAPSED,
24
24
  JSON_TREE_MAX_DEPTH_EXPANDED,
@@ -39,8 +39,15 @@ import {
39
39
  truncateToWidth,
40
40
  wrapBrackets,
41
41
  } from "./render-utils";
42
-
43
42
  export const EVAL_DEFAULT_PREVIEW_LINES = 10;
43
+ /**
44
+ * Rows of source kept in the *pending* eval preview. The window follows the
45
+ * streaming edge (newest lines pinned to the bottom) so you can watch the code
46
+ * being written, while staying bounded — a volatile tool block taller than the
47
+ * viewport would otherwise strand its scrolled-off head out of native scrollback
48
+ * on ED3-risk terminals. Matches the streaming windows used by edit/write.
49
+ */
50
+ export const EVAL_STREAMING_PREVIEW_LINES = 12;
44
51
 
45
52
  function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" {
46
53
  return language === "js" ? "javascript" : "python";
@@ -490,10 +497,10 @@ export const evalToolRenderer = {
490
497
 
491
498
  let cached: { key: string; width: number; result: string[] } | undefined;
492
499
 
493
- return {
500
+ return markFramedBlockComponent({
494
501
  render: (width: number): string[] => {
495
502
  const animate = options.isPartial && shimmerEnabled();
496
- const key = `${animate ? borderShimmerTick() : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
503
+ const key = `${animate ? borderShimmerTick() : 0}|${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
497
504
  if (cached && cached.key === key && cached.width === width) {
498
505
  return cached.result;
499
506
  }
@@ -510,8 +517,16 @@ export const evalToolRenderer = {
510
517
  title: cell.title,
511
518
  status: "pending",
512
519
  width,
513
- codeMaxLines: EVAL_DEFAULT_PREVIEW_LINES,
514
- expanded: true,
520
+ codeMaxLines: EVAL_STREAMING_PREVIEW_LINES,
521
+ // Follow the streaming edge with a bounded tail window so the
522
+ // newest source stays visible as it is written, instead of
523
+ // rendering every line of a >100-line `code` — which would
524
+ // overflow the viewport and, because a tool block is volatile
525
+ // (it collapses to a capped result), strand its scrolled-off head
526
+ // out of native scrollback, cutting the box top. `Ctrl+O` lifts
527
+ // the window via `expanded` for a deliberate full view.
528
+ codeTail: true,
529
+ expanded: options.expanded,
515
530
  animate,
516
531
  },
517
532
  uiTheme,
@@ -527,7 +542,7 @@ export const evalToolRenderer = {
527
542
  invalidate: () => {
528
543
  cached = undefined;
529
544
  },
530
- };
545
+ });
531
546
  },
532
547
 
533
548
  renderResult(
@@ -571,7 +586,7 @@ export const evalToolRenderer = {
571
586
  if (cellResults && cellResults.length > 0) {
572
587
  let cached: { key: string; width: number; result: string[] } | undefined;
573
588
 
574
- return {
589
+ return markFramedBlockComponent({
575
590
  render: (width: number): string[] => {
576
591
  const expanded = options.renderContext?.expanded ?? options.expanded;
577
592
  const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
@@ -649,7 +664,7 @@ export const evalToolRenderer = {
649
664
  invalidate: () => {
650
665
  cached = undefined;
651
666
  },
652
- };
667
+ });
653
668
  }
654
669
 
655
670
  const displayOutput = output;