@xynogen/pix-pretty 1.17.2 → 1.18.1
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.
- package/package.json +1 -1
- package/src/ansi.ts +9 -0
- package/src/diff-render.ts +3 -6
- package/src/icons.test.ts +18 -1
- package/src/icons.ts +13 -0
- package/src/renderers.ts +10 -3
- package/src/types.ts +4 -0
- package/src/utils.test.ts +102 -0
- package/src/utils.ts +83 -16
package/package.json
CHANGED
package/src/ansi.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export let RST = "\x1b[0m";
|
|
2
2
|
export const BOLD = "\x1b[1m";
|
|
3
|
+
export const BOLD_OFF = "\x1b[22m";
|
|
3
4
|
|
|
4
5
|
export const FG_LNUM = "\x1b[38;2;100;100;100m";
|
|
5
6
|
export const FG_DIM = "\x1b[38;2;80;80;80m";
|
|
@@ -23,6 +24,14 @@ export function resolveBaseBackground(_theme: unknown): void {
|
|
|
23
24
|
|
|
24
25
|
export const ANSI_CAPTURE_RE = /\x1b\[([0-9;]*)m/g;
|
|
25
26
|
|
|
27
|
+
/** The ESC byte that opens every ANSI escape sequence. */
|
|
28
|
+
export const ESC = "\x1b";
|
|
29
|
+
|
|
30
|
+
/** True when a string already contains an ANSI escape sequence. */
|
|
31
|
+
export function hasAnsi(s: string): boolean {
|
|
32
|
+
return s.includes(ESC);
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// ---------------------------------------------------------------------------
|
|
27
36
|
// Low-contrast fix (same as pi-diff)
|
|
28
37
|
// ---------------------------------------------------------------------------
|
package/src/diff-render.ts
CHANGED
|
@@ -49,8 +49,6 @@ const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
|
|
|
49
49
|
// Terminal bounds + thresholds
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
51
51
|
|
|
52
|
-
const MAX_TERM_WIDTH = 210;
|
|
53
|
-
|
|
54
52
|
const MAX_PREVIEW_LINES = envInt("PRETTY_MAX_PREVIEW_LINES", 80);
|
|
55
53
|
|
|
56
54
|
const SPLIT_MIN_WIDTH = envInt("DIFF_SPLIT_MIN_WIDTH", dc.splitMinWidth || 150);
|
|
@@ -158,8 +156,8 @@ function tabs(s: string): string {
|
|
|
158
156
|
function termW(): number {
|
|
159
157
|
// Single source of truth: utils.termW caches, falls back to tty ioctl, and
|
|
160
158
|
// invalidates on resize. Diff layout needs a hard floor of 80 cols for the
|
|
161
|
-
// split-view column math
|
|
162
|
-
return Math.max(80,
|
|
159
|
+
// split-view column math (no ceiling — fill the true width on ultrawide).
|
|
160
|
+
return Math.max(80, utilsTermW());
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
/** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
|
|
@@ -557,8 +555,7 @@ export async function renderUnified(
|
|
|
557
555
|
if (l.type === "sep") {
|
|
558
556
|
const gap = l.newNum;
|
|
559
557
|
const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
|
|
560
|
-
const
|
|
561
|
-
const pad = Math.max(0, totalW - label.length - 2);
|
|
558
|
+
const pad = Math.max(0, tw - label.length - 2);
|
|
562
559
|
const half1 = Math.floor(pad / 2);
|
|
563
560
|
const half2 = pad - half1;
|
|
564
561
|
out.push(`${BG_BASE}${FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${RST}`);
|
package/src/icons.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { dirIcon, fileIcon } from "./icons.ts";
|
|
2
|
+
import { dirIcon, fileColor, fileIcon } from "./icons.ts";
|
|
3
3
|
|
|
4
4
|
const theme = {
|
|
5
5
|
fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
|
|
@@ -16,3 +16,20 @@ describe("theme-derived file icons", () => {
|
|
|
16
16
|
expect(dirIcon(theme)).toContain("<accent>");
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
describe("theme-derived file name color", () => {
|
|
21
|
+
test("colors a filename with the same role as its icon", () => {
|
|
22
|
+
expect(fileColor("example.ts", "example.ts", theme)).toBe(
|
|
23
|
+
"<syntaxType>example.ts</syntaxType>",
|
|
24
|
+
);
|
|
25
|
+
expect(fileColor("package.json", "package.json", theme)).toContain("<syntaxString>");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("falls back to the text role for an unknown extension", () => {
|
|
29
|
+
expect(fileColor("notes.zzz", "notes.zzz", theme)).toBe("<text>notes.zzz</text>");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("passes the name through unchanged when no theme is supplied", () => {
|
|
33
|
+
expect(fileColor("example.ts", "example.ts")).toBe("example.ts");
|
|
34
|
+
});
|
|
35
|
+
});
|
package/src/icons.ts
CHANGED
|
@@ -100,3 +100,16 @@ export function fileIcon(fp: string, theme?: FgTheme): string {
|
|
|
100
100
|
export function dirIcon(theme?: FgTheme): string {
|
|
101
101
|
return USE_ICONS ? `${paint({ glyph: "\ue5ff", color: "accent" }, theme)} ` : "";
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Color a filename by its type, reusing the same per-extension palette the
|
|
106
|
+
* icons use (so name and icon share a hue). Unknown types fall back to the
|
|
107
|
+
* theme's default text color. Pure passthrough when no theme is supplied.
|
|
108
|
+
*/
|
|
109
|
+
export function fileColor(fp: string, name: string, theme?: FgTheme): string {
|
|
110
|
+
if (!theme) return name;
|
|
111
|
+
const base = basename(fp).toLowerCase();
|
|
112
|
+
const ext = extname(fp).slice(1).toLowerCase();
|
|
113
|
+
const spec = NAME_ICON[base] ?? EXT_ICON[ext];
|
|
114
|
+
return theme.fg(spec?.color ?? "text", name);
|
|
115
|
+
}
|
package/src/renderers.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { prettySection } from "@xynogen/pix-runtime/sections";
|
|
|
5
5
|
import { FG_DIM, FG_RULE, RST } from "./ansi.js";
|
|
6
6
|
import { MAX_PREVIEW_LINES } from "./config.js";
|
|
7
7
|
import { hlBlock } from "./highlight.js";
|
|
8
|
-
import { dirIcon, fileIcon } from "./icons.js";
|
|
8
|
+
import { dirIcon, fileColor, fileIcon } from "./icons.js";
|
|
9
9
|
import { lang } from "./lang.js";
|
|
10
10
|
import type { FgTheme } from "./types.js";
|
|
11
11
|
import { lnum, normalizeLineEndings, pluralize, rule, termW } from "./utils.js";
|
|
@@ -82,6 +82,13 @@ export function renderTree(text: string, basePath: string, theme?: FgTheme): str
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** Vertical tree view with connectors and icons. */
|
|
85
|
+
/** Color a listing entry: dirs use the theme accent, files use their
|
|
86
|
+
* per-extension hue (fileColor). Pure passthrough without a theme. */
|
|
87
|
+
function entryColor(isDir: boolean, name: string, theme?: FgTheme): string {
|
|
88
|
+
if (!theme) return name;
|
|
89
|
+
return isDir ? theme.fg("accent", name) : fileColor(name, name, theme);
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
function renderLsTree(text: string, _basePath: string, theme?: FgTheme): string {
|
|
86
93
|
const lines = text.trim().split("\n").filter(Boolean);
|
|
87
94
|
if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
|
|
@@ -99,7 +106,7 @@ function renderLsTree(text: string, _basePath: string, theme?: FgTheme): string
|
|
|
99
106
|
const isDir = entry.endsWith("/");
|
|
100
107
|
const name = isDir ? entry.slice(0, -1) : entry;
|
|
101
108
|
const icon = isDir ? dirIcon(theme) : fileIcon(name, theme);
|
|
102
|
-
const displayName = isDir
|
|
109
|
+
const displayName = entryColor(isDir, name, theme);
|
|
103
110
|
|
|
104
111
|
out.push(`${connector}${icon}${displayName}`);
|
|
105
112
|
}
|
|
@@ -130,7 +137,7 @@ function renderLsGrid(text: string, _basePath: string, theme?: FgTheme): string
|
|
|
130
137
|
const isDir = entry.endsWith("/");
|
|
131
138
|
const name = isDir ? entry.slice(0, -1) : entry;
|
|
132
139
|
const icon = isDir ? dirIcon(theme) : fileIcon(name, theme);
|
|
133
|
-
const displayName = isDir
|
|
140
|
+
const displayName = entryColor(isDir, name, theme);
|
|
134
141
|
const cell = `${icon}${displayName}`;
|
|
135
142
|
cells.push(cell);
|
|
136
143
|
cellWidths.push(visibleWidth(cell));
|
package/src/types.ts
CHANGED
|
@@ -196,6 +196,10 @@ export type GrepResultDetails = {
|
|
|
196
196
|
pattern: string;
|
|
197
197
|
path?: string;
|
|
198
198
|
matchCount: number;
|
|
199
|
+
/** Search flags, so the renderer can rebuild the matcher to highlight hits:
|
|
200
|
+
* literal → escape metacharacters; ignoreCase → case-insensitive. */
|
|
201
|
+
literal?: boolean;
|
|
202
|
+
ignoreCase?: boolean;
|
|
199
203
|
};
|
|
200
204
|
|
|
201
205
|
export type RenderDetails =
|
package/src/utils.test.ts
CHANGED
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
renderCollapsedToolRow,
|
|
14
14
|
renderDimPreview,
|
|
15
15
|
ruleFrame,
|
|
16
|
+
sectionRule,
|
|
16
17
|
setResultDetails,
|
|
18
|
+
termW,
|
|
17
19
|
viewportText,
|
|
18
20
|
} from "./utils.js";
|
|
19
21
|
|
|
@@ -159,6 +161,35 @@ function plain(text: string): string {
|
|
|
159
161
|
return text.replace(ANSI, "");
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
describe("termW", () => {
|
|
165
|
+
// termW() caches and invalidates on a stdout 'resize' event. Set columns then
|
|
166
|
+
// emit resize so the next call re-reads.
|
|
167
|
+
function setCols(cols: number): void {
|
|
168
|
+
(process.stdout as { columns?: number }).columns = cols;
|
|
169
|
+
process.stdout.emit("resize");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it("returns the true terminal width with no upper clamp (ultrawide)", () => {
|
|
173
|
+
const orig = process.stdout.columns;
|
|
174
|
+
try {
|
|
175
|
+
setCols(384); // wider than the old 210 cap
|
|
176
|
+
expect(termW()).toBe(384);
|
|
177
|
+
} finally {
|
|
178
|
+
setCols(orig ?? 80);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("floors width at 1 for a degenerate column count", () => {
|
|
183
|
+
const orig = process.stdout.columns;
|
|
184
|
+
try {
|
|
185
|
+
setCols(0); // falsy — falls through resolution chain, never < 1
|
|
186
|
+
expect(termW()).toBeGreaterThanOrEqual(1);
|
|
187
|
+
} finally {
|
|
188
|
+
setCols(orig ?? 80);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
162
193
|
describe("ruleFrame", () => {
|
|
163
194
|
it("wraps body with a rule top and bottom, then footer below the close", () => {
|
|
164
195
|
const out = ruleFrame(["a", "b"], ["… +3 more"], 10);
|
|
@@ -402,4 +433,75 @@ describe("renderDimPreview", () => {
|
|
|
402
433
|
expect(plain(raw)).toContain("call(foo)");
|
|
403
434
|
expect(raw).toContain("\x1b[");
|
|
404
435
|
});
|
|
436
|
+
|
|
437
|
+
it("highlights every regex match, not just a literal substring", () => {
|
|
438
|
+
// /te.t/ must light up both 'test' and 'text' — a literal indexOf can't.
|
|
439
|
+
const raw = renderDimPreview("test text", theme, { highlight: /te.t/g });
|
|
440
|
+
// Two bold-open codes = two highlighted hits.
|
|
441
|
+
expect(raw.split("\x1b[1m").length - 1).toBe(2);
|
|
442
|
+
expect(plain(raw)).toContain("test text");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("does not loop on a zero-width regex match", () => {
|
|
446
|
+
// /x*/ matches empty everywhere — must terminate and keep content intact.
|
|
447
|
+
const raw = renderDimPreview("abc", theme, { highlight: /x*/g });
|
|
448
|
+
expect(plain(raw)).toContain("abc");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("skips highlighting a line that already carries ANSI (no escape corruption)", () => {
|
|
452
|
+
// Pre-colored input: a match inside an escape would corrupt it, so the
|
|
453
|
+
// whole line is dimmed instead — no BOLD hit is injected.
|
|
454
|
+
const preColored = "\x1b[31mtest\x1b[0m done";
|
|
455
|
+
const raw = renderDimPreview(preColored, theme, { highlight: "test" });
|
|
456
|
+
expect(raw).not.toContain("\x1b[1m"); // no bold hit
|
|
457
|
+
expect(raw).toContain("\x1b[31m"); // original ANSI preserved
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("renders an === label === separator as a section divider", () => {
|
|
461
|
+
const raw = renderDimPreview("=== branch ===\nmain", theme, {});
|
|
462
|
+
// Label survives; the raw === markers are gone (replaced by a rule).
|
|
463
|
+
expect(plain(raw)).toContain("branch");
|
|
464
|
+
expect(plain(raw)).not.toContain("===");
|
|
465
|
+
expect(plain(raw)).toContain("─"); // divider glyph
|
|
466
|
+
expect(plain(raw)).toContain("main"); // ordinary lines untouched
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("sectionRule", () => {
|
|
471
|
+
// Tagging theme so we can assert which role each fragment uses.
|
|
472
|
+
const tag: FgTheme = { fg: (key, text) => `<${key}>${text}</${key}>` };
|
|
473
|
+
|
|
474
|
+
it("left-aligns the label after a short lead rule, all muted", () => {
|
|
475
|
+
const out = sectionRule("=== versions ===", tag, 40) ?? "";
|
|
476
|
+
expect(out).not.toBeNull();
|
|
477
|
+
// One muted span wrapping the whole divider; label starts after 4 dashes.
|
|
478
|
+
expect(out).toBe("<muted>──── versions ──────────────────────────</muted>");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("wraps an over-long label snugly with 2 dashes each side", () => {
|
|
482
|
+
const out = sectionRule("=== a very long section label here ===", tag, 20) ?? "";
|
|
483
|
+
expect(out).toBe("<muted>── a very long section label here ──</muted>");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("accepts extra whitespace and 2+ equals signs", () => {
|
|
487
|
+
expect(sectionRule("== dirty? ==", tag, 40)).toContain("──── dirty? ");
|
|
488
|
+
expect(sectionRule("===== a b c =====", tag, 40)).toContain("──── a b c ");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("returns null for a non-separator line", () => {
|
|
492
|
+
expect(sectionRule("just a normal line", tag, 20)).toBeNull();
|
|
493
|
+
expect(sectionRule("=== no closing", tag, 20)).toBeNull();
|
|
494
|
+
// Must span the whole line — leading text before the === disqualifies it.
|
|
495
|
+
expect(sectionRule("plain === middle === text", tag, 20)).toBeNull();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("returns null when the line already carries ANSI", () => {
|
|
499
|
+
expect(sectionRule("\x1b[31m=== x ===\x1b[0m", tag, 20)).toBeNull();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("fills the full requested width so it aligns with the tool frame", () => {
|
|
503
|
+
const out = sectionRule("=== x ===", tag, 400) ?? "";
|
|
504
|
+
const visible = out.replace(/<\/?[a-z]+>/g, "");
|
|
505
|
+
expect([...visible].length).toBe(400);
|
|
506
|
+
});
|
|
405
507
|
});
|
package/src/utils.ts
CHANGED
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
BG_BASE,
|
|
7
7
|
BG_ERROR,
|
|
8
8
|
BOLD,
|
|
9
|
-
|
|
9
|
+
BOLD_OFF,
|
|
10
10
|
FG_LNUM,
|
|
11
11
|
FG_RULE,
|
|
12
|
+
hasAnsi,
|
|
12
13
|
RST,
|
|
13
14
|
} from "./ansi.js";
|
|
14
15
|
import { MAX_PREVIEW_LINES } from "./config.js";
|
|
@@ -51,6 +52,9 @@ export function fillToolBackground(text: string, bg = BG_BASE, width?: number):
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export function viewportTextConstructor(TextComponent: TextComponentCtor): TextComponentCtor {
|
|
55
|
+
// SAFETY: a `new`-able ctor and a plain factory returning the same instance
|
|
56
|
+
// shape are interchangeable to callers here; TS can't see the returned
|
|
57
|
+
// ViewportText satisfies TextComponentCtor's construct signature.
|
|
54
58
|
return function ViewportTextComponent(text = "") {
|
|
55
59
|
const component = viewportText(TextComponent);
|
|
56
60
|
component.setText(text);
|
|
@@ -323,28 +327,62 @@ export type DimPreviewOptions = {
|
|
|
323
327
|
* the header is intentionally dropped (the collapsed row already carries the
|
|
324
328
|
* count, so a floating header above the frame is redundant). */
|
|
325
329
|
header?: string;
|
|
326
|
-
/** Pattern whose matches are highlighted (
|
|
327
|
-
|
|
330
|
+
/** Pattern whose matches are highlighted (bold, themed) inside dim lines.
|
|
331
|
+
* A string is matched literally (case-insensitive); a RegExp is used as-is
|
|
332
|
+
* (callers pass the compiled search pattern so regex greps highlight too). */
|
|
333
|
+
highlight?: string | RegExp;
|
|
328
334
|
/** Wrap the body in a top/bottom rule frame (overflow below), like bash/ls/mcp. */
|
|
329
335
|
frame?: boolean;
|
|
330
336
|
/** Tint the frame rules (green ok, red error) — same status color as bash/ls. */
|
|
331
337
|
paint?: RulePaint;
|
|
332
338
|
};
|
|
333
339
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
340
|
+
/** Compile a highlight pattern into a global RegExp, or null when it can't
|
|
341
|
+
* match anything. Strings match literally (case-insensitive); a RegExp is
|
|
342
|
+
* re-flagged global so every occurrence on a line lights up. */
|
|
343
|
+
function toHighlightRegex(pattern: string | RegExp): RegExp | null {
|
|
344
|
+
if (typeof pattern !== "string") {
|
|
345
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
346
|
+
try {
|
|
347
|
+
return new RegExp(pattern.source, flags);
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (!pattern) return null;
|
|
353
|
+
// Literal string → escape regex metacharacters, case-insensitive.
|
|
354
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
355
|
+
try {
|
|
356
|
+
return new RegExp(escaped, "gi");
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
339
361
|
|
|
362
|
+
function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string | RegExp): string {
|
|
363
|
+
if (!pattern) return theme.fg("dim", line);
|
|
364
|
+
// A line that already carries ANSI (e.g. a source that emitted its own color)
|
|
365
|
+
// can't be safely index-sliced — a match could land inside an escape and
|
|
366
|
+
// corrupt it. Skip highlighting and dim the whole line instead.
|
|
367
|
+
if (hasAnsi(line)) return theme.fg("dim", line);
|
|
368
|
+
const re = pattern ? toHighlightRegex(pattern) : null;
|
|
369
|
+
if (!re) return theme.fg("dim", line);
|
|
370
|
+
|
|
371
|
+
// Themed bold hit: theme.fg carries its own reset, so re-open BOLD after it
|
|
372
|
+
// and close with the bold-off code (\x1b[22m) to avoid leaking bold onward.
|
|
373
|
+
const hit = (s: string) => `${BOLD}${theme.fg("success", s)}${BOLD_OFF}`;
|
|
340
374
|
const parts: string[] = [];
|
|
341
375
|
let start = 0;
|
|
342
|
-
for (
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
376
|
+
for (let m = re.exec(line); m !== null; m = re.exec(line)) {
|
|
377
|
+
const idx = m.index;
|
|
378
|
+
const text = m[0];
|
|
379
|
+
if (text.length === 0) {
|
|
380
|
+
re.lastIndex++; // zero-width match (e.g. /a*/) — advance to avoid a loop
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (idx > start) parts.push(theme.fg("dim", line.slice(start, idx)));
|
|
384
|
+
parts.push(hit(text));
|
|
385
|
+
start = idx + text.length;
|
|
348
386
|
}
|
|
349
387
|
if (start < line.length) parts.push(theme.fg("dim", line.slice(start)));
|
|
350
388
|
return parts.length > 0 ? parts.join("") : theme.fg("dim", line);
|
|
@@ -359,9 +397,12 @@ export function renderDimPreview(
|
|
|
359
397
|
const highlight = opts.highlight;
|
|
360
398
|
const output = normalizeLineEndings(text).trim() || "done";
|
|
361
399
|
const lines = output.split("\n");
|
|
400
|
+
const sw = Math.max(8, termW() - 4); // section-rule width inside the 2-space indent
|
|
362
401
|
const body = lines
|
|
363
402
|
.slice(0, maxLines)
|
|
364
|
-
.map(
|
|
403
|
+
.map(
|
|
404
|
+
(line) => ` ${sectionRule(line, theme, sw) ?? dimLineWithHighlight(line, theme, highlight)}`,
|
|
405
|
+
);
|
|
365
406
|
const header = opts.header ? ` ${theme.fg("dim", opts.header)}` : undefined;
|
|
366
407
|
const overflow =
|
|
367
408
|
lines.length > maxLines
|
|
@@ -413,7 +454,9 @@ export function termW(): number {
|
|
|
413
454
|
Number.parseInt(process.env.COLUMNS ?? "", 10) ||
|
|
414
455
|
_readTtyColumns() ||
|
|
415
456
|
120;
|
|
416
|
-
|
|
457
|
+
// No upper clamp: frames must fill the true terminal width so our rules align
|
|
458
|
+
// with Pi's native full-width UI on ultrawide displays.
|
|
459
|
+
_cachedTermW = Math.max(1, raw);
|
|
417
460
|
|
|
418
461
|
return _cachedTermW;
|
|
419
462
|
}
|
|
@@ -459,6 +502,30 @@ export function rule(w: number, paint?: RulePaint): string {
|
|
|
459
502
|
return paint ? paint(glyphs) : `${FG_RULE}${glyphs}${RST}`;
|
|
460
503
|
}
|
|
461
504
|
|
|
505
|
+
/** Matches an `=== label ===` separator line (a common shell/echo idiom). */
|
|
506
|
+
const SECTION_RE = /^\s*={2,}\s*(.+?)\s*={2,}\s*$/;
|
|
507
|
+
|
|
508
|
+
/** Fixed dash count before a left-aligned section label. */
|
|
509
|
+
const SECTION_RULE_LEAD = 4;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* If `line` is an `=== label ===` separator, render it as a left-aligned
|
|
513
|
+
* section divider (`──── label ───────────`); otherwise return null. Callers
|
|
514
|
+
* fall back to their normal per-line rendering on null. Fills the full `width`
|
|
515
|
+
* so the divider aligns with the surrounding tool frame. Label and rule both
|
|
516
|
+
* use the muted role — theme-driven, ANSI-safe.
|
|
517
|
+
*/
|
|
518
|
+
export function sectionRule(line: string, theme: FgTheme, width: number): string | null {
|
|
519
|
+
const m = SECTION_RE.exec(line);
|
|
520
|
+
if (!m || hasAnsi(line)) return null;
|
|
521
|
+
const label = ` ${m[1]} `;
|
|
522
|
+
const trail = width - SECTION_RULE_LEAD - visibleWidth(label);
|
|
523
|
+
// Label fits → lead rule + trailing fill. Too long to fill → don't force a
|
|
524
|
+
// full-width rule; wrap it snugly with 2 dashes each side (`── long text ──`).
|
|
525
|
+
const [lead, tail] = trail >= 2 ? [SECTION_RULE_LEAD, trail] : [2, 2];
|
|
526
|
+
return theme.fg("muted", `${"─".repeat(lead)}${label}${"─".repeat(tail)}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
462
529
|
/**
|
|
463
530
|
* Frame tool output the way bash/read/sudo do: a top rule, the body lines, a
|
|
464
531
|
* bottom rule, then any footer lines (e.g. `… +N more`) below the close. The
|