@quandev104/pi-style 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -2
  3. package/dist/extensions/pi-style.js +5881 -4929
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -2
  6. package/extension-src/pi-style/app/index.ts +0 -1
  7. package/extension-src/pi-style/domain/config-authorization.ts +1 -2
  8. package/extension-src/pi-style/domain/config-normalization.ts +3 -3
  9. package/extension-src/pi-style/domain/config-types.ts +2 -2
  10. package/extension-src/pi-style/domain/theme.ts +3 -0
  11. package/extension-src/pi-style/features/messages/boxed-block.ts +16 -20
  12. package/extension-src/pi-style/features/messages/index.ts +97 -40
  13. package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
  14. package/extension-src/pi-style/features/startup/index.ts +4 -4
  15. package/extension-src/pi-style/features/tools/boxed/bash.ts +395 -19
  16. package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
  18. package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
  19. package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
  21. package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
  22. package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
  23. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
  24. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +45 -24
  25. package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
  26. package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
  27. package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
  28. package/extension-src/pi-style/features/tools/index.ts +14 -0
  29. package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
  30. package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
  31. package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
  32. package/extension-src/pi-style/pi/config-session.ts +0 -2
  33. package/extension-src/pi-style/pi/index.ts +35 -1
  34. package/extension-src/pi-style/pi/session-coordinator.ts +48 -4
  35. package/extension-src/pi-style/shared/ansi.ts +3 -0
  36. package/extension-src/pi-style/shared/box.ts +210 -69
  37. package/extension-src/pi-style/shared/split-diff.ts +395 -86
  38. package/extension-src/pi-style/shared/theme-extras.ts +0 -2
  39. package/package.json +1 -1
@@ -0,0 +1,368 @@
1
+ // Boxless output-tree primitives shared by the ls/find/grep/bash renderers.
2
+ //
3
+ // ls/find/grep and bash `ls/find/grep/rg` results render their parsed output as
4
+ // a **boxless tree panel** — a summary header line followed by `├─/└─` rows —
5
+ // instead of a boxed command/response shell. This module owns:
6
+ //
7
+ // - output parsers that turn native tool text (entries, paths, `file:line:`
8
+ // match lines) into structured records, dropping trailing truncation notices;
9
+ // - `renderOutputTree`, which lays out a flat list of entries under a header
10
+ // (used by lone ls/find and bash ls/find);
11
+ // - `renderGrepTree`, which lays out grep matches grouped by file (used by grep
12
+ // and bash grep/rg).
13
+ //
14
+ // Design notes:
15
+ // - Pure + theme-consuming: no filesystem, no global state, no caching (callers
16
+ // cache at the component boundary).
17
+ // - Every row is width-safe via safeTruncateToWidth; the header is never
18
+ // truncated by this layer (callers pass a concise, pre-sized header).
19
+ // - Tree indent matches the quiet-tool batch panel (` ├─`) so the panels read
20
+ // as one visual family.
21
+
22
+ import type { BoxTheme } from "../../../shared/box.js";
23
+ import { safeTruncateToWidth } from "../../../shared/render-budget.js";
24
+
25
+ /** Indent for top-level tree rows; matches the quiet-tool batch panel. */
26
+ export const TREE_INDENT = " ";
27
+ /** Extra indent for rows nested under a grouping node. */
28
+ export const TREE_CHILD_INDENT = " ";
29
+ /** Default number of entries/matches shown before collapsing to "… N more". */
30
+ export const OUTPUT_TREE_HEAD_LIMIT = 6;
31
+
32
+ // ── Nerd Font file-type icons ───────────────────────────────────────────────
33
+ // Only used when the session glyph mode is nerd (see withIcons). Unicode/ASCII
34
+ // modes render plain entries.
35
+
36
+ const FILE_ICON_FOLDER = "\u{F415}"; // (nf-md-folder)
37
+ const FILE_ICON_DEFAULT = "\u{E612}"; // (nf-seti-default)
38
+ /** Search (magnifying-glass) icon for find/grep headers (nf-fa-search). */
39
+ export const SEARCH_ICON = "\u{F002}";
40
+ const FILE_ICONS: Readonly<Record<string, string>> = {
41
+ ts: "\u{E628}", // (nf-seti-typescript)
42
+ tsx: "\u{E7BA}", // (nf-seti-react)
43
+ js: "\u{E62C}", // (nf-seti-javascript)
44
+ jsx: "\u{E7BA}", // (nf-seti-react)
45
+ mjs: "\u{E62C}",
46
+ cjs: "\u{E62C}",
47
+ json: "\u{E62B}", // (nf-seti-json)
48
+ md: "\u{E609}", // (nf-seti-markdown)
49
+ mdx: "\u{E609}",
50
+ css: "\u{E749}", // (nf-seti-css)
51
+ scss: "\u{E749}",
52
+ sass: "\u{E749}",
53
+ less: "\u{E749}",
54
+ html: "\u{E60E}", // (nf-seti-html)
55
+ htm: "\u{E60E}",
56
+ py: "\u{E606}", // (nf-seti-python)
57
+ go: "\u{E627}", // (nf-seti-go)
58
+ rs: "\u{E7A8}", // (nf-seti-rust)
59
+ sh: "\u{E795}", // (nf-seti-shell)
60
+ bash: "\u{E795}",
61
+ zsh: "\u{E795}",
62
+ fish: "\u{E795}",
63
+ yml: "\u{E615}", // (nf-seti-yaml)
64
+ yaml: "\u{E615}",
65
+ toml: "\u{E615}",
66
+ java: "\u{E738}", // (nf-seti-java)
67
+ c: "\u{E61E}", // (nf-seti-c)
68
+ h: "\u{E61E}",
69
+ cpp: "\u{E61E}",
70
+ hpp: "\u{E61E}",
71
+ cs: "\u{E61E}",
72
+ svg: "\u{E62A}", // (nf-seti-svg)
73
+ png: "\u{E61D}", // (nf-seti-image)
74
+ jpg: "\u{E61D}",
75
+ jpeg: "\u{E61D}",
76
+ gif: "\u{E61D}",
77
+ webp: "\u{E61D}",
78
+ pdf: "\u{E67A}", // (nf-seti-pdf)
79
+ dockerfile: "\u{E7B0}", // (nf-seti-docker)
80
+ lock: "\u{E7B0}",
81
+ gitignore: "\u{E702}", // (nf-seti-git)
82
+ gitattributes: "\u{E702}",
83
+ vue: "\u{ED43}", // (nf-vue)
84
+ svelte: "\u{E697}",
85
+ };
86
+
87
+ /** Nerd Font file-type icon for a path, or "" when not applicable. */
88
+ export function fileIcon(path: string): string {
89
+ if (path.endsWith("/")) return FILE_ICON_FOLDER;
90
+ const name = path.split("/").pop() ?? path;
91
+ const lower = name.toLowerCase();
92
+ const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".") + 1) : lower;
93
+ return FILE_ICONS[ext] ?? FILE_ICONS[lower] ?? FILE_ICON_DEFAULT;
94
+ }
95
+
96
+ /** Lines produced by these native tools to signal truncation (already in the
97
+ * output text, not separate metadata). Dropped before parsing. */
98
+ const NOTICE_LINE_PATTERN = /^\[[^\]]*\]$/;
99
+
100
+ /** A parsed grep match line. Context lines are not surfaced in the tree. */
101
+ export interface GrepMatch {
102
+ readonly file: string;
103
+ readonly line: number;
104
+ readonly content: string;
105
+ }
106
+
107
+ /** Drop trailing tool notices (`[Showing last …]`, `[Truncated: …]`) and blanks. */
108
+ function stripNotices(text: string): string[] {
109
+ return text
110
+ .replace(/\r/g, "")
111
+ .split("\n")
112
+ .map((line) => line.trimEnd())
113
+ .filter((line) => line.length > 0 && !NOTICE_LINE_PATTERN.test(line.trim()));
114
+ }
115
+
116
+ /**
117
+ * Parse native `ls` output into display entries. Directories keep their `/`
118
+ * suffix; the `(empty directory)` placeholder and truncation notices are
119
+ * removed. Output is already sorted alphabetically by the tool.
120
+ */
121
+ export function parseLsOutput(rawText: string): string[] {
122
+ return stripNotices(rawText)
123
+ .map((line) => line.trim())
124
+ .filter((line) => line.length > 0 && line !== "(empty directory)");
125
+ }
126
+
127
+ /**
128
+ * Parse `ls -l`/`ls -la` long-format output into display entries: the entry
129
+ * name is the text after the time column; directory names get a trailing `/`.
130
+ * The `total N` summary and `.`/`..` entries are dropped. Standard POSIX
131
+ * columns: perms links owner group size month day time name. macOS `@`/`+`
132
+ * permission suffixes are tolerated.
133
+ */
134
+ export function parseLsLongOutput(rawText: string): string[] {
135
+ const entries: string[] = [];
136
+ for (const line of stripNotices(rawText)) {
137
+ if (!/^[bcdlsp-][rwxtsST-]{9}/.test(line)) continue;
138
+ const parts = line.split(/\s+/);
139
+ const name = parts.slice(8).join(" ").trim();
140
+ if (!name || name === "." || name === "..") continue;
141
+ const isDir = (parts[0] ?? "").startsWith("d");
142
+ entries.push(isDir ? `${name}/` : name);
143
+ }
144
+ return entries;
145
+ }
146
+
147
+ /**
148
+ * Parse native `find` output into display paths (one per line). Notices are
149
+ * removed. The native tool returns paths relative to the search directory.
150
+ */
151
+ export function parseFindOutput(rawText: string): string[] {
152
+ return stripNotices(rawText)
153
+ .map((line) => line.trim())
154
+ .filter((line) => line.length > 0);
155
+ }
156
+
157
+ // Match line: path/to/file.ts:42: matched content (Pi grep adds a space;
158
+ // ripgrep/grep emit no space). Context lines (path-line- …) are dropped.
159
+ const GREP_MATCH_PATTERN = /^(.*):(\d+):[ \t]?(.*)$/;
160
+ // Single-file ripgrep/grep output: `42: content` (no filename).
161
+ const GREP_BARE_PATTERN = /^(\d+):[ \t]?(.*)$/;
162
+
163
+ /**
164
+ * Parse native `grep` output into match records. Only real match lines
165
+ * (`file:line: content`) are kept; context lines (`file-line- …`) are dropped
166
+ * so the tree stays focused on hits. Trailing notices are removed.
167
+ */
168
+ export function parseGrepOutput(rawText: string): GrepMatch[] {
169
+ const matches: GrepMatch[] = [];
170
+ for (const line of stripNotices(rawText)) {
171
+ const match = GREP_MATCH_PATTERN.exec(line);
172
+ if (!match) continue;
173
+ const [, file, lineNo, content] = match;
174
+ if (!file || lineNo === undefined || content === undefined) continue;
175
+ const parsed = Number(lineNo);
176
+ if (!Number.isFinite(parsed) || parsed < 1) continue;
177
+ matches.push({ file, line: parsed, content });
178
+ }
179
+ return matches;
180
+ }
181
+
182
+ /**
183
+ * Parse single-file grep/ripgrep output — `line: content` without a filename —
184
+ * attributing every match to the given file. Used by bash `grep pattern file`.
185
+ */
186
+ export function parseGrepBareOutput(rawText: string, file: string): GrepMatch[] {
187
+ const matches: GrepMatch[] = [];
188
+ for (const line of stripNotices(rawText)) {
189
+ const match = GREP_BARE_PATTERN.exec(line);
190
+ if (!match) continue;
191
+ const parsed = Number(match[1]);
192
+ if (!Number.isFinite(parsed) || parsed < 1) continue;
193
+ matches.push({ file, line: parsed, content: match[2] ?? "" });
194
+ }
195
+ return matches;
196
+ }
197
+
198
+ /** Group grep matches by file, preserving first-seen order. */
199
+ export function groupMatchesByFile(matches: readonly GrepMatch[]): { file: string; matches: GrepMatch[] }[] {
200
+ const order: string[] = [];
201
+ const buckets = new Map<string, GrepMatch[]>();
202
+ for (const match of matches) {
203
+ let bucket = buckets.get(match.file);
204
+ if (!bucket) {
205
+ bucket = [];
206
+ buckets.set(match.file, bucket);
207
+ order.push(match.file);
208
+ }
209
+ bucket.push(match);
210
+ }
211
+ return order.map((file) => ({ file, matches: buckets.get(file) ?? [] }));
212
+ }
213
+
214
+ export interface OutputTreeOptions {
215
+ /** Maximum entries shown before the "… N more" row. */
216
+ headLimit?: number;
217
+ /** Singular noun used in the collapse row (default "file"); pluralized automatically. */
218
+ moreUnit?: string;
219
+ /** Optional ANSI-themed color for entry text (defaults to "toolOutput"). */
220
+ entryColor?: string;
221
+ /** Indent prefix applied to every row (defaults to TREE_INDENT). */
222
+ indent?: string;
223
+ /** Nerd Font mode: prefix each entry with its file-type icon. */
224
+ withIcons?: boolean;
225
+ }
226
+
227
+ /**
228
+ * Render a flat output tree: `<header>` then `├─/└─` rows for the first entries
229
+ * and a trailing `└─ … N more <unit>` row when truncated. Used by lone ls/find
230
+ * (and bash ls/find).
231
+ */
232
+ export function renderOutputTree(
233
+ theme: BoxTheme,
234
+ header: string,
235
+ entries: readonly string[],
236
+ width: number,
237
+ options: OutputTreeOptions = {},
238
+ ): string[] {
239
+ const headLimit = options.headLimit ?? OUTPUT_TREE_HEAD_LIMIT;
240
+ const moreUnit = options.moreUnit ?? "file";
241
+ const entryColor = options.entryColor ?? "toolOutput";
242
+ const indent = options.indent ?? TREE_INDENT;
243
+ const safeWidth = Math.max(1, width);
244
+ const label = (entry: string) => (options.withIcons && entry ? `${fileIcon(entry)} ${entry}` : entry);
245
+
246
+ const out: string[] = [safeTruncateToWidth(header, safeWidth, "…")];
247
+ if (entries.length === 0) return out;
248
+
249
+ const visible = entries.slice(0, headLimit);
250
+ const more = entries.length - visible.length;
251
+ const lastIndex = visible.length - 1;
252
+ for (let i = 0; i < visible.length; i++) {
253
+ const branch = i < lastIndex || more > 0 ? "├─" : "└─";
254
+ const line = `${indent}${theme.fg("borderMuted", branch)} ${theme.fg(entryColor, label(visible[i] ?? ""))}`;
255
+ out.push(safeTruncateToWidth(line, safeWidth, "…"));
256
+ }
257
+ if (more > 0) {
258
+ const line = `${indent}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${more} more ${pluralForm(moreUnit, more)}`)}`;
259
+ out.push(safeTruncateToWidth(line, safeWidth, "…"));
260
+ }
261
+ return out;
262
+ }
263
+
264
+ export interface GrepTreeOptions {
265
+ /** Maximum matches shown (across all files) before the "… N more" row. */
266
+ headLimit?: number;
267
+ /** Indent prefix applied to top-level rows. */
268
+ indent?: string;
269
+ /** Nerd Font mode: prefix file nodes with their file-type icon. */
270
+ withIcons?: boolean;
271
+ }
272
+
273
+ function formatMatchRow(theme: BoxTheme, match: GrepMatch): string {
274
+ // Match rows render in the output text color (not primary) so they read like
275
+ // the matched code; only the file nodes carry the primary color.
276
+ const label = theme.fg("toolOutput", `*${match.line}`);
277
+ const sep = theme.fg("borderMuted", "│");
278
+ return `${label}${sep} ${theme.fg("toolOutput", match.content)}`;
279
+ }
280
+
281
+ /**
282
+ * Render a grep matches tree: `<header>` then matches grouped by file. With a
283
+ * single file the matches are direct children; with several files each file is
284
+ * a `├─ file` node and its matches hang off an indented trunk beneath. A
285
+ * trailing `└─ … N more matches` row appears when the match budget is exceeded.
286
+ */
287
+ export function renderGrepTree(
288
+ theme: BoxTheme,
289
+ header: string,
290
+ matches: readonly GrepMatch[],
291
+ width: number,
292
+ options: GrepTreeOptions = {},
293
+ ): string[] {
294
+ const headLimit = options.headLimit ?? OUTPUT_TREE_HEAD_LIMIT;
295
+ const indent = options.indent ?? TREE_INDENT;
296
+ const safeWidth = Math.max(1, width);
297
+
298
+ const out: string[] = [safeTruncateToWidth(header, safeWidth, "…")];
299
+ if (matches.length === 0) return out;
300
+
301
+ const groups = groupMatchesByFile(matches);
302
+ const singleFile = groups.length === 1;
303
+
304
+ // First decide which matches fit the budget so branch glyphs (├─ vs └─) and
305
+ // the trailing "… N more" row stay consistent.
306
+ const budget = matches.slice(0, headLimit);
307
+ const remaining = matches.length - budget.length;
308
+ const truncated = remaining > 0;
309
+ const totalVisible = budget.length;
310
+
311
+ const push = (line: string) => out.push(safeTruncateToWidth(line, safeWidth, "…"));
312
+
313
+ if (singleFile) {
314
+ budget.forEach((match, index) => {
315
+ const isLast = index === totalVisible - 1 && !truncated;
316
+ push(`${indent}${theme.fg("borderMuted", isLast ? "└─" : "├─")} ${formatMatchRow(theme, match)}`);
317
+ });
318
+ } else {
319
+ // Walk the budget, tracking position within each file group so the file
320
+ // node and its match subtree render as one connected unit.
321
+ let shown = 0;
322
+ for (let gi = 0; gi < groups.length && shown < totalVisible; gi++) {
323
+ const group = groups[gi];
324
+ if (!group) continue;
325
+ const isLastGroup = gi === groups.length - 1;
326
+ const trunk = isLastGroup ? " " : theme.fg("borderMuted", "│");
327
+
328
+ const visibleHere: GrepMatch[] = [];
329
+ for (const match of group.matches) {
330
+ if (shown >= totalVisible) break;
331
+ visibleHere.push(match);
332
+ shown++;
333
+ }
334
+ if (visibleHere.length === 0) continue;
335
+
336
+ const groupIsLastRendered = shown >= totalVisible && !truncated;
337
+ const fileLabel = options.withIcons ? `${fileIcon(group.file)} ${group.file}` : group.file;
338
+ // File nodes use the primary (accent) color, matching read/ls/find paths.
339
+ push(`${indent}${theme.fg("borderMuted", groupIsLastRendered ? "└─" : "├─")} ${theme.fg("accent", fileLabel)}`);
340
+
341
+ visibleHere.forEach((match, index) => {
342
+ const isLastInGroup = index === visibleHere.length - 1;
343
+ const isLastOverall = groupIsLastRendered && isLastInGroup;
344
+ push(
345
+ `${indent}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", isLastOverall ? "└─" : "├─")} ${formatMatchRow(theme, match)}`,
346
+ );
347
+ });
348
+ }
349
+ }
350
+
351
+ if (truncated) {
352
+ push(
353
+ `${indent}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${remaining} more ${pluralForm("match", remaining)}`)}`,
354
+ );
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /** Return the pluralized form of a noun for the given count. */
360
+ export function pluralForm(noun: string, count: number): string {
361
+ if (count === 1) return noun;
362
+ return /(s|x|z|ch|sh)$/i.test(noun) ? `${noun}es` : `${noun}s`;
363
+ }
364
+
365
+ /** Pluralize a count noun: "1 file" / "3 files", "1 match" / "3 matches". */
366
+ export function pluralize(count: number, noun: string): string {
367
+ return `${count} ${pluralForm(noun, count)}`;
368
+ }
@@ -1,16 +1,16 @@
1
1
  // Boxed quick-edit / substitute-edit / target-edit renderer.
2
2
 
3
3
  import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
4
- import { Text } from "@earendil-works/pi-tui";
5
4
  import { stripAnsi } from "../../../shared/ansi.js";
6
- import type { BoxTheme } from "../../../shared/box.js";
7
5
  import {
8
- formatBoxedFooterFromValues,
6
+ type BoxTheme,
7
+ boxInnerWidth,
9
8
  getTextOutput,
10
9
  renderBoxedToolCall,
11
10
  renderBoxedToolResult,
12
11
  } from "../../../shared/box.js";
13
- import { buildSplitRows, countDiffStats, renderDiffMeter, SplitDiffComponent } from "../../../shared/split-diff.js";
12
+ import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
13
+ import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../shared/split-diff.js";
14
14
  import { getStateElapsedMs } from "./session-config.js";
15
15
  import { type BoxedToolContext, type BoxedToolDefinition, displayPath, noteExecutionStart } from "./shared.js";
16
16
 
@@ -99,8 +99,27 @@ function extractQuickEditDiff(text: string): string | undefined {
99
99
  return diffLines.length > 0 ? diffLines.join("\n") : undefined;
100
100
  }
101
101
 
102
- function quickEditFooter(theme: BoxTheme, context: BoxedToolContext, output = ""): string {
103
- return formatBoxedFooterFromValues(theme, getStateElapsedMs(context.state), output);
102
+ /** `Diff · +3 -0` divider label. */
103
+ function quickEditDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
104
+ const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
105
+ const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
106
+ return `Diff · ${plus} ${minus}`;
107
+ }
108
+
109
+ /** Quick-edit footer: `1 file · +3 -0`, prefixed with elapsed time when known. */
110
+ function quickEditDiffFooter(
111
+ theme: BoxTheme,
112
+ result: { content?: readonly unknown[]; details?: unknown },
113
+ context: BoxedToolContext,
114
+ stats: { additions: number; removals: number },
115
+ ): string {
116
+ const elapsedMs = getElapsedMs(result) ?? getStateElapsedMs(context.state);
117
+ const parts: string[] = [];
118
+ if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
119
+ const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
120
+ const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
121
+ parts.push(theme.fg("dim", "1 file"), `${plus} ${minus}`);
122
+ return parts.join(theme.fg("dim", " · "));
104
123
  }
105
124
 
106
125
  function renderQuickEditResult(
@@ -122,7 +141,7 @@ function renderQuickEditResult(
122
141
  const output = getTextOutput(result);
123
142
  if (context.isError) {
124
143
  return renderBoxedToolResult(theme, () => [theme.fg("error", stripAnsi(output).trim() || "Error")], {
125
- footerLines: [quickEditFooter(theme, context, output)],
144
+ footerLines: [quickEditFooter(theme, context)],
126
145
  isError: true,
127
146
  });
128
147
  }
@@ -131,7 +150,7 @@ function renderQuickEditResult(
131
150
  if (!diff) {
132
151
  const fallback = stripAnsi(output).trim() || config.fallbackLabel;
133
152
  return renderBoxedToolResult(theme, () => [`${theme.fg("dim", "↳")} ${theme.fg("muted", fallback)}`], {
134
- footerLines: [quickEditFooter(theme, context, output)],
153
+ footerLines: [quickEditFooter(theme, context)],
135
154
  });
136
155
  }
137
156
 
@@ -141,43 +160,45 @@ function renderQuickEditResult(
141
160
  const language = argPath ? getLanguageFromPath(argPath) : undefined;
142
161
  const shouldHighlight =
143
162
  Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
144
-
145
- const { additions, removals } = countDiffStats(diff);
146
- const meter = renderDiffMeter(theme, additions, removals);
147
- const summary =
148
- `${theme.fg("dim", "↳")} ${theme.fg("muted", "diff")}` +
149
- ` ${theme.fg("toolDiffAdded", `+${additions}`)}` +
150
- ` ${theme.fg("toolDiffRemoved", `-${removals}`)}` +
151
- ` ${theme.fg("muted", "split")}` +
152
- (meter ? ` ${meter}` : "");
163
+ const stats = countDiffStats(diff);
153
164
 
154
165
  const maxRows = expanded ? 160 : 36;
155
- const split = new SplitDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
166
+ const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
167
+ const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
156
168
 
157
169
  return renderBoxedToolResult(
158
170
  theme,
159
171
  {
160
172
  render(width: number): string[] {
161
- const safeWidth = Math.max(20, width);
162
- const headerLines = new Text(summary, 0, 0).render(safeWidth);
163
- return [...headerLines, ...split.render(safeWidth)];
173
+ return diffView.render(width);
164
174
  },
165
175
  invalidate(): void {
166
- split.invalidate();
176
+ diffView.invalidate();
167
177
  },
168
178
  },
169
179
  {
170
- footerLines: [quickEditFooter(theme, context, output)],
180
+ dividerLabel: quickEditDividerLabel(theme, stats),
181
+ ...(expandHint ? { dividerRightLabel: expandHint } : {}),
182
+ footerLines: [quickEditDiffFooter(theme, result, context, stats)],
171
183
  },
172
184
  );
173
185
  }
174
186
 
187
+ function quickEditFooter(theme: BoxTheme, context: BoxedToolContext): string {
188
+ const elapsedMs = getStateElapsedMs(context.state);
189
+ const parts: string[] = [];
190
+ if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
191
+ parts.push(theme.fg("dim", "1 file"));
192
+ return parts.join(theme.fg("dim", " · "));
193
+ }
194
+
175
195
  export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition {
176
196
  return {
177
197
  call(args, theme, context) {
178
198
  noteExecutionStart(context);
179
199
  const detail = displayPath(String(args?.path ?? ""), context);
180
- return renderBoxedToolCall(theme, config.toolLabel, [`${theme.fg("dim", "Path: ")}${detail}`], {
200
+ return renderBoxedToolCall(theme, config.toolLabel, [], {
201
+ headerDetail: detail,
181
202
  isError: Boolean(context.isError),
182
203
  isPartial: Boolean(context.isPartial),
183
204
  isPending: Boolean(context.isPartial),