@quandev104/pi-style 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { Component } from "@earendil-works/pi-tui";
7
7
  import type { BoxTheme } from "../../shared/box.js";
8
- import { boxBorder, boxInnerWidth, boxInsetDivider, boxLine, boxLineWithRight, boxWidth } from "../../shared/box.js";
8
+ import { boxBlankLine, boxBorder, boxInnerWidth, boxLabeledBorder, boxLine, boxWidth } from "../../shared/box.js";
9
9
 
10
10
  export type MessageBlockOptions = {
11
11
  kind: string;
@@ -18,7 +18,7 @@ export type MessageBlockOptions = {
18
18
  };
19
19
 
20
20
  function formatMessageBlockTitle(theme: BoxTheme, kind: string, title?: string, icon = "➔"): string {
21
- const rawTitle = title ? `${icon} ${kind} | ${title}` : `${icon} ${kind}`;
21
+ const rawTitle = title ? `${icon} ${kind} · ${title}` : `${icon} ${kind}`;
22
22
  const coloredTitle = theme.fg("accent", rawTitle);
23
23
  return typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
24
24
  }
@@ -26,13 +26,17 @@ function formatMessageBlockTitle(theme: BoxTheme, kind: string, title?: string,
26
26
  /**
27
27
  * Render a boxed message block.
28
28
  *
29
+ * The title is embedded in the rounded top border, the body sits between
30
+ * blank padding rows, and the expand hint (when present) is embedded at the
31
+ * right end of the bottom border — no inset dividers.
32
+ *
29
33
  * Returns only border/content lines with foreground styling. Background is
30
34
  * applied by the parent Box (all patched components extend Box with a
31
35
  * customMessageBg bgFn), so this helper must NOT apply background itself —
32
36
  * that would create a double-background conflict.
33
37
  */
34
38
  export function renderBoxedMessageBlock(theme: BoxTheme, options: MessageBlockOptions): Component {
35
- const { kind, title, right, body, icon = "➔", hasDivider = true, cache: shouldCache = true } = options;
39
+ const { kind, title, right, body, icon = "➔", cache: shouldCache = true } = options;
36
40
  let cache: { width: number; lines: string[] } | null = null;
37
41
 
38
42
  return {
@@ -45,27 +49,19 @@ export function renderBoxedMessageBlock(theme: BoxTheme, options: MessageBlockOp
45
49
  const renderedWidth = boxWidth(width);
46
50
  const contentWidth = boxInnerWidth(renderedWidth);
47
51
  const titleLine = formatMessageBlockTitle(theme, kind, title, icon);
52
+ const bodyLines = body(contentWidth);
48
53
 
49
- const lines: string[] = [];
50
- lines.push(boxBorder(theme, "", "", renderedWidth));
51
-
54
+ const lines: string[] = [
55
+ boxLabeledBorder(theme, "", "", titleLine, undefined, renderedWidth),
56
+ boxBlankLine(theme, renderedWidth),
57
+ ...bodyLines.map((line) => boxLine(theme, line, renderedWidth)),
58
+ ];
59
+ if (bodyLines.length > 0) lines.push(boxBlankLine(theme, renderedWidth));
52
60
  if (right) {
53
- const rightStyled = theme.fg("dim", right);
54
- lines.push(boxLineWithRight(theme, titleLine, rightStyled, renderedWidth));
61
+ lines.push(boxLabeledBorder(theme, "╰", "╯", "", theme.fg("dim", right), renderedWidth));
55
62
  } else {
56
- lines.push(boxLine(theme, titleLine, renderedWidth));
57
- }
58
-
59
- const bodyLines = body(contentWidth);
60
- const showDivider = hasDivider === "auto" ? bodyLines.length > 0 : hasDivider;
61
- if (showDivider) {
62
- lines.push(boxInsetDivider(theme, renderedWidth));
63
+ lines.push(boxBorder(theme, "╰", "╯", renderedWidth));
63
64
  }
64
- for (const line of bodyLines) {
65
- lines.push(boxLine(theme, line, renderedWidth));
66
- }
67
-
68
- lines.push(boxBorder(theme, "└", "┘", renderedWidth));
69
65
 
70
66
  if (shouldCache) cache = { width, lines };
71
67
  return lines;
@@ -12,22 +12,66 @@ function extractOscEnvelope(line: string): OscParts | undefined {
12
12
  return { start: OSC133_ZONE_START, body: line.slice(OSC133_ZONE_START.length, bodyEnd), end: line.slice(bodyEnd) };
13
13
  }
14
14
 
15
- function prefixAtFirstContent(line: string, prefix: string): string {
15
+ const BG_RESET = "\x1b[49m";
16
+
17
+ /** Leading zero-width OSC sequences (e.g. OSC133 markers) of a line. */
18
+ function splitLeadingMarkers(line: string): { head: string; rest: string } {
16
19
  let index = 0;
20
+ while (line.startsWith("\x1b]", index)) {
21
+ const bel = line.indexOf("\x07", index + 2);
22
+ const st = line.indexOf("\x1b\\", index + 2);
23
+ const end = bel === -1 ? st : st === -1 ? bel : Math.min(bel, st);
24
+ if (end === -1) break;
25
+ index = end + 1;
26
+ }
27
+ return { head: line.slice(0, index), rest: line.slice(index) };
28
+ }
29
+
30
+ /** Leading SGR escape sequence of a line ("" when none). */
31
+ function leadingSgr(line: string): string {
32
+ if (!line.startsWith("\x1b[")) return "";
33
+ let index = 2;
17
34
  while (index < line.length) {
18
- if (line[index] === "\x1b" && line[index + 1] === "[") {
19
- index += 2;
20
- while (index < line.length && (line.charCodeAt(index) < 64 || line.charCodeAt(index) > 126)) index++;
21
- if (index < line.length) index++;
22
- continue;
23
- }
24
- if (/\s/u.test(line[index] ?? "")) {
25
- index++;
26
- continue;
27
- }
28
- break;
35
+ const code = line.charCodeAt(index);
36
+ if (code >= 64 && code <= 126) return line.slice(0, index + 1);
37
+ index++;
29
38
  }
30
- return `${line.slice(0, index)}${prefix}${line.slice(index)}`;
39
+ return "";
40
+ }
41
+
42
+ /** Whether an SGR sequence sets/resets the terminal background color. */
43
+ function isBackgroundSgr(sequence: string): boolean {
44
+ if (!sequence.startsWith("\x1b[") || !sequence.endsWith("m")) return false;
45
+ for (const code of sequence.slice(2, -1).split(";")) {
46
+ const value = Number(code);
47
+ if (value === 48 || value === 49) return true;
48
+ if (value >= 40 && value <= 47) return true;
49
+ if (value >= 100 && value <= 107) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Rebuild a native line so `lead` (prompt prefix / continuation indent) and the
56
+ * full target width are covered by the line's background.
57
+ *
58
+ * Native Box lines (user messages) are `bgAnsi + body + \x1b[49m`; prepending the
59
+ * prefix outside that wrap shifted the input row's background right by the
60
+ * prefix width while keeping the full container width, producing a staircase
61
+ * box (indented left, overflowing right). Rebuilding inside the wrap keeps the
62
+ * background flush across every row; plain (unwrapped) lines are padded to the
63
+ * target width instead so left/right edges stay aligned.
64
+ */
65
+ function rebuildAtWidth(line: string, width: number, lead: string): string {
66
+ const { head, rest } = splitLeadingMarkers(line);
67
+ const bgAnsi = leadingSgr(rest);
68
+ if (bgAnsi && isBackgroundSgr(bgAnsi) && rest.endsWith(BG_RESET)) {
69
+ const body = rest.slice(bgAnsi.length, rest.length - BG_RESET.length);
70
+ const pad = " ".repeat(Math.max(0, width - visibleWidth(lead) - visibleWidth(body)));
71
+ return `${head}${bgAnsi}${lead}${body}${pad}${BG_RESET}`;
72
+ }
73
+ const padded = `${lead}${line}`;
74
+ return `${padded}${" ".repeat(Math.max(0, width - visibleWidth(padded)))}`;
31
75
  }
32
76
 
33
77
  function decorateMessageLine(
@@ -35,6 +79,7 @@ function decorateMessageLine(
35
79
  index: number,
36
80
  lastIndex: number,
37
81
  contentIndex: number,
82
+ width: number,
38
83
  options: {
39
84
  firstEnvelope: OscParts | undefined;
40
85
  firstHasStart: boolean;
@@ -44,14 +89,29 @@ function decorateMessageLine(
44
89
  ): string {
45
90
  const { firstEnvelope, firstHasStart, multilineEnvelope, prefix } = options;
46
91
  const prefixWidth = visibleWidth(prefix);
92
+ const lead = index === contentIndex ? prefix : index > contentIndex ? " ".repeat(prefixWidth) : "";
47
93
  if (index === contentIndex && firstEnvelope)
48
- return `${firstEnvelope.start}${prefixAtFirstContent(firstEnvelope.body, prefix)}${firstEnvelope.end}`;
94
+ return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix)}${firstEnvelope.end}`;
49
95
  if (index === contentIndex && firstHasStart)
50
- return `${OSC133_ZONE_START}${prefix}${line.slice(OSC133_ZONE_START.length)}`;
96
+ return `${OSC133_ZONE_START}${rebuildAtWidth(line.slice(OSC133_ZONE_START.length), width, prefix)}`;
51
97
  if (index === lastIndex && multilineEnvelope && index !== contentIndex)
52
- return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length)}`;
53
- if (index === contentIndex) return `${prefix}${line}`;
54
- return index > contentIndex ? `${" ".repeat(prefixWidth)}${line}` : line;
98
+ return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${rebuildAtWidth(
99
+ line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length),
100
+ width,
101
+ lead,
102
+ )}`;
103
+ if (
104
+ index === contentIndex &&
105
+ index === lastIndex &&
106
+ multilineEnvelope &&
107
+ line.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL)
108
+ )
109
+ return `${OSC133_ZONE_END}${OSC133_ZONE_FINAL}${rebuildAtWidth(
110
+ line.slice((OSC133_ZONE_END + OSC133_ZONE_FINAL).length),
111
+ width,
112
+ prefix,
113
+ )}`;
114
+ return rebuildAtWidth(line, width, lead);
55
115
  }
56
116
 
57
117
  function contentText(line: string): string {
@@ -88,23 +148,26 @@ function prefixNative(lines: unknown, width: number, prefix: string): string[] |
88
148
  const first = nativeLines[0] ?? "";
89
149
  const last = nativeLines.at(-1) ?? "";
90
150
  const multilineEnvelope = nativeLines.length > 1 && last.startsWith(OSC133_ZONE_END + OSC133_ZONE_FINAL);
91
- const firstContentIndex = nativeLines.findIndex((line, index) =>
92
- index !== nativeLines.length - 1 || !multilineEnvelope ? hasContent(line) : false,
93
- );
151
+ // The last line is a content-start candidate only when the envelope is
152
+ // single-line, or when no earlier line carries content. Assistant messages
153
+ // with a single content line render as a multiline envelope whose only body
154
+ // sits on the final line ([OSC133_A, OSC133_END+FINAL+body]); excluding it
155
+ // would drop the prefix for every short assistant reply.
156
+ const firstContentIndex = nativeLines.findIndex((line, index) => {
157
+ if (index !== nativeLines.length - 1 || !multilineEnvelope) return hasContent(line);
158
+ return !nativeLines.slice(0, index).some((earlier) => hasContent(earlier)) && hasContent(line);
159
+ });
94
160
  if (firstContentIndex < 0) return nativeLines;
95
161
  const firstEnvelope = firstContentIndex === 0 ? extractOscEnvelope(first) : undefined;
96
162
  const firstHasStart = firstContentIndex === 0 && first.startsWith(OSC133_ZONE_START);
97
- const decorated = nativeLines.map((line, index) => {
98
- if (index === firstContentIndex)
99
- return decorateMessageLine(line, index, nativeLines.length - 1, firstContentIndex, {
100
- firstEnvelope,
101
- firstHasStart,
102
- multilineEnvelope,
103
- prefix,
104
- });
105
- if (index > firstContentIndex && hasContent(line)) return `${" ".repeat(prefixWidth)}${line}`;
106
- return line;
107
- });
163
+ const decorated = nativeLines.map((line, index) =>
164
+ decorateMessageLine(line, index, nativeLines.length - 1, firstContentIndex, width, {
165
+ firstEnvelope,
166
+ firstHasStart,
167
+ multilineEnvelope,
168
+ prefix,
169
+ }),
170
+ );
108
171
  if (!decorated.every((line) => visibleWidth(line) <= width)) return undefined;
109
172
  if (!nativeLines.every((line) => visibleWidth(line) <= bodyWidth)) return undefined;
110
173
  return decorated;
@@ -106,7 +106,7 @@ function patchCompaction(instance: MessageBlockInstance, _original: () => void,
106
106
  const block = renderBoxedMessageBlock(theme, {
107
107
  kind: "Compaction",
108
108
  title: `${tokenStr} tokens`,
109
- ...(expanded ? {} : { right: `(${expandHint()})` }),
109
+ ...(expanded ? {} : { right: expandHint() }),
110
110
  body,
111
111
  icon: "⊟",
112
112
  hasDivider: expanded,
@@ -132,7 +132,7 @@ function patchSkill(instance: MessageBlockInstance, _original: () => void, theme
132
132
  const block = renderBoxedMessageBlock(theme, {
133
133
  kind: "Skill",
134
134
  title: skillName,
135
- ...(expanded ? {} : { right: `(${expandHint()})` }),
135
+ ...(expanded ? {} : { right: expandHint() }),
136
136
  body,
137
137
  icon: "⊟",
138
138
  hasDivider: expanded,
@@ -156,7 +156,7 @@ function patchBranch(instance: MessageBlockInstance, _original: () => void, them
156
156
 
157
157
  const block = renderBoxedMessageBlock(theme, {
158
158
  kind: "Branch",
159
- ...(expanded ? {} : { right: `(${expandHint()})` }),
159
+ ...(expanded ? {} : { right: expandHint() }),
160
160
  body,
161
161
  icon: "⊟",
162
162
  hasDivider: expanded,
@@ -193,7 +193,7 @@ function renderSystemContextPanel(
193
193
  "─".repeat(pathWidth),
194
194
  )}${divider}${resolved.apply("dim", "─".repeat(metricWidth))}`;
195
195
  const lines = [
196
- renderPanelBorder(resolved, "", "", panelWidth),
196
+ renderPanelBorder(resolved, "", "", panelWidth),
197
197
  renderPanelLine(resolved, titleLine, panelWidth),
198
198
  renderPanelLine(resolved, header, panelWidth),
199
199
  renderPanelLine(resolved, separator, panelWidth),
@@ -215,7 +215,7 @@ function renderSystemContextPanel(
215
215
  ),
216
216
  );
217
217
  }
218
- lines.push(renderPanelBorder(resolved, "", "", panelWidth));
218
+ lines.push(renderPanelBorder(resolved, "", "", panelWidth));
219
219
  return lines;
220
220
  }
221
221
 
@@ -269,7 +269,7 @@ function renderToolsPanel(resolved: ResolvedTheme, tools: readonly StartupToolIt
269
269
  "─".repeat(countWidth),
270
270
  )}${divider}${resolved.apply("dim", "─".repeat(toolsWidth))}`;
271
271
  const lines = [
272
- renderPanelBorder(resolved, "", "", panelWidth),
272
+ renderPanelBorder(resolved, "", "", panelWidth),
273
273
  renderPanelLine(resolved, titleLine, panelWidth),
274
274
  renderPanelLine(resolved, header, panelWidth),
275
275
  renderPanelLine(resolved, separator, panelWidth),
@@ -291,7 +291,7 @@ function renderToolsPanel(resolved: ResolvedTheme, tools: readonly StartupToolIt
291
291
  ),
292
292
  );
293
293
  }
294
- lines.push(renderPanelBorder(resolved, "", "", panelWidth));
294
+ lines.push(renderPanelBorder(resolved, "", "", panelWidth));
295
295
  return lines;
296
296
  }
297
297
 
@@ -18,7 +18,6 @@ import { safeTruncateToWidth, truncateAtCodePointBoundary } from "../../../share
18
18
  import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
19
19
  import { type BoxedToolContext, type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
20
20
 
21
- const MAX_BASH_PREVIEW_LINES = 5;
22
21
  const MAX_LINE_CHARS = 2000;
23
22
  const ESC = "\x1b";
24
23
  const BASH_TOOL_NOTICE_PATTERN = /^\[Showing (?:last|lines)\b.*\. Full output: .+\]$/;
@@ -186,13 +185,17 @@ function renderBoxedBashResult(
186
185
  inner: Component,
187
186
  result: unknown,
188
187
  context: BoxedToolContext,
188
+ expandHint?: string,
189
189
  ): Component {
190
190
  const rawCommand = String(context?.args?.command ?? "...");
191
191
  const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
192
192
  return renderBoxedToolResult(theme, inner, {
193
193
  widthKey: bashWidthKey(rawCommand, context?.args?.timeout),
194
194
  referenceLines,
195
- footerLines: [formatBoxedFooter(theme, result as never, [`⏹ ${formatTimeout(context)}`], getElapsed(context))],
195
+ footerLines: [
196
+ formatBoxedFooter(theme, result as never, [`timeout ${formatTimeout(context)}`], getElapsed(context)),
197
+ ],
198
+ ...(expandHint ? { expandHint } : {}),
196
199
  isError: context.isError,
197
200
  isPartial: Boolean(context.isPartial),
198
201
  });
@@ -207,7 +210,6 @@ function createBashResultPreview(
207
210
  text: string,
208
211
  options: { expanded: boolean },
209
212
  color: "toolOutput" | "error",
210
- extraLinesBefore: number = 0,
211
213
  ): Component {
212
214
  let cacheKey = "";
213
215
  let cacheLines: string[] | null = null;
@@ -226,7 +228,7 @@ function createBashResultPreview(
226
228
 
227
229
  if (!expanded) {
228
230
  // Collapsed: only process the tail of the output
229
- const needed = MAX_BASH_PREVIEW_LINES;
231
+ const needed = cfg.maxCollapsedLines;
230
232
  let totalNewlines = 0;
231
233
  let scanFrom = 0; // default: take full text if fewer than needed newlines
232
234
  for (let i = text.length - 1; i >= 0; i--) {
@@ -262,18 +264,8 @@ function createBashResultPreview(
262
264
  : formatToolOutputLine(theme, truncated, "text");
263
265
  });
264
266
 
265
- // Count remaining lines (lines before scanFrom)
266
- const remaining = extraLinesBefore + (scanFrom > 0 ? countNewlines(text, 0, scanFrom) : 0);
267
-
268
- if (remaining <= 0) {
269
- cacheKey = cacheId;
270
- cacheLines = truncatedShown;
271
- return cacheLines;
272
- }
273
-
274
- const hint = safeTruncateToWidth(`... ${remaining} more lines, press Ctrl+o to expand`, bodyWidth, "…");
275
267
  cacheKey = cacheId;
276
- cacheLines = [...truncatedShown, "", theme.fg("muted", hint)];
268
+ cacheLines = truncatedShown;
277
269
  return cacheLines;
278
270
  }
279
271
 
@@ -323,7 +315,7 @@ export const bashTool: BoxedToolDefinition = {
323
315
  const outputColor = context.isError ? "error" : "toolOutput";
324
316
 
325
317
  if (!options.expanded) {
326
- const scanLines = MAX_BASH_PREVIEW_LINES + 10;
318
+ const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
327
319
  let nlCount = 0;
328
320
  let tailStart = 0;
329
321
  for (let i = raw.length - 1; i >= 0; i--) {
@@ -337,11 +329,11 @@ export const bashTool: BoxedToolDefinition = {
337
329
  }
338
330
  const tail = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart)));
339
331
  const totalLinesBefore = tailStart > 0 ? countNewlines(raw, 0, tailStart) : 0;
340
- const inner = createBashResultPreview(theme, tail, options, outputColor, totalLinesBefore);
341
- return renderBoxedBashResult(theme, inner, result, context);
332
+ const inner = createBashResultPreview(theme, tail, options, outputColor);
333
+ return renderBoxedBashResult(theme, inner, result, context, totalLinesBefore > 0 ? "Ctrl+O for more" : undefined);
342
334
  }
343
335
  const output = stripBashToolNoticeLines(stripAnsi(raw));
344
- const inner = createBashResultPreview(theme, output, options, outputColor, 0);
336
+ const inner = createBashResultPreview(theme, output, options, outputColor);
345
337
  return renderBoxedBashResult(theme, inner, result, context);
346
338
  },
347
339
  };
@@ -2,16 +2,15 @@
2
2
  // (renderCall/renderResult only; no edit-core re-registration).
3
3
 
4
4
  import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
5
- import { Text } from "@earendil-works/pi-tui";
6
5
  import { stripAnsi } from "../../../shared/ansi.js";
7
- import { getTextOutput, renderBoxedToolCall, renderBoxedToolResult } from "../../../shared/box.js";
6
+ import { type BoxTheme, getTextOutput, renderBoxedToolCall, renderBoxedToolResult } from "../../../shared/box.js";
7
+ import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
8
8
  import {
9
+ AdaptiveDiffComponent,
9
10
  buildSplitRows,
10
11
  countDiffStats,
11
12
  extractEditedPath,
12
13
  firstText,
13
- renderDiffMeter,
14
- SplitDiffComponent,
15
14
  } from "../../../shared/split-diff.js";
16
15
  import {
17
16
  type BoxedToolContext,
@@ -19,6 +18,7 @@ import {
19
18
  displayPath,
20
19
  noteExecutionStart,
21
20
  resultFooterLines,
21
+ stateElapsedMs,
22
22
  } from "./shared.js";
23
23
 
24
24
  const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
@@ -26,11 +26,35 @@ const MAX_HIGHLIGHT_DIFF_ROWS = 120;
26
26
 
27
27
  type EditResultDetails = { diff?: string; path?: string } | undefined;
28
28
 
29
+ /** `Diff · +3 -0` divider label. */
30
+ function diffDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
31
+ const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
32
+ const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
33
+ return `Diff · ${plus} ${minus}`;
34
+ }
35
+
36
+ /** Edit footer: `1 file · +3 -0`, prefixed with elapsed time when known. */
37
+ function editDiffFooter(
38
+ theme: BoxTheme,
39
+ result: { content?: readonly unknown[]; details?: unknown },
40
+ context: BoxedToolContext,
41
+ stats: { additions: number; removals: number },
42
+ ): string {
43
+ const elapsedMs = getElapsedMs(result) ?? stateElapsedMs(context);
44
+ const parts: string[] = [];
45
+ if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
46
+ const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
47
+ const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
48
+ parts.push(theme.fg("dim", "1 file"), `${plus} ${minus}`);
49
+ return parts.join(theme.fg("dim", " · "));
50
+ }
51
+
29
52
  export const editTool: BoxedToolDefinition = {
30
53
  call(args, theme, context) {
31
54
  noteExecutionStart(context);
32
55
  const detail = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
33
- return renderBoxedToolCall(theme, "Edit", [`${theme.fg("dim", "Path: ")}${detail}`], {
56
+ return renderBoxedToolCall(theme, "Edit", [], {
57
+ headerDetail: detail,
34
58
  isError: Boolean(context.isError),
35
59
  isPartial: Boolean(context.isPartial),
36
60
  isPending: Boolean(context.isPartial),
@@ -71,40 +95,32 @@ export const editTool: BoxedToolDefinition = {
71
95
  const sourcePath = details?.path ?? (argPath || extractEditedPath(message));
72
96
  const language = sourcePath ? getLanguageFromPath(sourcePath) : undefined;
73
97
 
74
- // Build split-diff rows
98
+ // Build diff rows + adaptive layout
75
99
  const rows = buildSplitRows(diff);
76
100
  const expanded = options.expanded;
77
101
  const shouldHighlight =
78
102
  Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
103
+ const stats = countDiffStats(diff);
79
104
 
80
- // Build summary header with diff stats and meter
81
- const { additions, removals } = countDiffStats(diff);
82
- const meter = renderDiffMeter(theme, additions, removals);
83
- const summary =
84
- `${theme.fg("dim", "↳")} ${theme.fg("muted", "diff")}` +
85
- ` ${theme.fg("toolDiffAdded", `+${additions}`)}` +
86
- ` ${theme.fg("toolDiffRemoved", `-${removals}`)}` +
87
- ` ${theme.fg("muted", "split")}` +
88
- (meter ? ` ${meter}` : "");
89
-
90
- // Render split-diff with syntax colors for small outputs.
105
+ // Render adaptive diff (unified/split per width) with syntax colors for small outputs.
91
106
  const maxRows = expanded ? 160 : 36;
92
- const split = new SplitDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
107
+ const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
108
+ const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
93
109
 
94
110
  return renderBoxedToolResult(
95
111
  theme,
96
112
  {
97
113
  render(width: number): string[] {
98
- const safeWidth = Math.max(20, width);
99
- const headerLines = new Text(summary, 0, 0).render(safeWidth);
100
- return [...headerLines, ...split.render(safeWidth)];
114
+ return diffView.render(width);
101
115
  },
102
116
  invalidate(): void {
103
- split.invalidate();
117
+ diffView.invalidate();
104
118
  },
105
119
  },
106
120
  {
107
- footerLines: resultFooterLines(theme, result, context),
121
+ dividerLabel: diffDividerLabel(theme, stats),
122
+ ...(expandHint ? { dividerRightLabel: expandHint } : {}),
123
+ footerLines: [editDiffFooter(theme, result, context, stats)],
108
124
  },
109
125
  );
110
126
  },
@@ -6,11 +6,12 @@ import type { BoxTheme, MetricResultLike } from "../../../shared/box.js";
6
6
  import {
7
7
  formatBoxedFooter,
8
8
  formatToolName,
9
+ formatToolOutputLine,
9
10
  formatToolParamLines,
10
11
  getTextOutput,
11
12
  renderBoxedToolCall,
12
13
  renderBoxedToolResult,
13
- renderLines,
14
+ selectRenderLines,
14
15
  } from "../../../shared/box.js";
15
16
  import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
16
17
  import { type BoxedToolContext, noteExecutionStart } from "./shared.js";
@@ -43,20 +44,21 @@ export function renderFallbackResult(
43
44
  const maxLines = expanded ? getToolsRenderConfig().maxExpandedLines : MAX_FALLBACK_PREVIEW_LINES;
44
45
  const output = getTextOutput(result);
45
46
  const elapsedMs = getStateElapsedMs(context.state);
47
+ const { lines, omitted } = selectRenderLines(output, maxLines);
46
48
 
47
49
  return renderBoxedToolResult(
48
50
  theme,
49
- (contentWidth) => {
50
- const body = renderLines(theme, output, options, {
51
- maxLines,
52
- color: isError ? "error" : "toolOutput",
53
- width: contentWidth,
54
- });
55
- return body ? body.split("\n") : [];
51
+ () => {
52
+ const body = lines.map((line) => formatToolOutputLine(theme, line, isError ? "error" : "toolOutput"));
53
+ if (expanded && omitted > 0) {
54
+ body.push(theme.fg("muted", `… ${omitted} more lines omitted by render budget`));
55
+ }
56
+ return body;
56
57
  },
57
58
  {
58
59
  footerLines: [formatBoxedFooter(theme, result, [], elapsedMs)],
59
60
  renderLineBudget: maxLines,
61
+ ...(expanded || omitted <= 0 ? {} : { expandHint: "Ctrl+O for more" }),
60
62
  isError,
61
63
  isPartial: Boolean(options.isPartial),
62
64
  },