@xynogen/pix-pretty 1.7.14 → 1.7.17

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/src/fff.ts CHANGED
@@ -31,9 +31,7 @@ export function getPiPrettyFffDir(_agentDir: string): string {
31
31
  return join(cacheHome, "pi", "fff");
32
32
  }
33
33
 
34
- export async function fffEnsureFinder(
35
- cwd: string,
36
- ): Promise<FffBackedFinder | null> {
34
+ export async function fffEnsureFinder(cwd: string): Promise<FffBackedFinder | null> {
37
35
  if (fffState.finder && !fffState.finder.isDestroyed) return fffState.finder;
38
36
  if (!fffState.module || !fffState.dbDir) return null;
39
37
 
@@ -68,13 +66,9 @@ export function fffDestroy(): void {
68
66
  function sanitizeGrepRecordContent(text: string): string {
69
67
  let content = text;
70
68
  if (content.endsWith("\r\n")) content = content.slice(0, -2);
71
- else if (content.endsWith("\r") || content.endsWith("\n"))
72
- content = content.slice(0, -1);
69
+ else if (content.endsWith("\r") || content.endsWith("\n")) content = content.slice(0, -1);
73
70
 
74
- return content
75
- .replace(/\r\n/g, "\\n")
76
- .replace(/\r/g, "\\r")
77
- .replace(/\n/g, "\\n");
71
+ return content.replace(/\r\n/g, "\\n").replace(/\r/g, "\\r").replace(/\n/g, "\\n");
78
72
  }
79
73
 
80
74
  function truncateGrepRecordContent(text: string): string {
@@ -27,9 +27,7 @@ interface Wired {
27
27
  * the SelectList keys; simpler: we expose the live component so the test can
28
28
  * call its handlers. We do the latter via the captured component ref.
29
29
  */
30
- function makeUI(
31
- onReady: (comp: Wired, finish: (v: unknown) => void) => void,
32
- ): OverlayUI {
30
+ function makeUI(onReady: (comp: Wired, finish: (v: unknown) => void) => void): OverlayUI {
33
31
  return {
34
32
  custom: async <T>(
35
33
  cb: (
@@ -195,9 +193,7 @@ function makeTimerUI(onReady?: (comp: Wired) => void): OverlayUI {
195
193
  ) => Wired,
196
194
  ): Promise<T | undefined> =>
197
195
  new Promise((resolve) => {
198
- const comp = cb({ requestRender: () => {} }, theme, undefined, (v) =>
199
- resolve(v),
200
- );
196
+ const comp = cb({ requestRender: () => {} }, theme, undefined, (v) => resolve(v));
201
197
  comp.render(80);
202
198
  onReady?.(comp);
203
199
  }),
@@ -14,12 +14,7 @@
14
14
  * - Single source of truth for the overlay look across pix-gate and pix-sudo.
15
15
  */
16
16
 
17
- import {
18
- Input,
19
- type SelectItem,
20
- SelectList,
21
- wrapTextWithAnsi,
22
- } from "@earendil-works/pi-tui";
17
+ import { Input, type SelectItem, SelectList, wrapTextWithAnsi } from "@earendil-works/pi-tui";
23
18
  import { frameLines, modalWidth, selectListTheme } from "./modal-frame.js";
24
19
 
25
20
  // ── Types ─────────────────────────────────────────────────────────────────────
@@ -135,16 +130,7 @@ function buildLines(opts: {
135
130
  countdownLine: string | undefined;
136
131
  width: number;
137
132
  }): string[] {
138
- const {
139
- theme,
140
- accent,
141
- config,
142
- stage,
143
- selectList,
144
- maskedInput,
145
- countdownLine,
146
- width,
147
- } = opts;
133
+ const { theme, accent, config, stage, selectList, maskedInput, countdownLine, width } = opts;
148
134
  const inner = width - 4; // CHROME = 2 border + 2 padding
149
135
  const lines: string[] = [];
150
136
 
@@ -172,10 +158,7 @@ function buildLines(opts: {
172
158
  lines.push("");
173
159
  lines.push(theme.fg("dim", "↑↓ navigate • enter select • esc deny"));
174
160
  } else {
175
- const label =
176
- config.mode === "sudo"
177
- ? (config.passwordLabel ?? "Sudo password:")
178
- : "Password:";
161
+ const label = config.mode === "sudo" ? (config.passwordLabel ?? "Sudo password:") : "Password:";
179
162
  lines.push(theme.fg("muted", label));
180
163
  const inputLines = maskedInput.render(inner);
181
164
  for (const l of inputLines) lines.push(l);
@@ -217,10 +200,7 @@ function buildLines(opts: {
217
200
  * if (result.action === "approved") runWithSudo(cmd, result.password!);
218
201
  * ```
219
202
  */
220
- export function showOverlay(
221
- ui: OverlayUI,
222
- config: OverlayConfig,
223
- ): Promise<OverlayResult> {
203
+ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<OverlayResult> {
224
204
  const accent = config.accent ?? "accent";
225
205
  const choices = config.choices ?? DEFAULT_CHOICES;
226
206
  const approveVal = config.approveValue ?? "yes";
@@ -291,8 +271,7 @@ export function showOverlay(
291
271
  };
292
272
  selectList.onCancel = () => finish({ action: "denied" });
293
273
 
294
- maskedInput.onSubmit = (pw) =>
295
- finish({ action: "approved", password: pw });
274
+ maskedInput.onSubmit = (pw) => finish({ action: "approved", password: pw });
296
275
  maskedInput.onEscape = () => finish({ action: "denied" });
297
276
 
298
277
  // ── component interface ──────────────────────────────────────────
package/src/highlight.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { normalizeShikiContrast } from "./ansi.js";
2
- import { CACHE_LIMIT, MAX_HL_CHARS, THEME } from "./config.js";
2
+ import { CACHE_LIMIT, MAX_HL_CHARS } from "./config.js";
3
3
  import type { BundledLanguage } from "./types.js";
4
4
 
5
5
  // Engine: cli-highlight (highlight.js-backed, synchronous ANSI output).
@@ -10,10 +10,7 @@ import type { BundledLanguage } from "./types.js";
10
10
  // is not the process stdout chalk inspects) we default FORCE_COLOR before chalk
11
11
  // initializes, and lazy-load cli-highlight so this runs first. Respect an
12
12
  // explicit FORCE_COLOR/NO_COLOR if the user set one.
13
- if (
14
- process.env.FORCE_COLOR === undefined &&
15
- process.env.NO_COLOR === undefined
16
- ) {
13
+ if (process.env.FORCE_COLOR === undefined && process.env.NO_COLOR === undefined) {
17
14
  process.env.FORCE_COLOR = "3";
18
15
  }
19
16
 
@@ -91,7 +88,7 @@ export async function hlBlock(
91
88
  const hljsLang = toHljsLang(language);
92
89
  if (!hljsLang) return code.split("\n");
93
90
 
94
- const k = `${THEME}\0${hljsLang}\0${code}`;
91
+ const k = `${hljsLang}\0${code}`;
95
92
  const hit = _cache.get(k);
96
93
  if (hit) return _touch(k, hit);
97
94
 
@@ -10,16 +10,16 @@
10
10
  * mode: "nerd" | "unicode" | "ascii" (the "locale")
11
11
  * icon(k): catalog[k][mode] (the "t(key)")
12
12
  *
13
- * One global mode governs the whole stack. It is switched via the `/pretty`
14
- * command (see pretty-command.ts), persisted to ~/.pi/agent/pretty.json, and
15
- * seeded from the PRETTY_ICONS env var on first load.
13
+ * One global mode governs the whole stack. It is switched via the `/pix`
14
+ * settings command (in pix-data), persisted to `~/.pi/agent/pix.json`
15
+ * (`pretty.icons`), and seeded from the PRETTY_ICONS env var on first load.
16
16
  *
17
17
  * Why a catalog instead of per-package toggles: reskinning or fixing a
18
18
  * missing-glyph ("tofu") problem becomes a one-file edit here, and there is
19
19
  * exactly ONE knob (the mode) rather than one env var per package.
20
20
  */
21
21
 
22
- /** Presentation modes, in /pretty cycle order. nerd = Nerd Font PUA glyphs. */
22
+ /** Presentation modes, in /pix settings cycle order. nerd = Nerd Font PUA glyphs. */
23
23
  export type IconMode = "nerd" | "unicode" | "ascii";
24
24
 
25
25
  /** All modes in cycle order. */
@@ -77,17 +77,20 @@ const CATALOG = {
77
77
 
78
78
  // ── subagent widget (pix-subagent) ────────────────────────────────────
79
79
  agent: { nerd: "\u{F0BA0}", unicode: `\u2699${VS}`, ascii: "@" },
80
+ turns: { nerd: "\u{F006A}", unicode: `\u21BB${VS}`, ascii: "~" },
81
+ tools: { nerd: "\u{F1064}", unicode: `\u2692${VS}`, ascii: "T" },
82
+ tokens: { nerd: "\u{F027F}", unicode: `\u25A4${VS}`, ascii: "tk" },
80
83
  } as const;
81
84
 
82
85
  /** Every valid semantic icon key. */
83
86
  export type IconKey = keyof typeof CATALOG;
84
87
 
85
- /** All catalog keys (useful for /pretty previews and tests). */
88
+ /** All catalog keys (useful for /pix previews and tests). */
86
89
  export const ICON_KEYS = Object.keys(CATALOG) as IconKey[];
87
90
 
88
91
  /**
89
92
  * Active mode. Seeded from PRETTY_ICONS env (back-compat: none/off => ascii),
90
- * then overridden by a persisted choice when the host loads pretty.json.
93
+ * then overridden by a persisted choice when the host loads pix.json.
91
94
  */
92
95
  function envMode(): IconMode {
93
96
  const raw = (process.env.PRETTY_ICONS ?? "").toLowerCase();
@@ -120,7 +123,7 @@ export function onIconModeChange(cb: ModeListener): () => void {
120
123
 
121
124
  /**
122
125
  * Set the global icon mode (does NOT persist — callers that want persistence
123
- * use pretty-command.ts, which writes pretty.json then calls this). Fires
126
+ * use the /pix command, which writes pix.json then calls this). Fires
124
127
  * subscribers only on an actual change (no-op re-sets are ignored).
125
128
  */
126
129
  export function setIconMode(mode: IconMode): void {
@@ -138,7 +141,7 @@ export function icon(key: IconKey): string {
138
141
  return entry ? entry[activeMode] : "";
139
142
  }
140
143
 
141
- /** Resolve a key for an explicit mode (used by /pretty previews + tests). */
144
+ /** Resolve a key for an explicit mode (used by /pix previews + tests). */
142
145
  export function iconFor(key: IconKey, mode: IconMode): string {
143
146
  const entry = CATALOG[key];
144
147
  return entry ? entry[mode] : "";
@@ -2,17 +2,25 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { reloadPixConfig } from "@xynogen/pix-data/pix-config";
5
6
  import { getIconMode, setIconMode } from "./icon-catalog.ts";
6
7
  import { initIconMode, loadIconMode, saveIconMode } from "./icon-persist.ts";
7
8
 
8
9
  let tmpAgentDir: string;
10
+ let origHome: string | undefined;
9
11
 
10
12
  beforeAll(() => {
11
13
  tmpAgentDir = mkdtempSync(join(tmpdir(), "pretty-persist-test-"));
14
+ origHome = process.env.HOME;
15
+ // Point HOME at the temp dir so pixConfig() reads from there, not the real ~/.pi/agent/pix.json
16
+ process.env.HOME = tmpAgentDir;
12
17
  process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
18
+ // Force pix-config to re-read from the temp HOME (clears cached real config).
19
+ reloadPixConfig();
13
20
  });
14
21
 
15
22
  afterAll(() => {
23
+ process.env.HOME = origHome;
16
24
  delete process.env.PI_CODING_AGENT_DIR;
17
25
  try {
18
26
  rmSync(tmpAgentDir, { recursive: true });
@@ -24,8 +32,8 @@ afterAll(() => {
24
32
  describe("icon-persist", () => {
25
33
  afterEach(() => setIconMode("nerd"));
26
34
 
27
- it("returns undefined before anything is saved", () => {
28
- expect(loadIconMode()).toBeUndefined();
35
+ it("returns default (nerd) in a fresh config", () => {
36
+ expect(loadIconMode()).toBe("nerd");
29
37
  });
30
38
 
31
39
  it("round-trips a mode across save/load (new-session sim)", () => {
@@ -1,37 +1,25 @@
1
1
  /**
2
2
  * icon-persist.ts — disk persistence for the global icon mode.
3
3
  *
4
- * Stores the single icon mode in ~/.pi/agent/pretty.json so a choice made via
5
- * /pretty survives quit/restart. Kept separate from icon-catalog.ts so the
6
- * catalog resolver stays pure (no fs) and trivially testable.
4
+ * Reads/writes the icon mode via the unified pix.json config
5
+ * (`~/.pi/agent/pix.json` `pretty.icons`). Kept separate from
6
+ * icon-catalog.ts so the catalog resolver stays pure (no fs) and trivially
7
+ * testable.
7
8
  *
8
- * ~/.pi/agent/pretty.json -> { "icons": "unicode" }
9
- *
10
- * Precedence: a persisted value wins; otherwise the catalog's env-seeded
11
- * default (PRETTY_ICONS) stands.
9
+ * Precedence: env PRETTY_ICONS → pix.json pretty.icons → default ("nerd")
12
10
  */
13
11
 
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
- import { dirname, join } from "node:path";
16
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
- import { pixConfig } from "@xynogen/pix-data/pix-config";
12
+ import { onPixConfigChange, pixConfig, savePixConfig } from "@xynogen/pix-data/pix-config";
18
13
  import { ICON_MODES, type IconMode, setIconMode } from "./icon-catalog.js";
19
14
 
20
15
  function isIconMode(m: string): m is IconMode {
21
16
  return (ICON_MODES as readonly string[]).includes(m);
22
17
  }
23
18
 
24
- function statePath(): string {
25
- return join(getAgentDir(), "pretty.json");
26
- }
27
-
28
- /** Read the persisted icon mode, or undefined if unset/invalid/missing. */
19
+ /** Read the persisted icon mode from pix.json, or undefined if unset/invalid. */
29
20
  export function loadIconMode(): IconMode | undefined {
30
21
  try {
31
- const p = statePath();
32
- if (!existsSync(p)) return undefined;
33
- const raw = JSON.parse(readFileSync(p, "utf-8")) as { icons?: string };
34
- const mode = raw?.icons;
22
+ const mode = pixConfig().pretty.icons;
35
23
  if (mode == null) return undefined;
36
24
  return isIconMode(mode) ? mode : undefined;
37
25
  } catch {
@@ -39,41 +27,28 @@ export function loadIconMode(): IconMode | undefined {
39
27
  }
40
28
  }
41
29
 
42
- /** Persist the icon mode, merging into pretty.json. */
30
+ /** Persist the icon mode to pix.json (`pretty.icons`). */
43
31
  export function saveIconMode(mode: IconMode): void {
44
32
  try {
45
- const p = statePath();
46
- mkdirSync(dirname(p), { recursive: true });
47
- let existing: Record<string, unknown> = {};
48
- if (existsSync(p)) {
49
- try {
50
- existing = JSON.parse(readFileSync(p, "utf-8")) as Record<
51
- string,
52
- unknown
53
- >;
54
- } catch {
55
- existing = {};
56
- }
57
- }
58
- writeFileSync(p, JSON.stringify({ ...existing, icons: mode }, null, 2));
33
+ savePixConfig({ pretty: { icons: mode } });
59
34
  } catch (err) {
60
35
  console.warn("pix-pretty: persist icon mode failed:", err);
61
36
  }
62
37
  }
63
38
 
64
39
  /**
65
- * Apply the persisted mode (if any) to the catalog. Called once at extension
66
- * load so the env-seeded default is overridden by the user's saved choice.
40
+ * Apply the persisted mode (if any) to the catalog and subscribe to live
41
+ * changes from the /pix settings command. Called once at extension load.
67
42
  *
68
- * Precedence: env PRETTY_ICONS → pretty.json → pix.json pretty.icons → default ("nerd")
43
+ * Precedence: env PRETTY_ICONS → pix.json pretty.icons → default ("nerd")
69
44
  */
70
45
  export function initIconMode(): void {
71
- const saved = loadIconMode();
72
- if (saved) {
73
- setIconMode(saved);
74
- return;
75
- }
76
- // No persisted choice — try pix.json
77
46
  const pixIcons = pixConfig().pretty.icons;
78
47
  if (pixIcons && isIconMode(pixIcons)) setIconMode(pixIcons);
48
+
49
+ // Keep the in-memory icon mode in sync when /pix changes pretty.icons.
50
+ onPixConfigChange((cfg) => {
51
+ const mode = cfg.pretty.icons;
52
+ if (mode && isIconMode(mode)) setIconMode(mode);
53
+ });
79
54
  }
package/src/index.ts CHANGED
@@ -8,33 +8,19 @@
8
8
  * UI features (paste chips, thinking blocks) live in pix-display.
9
9
  */
10
10
 
11
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
11
  import { registerFffCommands } from "./commands/fff.js";
13
- import { registerPrettyCommand } from "./commands/pretty.js";
14
- import { getDefaultAgentDir, setPrettyTheme } from "./config.js";
15
12
  import { fffState } from "./fff.js";
16
13
  import { clearHighlightCache } from "./highlight.js";
17
14
  import { initIconMode } from "./icon-persist.js";
18
15
  import type { PiPrettyApi } from "./types.js";
19
16
 
20
17
  export default function piPrettyExtension(pi: PiPrettyApi): void {
21
- // ── Theme init ──────────────────────────────────────────────────────
22
- setPrettyTheme(
23
- (() => {
24
- try {
25
- return getAgentDir?.() ?? getDefaultAgentDir();
26
- } catch {
27
- return getDefaultAgentDir();
28
- }
29
- })(),
30
- );
31
18
  clearHighlightCache();
32
19
 
33
20
  // ── Icon mode ───────────────────────────────────────────
34
- // Seed the global icon mode from pretty.json (overrides env default), then
35
- // register the single /pretty switch.
21
+ // Seed the global icon mode from pix.json (overrides env default).
22
+ // The /pix settings command lives in pix-data.
36
23
  initIconMode();
37
- registerPrettyCommand(pi);
38
24
 
39
25
  // ── FFF slash commands ──────────────────────────────────────────────
40
26
  // fffState is a module-level singleton shared with pix-grep/pix-find.
@@ -71,8 +71,7 @@ export function frameLines(opts: FrameOptions): string[] {
71
71
 
72
72
  const row = (content: string): string => {
73
73
  const pad = inner - visibleWidth(content);
74
- const padded =
75
- pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
74
+ const padded = pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
76
75
  return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
77
76
  };
78
77
 
@@ -101,10 +100,7 @@ interface FgTheme {
101
100
  * Canonical SelectList theme for interactive overlays.
102
101
  * accent = active/selected, muted = descriptions, dim = scroll/hints, warning = no-match.
103
102
  */
104
- export function selectListTheme(
105
- theme: FgTheme,
106
- accent = "accent",
107
- ): SelectListThemeConfig {
103
+ export function selectListTheme(theme: FgTheme, accent = "accent"): SelectListThemeConfig {
108
104
  return {
109
105
  selectedPrefix: (t) => theme.fg(accent, t),
110
106
  selectedText: (t) => theme.fg(accent, t),
package/src/progress.ts CHANGED
@@ -55,11 +55,7 @@ const SPINNER_INTERVAL_MS = 120;
55
55
  * Open a modal progress overlay. Returns a handle to update the label and
56
56
  * close it. The overlay swallows all keystrokes until closed.
57
57
  */
58
- export function openProgress(
59
- ui: ProgressUI,
60
- title: string,
61
- accent = "accent",
62
- ): ProgressHandle {
58
+ export function openProgress(ui: ProgressUI, title: string, accent = "accent"): ProgressHandle {
63
59
  let setLabelImpl: (label: string) => void = () => {};
64
60
  let closeImpl: () => void = () => {};
65
61
 
package/src/renderers.ts CHANGED
@@ -1,15 +1,7 @@
1
- import { truncateToWidth } from "@earendil-works/pi-tui";
2
-
3
- import {
4
- BOLD,
5
- FG_BLUE,
6
- FG_DIM,
7
- FG_GREEN,
8
- FG_RED,
9
- FG_RULE,
10
- FG_YELLOW,
11
- RST,
12
- } from "./ansi.js";
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { getLsStyle } from "@xynogen/pix-data/pix-config";
3
+
4
+ import { BOLD, FG_BLUE, FG_DIM, FG_GREEN, FG_RED, FG_RULE, FG_YELLOW, RST } from "./ansi.js";
13
5
  import { MAX_PREVIEW_LINES } from "./config.js";
14
6
  import { hlBlock } from "./highlight.js";
15
7
  import { dirIcon, fileIcon } from "./icons.js";
@@ -49,9 +41,7 @@ export async function renderFileContent(
49
41
 
50
42
  out.push(rule(tw));
51
43
  if (total > maxLines) {
52
- out.push(
53
- `${FG_DIM} … ${pluralize(total - maxLines, "more line")} (${total} total)${RST}`,
54
- );
44
+ out.push(`${FG_DIM} … ${pluralize(total - maxLines, "more line")} (${total} total)${RST}`);
55
45
  }
56
46
  return out.join("\n");
57
47
  }
@@ -82,8 +72,13 @@ export function renderBashOutput(
82
72
  return { summary: codeStr, body };
83
73
  }
84
74
 
85
- /** Render ls output as a tree view with icons. */
86
- export function renderTree(text: string, _basePath: string): string {
75
+ /** Render ls output using the configured style (grid or tree). */
76
+ export function renderTree(text: string, basePath: string): string {
77
+ return getLsStyle() === "tree" ? renderLsTree(text, basePath) : renderLsGrid(text, basePath);
78
+ }
79
+
80
+ /** Vertical tree view with connectors and icons. */
81
+ function renderLsTree(text: string, _basePath: string): string {
87
82
  const lines = text.trim().split("\n").filter(Boolean);
88
83
  if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
89
84
 
@@ -97,7 +92,6 @@ export function renderTree(text: string, _basePath: string): string {
97
92
  const prefix = isLast ? "└── " : "├── ";
98
93
  const connector = `${FG_RULE}${prefix}${RST}`;
99
94
 
100
- // Detect directories (entries ending with /)
101
95
  const isDir = entry.endsWith("/");
102
96
  const name = isDir ? entry.slice(0, -1) : entry;
103
97
  const icon = isDir ? dirIcon() : fileIcon(name);
@@ -116,6 +110,109 @@ export function renderTree(text: string, _basePath: string): string {
116
110
  return out.join("\n");
117
111
  }
118
112
 
113
+ /** Horizontal grid with icons (like eza/ls). */
114
+ function renderLsGrid(text: string, _basePath: string): string {
115
+ const lines = text.trim().split("\n").filter(Boolean);
116
+ if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
117
+
118
+ const total = lines.length;
119
+ const show = lines.slice(0, MAX_PREVIEW_LINES);
120
+
121
+ // Build styled cells + measure their visible widths
122
+ const cells: string[] = [];
123
+ const cellWidths: number[] = [];
124
+
125
+ for (const raw of show) {
126
+ const entry = raw.trim();
127
+ const isDir = entry.endsWith("/");
128
+ const name = isDir ? entry.slice(0, -1) : entry;
129
+ const icon = isDir ? dirIcon() : fileIcon(name);
130
+ const fg = isDir ? FG_BLUE + BOLD : "";
131
+ const reset = isDir ? RST : "";
132
+ const cell = `${icon}${fg}${name}${reset}`;
133
+ cells.push(cell);
134
+ cellWidths.push(visibleWidth(cell));
135
+ }
136
+
137
+ // Layout into columns that fit the terminal width
138
+ const tw = termW();
139
+ const GAP = 3; // spaces between columns
140
+ const rows = layoutGrid(cells, cellWidths, tw, GAP);
141
+
142
+ if (total > MAX_PREVIEW_LINES) {
143
+ rows.push(
144
+ `${FG_DIM}… ${pluralize(total - MAX_PREVIEW_LINES, "more entry", "more entries")}${RST}`,
145
+ );
146
+ }
147
+
148
+ return rows.join("\n");
149
+ }
150
+
151
+ /**
152
+ * Lay out styled cells into a grid that fills rows left-to-right,
153
+ * using as many columns as fit within `maxWidth`.
154
+ */
155
+ function layoutGrid(cells: string[], widths: number[], maxWidth: number, gap: number): string[] {
156
+ const n = cells.length;
157
+ if (n === 0) return [];
158
+
159
+ // Try increasing column counts to find the maximum that fits
160
+ let bestCols = 1;
161
+ for (let cols = 2; cols <= n; cols++) {
162
+ const numRows = Math.ceil(n / cols);
163
+ let totalW = 0;
164
+ let fits = true;
165
+ for (let c = 0; c < cols; c++) {
166
+ // Find max width in this column
167
+ let colW = 0;
168
+ for (let r = 0; r < numRows; r++) {
169
+ const idx = r * cols + c;
170
+ if (idx < n && (widths[idx] ?? 0) > colW) colW = widths[idx] ?? 0;
171
+ }
172
+ totalW += colW + (c < cols - 1 ? gap : 0);
173
+ if (totalW > maxWidth) {
174
+ fits = false;
175
+ break;
176
+ }
177
+ }
178
+ if (fits) bestCols = cols;
179
+ else break;
180
+ }
181
+
182
+ const cols = bestCols;
183
+ const numRows = Math.ceil(n / cols);
184
+
185
+ // Compute column widths
186
+ const colWidths: number[] = [];
187
+ for (let c = 0; c < cols; c++) {
188
+ let colW = 0;
189
+ for (let r = 0; r < numRows; r++) {
190
+ const idx = r * cols + c;
191
+ if (idx < n && (widths[idx] ?? 0) > colW) colW = widths[idx] ?? 0;
192
+ }
193
+ colWidths.push(colW);
194
+ }
195
+
196
+ // Render rows
197
+ const out: string[] = [];
198
+ for (let r = 0; r < numRows; r++) {
199
+ const parts: string[] = [];
200
+ for (let c = 0; c < cols; c++) {
201
+ const idx = r * cols + c;
202
+ if (idx >= n) break;
203
+ const cell = cells[idx] ?? "";
204
+ const w = widths[idx] ?? 0;
205
+ const target = colWidths[c] ?? 0;
206
+ // Pad to column width, except for the last column in a row
207
+ const pad = c < cols - 1 ? " ".repeat(Math.max(0, target - w + gap)) : "";
208
+ parts.push(cell + pad);
209
+ }
210
+ out.push(parts.join(""));
211
+ }
212
+
213
+ return out;
214
+ }
215
+
119
216
  // ---------------------------------------------------------------------------
120
217
  // FFF integration (optional) — Fast File Finder with frecency & SIMD search
121
218
  //
@@ -32,9 +32,5 @@ declare module "diff" {
32
32
  count?: number;
33
33
  }
34
34
 
35
- export function diffWords(
36
- oldStr: string,
37
- newStr: string,
38
- options?: unknown,
39
- ): Change[];
35
+ export function diffWords(oldStr: string, newStr: string, options?: unknown): Change[];
40
36
  }
package/src/types.ts CHANGED
@@ -41,28 +41,19 @@ export type ToolImageContent = ImageContent;
41
41
 
42
42
  export type ToolContent = TextContent | ImageContent;
43
43
 
44
- export type ToolResultLike<TDetails = unknown> = AgentToolResult<
45
- TDetails | undefined
46
- >;
44
+ export type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
47
45
 
48
46
  type TextComponentLike = {
49
47
  setText(value: string): void;
50
48
  getText?: () => string;
51
49
  };
52
50
 
53
- export type TextComponentCtor = new (
54
- text?: string,
55
- x?: number,
56
- y?: number,
57
- ) => TextComponentLike;
51
+ export type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
58
52
 
59
53
  export type ThemeLike = BgTheme & FgTheme & { bold: (text: string) => string };
60
54
 
61
55
  export type RenderContextLike<
62
- TState extends Record<string, string | undefined> = Record<
63
- string,
64
- string | undefined
65
- >,
56
+ TState extends Record<string, string | undefined> = Record<string, string | undefined>,
66
57
  > = {
67
58
  lastComponent?: TextComponentLike;
68
59
  state: TState;
package/src/utils.test.ts CHANGED
@@ -49,20 +49,14 @@ describe("renderDimPreview", () => {
49
49
  });
50
50
 
51
51
  it("adds singular overflow marker for 1 extra line", () => {
52
- const body = Array.from(
53
- { length: MAX_PREVIEW_LINES + 1 },
54
- (_, i) => `L${i}`,
55
- );
52
+ const body = Array.from({ length: MAX_PREVIEW_LINES + 1 }, (_, i) => `L${i}`);
56
53
  const out = plain(renderDimPreview(body.join("\n"), theme));
57
54
  expect(out).toContain("… 1 more line");
58
55
  expect(out).not.toContain("more lines");
59
56
  });
60
57
 
61
58
  it("adds plural overflow marker for many extra lines", () => {
62
- const body = Array.from(
63
- { length: MAX_PREVIEW_LINES + 3 },
64
- (_, i) => `L${i}`,
65
- );
59
+ const body = Array.from({ length: MAX_PREVIEW_LINES + 3 }, (_, i) => `L${i}`);
66
60
  const out = plain(renderDimPreview(body.join("\n"), theme));
67
61
  expect(out).toContain("… 3 more lines");
68
62
  });
@@ -86,8 +80,6 @@ describe("renderDimPreview", () => {
86
80
  });
87
81
 
88
82
  it("does not throw on an invalid highlight regex", () => {
89
- expect(() =>
90
- renderDimPreview("text", theme, { highlight: "(" }),
91
- ).not.toThrow();
83
+ expect(() => renderDimPreview("text", theme, { highlight: "(" })).not.toThrow();
92
84
  });
93
85
  });