@oh-my-pi/pi-tui 18.14.2 → 18.16.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 (93) hide show
  1. package/CHANGELOG.md +64 -50
  2. package/README.md +1 -1
  3. package/dist/types/app-keybindings.d.ts +5 -0
  4. package/dist/types/apps/session-picker.d.ts +5 -3
  5. package/dist/types/chat/bash-execution.d.ts +4 -1
  6. package/dist/types/chat/eval-execution.d.ts +2 -0
  7. package/dist/types/chat/execution-shared.d.ts +14 -8
  8. package/dist/types/chat/transcript-render-helpers.d.ts +8 -0
  9. package/dist/types/chrome/dynamic-border.d.ts +6 -0
  10. package/dist/types/chrome/transcript-status.d.ts +2 -2
  11. package/dist/types/components/editor.d.ts +15 -6
  12. package/dist/types/components/loader.d.ts +2 -0
  13. package/dist/types/keybinding-matchers.d.ts +7 -0
  14. package/dist/types/overlays/btw-history-panel.d.ts +13 -0
  15. package/dist/types/overlays/btw-history.d.ts +24 -0
  16. package/dist/types/overlays/btw-inject.d.ts +22 -0
  17. package/dist/types/overlays/btw-panel.d.ts +7 -0
  18. package/dist/types/overlays/hook-editor.d.ts +2 -0
  19. package/dist/types/overlays/jobs-panel.d.ts +4 -0
  20. package/dist/types/overlays/session-selector.d.ts +24 -3
  21. package/dist/types/render/render-utils.d.ts +21 -1
  22. package/dist/types/status-line/startup.d.ts +0 -15
  23. package/dist/types/terminal-capabilities.d.ts +1 -1
  24. package/dist/types/theme/symbols.d.ts +3 -1
  25. package/dist/types/theme/theme-class.d.ts +1 -0
  26. package/dist/types/tools/grouped-file-output.d.ts +76 -8
  27. package/dist/types/tools/hashline-format.d.ts +5 -0
  28. package/dist/types/tools/native-view.d.ts +2 -0
  29. package/dist/types/tools/output-meta.d.ts +27 -0
  30. package/dist/types/tools/read.d.ts +10 -0
  31. package/dist/types/tools/wait.d.ts +2 -0
  32. package/dist/types/utils.d.ts +4 -0
  33. package/package.json +9 -9
  34. package/src/app-keybindings.ts +5 -0
  35. package/src/apps/session-picker.ts +7 -3
  36. package/src/chat/bash-execution.ts +20 -9
  37. package/src/chat/chat-transcript-builder.ts +5 -2
  38. package/src/chat/eval-execution.ts +14 -7
  39. package/src/chat/execution-shared.ts +39 -18
  40. package/src/chat/read-tool-group.ts +56 -36
  41. package/src/chat/transcript-render-helpers.ts +52 -6
  42. package/src/chrome/dynamic-border.ts +28 -2
  43. package/src/chrome/transcript-status.ts +5 -3
  44. package/src/components/editor.ts +123 -24
  45. package/src/components/key-value-list.ts +9 -9
  46. package/src/components/loader.ts +7 -0
  47. package/src/components/section.ts +1 -9
  48. package/src/components/table.ts +1 -9
  49. package/src/keybinding-matchers.ts +13 -0
  50. package/src/overlays/btw-history-panel.ts +98 -14
  51. package/src/overlays/btw-history.ts +65 -0
  52. package/src/overlays/btw-inject.ts +91 -0
  53. package/src/overlays/btw-panel.ts +53 -12
  54. package/src/overlays/hook-editor.ts +6 -0
  55. package/src/overlays/jobs-panel.ts +45 -19
  56. package/src/overlays/model-browser.ts +65 -8
  57. package/src/overlays/session-selector.ts +131 -26
  58. package/src/overlays/usage-dashboard.ts +2 -2
  59. package/src/render/code-cell.ts +1 -1
  60. package/src/render/file-list.ts +4 -7
  61. package/src/render/render-utils.ts +36 -6
  62. package/src/setup/scenes/glyph.ts +1 -1
  63. package/src/status-line/startup.ts +36 -70
  64. package/src/terminal-capabilities.ts +5 -0
  65. package/src/theme/symbols.ts +16 -9
  66. package/src/theme/theme-class.ts +3 -0
  67. package/src/tmux.ts +1 -0
  68. package/src/tools/agent-tree.ts +13 -11
  69. package/src/tools/ask.ts +13 -6
  70. package/src/tools/ast-edit.ts +48 -93
  71. package/src/tools/ast-grep.ts +22 -66
  72. package/src/tools/bash.ts +7 -0
  73. package/src/tools/default-renderer.ts +1 -1
  74. package/src/tools/edit.ts +50 -43
  75. package/src/tools/fetch.ts +2 -1
  76. package/src/tools/find.ts +6 -3
  77. package/src/tools/github.ts +14 -46
  78. package/src/tools/glob.ts +2 -1
  79. package/src/tools/grep.ts +43 -287
  80. package/src/tools/grouped-file-output.ts +250 -64
  81. package/src/tools/hashline-format.ts +5 -0
  82. package/src/tools/json-tree.ts +1 -5
  83. package/src/tools/lsp.ts +53 -25
  84. package/src/tools/native-view.ts +11 -5
  85. package/src/tools/output-meta.ts +68 -4
  86. package/src/tools/proc-render.ts +3 -3
  87. package/src/tools/read.ts +14 -0
  88. package/src/tools/task.ts +34 -77
  89. package/src/tools/vibe.ts +4 -4
  90. package/src/tools/wait.ts +8 -5
  91. package/src/tools/write.ts +17 -27
  92. package/src/tools/yield.ts +1 -1
  93. package/src/utils.ts +10 -0
@@ -13,9 +13,9 @@
13
13
  * statusLine.attachToEditor(editor, getComposerStyle(shape));
14
14
  * ```
15
15
  */
16
+ import { type } from "@oh-my-pi/omptype";
16
17
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
18
  import type { Model } from "@oh-my-pi/pi-catalog/types";
18
- import { isRecord } from "@oh-my-pi/pi-utils/type-guards";
19
19
  import { parseThinkingLevel } from "../thinking";
20
20
  import { StatusLineComponent } from "./component";
21
21
  import type { CompactionBoundaries } from "./context-usage";
@@ -100,82 +100,48 @@ export function createStartupStatusLine(data: StatusLineStartupData): StatusLine
100
100
  return statusLine;
101
101
  }
102
102
 
103
- function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T {
104
- return typeof value === "string" && values.some(candidate => candidate === value);
105
- }
106
-
107
- function isOptionalBoolean(value: unknown): boolean {
108
- return value === undefined || typeof value === "boolean";
109
- }
110
-
111
- function isSegmentList(value: unknown): boolean {
112
- return value === undefined || (Array.isArray(value) && value.every(id => isOneOf(STATUS_LINE_SEGMENT_IDS, id)));
113
- }
103
+ const SegmentIds = type.enumerated(...STATUS_LINE_SEGMENT_IDS).array();
114
104
 
115
105
  /** Shallow check of persisted settings; nested segment options are trusted as written by this version. */
116
- function isStatusLineSettings(value: unknown): value is StatusLineSettings {
117
- return (
118
- isRecord(value) &&
119
- (value.preset === undefined || isOneOf(STATUS_LINE_PRESET_VALUES, value.preset)) &&
120
- isSegmentList(value.leftSegments) &&
121
- isSegmentList(value.rightSegments) &&
122
- (value.separator === undefined || isOneOf(STATUS_LINE_SEPARATOR_VALUES, value.separator)) &&
123
- (value.segmentOptions === undefined || isRecord(value.segmentOptions)) &&
124
- (value.contextLine === undefined || isOneOf(CONTEXT_LINE_MODE_VALUES, value.contextLine)) &&
125
- isOptionalBoolean(value.showHookStatus) &&
126
- isOptionalBoolean(value.sessionAccent) &&
127
- isOptionalBoolean(value.transparent) &&
128
- isOptionalBoolean(value.compactThinkingLevel)
129
- );
130
- }
131
-
132
- /** Check the model fields the status line reads; the rest is trusted as serialized by this version. */
133
- function isPersistedModel(value: unknown): value is Model {
134
- return (
135
- isRecord(value) &&
136
- typeof value.id === "string" &&
137
- typeof value.name === "string" &&
138
- typeof value.provider === "string" &&
139
- typeof value.api === "string" &&
140
- typeof value.contextWindow === "number"
141
- );
142
- }
106
+ const StatusLineSettingsShape = type({
107
+ "preset?": type.enumerated(...STATUS_LINE_PRESET_VALUES),
108
+ "leftSegments?": SegmentIds,
109
+ "rightSegments?": SegmentIds,
110
+ "separator?": type.enumerated(...STATUS_LINE_SEPARATOR_VALUES),
111
+ "segmentOptions?": "Record<string, unknown>",
112
+ "contextLine?": type.enumerated(...CONTEXT_LINE_MODE_VALUES),
113
+ "showHookStatus?": "boolean",
114
+ "sessionAccent?": "boolean",
115
+ "transparent?": "boolean",
116
+ "compactThinkingLevel?": "boolean",
117
+ });
143
118
 
144
- function readCompactionBoundaries(value: unknown): CompactionBoundaries | null | undefined {
145
- if (value === null) return null;
146
- if (!isRecord(value) || typeof value.thresholdPercent !== "number") return undefined;
147
- const speculationPercent = value.speculationPercent;
148
- if (speculationPercent !== null && typeof speculationPercent !== "number") return undefined;
149
- return { thresholdPercent: value.thresholdPercent, speculationPercent };
150
- }
119
+ const StartupDataShape = type({
120
+ "+": "delete",
121
+ settings: StatusLineSettingsShape,
122
+ gitEnabled: "boolean",
123
+ // Only the fields the status line reads; the rest is trusted as serialized by this version.
124
+ "model?": { id: "string", name: "string", provider: "string", api: "string", contextWindow: "number" },
125
+ "thinkingLevel?": "string",
126
+ autoThinking: "boolean",
127
+ fastMode: "boolean",
128
+ usingSubscription: "boolean",
129
+ autoCompactEnabled: "boolean",
130
+ compactionBoundaries: type({ "+": "delete", thresholdPercent: "number", speculationPercent: "number | null" }).or(
131
+ "null",
132
+ ),
133
+ });
151
134
 
152
135
  /** Validate persisted {@link StatusLineStartupData}; `undefined` for anything malformed. */
153
136
  export function readStatusLineStartupData(value: unknown): StatusLineStartupData | undefined {
154
- if (!isRecord(value) || !isStatusLineSettings(value.settings)) return undefined;
155
- const model = value.model;
156
- if (model !== undefined && !isPersistedModel(model)) return undefined;
157
- const thinkingLevel = typeof value.thinkingLevel === "string" ? parseThinkingLevel(value.thinkingLevel) : undefined;
158
- if (value.thinkingLevel !== undefined && thinkingLevel === undefined) return undefined;
159
- const compactionBoundaries = readCompactionBoundaries(value.compactionBoundaries);
160
- if (
161
- typeof value.gitEnabled !== "boolean" ||
162
- typeof value.autoThinking !== "boolean" ||
163
- typeof value.fastMode !== "boolean" ||
164
- typeof value.usingSubscription !== "boolean" ||
165
- typeof value.autoCompactEnabled !== "boolean" ||
166
- compactionBoundaries === undefined
167
- ) {
168
- return undefined;
169
- }
137
+ const data = StartupDataShape(value);
138
+ if (data instanceof type.errors) return undefined;
139
+ const thinkingLevel = data.thinkingLevel === undefined ? undefined : parseThinkingLevel(data.thinkingLevel);
140
+ if (data.thinkingLevel !== undefined && thinkingLevel === undefined) return undefined;
170
141
  return {
171
- settings: value.settings,
172
- gitEnabled: value.gitEnabled,
173
- model,
142
+ ...data,
143
+ settings: data.settings as StatusLineSettings,
144
+ model: data.model as Model | undefined,
174
145
  thinkingLevel,
175
- autoThinking: value.autoThinking,
176
- fastMode: value.fastMode,
177
- usingSubscription: value.usingSubscription,
178
- autoCompactEnabled: value.autoCompactEnabled,
179
- compactionBoundaries,
180
146
  };
181
147
  }
@@ -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";
@@ -19,6 +19,7 @@ export type SymbolKey =
19
19
  | "status.running"
20
20
  | "status.shadowed"
21
21
  | "status.aborted"
22
+ /** @deprecated Alias of `status.success`; kept so extensions and theme overrides that name it keep working. */
22
23
  | "status.done"
23
24
  // Navigation
24
25
  | "nav.cursor"
@@ -212,6 +213,8 @@ export type SymbolKey =
212
213
  | "advisor.rail"
213
214
  // Skill callout rail (user turn that opened with a skill chip)
214
215
  | "skill.rail"
216
+ // /btw steering-prompt rail (the <inject> block a side answer drafted)
217
+ | "btw.rail"
215
218
  // Language/file type icons
216
219
  | "lang.default"
217
220
  | "lang.typescript"
@@ -404,7 +407,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
404
407
  "status.running": "⟳",
405
408
  "status.shadowed": "○",
406
409
  "status.aborted": "⏹",
407
- "status.done": "•",
410
+ "status.done": "✔",
408
411
  // Navigation
409
412
  "nav.cursor": "❯",
410
413
  "nav.selected": "➤",
@@ -598,6 +601,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
598
601
  // Advisor note rail (heavier than md.quoteBorder so notes read as a distinct voice)
599
602
  "advisor.rail": "▎",
600
603
  "skill.rail": "▎",
604
+ "btw.rail": "▎",
601
605
  // Language/file icons (emoji-centric, no Nerd Font required)
602
606
  "lang.default": "⌘",
603
607
  "lang.typescript": "🟦",
@@ -721,8 +725,8 @@ const NERD_SYMBOLS: SymbolMap = {
721
725
  "status.shadowed": "\uf10c",
722
726
  // pick:  | alt:  
723
727
  "status.aborted": "\uf04d",
724
- // pick: • | alt: ● ·
725
- "status.done": "•",
728
+ // alias of status.success
729
+ "status.done": "\uf00c",
726
730
  // Navigation
727
731
  // pick:  | alt:  
728
732
  "nav.cursor": "\uf054",
@@ -1072,6 +1076,7 @@ const NERD_SYMBOLS: SymbolMap = {
1072
1076
  // pick: ▎ | alt: ┃ │
1073
1077
  "advisor.rail": "▎",
1074
1078
  "skill.rail": "▎",
1079
+ "btw.rail": "▎",
1075
1080
  // Language icons (nerd font devicons)
1076
1081
  "lang.default": "",
1077
1082
  "lang.typescript": "\u{E628}",
@@ -1186,17 +1191,17 @@ const NERD_SYMBOLS: SymbolMap = {
1186
1191
 
1187
1192
  const ASCII_SYMBOLS: SymbolMap = {
1188
1193
  // Status Indicators
1189
- "status.success": "[ok]",
1190
- "status.error": "[!!]",
1194
+ "status.success": "[+]",
1195
+ "status.error": "[-]",
1191
1196
  "status.warning": "[!]",
1192
1197
  "status.info": "[i]",
1193
- "status.pending": "[*]",
1198
+ "status.pending": "[ ]",
1194
1199
  "status.disabled": "[ ]",
1195
1200
  "status.enabled": "[x]",
1196
1201
  "status.running": "[~]",
1197
1202
  "status.shadowed": "[/]",
1198
- "status.aborted": "[-]",
1199
- "status.done": "*",
1203
+ "status.aborted": "[#]",
1204
+ "status.done": "[+]",
1200
1205
  // Navigation
1201
1206
  "nav.cursor": ">",
1202
1207
  "nav.selected": "->",
@@ -1386,6 +1391,7 @@ const ASCII_SYMBOLS: SymbolMap = {
1386
1391
  "md.colorSwatch": "[]",
1387
1392
  "advisor.rail": "|",
1388
1393
  "skill.rail": "|",
1394
+ "btw.rail": "|",
1389
1395
  // Language icons (ASCII uses abbreviations)
1390
1396
  "lang.default": "code",
1391
1397
  "lang.typescript": "ts",
@@ -1502,7 +1508,8 @@ export const SPINNER_FRAMES: Record<SymbolPreset, Record<SpinnerType, string[]>>
1502
1508
  activity: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
1503
1509
  },
1504
1510
  ascii: {
1505
- status: ["|", "/", "-", "\\"],
1511
+ // Bracketed like the `status.*` glyphs, so a settling call keeps its column width.
1512
+ status: ["[|]", "[/]", "[-]", "[\\]"],
1506
1513
  activity: ["-", "\\", "|", "/"],
1507
1514
  },
1508
1515
  };
@@ -243,6 +243,8 @@ export class Theme {
243
243
  logger.debug("Invalid symbol key in override", { key, availableKeys: Object.keys(this.#symbols) });
244
244
  }
245
245
  }
246
+ // `status.done` aliases `status.success` unless a theme overrides it explicitly.
247
+ if (!("status.done" in symbolOverrides)) this.#symbols["status.done"] = this.#symbols["status.success"];
246
248
  this.#spinnerFramesOverrides = spinnerFramesOverrides;
247
249
  }
248
250
 
@@ -528,6 +530,7 @@ export class Theme {
528
530
  running: this.#symbols["status.running"],
529
531
  shadowed: this.#symbols["status.shadowed"],
530
532
  aborted: this.#symbols["status.aborted"],
533
+ /** @deprecated Alias of `success`. */
531
534
  done: this.#symbols["status.done"],
532
535
  };
533
536
  }
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;
@@ -74,20 +74,22 @@ export function renderAgentTreeRow(
74
74
  const task = options.presentation === "task";
75
75
  const live = status === "pending" || status === "running";
76
76
  const failed = status === "failed" || status === "aborted";
77
- const iconColor = status === "completed" ? "success" : failed ? "error" : "accent";
78
77
  const nameColor = task && live && options.frozen ? "dim" : task && status === "completed" ? "text" : "accent";
79
- const iconStatus = status === "failed" ? "error" : status === "completed" ? "done" : status;
78
+ const iconStatus = status === "failed" ? "error" : status === "completed" ? "success" : status;
79
+ // Task rows draw a static glyph, so their bytes hold across spinner frames; eval rows animate.
80
+ const spinnerFrame = !task && status === "running" ? options.spinnerFrame : undefined;
81
+ // formatStatusIcon colors every glyph except a raw spinner frame, which eval rows paint accent.
82
+ // A frozen snapshot of a live Task row dims its glyph with its name, so it stops reading as in flight.
80
83
  const icon =
81
- task && !failed
82
- ? theme.styledSymbol("status.done", nameColor)
83
- : !task && status === "completed"
84
- ? theme.styledSymbol("tool.eval", "accent")
85
- : theme.fg(
86
- iconColor,
87
- formatStatusIcon(iconStatus, theme, status === "running" ? options.spinnerFrame : undefined),
88
- );
84
+ !task && status === "completed"
85
+ ? theme.styledSymbol("tool.eval", "accent")
86
+ : task && live && options.frozen
87
+ ? theme.fg("dim", status === "running" ? theme.status.running : theme.status.pending)
88
+ : spinnerFrame === undefined
89
+ ? formatStatusIcon(iconStatus, theme)
90
+ : theme.fg("accent", formatStatusIcon(iconStatus, theme, spinnerFrame));
89
91
  const lead = `${options.prefix}${options.prefix || !task ? " " : ""}${icon} `;
90
- const statusBadge = options.statusBadge ?? (failed ? ` ${formatBadge(status, iconColor, theme)}` : "");
92
+ const statusBadge = options.statusBadge ?? (failed ? ` ${formatBadge(status, "error", theme)}` : "");
91
93
  const id = Number.isFinite(width)
92
94
  ? truncateToWidth(options.id, Math.max(0, width - visibleWidth(lead) - visibleWidth(statusBadge)))
93
95
  : options.id;
package/src/tools/ask.ts CHANGED
@@ -25,7 +25,13 @@ import { outputBlockContentWidth, renderStatusLine } from "../render";
25
25
  import { framedToolCard } from "../render/tool-card";
26
26
  import { wrapPrefixedLine } from "../render/hanging-wrap";
27
27
 
28
- import { formatErrorMessage, formatMeta, formatTitle, sanitizeCarriageReturns } from "../render/render-utils";
28
+ import {
29
+ formatErrorMessage,
30
+ formatMeta,
31
+ formatStatusIcon,
32
+ formatTitle,
33
+ sanitizeCarriageReturns,
34
+ } from "../render/render-utils";
29
35
 
30
36
  /** Result for a single question */
31
37
  export interface QuestionResult {
@@ -176,10 +182,11 @@ function normalizeRenderQuestions(raw: unknown): NonNullable<AskRenderArgs["ques
176
182
  /** Render a custom free-text answer as a status line plus indented continuation rows. */
177
183
  function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] {
178
184
  const lines = customInput.split("\n");
179
- const out: string[] = [
180
- ` ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`,
181
- ];
182
- for (let i = 1; i < lines.length; i++) out.push(` ${uiTheme.fg("toolOutput", lines[i])}`);
185
+ const icon = formatStatusIcon("success", uiTheme);
186
+ // Continuation rows line up under the answer text, past the glyph's own width.
187
+ const indent = " ".repeat(visibleWidth(icon) + 2);
188
+ const out: string[] = [` ${icon} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`];
189
+ for (let i = 1; i < lines.length; i++) out.push(`${indent}${uiTheme.fg("toolOutput", lines[i])}`);
183
190
  return out;
184
191
  }
185
192
 
@@ -282,7 +289,7 @@ function renderAnswerOptionLines(
282
289
 
283
290
  // Nothing was chosen (and no custom answer) → a lone cancelled marker.
284
291
  if (selected.size === 0 && customInput === undefined && note === undefined) {
285
- return [` ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`];
292
+ return [` ${formatStatusIcon("warning", uiTheme)} ${uiTheme.fg("warning", "Cancelled")}`];
286
293
  }
287
294
 
288
295
  const out: string[] = [];
@@ -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 };