pi-compact-tools 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,24 @@ import {
5
5
  type ExtensionContext,
6
6
  type Theme,
7
7
  } from "@earendil-works/pi-coding-agent";
8
- import { Container, Spacer, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
9
- import { claudeFailure, lookHint, lookKind, pathOf, type LookKind } from "./compact-tools-claude.ts";
8
+ import { Container, Spacer, Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
9
+ import { CLAUDE_RESULT_PREFIX, claudeFailure, lookHint, lookKind, pathOf, type LookKind } from "./compact-tools-claude.ts";
10
+ import {
11
+ CODEX_BULLET,
12
+ CODEX_INDENT,
13
+ CODEX_MARGIN,
14
+ CODEX_PREVIEW_ROWS,
15
+ CODEX_RESULT_PREFIX,
16
+ codexDisclosure,
17
+ exploreActions,
18
+ exploreFailure,
19
+ exploreLines,
20
+ openExplorations,
21
+ paintSegments,
22
+ wrapSegments,
23
+ type ExploreAction,
24
+ type Segment,
25
+ } from "./compact-tools-codex.ts";
10
26
  import { formatDurationMs, type RowStatus } from "./compact-tools-core.ts";
11
27
  import { hookMethod } from "./compact-tools-hook.ts";
12
28
  import { getTextResult } from "./compact-tools-invocation.ts";
@@ -25,7 +41,8 @@ const PHRASES: ReadonlyArray<{ kind: LookKind; running: string; done: string; si
25
41
  { kind: "list", running: "listing", done: "listed", singular: "directory", plural: "directories" },
26
42
  ];
27
43
 
28
- export const EXPAND_HINT = "(ctrl+o to expand)";
44
+ /** A click opens one group; Ctrl+O opens every row and its output, which is rarely what a reader wants here. */
45
+ export const EXPAND_HINT = "(click to expand)";
29
46
 
30
47
  /** The fields of Pi's tool row a group summary reads. */
31
48
  export interface ToolRowLike {
@@ -38,6 +55,7 @@ export interface ToolRowLike {
38
55
  render(width: number): string[];
39
56
  handleMouse?(event: unknown): unknown;
40
57
  invalidate(): void;
58
+ setExpanded?(expanded: boolean): void;
41
59
  }
42
60
 
43
61
  /** Whether a call without its result can still get one; the row itself answers the same way. */
@@ -71,6 +89,17 @@ function kindOf(row: ToolRowLike): LookKind | undefined {
71
89
  return kind;
72
90
  }
73
91
 
92
+ // Codex parses a command into what it does, once for each set of arguments as they stream in.
93
+ const explorations = new WeakMap<object, { args: unknown; actions: ExploreAction[] | undefined }>();
94
+
95
+ function exploreOf(row: ToolRowLike): ExploreAction[] | undefined {
96
+ const cached = explorations.get(row);
97
+ if (cached && cached.args === row.args) return cached.actions;
98
+ const actions = exploreActions(row.toolName, argsOf(row));
99
+ explorations.set(row, { args: row.args, actions });
100
+ return actions;
101
+ }
102
+
74
103
  function lastRunning(rows: readonly ToolRowLike[], canRun: CanRun): ToolRowLike | undefined {
75
104
  for (let index = rows.length - 1; index >= 0; index--) {
76
105
  if (memberStatus(rows[index]!, canRun) === "running") return rows[index];
@@ -149,7 +178,7 @@ type GroupState = { expanded: boolean; lastHostExpanded: boolean; header?: Heade
149
178
  type Paint = { theme: Theme; chrome: (text: string) => string; key: string };
150
179
 
151
180
  /** Theme colors a group line uses; a theme that changes any of them draws the lines anew. */
152
- const PAINT_COLORS = ["toolTitle", "error", "success", "muted", "dim", "text", "borderMuted"] as const;
181
+ const PAINT_COLORS = ["toolTitle", "error", "success", "muted", "dim", "text", "borderMuted", "accent"] as const;
153
182
 
154
183
  function headerStill(cache: HeaderCache, rows: readonly ToolRowLike[], paint: string, width: number, expanded: boolean): boolean {
155
184
  if (cache.paint !== paint || cache.width !== width || cache.expanded !== expanded || cache.rows.length !== rows.length) return false;
@@ -165,7 +194,9 @@ export class ToolGroupComponent extends Container {
165
194
  members: readonly unknown[],
166
195
  private readonly state: GroupState,
167
196
  private readonly draw: (rows: readonly ToolRowLike[], width: number, expanded: boolean) => string[],
168
- private readonly onToggle: () => void,
197
+ private readonly onToggle: (expanded: boolean) => void,
198
+ /** Lines beneath an opened group's members, which a click closes too. */
199
+ footer?: (width: number) => string[],
169
200
  ) {
170
201
  super();
171
202
  // Ctrl+O expands every tool row in Pi; the group follows it both ways.
@@ -174,24 +205,26 @@ export class ToolGroupComponent extends Container {
174
205
  state.lastHostExpanded = hostExpanded;
175
206
  state.expanded = hostExpanded;
176
207
  }
177
- const header: Component = {
178
- render: (width) => this.draw(this.rows, width, this.state.expanded),
179
- invalidate() {},
180
- handleMouse: (event: { type?: string; button?: string }) => {
181
- if (event.type !== "click" || event.button !== "left") return undefined;
182
- captureViewport();
183
- this.state.expanded = !this.state.expanded;
184
- this.onToggle();
185
- return { handled: true };
186
- },
187
- } as Component;
188
- this.addChild(header);
189
- if (state.expanded) for (const member of members) this.addChild(member as Component);
208
+ const handleMouse = (event: { type?: string; button?: string }) => {
209
+ if (event.type !== "click" || event.button !== "left") return undefined;
210
+ captureViewport();
211
+ this.state.expanded = !this.state.expanded;
212
+ this.onToggle(this.state.expanded);
213
+ return { handled: true };
214
+ };
215
+ this.addChild({ render: (width) => this.draw(this.rows, width, this.state.expanded), invalidate() {}, handleMouse } as Component);
216
+ if (!state.expanded) return;
217
+ for (const member of members) this.addChild(member as Component);
218
+ if (footer) this.addChild({ render: footer, invalidate() {}, handleMouse } as Component);
190
219
  }
191
220
  }
192
221
 
193
- function isGroupable(child: unknown): child is ToolRowLike {
194
- return isKind(child, ToolExecutionComponent) && kindOf(child as ToolRowLike) !== undefined;
222
+ /** Which rows fold into a group: what Claude Code counts as looking around, or what Codex parses as exploring. */
223
+ export type GroupStyle = "claude" | "codex";
224
+
225
+ function isGroupable(child: unknown, style: GroupStyle = "claude"): child is ToolRowLike {
226
+ if (!isKind(child, ToolExecutionComponent)) return false;
227
+ return style === "codex" ? exploreOf(child as ToolRowLike) !== undefined : kindOf(child as ToolRowLike) !== undefined;
195
228
  }
196
229
 
197
230
  type AssistantLike = { lastMessage?: { content?: Array<{ type: string; text?: string }> } };
@@ -219,6 +252,7 @@ function isFoldable(child: unknown): boolean {
219
252
  export function groupChildren(
220
253
  children: readonly unknown[],
221
254
  makeGroup: (rows: ToolRowLike[], members: unknown[]) => Component,
255
+ style: GroupStyle = "claude",
222
256
  ): unknown[] {
223
257
  const grouped: unknown[] = [];
224
258
  let rows: ToolRowLike[] = [];
@@ -230,7 +264,7 @@ export function groupChildren(
230
264
  members = [];
231
265
  };
232
266
  for (const child of children) {
233
- if (isGroupable(child)) {
267
+ if (isGroupable(child, style)) {
234
268
  rows.push(child);
235
269
  members.push(...pending, child);
236
270
  pending = [];
@@ -249,7 +283,7 @@ export function groupChildren(
249
283
  return grouped;
250
284
  }
251
285
 
252
- /** Claude style: runs of reads, searches, and listings fold into one line each. */
286
+ /** Claude and Codex styles: runs of reads, searches, and listings fold into one group each. */
253
287
  export class ToolGroupController {
254
288
  private context: ExtensionContext | undefined;
255
289
  private readonly states = new WeakMap<object, GroupState>();
@@ -268,10 +302,12 @@ export class ToolGroupController {
268
302
  const chat = findChat(tui);
269
303
  if (chat) {
270
304
  hookMethod(chat, "render", "claude.groups", (self, args, original) => {
271
- if (!this.context || isSilent() || this.runtime.config.style !== "claude") return original.apply(self, args);
305
+ const style = this.runtime.config.style;
306
+ if (!this.context || isSilent() || (style !== "claude" && style !== "codex")) return original.apply(self, args);
272
307
  const children = self.children as unknown[];
273
308
  this.paint = this.resolvePaint();
274
- self.children = groupChildren(children, (rows, members) => this.makeGroup(rows, members, tui));
309
+ openExplorations.clear();
310
+ self.children = groupChildren(children, (rows, members) => this.makeGroup(rows, members, tui), style);
275
311
  try {
276
312
  return original.apply(self, args);
277
313
  } finally {
@@ -297,15 +333,35 @@ export class ToolGroupController {
297
333
  this.states.set(key, state);
298
334
  }
299
335
  const groupState = state;
300
- return new ToolGroupComponent(rows, members, state,
301
- (group, width, expanded) => this.drawCached(group, width, expanded, groupState), () => tui.requestRender());
336
+ const draw = (group: readonly ToolRowLike[], width: number, expanded: boolean) => this.drawCached(group, width, expanded, groupState);
337
+ if (this.runtime.config.style !== "codex") return new ToolGroupComponent(rows, members, state, draw, () => tui.requestRender());
338
+ // Codex opens a group to the whole of each call, and closes it from one "− Show less" beneath them.
339
+ const toggle = (expanded: boolean) => {
340
+ for (const row of rows) row.setExpanded?.(expanded);
341
+ tui.requestRender();
342
+ };
343
+ const footer = (width: number) => {
344
+ const theme = this.paint?.theme ?? this.context?.ui.theme;
345
+ return theme ? [truncateToWidth(theme.fg("dim", codexDisclosure(width, true)), Math.max(1, width), "…")] : [];
346
+ };
347
+ const group = new ToolGroupComponent(rows, members, state, draw, toggle, footer);
348
+ // Read once the group has followed Ctrl+O, which it does as it is made. A call that joins an
349
+ // opened group opens with it, as every call in an opened Codex group shows whole.
350
+ if (state.expanded) {
351
+ for (const row of rows) {
352
+ openExplorations.add(row.toolCallId);
353
+ if (!row.expanded) row.setExpanded?.(true);
354
+ }
355
+ }
356
+ return group;
302
357
  }
303
358
 
304
359
  private resolvePaint(): Paint | undefined {
305
360
  const theme = this.context?.ui.theme;
306
361
  if (!theme) return undefined;
307
362
  const chrome = chromePainter(theme);
308
- const key = PAINT_COLORS.map((color) => theme.fg(color, "x")).join("") + theme.bold("x") + chrome("x");
363
+ // The style is part of the key: a group drawn in one style is never reused in another.
364
+ const key = this.runtime.config.style + PAINT_COLORS.map((color) => theme.fg(color, "x")).join("") + theme.bold("x") + chrome("x");
309
365
  return { theme, chrome, key };
310
366
  }
311
367
 
@@ -338,6 +394,7 @@ export class ToolGroupController {
338
394
  * looks at.
339
395
  */
340
396
  private draw(rows: readonly ToolRowLike[], width: number, expanded: boolean, paint: Paint): string[] {
397
+ if (this.runtime.config.style === "codex") return this.drawCodex(rows, width, expanded, paint);
341
398
  const { theme, chrome } = paint;
342
399
  const fit = (line: string) => truncateToWidth(line, Math.max(1, width), "…");
343
400
  const running = lastRunning(rows, this.canRun);
@@ -346,7 +403,7 @@ export class ToolGroupController {
346
403
  const status: RowStatus = running ? "running" : statuses.includes("error") ? "error"
347
404
  : statuses.includes("pending") ? "pending" : "success";
348
405
  const summary = summarizeGroup(rows, undefined, this.canRun);
349
- let title = `${paintIndicator(theme, status, this.runtime.frame)} ${theme.fg("toolTitle", theme.bold(summary))}`;
406
+ let title = ` ${paintIndicator(theme, status, this.runtime.frame)} ${theme.fg("toolTitle", theme.bold(summary))}`;
350
407
  if (running) {
351
408
  // The folded row is not drawn, so the line that pulses for it keeps the clock going.
352
409
  this.runtime.keepAnimating(running.toolCallId);
@@ -355,10 +412,52 @@ export class ToolGroupController {
355
412
  title += `${elapsed >= 2000 ? chrome(` · ${formatDurationMs(elapsed)}`) : ""}…`;
356
413
  }
357
414
  if (!expanded) title += chrome(` ${EXPAND_HINT}`);
358
- const lines = ["", fit(` ${title}`)];
415
+ const lines = ["", fit(title)];
359
416
  const hintText = groupHint(rows, this.canRun);
360
- if (hintText) lines.push(fit(chrome(` └ ${hintText}`)));
361
- if (!expanded) for (const failure of groupFailures(rows)) lines.push(fit(chrome(" └ ") + theme.fg("error", failure)));
417
+ if (hintText) lines.push(fit(chrome(`${CLAUDE_RESULT_PREFIX}${hintText}`)));
418
+ if (!expanded) for (const failure of groupFailures(rows)) lines.push(fit(chrome(CLAUDE_RESULT_PREFIX) + theme.fg("error", failure)));
419
+ return lines;
420
+ }
421
+
422
+ /**
423
+ * Codex's exploration: `• Exploring` while a call runs and `• Explored` after,
424
+ * then a line for what each call did, three rows of them until the group opens.
425
+ * A failed call says so at the end of its line and in the head's count.
426
+ */
427
+ private drawCodex(rows: readonly ToolRowLike[], width: number, expanded: boolean, paint: Paint): string[] {
428
+ const { theme } = paint;
429
+ const fit = (line: string) => truncateToWidth(line, Math.max(1, width), "…");
430
+ const running = lastRunning(rows, this.canRun);
431
+ const statuses = rows.map((row) => memberStatus(row, this.canRun));
432
+ if (running) this.runtime.keepAnimating(running.toolCallId);
433
+ const bullet = running ? paintIndicator(theme, "running", this.runtime.frame, CODEX_BULLET)
434
+ : statuses.includes("pending") ? paintIndicator(theme, "pending", 0, CODEX_BULLET) : theme.fg("dim", CODEX_BULLET);
435
+ const failed = statuses.filter((status) => status === "error").length;
436
+ let head = `${CODEX_MARGIN}${bullet} ${theme.bold(running ? "Exploring" : "Explored")}`;
437
+ if (failed > 0) head += theme.bold(theme.fg("error", ` · ${failed} failed`));
438
+ // Opened, Codex lists the calls themselves in place of the summary; each brings its own blank line.
439
+ if (expanded) return [];
440
+ const lines = ["", fit(head)];
441
+ const calls = rows.map((row) => {
442
+ const actions = exploreOf(row) ?? [];
443
+ const failure = row.result && !row.isPartial
444
+ ? exploreFailure(row.toolName, getTextResult(row.result), row.result.isError === true, actions) : undefined;
445
+ return { actions, failure };
446
+ });
447
+ const details: string[] = [];
448
+ for (const line of exploreLines(calls)) {
449
+ const verb = `${line.verb} `;
450
+ const segments: Segment[] = line.parts.map((part) => ({ text: part.text, color: part.dim ? "dim" : undefined }));
451
+ if (line.failure) segments.push({ text: line.failure.text, color: line.failure.quiet ? "dim" : "error" });
452
+ const available = Math.max(1, width - visibleWidth(CODEX_INDENT) - verb.length);
453
+ wrapSegments(segments, available, available).forEach((row, index) => {
454
+ details.push((index === 0 ? theme.fg("accent", verb) : " ".repeat(verb.length)) + paintSegments(row, theme));
455
+ });
456
+ }
457
+ details.slice(0, CODEX_PREVIEW_ROWS).forEach((row, index) => {
458
+ lines.push(fit(theme.fg("dim", index === 0 ? CODEX_RESULT_PREFIX : CODEX_INDENT) + row));
459
+ });
460
+ lines.push(fit(theme.fg("dim", codexDisclosure(width, false))));
362
461
  return lines;
363
462
  }
364
463
  }
@@ -290,9 +290,38 @@ function summarizeFileOperation(executable: string, words: string[]): string | u
290
290
  return undefined;
291
291
  }
292
292
 
293
+ /** One step split at its unquoted pipes: `cat a | tee b` is two commands, the second writing a file. */
294
+ function pipelineStages(segment: string): string[] {
295
+ const stages: string[] = [];
296
+ let current = "";
297
+ let quote: string | undefined;
298
+ for (let index = 0; index < segment.length; index++) {
299
+ const character = segment[index]!;
300
+ if (quote) {
301
+ if (character === "\\" && quote === '"' && index + 1 < segment.length) {
302
+ current += character + segment[++index];
303
+ continue;
304
+ }
305
+ if (character === quote) quote = undefined;
306
+ } else if (character === "'" || character === '"' || character === "`") {
307
+ quote = character;
308
+ } else if (character === "\\" && index + 1 < segment.length) {
309
+ current += character + segment[++index];
310
+ continue;
311
+ } else if (character === "|") {
312
+ if (current.trim()) stages.push(current.trim());
313
+ current = "";
314
+ continue;
315
+ }
316
+ current += character;
317
+ }
318
+ if (current.trim()) stages.push(current.trim());
319
+ return stages;
320
+ }
321
+
293
322
  /** A calm collapsed label that describes intent while retaining primary command targets. */
294
323
  export function summarizeShellCommand(name: string, command: string): string {
295
- const allSegments = shellSegments(normalizeLineEndings(command));
324
+ const allSegments = shellSegments(normalizeLineEndings(command)).flatMap(pipelineStages);
296
325
  const segments = allSegments.filter((segment) => !/^\s*(?:cd|pushd|popd)\b/iu.test(segment));
297
326
  const primary = segments[0] ?? allSegments[0] ?? "";
298
327
  if (!primary) return name === "powershell" ? "Prepare PowerShell command" : "Prepare shell command";
@@ -15,7 +15,7 @@ import { highlightMarkdown } from "./compact-tools-markdown.ts";
15
15
  import { paintChrome } from "./compact-tools-palette.ts";
16
16
  import type { ToolArgs } from "./compact-tools-types.ts";
17
17
 
18
- class CachedComponent implements Component {
18
+ export class CachedComponent implements Component {
19
19
  private cachedWidth?: number;
20
20
  private cachedLines?: string[];
21
21
 
@@ -160,6 +160,20 @@ export function prefixedLines(text: string, firstPrefix: string, continuationPre
160
160
  });
161
161
  }
162
162
 
163
+ /**
164
+ * A sentence under a tree prefix, wrapped at words as a terminal UI wraps prose:
165
+ * a word moves to the next row whole, and only a word longer than a row is split.
166
+ */
167
+ export function prefixedSentence(text: string, firstPrefix: string, continuationPrefix: string): Component {
168
+ const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
169
+ const lines = text.replace(/\t/g, " ").split("\n");
170
+ return new CachedComponent((width) => {
171
+ const available = Math.max(1, width - prefixWidth);
172
+ return lines.flatMap((line) => wrapTextWithAnsi(line, available).map((row) => truncateToWidth(row, available, "")))
173
+ .map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
174
+ });
175
+ }
176
+
163
177
  /**
164
178
  * Output as Claude Code previews it. Each line is wrapped to the width, and rows
165
179
  * rather than lines are counted: collapsed, `maxRows` show, all of them when only
@@ -179,7 +193,7 @@ export function claudeRows(
179
193
  const wrapWidth = maxRows === undefined ? available : Math.min(available, Math.max(width - 10, 10));
180
194
  let rows = lines.flatMap((line) => hardWrapTextWithAnsi(line, wrapWidth).map((row) => row.trimEnd()));
181
195
  if (maxRows !== undefined && rows.length > maxRows + 1) {
182
- rows = [...rows.slice(0, maxRows), more(rows.length - maxRows)];
196
+ rows = [...rows.slice(0, maxRows), truncateToWidth(more(rows.length - maxRows), available, "…")];
183
197
  }
184
198
  return rows.map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
185
199
  });
@@ -209,10 +223,14 @@ export function hardWrapTextWithAnsi(text: string, width: number): string[] {
209
223
  return wrapped;
210
224
  }
211
225
 
212
- export function renderToolCall(title: string, details: string | undefined, theme: Theme): Component {
226
+ export function renderToolCall(
227
+ title: string,
228
+ details: string | undefined,
229
+ theme: Theme,
230
+ continuationPrefix = paintChrome(theme, " │ "),
231
+ ): Component {
213
232
  const text = details ? `${title} ${details}` : title;
214
233
  const firstPrefix = " ";
215
- const continuationPrefix = paintChrome(theme, " │ ");
216
234
  const prefixWidth = visibleWidth(continuationPrefix);
217
235
  return new CachedComponent((width) => {
218
236
  const lines = hardWrapTextWithAnsi(text, width - prefixWidth);
@@ -224,12 +242,12 @@ export function styleMultiline(text: string, style: (line: string) => string): s
224
242
  return normalizeLineEndings(text).split("\n").map(style).join("\n");
225
243
  }
226
244
 
227
- export function renderOutput(output: string, theme: Theme, isError: boolean): Component | undefined {
245
+ export function renderOutput(output: string, theme: Theme, isError: boolean, rail = paintChrome(theme, " │ ")): Component | undefined {
228
246
  const normalized = normalizeLineEndings(output).trimEnd();
229
247
  if (!normalized) return undefined;
230
248
  const color = isError ? "error" : "toolOutput";
231
249
  const styled = normalized.split("\n").map((line) => theme.fg(color, line)).join("\n");
232
- return prefixedText(styled, paintChrome(theme, " │ "));
250
+ return prefixedText(styled, rail);
233
251
  }
234
252
 
235
253
  export type CodeDiffLineKind = "context" | "add" | "remove" | "separator";
@@ -249,7 +267,7 @@ export type CodeDiffOptions = {
249
267
 
250
268
  const DIFF_SEPARATOR = "\u22ee";
251
269
 
252
- function parseUnifiedPatch(patch: string): CodeDiffLine[] {
270
+ function parseUnifiedPatch(patch: string, tab: string): CodeDiffLine[] {
253
271
  const result: CodeDiffLine[] = [];
254
272
  let oldLine = 0;
255
273
  let newLine = 0;
@@ -265,7 +283,7 @@ function parseUnifiedPatch(patch: string): CodeDiffLine[] {
265
283
  }
266
284
  if (hunk < 0 || line.startsWith("\")) continue;
267
285
  const prefix = line[0];
268
- const content = line.slice(1).replace(/\t/gu, " ");
286
+ const content = line.slice(1).replace(/\t/gu, tab);
269
287
  if (prefix === "+") {
270
288
  result.push({ kind: "add", lineNumber: newLine++, content, hunk });
271
289
  } else if (prefix === "-") {
@@ -278,7 +296,7 @@ function parseUnifiedPatch(patch: string): CodeDiffLine[] {
278
296
  return result;
279
297
  }
280
298
 
281
- function parseDisplayDiff(diff: string): CodeDiffLine[] {
299
+ function parseDisplayDiff(diff: string, tab: string): CodeDiffLine[] {
282
300
  const result: CodeDiffLine[] = [];
283
301
  let hunk = 0;
284
302
  for (const line of normalizeLineEndings(diff).split("\n")) {
@@ -291,17 +309,20 @@ function parseDisplayDiff(diff: string): CodeDiffLine[] {
291
309
  result.push({
292
310
  kind: match[1] === "+" ? "add" : match[1] === "-" ? "remove" : "context",
293
311
  lineNumber: Number(match[2]),
294
- content: match[3]!.replace(/\t/gu, " "),
312
+ content: match[3]!.replace(/\t/gu, tab),
295
313
  hunk,
296
314
  });
297
315
  }
298
316
  return result;
299
317
  }
300
318
 
301
- /** Parse a standard patch, falling back to Pi's display-oriented numbered diff. */
302
- export function parseCodeDiff(patch: string, displayDiff = ""): CodeDiffLine[] {
303
- const parsedPatch = patch ? parseUnifiedPatch(patch) : [];
304
- return parsedPatch.length > 0 ? parsedPatch : parseDisplayDiff(displayDiff);
319
+ /**
320
+ * Parse a standard patch, falling back to Pi's display-oriented numbered diff. A tab
321
+ * becomes `tab`: three columns as Pi draws code, four as Codex does.
322
+ */
323
+ export function parseCodeDiff(patch: string, displayDiff = "", tab = " "): CodeDiffLine[] {
324
+ const parsedPatch = patch ? parseUnifiedPatch(patch, tab) : [];
325
+ return parsedPatch.length > 0 ? parsedPatch : parseDisplayDiff(displayDiff, tab);
305
326
  }
306
327
 
307
328
  /** Drop context that sits far from any change so a collapsed preview still reaches the edit. */
@@ -358,14 +379,11 @@ function highlightCodeLines(lines: CodeDiffLine[], language: string | undefined,
358
379
  }
359
380
  let end = index;
360
381
  while (end < lines.length && lines[end]!.kind !== "separator") end++;
361
- const block = lines.slice(index, end);
362
- const contents = block.map((line) => line.content);
382
+ const contents = lines.slice(index, end).map((line) => line.content);
363
383
  const highlighted = language === "markdown"
364
384
  ? highlightMarkdown(contents, theme)
365
385
  : highlightCode(contents.join("\n"), language);
366
- block.forEach((line, offset) => {
367
- rendered[index + offset] = highlighted[offset] ?? line.content;
368
- });
386
+ for (let offset = 0; offset < contents.length; offset++) rendered[index + offset] = highlighted[offset] ?? contents[offset]!;
369
387
  index = end;
370
388
  }
371
389
  return rendered;
@@ -411,14 +429,18 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
411
429
  // read at an offset or a diff hunk never reads one out of a comment.
412
430
  const first = lines[0];
413
431
  const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
414
- const highlighted = highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
415
- const numberWidth = Math.max(1, ...lines.map((line) => String(line.lineNumber ?? "").length));
432
+ // ToolExecutionComponent builds result components even when silent mode hides
433
+ // the row. Defer syntax work until the code is actually drawn, and keep it
434
+ // across width changes so resize does not highlight the same source again.
435
+ let highlighted: string[] | undefined;
436
+ const numberWidth = lines.reduce((maximum, line) => Math.max(maximum, String(line.lineNumber ?? "").length), 1);
416
437
  const prefix = options.rail ?? paintChrome(theme, " \u2502 ");
417
438
  const prefixWidth = visibleWidth(prefix);
418
439
  const gutterWidth = numberWidth + (options.signs ? 3 : 2);
419
440
  const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
420
441
  const removedTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved") : undefined;
421
442
  return new CachedComponent((width) => {
443
+ highlighted ??= highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
422
444
  const bodyWidth = Math.max(1, width - prefixWidth);
423
445
  const codeWidth = Math.max(1, bodyWidth - gutterWidth);
424
446
  const output: string[] = [];
@@ -428,7 +450,7 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
428
450
  return;
429
451
  }
430
452
  const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
431
- for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted[index] ?? line.content, codeWidth).entries()) {
453
+ for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted![index] ?? line.content, codeWidth).entries()) {
432
454
  const gutter = codeGutter(
433
455
  line.kind,
434
456
  chunkIndex === 0 ? line.lineNumber : undefined,
@@ -487,6 +509,82 @@ export function renderCodeView(code: string, path: string, theme: Theme, options
487
509
  return renderCodeRows(lines, path, theme, { signs: false, footer: options.footer, rail: options.rail });
488
510
  }
489
511
 
512
+ /**
513
+ * Diff rows the way Codex draws them: `{line} {sign}{code}` after `indent`, the
514
+ * number dim and the sign in its change color, and an added or removed row tinted
515
+ * across the whole width. A wrapped row continues under the code; `⋮` marks a gap
516
+ * between hunks. `maxRows` keeps the first rows, as Codex's collapsed preview does.
517
+ */
518
+ export function renderCodexDiff(
519
+ lines: readonly CodeDiffLine[],
520
+ path: string,
521
+ theme: Theme,
522
+ indent: string,
523
+ maxRows?: number,
524
+ ): Component | undefined {
525
+ if (lines.length === 0) return undefined;
526
+ const first = lines[0];
527
+ const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
528
+ let highlighted: string[] | undefined;
529
+ const numberWidth = lines.reduce((maximum, line) => Math.max(maximum, String(line.lineNumber ?? "").length), 1);
530
+ const indentWidth = visibleWidth(indent);
531
+ const addedTint = diffTintRgb(theme, "toolDiffAdded");
532
+ const removedTint = diffTintRgb(theme, "toolDiffRemoved");
533
+ return new CachedComponent((width) => {
534
+ highlighted ??= highlightCodeLines([...lines], resolveLanguage(path, shebangLine), theme);
535
+ const codeWidth = Math.max(1, width - indentWidth - numberWidth - 2);
536
+ const output: string[] = [];
537
+ for (const [index, line] of lines.entries()) {
538
+ if (maxRows !== undefined && output.length >= maxRows) break;
539
+ if (line.kind === "separator") {
540
+ output.push(indent + " ".repeat(numberWidth + 1) + theme.fg("dim", line.content));
541
+ continue;
542
+ }
543
+ const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
544
+ const sign = line.kind === "add" ? theme.fg("toolDiffAdded", "+") : line.kind === "remove" ? theme.fg("toolDiffRemoved", "-") : " ";
545
+ for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted[index] ?? line.content, codeWidth).entries()) {
546
+ const gutter = chunkIndex === 0
547
+ ? theme.fg("dim", `${String(line.lineNumber ?? "").padStart(numberWidth)} `) + sign
548
+ : " ".repeat(numberWidth + 2);
549
+ // Narrower than the gutter itself, a row is cut rather than drawn past the edge.
550
+ const row = truncateToWidth(indent + gutter + chunk, Math.max(1, width), "");
551
+ output.push(tint ? fillRgb(theme, tint, row + " ".repeat(Math.max(0, width - visibleWidth(row)))) : row);
552
+ }
553
+ }
554
+ return maxRows === undefined ? output : output.slice(0, maxRows);
555
+ });
556
+ }
557
+
558
+ /**
559
+ * A row that never draws past the pane's edge. Chrome has a width of its own, so in
560
+ * a pane narrower than a prefix the lines are cut to it. Lines are checked again only
561
+ * when the component draws new ones.
562
+ */
563
+ export class WithinWidth implements Component {
564
+ private source: string[] | undefined;
565
+ private width: number | undefined;
566
+ private lines: string[] = [];
567
+
568
+ constructor(private readonly inner: Component) {}
569
+
570
+ render(width: number): string[] {
571
+ const lines = this.inner.render(width);
572
+ if (lines !== this.source || width !== this.width) {
573
+ this.source = lines;
574
+ this.width = width;
575
+ this.lines = lines.some((line) => visibleWidth(line) > width)
576
+ ? lines.map((line) => visibleWidth(line) > width ? truncateToWidth(line, Math.max(1, width), "") : line)
577
+ : lines;
578
+ }
579
+ return this.lines;
580
+ }
581
+
582
+ invalidate(): void {
583
+ this.inner.invalidate?.();
584
+ this.source = undefined;
585
+ }
586
+ }
587
+
490
588
  /** Limit a result preview by rendered rows while preserving the full source component for expansion. */
491
589
  export function limitComponentLines(
492
590
  component: Component,
@@ -498,13 +596,12 @@ export function limitComponentLines(
498
596
  return new CachedComponent((width) => {
499
597
  const lines = component.render(width);
500
598
  if (lines.length <= maximumLines) return lines;
501
- return [...lines.slice(0, maximumLines), footer(lines.length - maximumLines)];
599
+ return [...lines.slice(0, maximumLines), truncateToWidth(footer(lines.length - maximumLines), Math.max(1, width), "…")];
502
600
  }, () => component.invalidate?.());
503
601
  }
504
602
 
505
603
  /** Indent another renderer's component under the result rail so it reads as part of the row. */
506
- export function railComponent(component: Component, theme: Theme): Component {
507
- const rail = paintChrome(theme, " │ ");
604
+ export function railComponent(component: Component, theme: Theme, rail = paintChrome(theme, " │ ")): Component {
508
605
  const railWidth = visibleWidth(rail);
509
606
  return new CachedComponent(
510
607
  (width) => component.render(Math.max(1, width - railWidth)).map((line) => rail + line),
@@ -512,8 +609,8 @@ export function railComponent(component: Component, theme: Theme): Component {
512
609
  );
513
610
  }
514
611
 
515
- export function renderArguments(args: ToolArgs, theme: Theme): Component {
612
+ export function renderArguments(args: ToolArgs, theme: Theme, rail = paintChrome(theme, " │ ")): Component {
516
613
  const json = JSON.stringify(args, null, 2) ?? "{}";
517
614
  const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
518
- return prefixedText(styled, paintChrome(theme, " │ "));
615
+ return prefixedText(styled, rail);
519
616
  }
@@ -122,18 +122,17 @@ function ramp(theme: Theme, low: ContrastBand, high: ContrastBand): ColorRamp |
122
122
  * A row's status dot; a running one pulses between the theme's endpoints as frames advance.
123
123
  * Every running row repaints its dot on every frame, so the pulse is painted once per palette.
124
124
  */
125
- export function paintIndicator(theme: Theme, status: RowStatus, frame: number): string {
126
- if (status !== "running") return theme.fg(indicatorTone(status), indicatorGlyph(status));
127
- const pulse = runningPulse(theme);
125
+ export function paintIndicator(theme: Theme, status: RowStatus, frame: number, glyph = indicatorGlyph(status)): string {
126
+ if (status !== "running") return theme.fg(indicatorTone(status), glyph);
127
+ const pulse = runningPulse(theme, glyph);
128
128
  return pulse[((frame % pulse.length) + pulse.length) % pulse.length]!;
129
129
  }
130
130
 
131
- /** Every step of the running dot's pulse, painted once per palette. */
132
- function runningPulse(theme: Theme): readonly string[] {
133
- return perPalette(theme, "running-pulse", () => {
131
+ /** Every step of the running dot's pulse, painted once per palette and glyph. */
132
+ function runningPulse(theme: Theme, glyph: string): readonly string[] {
133
+ return perPalette(theme, `running-pulse:${glyph}`, () => {
134
134
  const pulse = ramp(theme, PULSE_LOW, PULSE_HIGH);
135
135
  return Array.from({ length: RUNNING_INDICATOR_FRAME_COUNT }, (_, step) => {
136
- const glyph = indicatorGlyph("running", step);
137
136
  return pulse
138
137
  ? colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength("running", step)), glyph)
139
138
  : theme.fg(indicatorTone("running", step), glyph);