@oh-my-pi/pi-tui 18.14.2 → 18.15.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +29 -33
  2. package/README.md +1 -1
  3. package/dist/types/chat/transcript-render-helpers.d.ts +8 -0
  4. package/dist/types/chrome/transcript-status.d.ts +2 -2
  5. package/dist/types/overlays/hook-editor.d.ts +2 -0
  6. package/dist/types/overlays/jobs-panel.d.ts +4 -0
  7. package/dist/types/render/render-utils.d.ts +17 -0
  8. package/dist/types/terminal-capabilities.d.ts +1 -1
  9. package/dist/types/tools/grouped-file-output.d.ts +76 -8
  10. package/dist/types/tools/hashline-format.d.ts +5 -0
  11. package/dist/types/tools/native-view.d.ts +2 -0
  12. package/dist/types/tools/read.d.ts +10 -0
  13. package/dist/types/tools/wait.d.ts +2 -0
  14. package/package.json +9 -9
  15. package/src/chat/chat-transcript-builder.ts +4 -2
  16. package/src/chat/read-tool-group.ts +48 -21
  17. package/src/chat/transcript-render-helpers.ts +36 -2
  18. package/src/chrome/transcript-status.ts +5 -3
  19. package/src/overlays/hook-editor.ts +6 -0
  20. package/src/overlays/jobs-panel.ts +45 -19
  21. package/src/render/file-list.ts +4 -7
  22. package/src/render/render-utils.ts +31 -3
  23. package/src/terminal-capabilities.ts +5 -0
  24. package/src/tmux.ts +1 -0
  25. package/src/tools/ast-edit.ts +48 -93
  26. package/src/tools/ast-grep.ts +22 -66
  27. package/src/tools/edit.ts +11 -2
  28. package/src/tools/find.ts +6 -3
  29. package/src/tools/glob.ts +2 -1
  30. package/src/tools/grep.ts +43 -287
  31. package/src/tools/grouped-file-output.ts +250 -64
  32. package/src/tools/hashline-format.ts +5 -0
  33. package/src/tools/lsp.ts +15 -15
  34. package/src/tools/native-view.ts +11 -5
  35. package/src/tools/proc-render.ts +2 -2
  36. package/src/tools/read.ts +14 -0
  37. package/src/tools/wait.ts +6 -3
@@ -6,10 +6,10 @@ import { type ThemeColor, theme } from "../theme";
6
6
  import { TranscriptBlock } from "./transcript-container";
7
7
  import { type NoticeTone, noticeRow } from "./transcript-notice";
8
8
 
9
- /** One themed run of a status row's terminal line. */
9
+ /** One run of a status row's terminal line, in `color` unless the text is already styled. */
10
10
  export interface TranscriptStatusPart {
11
11
  readonly text: string;
12
- readonly color: ThemeColor;
12
+ readonly color?: ThemeColor;
13
13
  }
14
14
 
15
15
  /** A status row in a native terminal: its {@link noticeRow}'s tone, icon and message. */
@@ -41,7 +41,9 @@ export class TranscriptStatusBlock extends TranscriptBlock {
41
41
  constructor(rows: readonly TranscriptStatusRow[]) {
42
42
  super();
43
43
  for (const row of rows) {
44
- const styled = row.parts.flatMap(part => (part ? [theme.fg(part.color, part.text)] : []));
44
+ const styled = row.parts.flatMap(part =>
45
+ part ? [part.color ? theme.fg(part.color, part.text) : part.text] : [],
46
+ );
45
47
  this.addChild(new Text(styled.join(" "), row.indent ?? 1, 0));
46
48
  }
47
49
  this.#native = col(
@@ -407,6 +407,12 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
407
407
  else this.#editor.pasteText(text);
408
408
  }
409
409
 
410
+ /** Replace the draft, as Ctrl+R history search does for the main composer. */
411
+ setText(text: string): void {
412
+ if (this.#disposed) return;
413
+ this.#editor.setText(text);
414
+ }
415
+
410
416
  /**
411
417
  * Prompt-style: raw Enter submits; Editor owns newline-producing sequences.
412
418
  * The follow-up chord (`app.message.followUp` → Ctrl+Q / Ctrl+Enter) also
@@ -26,6 +26,10 @@ export interface JobsPanelJob {
26
26
  readonly type: string;
27
27
  readonly status: "running" | "completed" | "failed" | "cancelled";
28
28
  readonly label: string;
29
+ /** What the job is for, in the model's words; rows and the detail lead with it over a command `label`. */
30
+ readonly intent?: string;
31
+ /** One line on what a command does and when it ends, from the cheap model; the detail shows it under the intent. */
32
+ readonly caption?: string;
29
33
  readonly startTime: number;
30
34
  readonly endTime?: number;
31
35
  /** Registry id of the agent that owns the job. */
@@ -146,13 +150,14 @@ export class JobsSheet implements Component {
146
150
  const detail = selected && this.#inspect(selected.id);
147
151
  const now = Date.now();
148
152
  // Elapsed nodes tick on their own; list values and the output pane redraw
149
- // when a job starts, settles, leaves or moves in the tree, its process
150
- // changes, its output grows, or (while one runs) once a second.
153
+ // when a job starts, settles, leaves or moves in the tree, gets its
154
+ // caption (it can land after the job settles), its process changes, its
155
+ // output grows, or (while one runs) once a second.
151
156
  const key = [
152
157
  rows
153
158
  .map(
154
159
  entry =>
155
- `${jobTreeGuide(entry, theme)}${entry.kind === "job" ? `${entry.job.id}:${entry.job.status}` : entry.agent.id}`,
160
+ `${jobTreeGuide(entry, theme)}${entry.kind === "job" ? `${entry.job.id}:${entry.job.status}:${entry.job.caption ?? ""}` : entry.agent.id}`,
156
161
  )
157
162
  .join(","),
158
163
  selected?.id,
@@ -200,15 +205,20 @@ export class JobsSheet implements Component {
200
205
  const cursor = job === selected ? ">" : " ";
201
206
  const age = formatDuration(jobAge(job, now));
202
207
  const agent = entry.agent && entry.agent.id !== job.label ? `${entry.agent.id}: ` : "";
203
- lines.push(
204
- truncateToWidth(
205
- `${cursor} ${guide}${job.status} ${job.type} ${age} ${agent}${collapseCommand(job.label)}`,
206
- width,
207
- ),
208
- );
208
+ // The intent is model-written: strip terminal controls and flatten it to one row like the command.
209
+ const title = shortenEmbeddedPaths(collapseCommand(sanitizeText(job.intent ?? job.label)));
210
+ lines.push(truncateToWidth(`${cursor} ${guide}${job.status} ${job.type} ${age} ${agent}${title}`, width));
209
211
  }
210
212
  const detail = selected && this.#inspect(selected.id);
211
213
  if (detail) {
214
+ if (selected.caption) {
215
+ for (const line of wrapTextWithAnsi(
216
+ shortenEmbeddedPaths(sanitizeText(selected.caption)),
217
+ Math.max(1, width - 3),
218
+ )) {
219
+ lines.push(` ${theme.fg("muted", line)}`);
220
+ }
221
+ }
212
222
  // Rows show the cut label; the selected job's command wraps here,
213
223
  // bounded so a long script never pushes the facts and output off the sheet.
214
224
  if (detail.command) {
@@ -227,7 +237,7 @@ export class JobsSheet implements Component {
227
237
  detail.artifactId && `artifact://${detail.artifactId}`,
228
238
  ]);
229
239
  if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
230
- if (detail.widget) {
240
+ if (detail.widget && widgetHasData(detail.widget)) {
231
241
  for (const line of renderJobWidgetLines(detail.widget, Math.max(0, width - 3), theme)) {
232
242
  lines.push(` ${line}`);
233
243
  }
@@ -331,6 +341,11 @@ function jobAge(job: JobsPanelJob, nowMs: number): number {
331
341
  return Math.max(0, (job.endTime ?? nowMs) - job.startTime);
332
342
  }
333
343
 
344
+ /** Whether a widget draws more than its title: one without data only repeats the job's heading. */
345
+ function widgetHasData(widget: JobWidgetView): boolean {
346
+ return widget.blocks.length > 0 || widget.headline !== undefined || Boolean(widget.stage);
347
+ }
348
+
334
349
  /**
335
350
  * The last `count` pieces of `text.trimEnd().split("\n")`, found by scanning
336
351
  * back from the end so a long running output is never split as a whole.
@@ -373,8 +388,9 @@ function describeJobList(rows: readonly JobTreeRow<JobsPanelJob>[], selected: Jo
373
388
 
374
389
  /**
375
390
  * One list row: the type icon tinted by status, the tree connector, the
376
- * subagent a task job runs, the one-line label (a process job's command in
377
- * mono, a task's work in prose) and how long it ran.
391
+ * subagent a task job runs, the one-line label (what the job is for when the
392
+ * model said, else a process job's command in mono or a task's work in prose)
393
+ * and how long it ran.
378
394
  */
379
395
  function describeJobItem(job: JobsPanelJob, agentName: string | undefined, guide: string, nowMs: number): NativeNode {
380
396
  const status = STATUS_LABEL[job.status];
@@ -383,13 +399,17 @@ function describeJobItem(job: JobsPanelJob, agentName: string | undefined, guide
383
399
  label: compact([
384
400
  guide !== "" && span(guide, "mono dim"),
385
401
  agentName !== undefined && agentName !== job.label && span(`${agentName} `, "strong"),
386
- job.type === "task" ? span(job.label) : span(collapseCommand(job.label), "mono"),
402
+ job.intent
403
+ ? span(job.intent)
404
+ : job.type === "task"
405
+ ? span(job.label)
406
+ : span(collapseCommand(job.label), "mono"),
387
407
  ]),
388
408
  value: [span(age, "mono")],
389
409
  icon: TYPE_ICON[job.type] ?? "job",
390
410
  tone: STATUS_TONE[job.status],
391
- title: `${job.label}\n${status} · ${job.type} · ${job.id}`,
392
- aria: `${job.label}, ${status.toLowerCase()}, ${age}`,
411
+ title: `${job.intent ? `${job.intent}\n` : ""}${job.caption ? `${job.caption}\n` : ""}${job.label}\n${status} · ${job.type} · ${job.id}`,
412
+ aria: `${job.intent ?? job.label}, ${status.toLowerCase()}, ${age}`,
393
413
  });
394
414
  }
395
415
 
@@ -422,8 +442,9 @@ function describeEmpty(): NativeNode {
422
442
 
423
443
  /**
424
444
  * The selected job: a status line (pill, live elapsed, type · id, Cancel job
425
- * when `cancellable`), its task or full command, facts and the output pane (or
426
- * its empty state).
445
+ * when `cancellable`), what it is for, what its command does (the cheap
446
+ * model's caption), its full command, facts, its widget
447
+ * when it has data to draw and the output pane (or its empty state).
427
448
  */
428
449
  function describeDetail(
429
450
  job: JobsPanelJob,
@@ -435,6 +456,7 @@ function describeDetail(
435
456
  const exit = detail?.exitCode;
436
457
  const age = Math.round(jobAge(job, nowMs));
437
458
  const command = detail?.command ?? (job.type === "task" ? undefined : job.label);
459
+ const title = job.intent ?? (command ? undefined : job.label);
438
460
  const pids = detail?.pids ?? [];
439
461
  const facts = compact([
440
462
  job.agentId ? { k: "Agent", v: [span(job.agentId, "mono")] } : undefined,
@@ -464,11 +486,15 @@ function describeDetail(
464
486
  ]),
465
487
  { role: "omp.jobs.status", gap: "sm", align: "center" },
466
488
  ),
489
+ title ? text(title, { role: "omp.jobs.title", wrap: "word" }) : undefined,
490
+ job.caption ? text([span(job.caption, "muted")], { role: "omp.jobs.caption", wrap: "word" }) : undefined,
467
491
  command
468
492
  ? code(command, { lang: "bash", wrap: true, preview: { lines: 4 }, role: "omp.jobs.command" })
469
- : text(job.label, { role: "omp.jobs.title", wrap: "word" }),
493
+ : undefined,
470
494
  facts.length > 0 && node("kv", { items: facts, role: "omp.jobs.facts" }),
471
- detail?.widget && node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
495
+ detail?.widget &&
496
+ widgetHasData(detail.widget) &&
497
+ node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
472
498
  node(
473
499
  "col",
474
500
  { role: "omp.jobs.out", gap: "sm" },
@@ -6,6 +6,7 @@ import { node, span } from "../native/describe";
6
6
  import type { NativeNode } from "../native/node";
7
7
  import type { Theme } from "../theme/theme";
8
8
  import { fileLinkSpan } from "./hyperlink";
9
+ import { formatStyledPath } from "./render-utils";
9
10
  import { renderTreeList } from "./tree-list";
10
11
 
11
12
  /** A file-list item with optional display metadata. */
@@ -69,15 +70,11 @@ export function renderFileList(options: FileListOptions, theme: Theme): string[]
69
70
  const isDirectory = entry.isDirectory ?? entry.path.endsWith("/");
70
71
  const displayPath = entry.path;
71
72
  const lang = isDirectory ? undefined : getLanguageFromPath(displayPath);
72
- const icon = !showIcons
73
- ? ""
74
- : isDirectory
75
- ? theme.fg("accent", theme.icon.folder)
76
- : theme.fg("muted", theme.getLangIcon(lang));
77
- const labelColor = isDirectory ? "accent" : "toolOutput";
73
+ // Type icons stay muted beside the dim directory and colored file name.
74
+ const icon = !showIcons ? "" : theme.fg("muted", isDirectory ? theme.icon.folder : theme.getLangIcon(lang));
78
75
  const meta = entry.meta ? ` ${theme.fg("dim", entry.meta)}` : "";
79
76
  const iconPrefix = icon ? `${icon} ` : "";
80
- const pathStr = theme.fg(labelColor, displayPath);
77
+ const pathStr = formatStyledPath(displayPath, theme);
81
78
  const linkedPath = entry.absPath && hyperlinkFn ? hyperlinkFn(entry.absPath, pathStr) : pathStr;
82
79
  return `${iconPrefix}${linkedPath}${meta}`;
83
80
  },
@@ -13,7 +13,7 @@ import { expandWindowsLongPath, getWindowsShortPath } from "@oh-my-pi/pi-natives
13
13
  import { pluralize, sanitizeText } from "@oh-my-pi/pi-utils";
14
14
  import { formatKeyHint, type KeyId } from "../app-keybindings";
15
15
  import { getKeybindings } from "../keybindings";
16
- import type { Theme } from "../theme/theme";
16
+ import type { Theme, ThemeColor } from "../theme/theme";
17
17
  import type { Component } from "../tui";
18
18
  import { replaceTabs, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
19
19
  import { Hasher } from "./utils";
@@ -435,6 +435,34 @@ export function formatEmptyMessage(message: string, theme: Theme): string {
435
435
  return `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`;
436
436
  }
437
437
 
438
+ /**
439
+ * Color of a file's name, or a whole URL, in tool bodies: it keeps paths apart
440
+ * from the tool titles above them (bronze in titanium).
441
+ */
442
+ export const FILE_NAME_COLOR = "customMessageLabel" satisfies ThemeColor;
443
+
444
+ /**
445
+ * Where a display path's final segment (file or folder name) starts, past its
446
+ * last `/` or `\` separator; a trailing-slash directory keeps its slash on the
447
+ * name. Shared by the terminal ({@link formatStyledPath}) and Tern file rows.
448
+ */
449
+ export function pathNameStart(filePath: string): number {
450
+ let end = filePath.length;
451
+ while (end > 0 && (filePath[end - 1] === "/" || filePath[end - 1] === "\\")) end--;
452
+ return end === 0 ? 0 : Math.max(filePath.lastIndexOf("/", end - 1), filePath.lastIndexOf("\\", end - 1)) + 1;
453
+ }
454
+
455
+ /**
456
+ * A file path in a tool body: the directory (with its trailing separator) dim,
457
+ * the final segment in {@link FILE_NAME_COLOR}. Tool headers keep paths in
458
+ * their plain subject color instead.
459
+ */
460
+ export function formatStyledPath(filePath: string, theme: Theme): string {
461
+ const cut = pathNameStart(filePath);
462
+ const name = theme.fg(FILE_NAME_COLOR, filePath.slice(cut));
463
+ return cut > 0 ? `${theme.fg("dim", filePath.slice(0, cut))}${name}` : name;
464
+ }
465
+
438
466
  // =============================================================================
439
467
  // Code Frame Formatting
440
468
  // =============================================================================
@@ -600,7 +628,7 @@ export function formatDiagnostics(
600
628
  const fileBranch = isLastFileNode ? theme.tree.last : theme.tree.branch;
601
629
 
602
630
  const fileIcon = theme.fg("muted", getLangIcon(filePath));
603
- output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${theme.fg("accent", filePath)}`;
631
+ output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${formatStyledPath(filePath, theme)}`;
604
632
 
605
633
  for (let di = 0; di < diagnostics.length && diagsShown < maxDiags; di++) {
606
634
  const d = diagnostics[di];
@@ -624,7 +652,7 @@ export function formatDiagnostics(
624
652
  : d.severity === "warning"
625
653
  ? theme.styledSymbol("status.warning", "warning")
626
654
  : theme.styledSymbol("status.info", "muted");
627
- const location = theme.fg("dim", `:${d.line}:${d.col}`);
655
+ const location = theme.fg("muted", `:${d.line}:${d.col}`);
628
656
  const codeTag = d.code ? theme.fg("dim", ` (${d.code})`) : "";
629
657
  const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
630
658
 
@@ -67,6 +67,7 @@ export type TerminalId =
67
67
  | "rio"
68
68
  | "tern"
69
69
  | "monstar"
70
+ | "rex"
70
71
  | "base"
71
72
  | "trueColor";
72
73
 
@@ -626,6 +627,8 @@ const KNOWN_TERMINALS = Object.freeze({
626
627
  // OSC 99, and OSC 66 text sizing are outside rio's supported set and keep
627
628
  // the conservative defaults.
628
629
  rio: new TerminalInfo("rio", ImageProtocol.Kitty, true, true),
630
+ // Rex (TERM_PROGRAM=rex / TERM=xterm-rex): only Kitty graphics is confirmed.
631
+ rex: new TerminalInfo("rex", ImageProtocol.Kitty, true, false),
629
632
  // Tern (Stencil's terminal, `stencil-term`) sets TERM_PROGRAM=tern and
630
633
  // implements Kitty graphics, OSC 8 and OSC 9/99 notifications. Whether omp
631
634
  // renders natively (Tern Surface Protocol) is decided by the `hello`
@@ -666,6 +669,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
666
669
  if (caseEq(program, "rio")) return "rio";
667
670
  if (caseEq(program, "tern")) return "tern";
668
671
  if (caseEq(program, "monstar")) return "monstar";
672
+ if (caseEq(program, "rex")) return "rex";
669
673
  return null;
670
674
  }
671
675
 
@@ -717,6 +721,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
717
721
 
718
722
  if (TERM?.toLowerCase().includes("ghostty")) return "ghostty";
719
723
  if (TERM && caseEq(TERM, "monstar")) return "monstar";
724
+ if (TERM && caseEq(TERM, "xterm-rex")) return "rex";
720
725
 
721
726
  if (COLORTERM) {
722
727
  if (caseEq(COLORTERM, "truecolor") || caseEq(COLORTERM, "24bit")) return "trueColor";
package/src/tmux.ts CHANGED
@@ -21,6 +21,7 @@ const CLIENT_TERMINAL_TIMEOUT_MS = 2_000;
21
21
  const CLIENT_TERMNAME_PROGRAMS: ReadonlyArray<readonly [RegExp, string]> = [
22
22
  [/^xterm-kitty$/u, "kitty"],
23
23
  [/^xterm-ghostty$/u, "ghostty"],
24
+ [/^xterm-rex$/u, "rex"],
24
25
  [/^wezterm$/u, "wezterm"],
25
26
  ];
26
27
  let cachedClientTerminalNames: readonly string[] | undefined;
@@ -1,19 +1,24 @@
1
- import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
2
1
  import type { Component } from "../tui";
3
2
  import { Text } from "../components/text";
4
- import { replaceTabs } from "../utils";
5
3
  import type { Theme } from "../theme/theme";
6
- import { Ellipsis, fileHyperlink, renderStatusLine, truncateToWidth } from "../render";
4
+ import { Ellipsis, renderStatusLine, truncateToWidth } from "../render";
7
5
  import { framedToolCard } from "../render/tool-card";
8
6
  import {
9
7
  appendParseErrorsBulletList,
10
8
  formatCount,
11
9
  formatErrorDetail,
12
- formatMoreItems,
13
10
  formatParseErrorsCountLabel,
14
11
  PREVIEW_LIMITS,
15
12
  } from "../render/render-utils";
16
- import { classifyGroupedLines, groupLineIndicesByBlank } from "./grouped-file-output";
13
+ import {
14
+ classifyGroupedLines,
15
+ createGroupedRowStyler,
16
+ fitGroupsToBudget,
17
+ groupedLinkTargets,
18
+ groupLineIndicesByBlank,
19
+ groupsByFile,
20
+ walkGroupedOutput,
21
+ } from "./grouped-file-output";
17
22
  import type { OutputMeta } from "./output-meta";
18
23
  import type {
19
24
  NativeToolHead,
@@ -59,34 +64,24 @@ interface AstEditRenderArgs {
59
64
  const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
60
65
 
61
66
  /**
62
- * Flatten change groups (line indices, styled on demand) into frame body
63
- * lines. Groups are separated by a blank line and carry no tree guides — the
64
- * frame border is the container, so nested `├─ │` gutters would just be
65
- * noise. Collapsed mode always shows at least the first group, then fills up
66
- * to `budget` lines before summarizing the rest as `… N more changes`.
67
+ * Frame body lines for per-file change groups, styled on demand: files apart by
68
+ * a blank line and without tree guides (the frame border is the container), fit
69
+ * to `budget` with the rest summarized as `… N more files`.
67
70
  */
68
71
  function buildChangeBody(
69
72
  groups: readonly (readonly number[])[],
70
73
  styleLine: (index: number) => string,
71
- expanded: boolean,
74
+ isFileRow: (index: number) => boolean,
72
75
  budget: number,
73
76
  theme: Theme,
74
77
  ): string[] {
78
+ const { shown, summary } = fitGroupsToBudget(groups, budget, { isUnit: isFileRow, unit: "file", gap: 1 });
75
79
  const lines: string[] = [];
76
- let shown = 0;
77
- for (let i = 0; i < groups.length; i++) {
78
- const group = groups[i]!;
79
- const separator = shown > 0 ? 1 : 0;
80
- const remainingAfter = groups.length - (i + 1);
81
- const reserved = !expanded && remainingAfter > 0 ? 1 : 0;
82
- // Always emit the first group; budget only gates subsequent ones.
83
- if (!expanded && shown > 0 && lines.length + separator + group.length + reserved > budget) break;
84
- if (separator) lines.push("");
80
+ for (const group of shown) {
81
+ if (lines.length > 0) lines.push("");
85
82
  for (const index of group) lines.push(styleLine(index));
86
- shown++;
87
83
  }
88
- const remaining = groups.length - shown;
89
- if (!expanded && remaining > 0) lines.push(theme.fg("muted", formatMoreItems(remaining, "change")));
84
+ if (summary) lines.push(theme.fg("muted", summary));
90
85
  return lines;
91
86
  }
92
87
 
@@ -98,9 +93,7 @@ function patternPreview(pat: string | undefined): string | undefined {
98
93
  return collapsed || undefined;
99
94
  }
100
95
 
101
- const AST_EDIT_HEADER_RE = /^(#+)\s+(.*)$/;
102
96
  const AST_EDIT_CHANGE_LINE_RE = /^([+\- ])\s*(\d+)(?:│|[:|])(.*)$/;
103
- const AST_EDIT_HEADER_SUFFIX_RE = /\s+\([^)]*\)\s*$/;
104
97
 
105
98
  /** One file's proposed rewrite: its unified diff text and change counts. */
106
99
  interface AstEditFileDiff {
@@ -117,29 +110,15 @@ interface AstEditFileDiff {
117
110
  */
118
111
  function parseAstEditChanges(lines: readonly string[]): AstEditFileDiff[] {
119
112
  const files: AstEditFileDiff[] = [];
120
- const dirs: string[] = [];
121
113
  let file: AstEditFileDiff | undefined;
122
- const flush = () => {
123
- if (file && file.lines.length > 0) files.push(file);
124
- file = undefined;
125
- };
126
- for (const line of lines) {
127
- const header = AST_EDIT_HEADER_RE.exec(line);
128
- if (header) {
129
- flush();
130
- const depth = header[1]!.length;
131
- const rest = header[2]!.trimEnd().replace(AST_EDIT_HEADER_SUFFIX_RE, "");
132
- dirs.length = depth - 1;
133
- if (rest.endsWith("/")) {
134
- dirs[depth - 1] = rest.slice(0, -1);
135
- continue;
136
- }
137
- const prefix = dirs.filter(Boolean).join("/");
138
- file = { path: prefix ? `${prefix}/${rest}` : rest, lines: [], added: 0, removed: 0 };
114
+ for (const event of walkGroupedOutput(lines)) {
115
+ if (event.kind !== "line") {
116
+ if (file && file.lines.length > 0) files.push(file);
117
+ file = event.kind === "file" ? { path: event.path, lines: [], added: 0, removed: 0 } : undefined;
139
118
  continue;
140
119
  }
141
- if (!file || line.trim().length === 0) continue;
142
- const change = AST_EDIT_CHANGE_LINE_RE.exec(line);
120
+ if (!file) continue;
121
+ const change = AST_EDIT_CHANGE_LINE_RE.exec(event.text);
143
122
  if (!change) continue;
144
123
  const lineNumber = Number.parseInt(change[2]!, 10);
145
124
  if (file.lastOld === undefined || lineNumber > file.lastOld + 1 || lineNumber < file.lastOld) {
@@ -150,7 +129,7 @@ function parseAstEditChanges(lines: readonly string[]): AstEditFileDiff[] {
150
129
  else if (change[1] === "-") file.removed++;
151
130
  file.lines.push(`${change[1]}${change[3]}`);
152
131
  }
153
- flush();
132
+ if (file && file.lines.length > 0) files.push(file);
154
133
  return files;
155
134
  }
156
135
 
@@ -248,53 +227,23 @@ export const astEditToolRenderer = {
248
227
 
249
228
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
250
229
  const allLines = textContent.split("\n");
251
- // Resolve hyperlinks over the whole output so nested directory headers
252
- // reconstruct across the blank-line groups the tree list collapses by.
230
+ // Classify the whole output so nested directory headers resolve across blank-line groups.
253
231
  const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
254
- // The display lines are sanitized; reconstruct link targets from the original
255
- // grouped headers only when they still align with those display lines.
256
- const rawDetails = options.renderContext?.rawDetails;
257
- const rawContent =
258
- isRecord(rawDetails) && typeof rawDetails.displayContent === "string" ? rawDetails.displayContent : undefined;
259
- const rawLines = rawContent?.split("\n");
260
- const rawCwd = isRecord(rawDetails) && typeof rawDetails.cwd === "string" ? rawDetails.cwd : undefined;
261
- const rawSearchPath =
262
- isRecord(rawDetails) && typeof rawDetails.searchPath === "string" ? rawDetails.searchPath : undefined;
263
- const rawContexts =
264
- rawContent !== undefined && rawLines?.length === allLines.length && sanitizeText(rawContent) === textContent
265
- ? classifyGroupedLines(rawLines, rawCwd ?? rawSearchPath, rawSearchPath)
266
- : undefined;
267
- // Style lazily: collapsed bodies show only the first groups.
268
- const styledLines: (string | undefined)[] = Array.from({ length: allLines.length }, () => undefined);
269
- const styleLine = (index: number): string => {
270
- const cached = styledLines[index];
271
- if (cached !== undefined) return cached;
272
- const ctx = contexts[index]!;
273
- // Swap the inner code-frame gutter `│` for a space so it does not nest a
274
- // second vertical bar inside the frame border.
275
- const display = replaceTabs(allLines[index]!.replace("│", " "));
276
- let styled: string;
277
- const linkPath =
278
- ctx.headerPath && rawContexts?.[index]?.kind === ctx.kind && rawContexts[index]?.depth === ctx.depth
279
- ? (rawContexts[index]?.headerPath ?? ctx.headerPath)
280
- : ctx.headerPath;
281
- if (ctx.kind === "dir") {
282
- const accent = uiTheme.fg("accent", display);
283
- styled = linkPath ? fileHyperlink(linkPath, accent) : accent;
284
- } else if (ctx.kind === "file") {
285
- const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", display);
286
- styled = linkPath ? fileHyperlink(linkPath, tinted) : tinted;
287
- } else if (display.startsWith("+")) {
288
- styled = uiTheme.fg("toolDiffAdded", display);
289
- } else if (display.startsWith("-")) {
290
- styled = uiTheme.fg("toolDiffRemoved", display);
291
- } else {
292
- styled = uiTheme.fg("toolOutput", display);
293
- }
294
- styledLines[index] = styled;
295
- return styled;
296
- };
297
- const changeGroups = groupLineIndicesByBlank(allLines).filter(indices => {
232
+ const styleLine = createGroupedRowStyler(allLines, contexts, uiTheme, {
233
+ linkTarget: groupedLinkTargets(contexts, textContent, details, options.renderContext?.rawDetails),
234
+ styleBody: index => {
235
+ // Swap the code-frame gutter `│` for a space so it does not nest a
236
+ // second vertical bar inside the frame border.
237
+ const line = allLines[index]!.replace("│", " ");
238
+ const color = line.startsWith("+")
239
+ ? "toolDiffAdded"
240
+ : line.startsWith("-")
241
+ ? "toolDiffRemoved"
242
+ : "toolOutput";
243
+ return uiTheme.fg(color, line);
244
+ },
245
+ });
246
+ const changeGroups = groupsByFile(groupLineIndicesByBlank(allLines), contexts).filter(indices => {
298
247
  const first = allLines[indices[0]!]!;
299
248
  return !first.startsWith("Safety cap reached") && !first.startsWith("Parse issues:");
300
249
  });
@@ -321,7 +270,13 @@ export const astEditToolRenderer = {
321
270
  ({ contentWidth }) => {
322
271
  const expanded = Boolean(options.expanded);
323
272
  if (bodyMemo === undefined || bodyMemo.expanded !== expanded || bodyMemo.width !== contentWidth) {
324
- const changeLines = buildChangeBody(changeGroups, styleLine, expanded, COLLAPSED_CHANGE_LIMIT, uiTheme);
273
+ const changeLines = buildChangeBody(
274
+ changeGroups,
275
+ styleLine,
276
+ index => contexts[index]!.kind === "file",
277
+ expanded ? Number.POSITIVE_INFINITY : COLLAPSED_CHANGE_LIMIT,
278
+ uiTheme,
279
+ );
325
280
  const lines = [...changeLines, ...extraLines].map(l => truncateToWidth(l, contentWidth, Ellipsis.Omit));
326
281
  while (lines.length > 0 && lines[0].trim() === "") lines.shift();
327
282
  bodyMemo = { expanded, width: contentWidth, lines };
@@ -1,8 +1,7 @@
1
- import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
2
1
  import type { Component } from "../tui";
3
2
  import { Text } from "../components/text";
4
3
  import type { Theme } from "../theme/theme";
5
- import { fileHyperlink, renderStatusLine, renderTreeList } from "../render";
4
+ import { renderStatusLine, renderTreeList } from "../render";
6
5
  import {
7
6
  appendParseErrorsBulletList,
8
7
  createCachedComponent,
@@ -17,8 +16,13 @@ import {
17
16
  import {
18
17
  classifyGroupedLines,
19
18
  createCodeFrameStyler,
19
+ createGroupedRowStyler,
20
20
  describeGroupedOutput,
21
+ fitGroupsToBudget,
22
+ groupedLinkTargets,
21
23
  groupLineIndicesByBlank,
24
+ groupsByFile,
25
+ isMatchRow,
22
26
  } from "./grouped-file-output";
23
27
  import { code, compact, node, span } from "../native/describe";
24
28
  import type { NativeChild } from "../native/node";
@@ -128,64 +132,21 @@ export const astGrepToolRenderer = {
128
132
 
129
133
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
130
134
  const allLines = textContent.split("\n");
131
- // Resolve hyperlinks over the whole output so nested directory headers
132
- // reconstruct across the blank-line groups the tree list collapses by.
135
+ // Classify the whole output so nested directory headers resolve across blank-line groups.
133
136
  const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
134
137
  const styleCodeFrame = createCodeFrameStyler(allLines, contexts, "toolOutput", uiTheme, args?.lang);
135
- // The displayed lines are sanitized, but OSC 8 destinations must retain the
136
- // original path bytes. Only substitute targets for headers already linked
137
- // by the sanitized output; never let raw text create additional links.
138
- const rawDetails = options.renderContext?.rawDetails;
139
- const rawContent =
140
- isRecord(rawDetails) && typeof rawDetails.displayContent === "string" ? rawDetails.displayContent : undefined;
141
- const rawLines = rawContent?.split("\n");
142
- const rawCwd = isRecord(rawDetails) && typeof rawDetails.cwd === "string" ? rawDetails.cwd : undefined;
143
- const rawSearchPath =
144
- isRecord(rawDetails) && typeof rawDetails.searchPath === "string" ? rawDetails.searchPath : undefined;
145
- const rawContexts =
146
- rawContent !== undefined && rawLines?.length === allLines.length && sanitizeText(rawContent) === textContent
147
- ? classifyGroupedLines(rawLines, rawCwd ?? rawSearchPath, rawSearchPath)
148
- : undefined;
149
- // Style lazily: the collapsed tree list only renders the first few groups,
150
- // so a large result never styles/hyperlinks rows nobody sees.
151
- const styledLines: (string | undefined)[] = Array.from({ length: allLines.length }, () => undefined);
152
- const styleLine = (index: number): string => {
153
- const cached = styledLines[index];
154
- if (cached !== undefined) return cached;
155
- const line = allLines[index]!;
156
- const ctx = contexts[index]!;
157
- const rawContext = rawContexts?.[index];
158
- const target =
159
- ctx.headerPath && rawContext?.kind === ctx.kind && rawContext.depth === ctx.depth
160
- ? (rawContext.headerPath ?? ctx.headerPath)
161
- : ctx.headerPath;
162
- let styled: string;
163
- if (ctx.kind === "dir") {
164
- const accent = uiTheme.fg("accent", line);
165
- styled = target ? fileHyperlink(target, accent) : accent;
166
- } else if (ctx.kind === "file") {
167
- const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line);
168
- styled = target ? fileHyperlink(target, tinted) : tinted;
169
- } else {
170
- styled = styleCodeFrame(index) ?? uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
171
- }
172
- styledLines[index] = styled;
173
- return styled;
174
- };
175
- const matchGroups = groupLineIndicesByBlank(allLines).filter(indices => {
138
+ const styleLine = createGroupedRowStyler(allLines, contexts, uiTheme, {
139
+ linkTarget: groupedLinkTargets(contexts, textContent, details, options.renderContext?.rawDetails),
140
+ styleBody: index => {
141
+ const line = allLines[index]!;
142
+ return styleCodeFrame(index) ?? uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
143
+ },
144
+ linkLines: true,
145
+ });
146
+ const matchGroups = groupsByFile(groupLineIndicesByBlank(allLines), contexts).filter(indices => {
176
147
  const first = allLines[indices[0]!]!;
177
148
  return !first.startsWith("Result limit reached") && !first.startsWith("Parse issues:");
178
149
  });
179
- // Line offset at which each group starts. A collapsed tree list stops at the
180
- // first group that overflows its line budget, so groups starting at or past
181
- // the budget are never shown — hand it same-length placeholders instead of
182
- // styling them (renderTreeList only needs their line counts).
183
- const groupStarts: number[] = [];
184
- let groupStart = 0;
185
- for (const group of matchGroups) {
186
- groupStarts.push(groupStart);
187
- groupStart += group.length;
188
- }
189
150
 
190
151
  const extraLines: string[] = [];
191
152
  if (limitReached) {
@@ -200,18 +161,13 @@ export const astGrepToolRenderer = {
200
161
  return createCachedComponent(
201
162
  () => options.expanded,
202
163
  width => {
164
+ const { shown, summary } = fitGroupsToBudget(
165
+ matchGroups,
166
+ options.expanded ? Number.POSITIVE_INFINITY : COLLAPSED_MATCH_LIMIT,
167
+ { isUnit: index => isMatchRow(allLines[index]!), unit: "match", total: matchCount },
168
+ );
203
169
  const matchLines = renderTreeList(
204
- {
205
- items: matchGroups,
206
- expanded: options.expanded,
207
- maxCollapsed: matchGroups.length,
208
- maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
209
- itemType: "match",
210
- renderItem: (group, context) =>
211
- !options.expanded && groupStarts[context.index]! >= COLLAPSED_MATCH_LIMIT
212
- ? Array.from({ length: group.length }, () => "")
213
- : group.map(styleLine),
214
- },
170
+ { items: shown, trailingSummary: summary, renderItem: group => group.map(styleLine) },
215
171
  uiTheme,
216
172
  );
217
173
  return fitHeaderedRows(header, [...matchLines, ...extraLines], width);