@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/README.md +12 -10
- package/package.json +1 -1
- package/src/ansi.ts +4 -12
- package/src/config.ts +5 -72
- package/src/confirm.ts +6 -24
- package/src/diff-render.ts +42 -174
- package/src/diff.ts +1 -2
- package/src/fff.ts +3 -9
- package/src/gate-overlay.test.ts +2 -6
- package/src/gate-overlay.ts +5 -26
- package/src/highlight.ts +3 -6
- package/src/icon-catalog.ts +11 -8
- package/src/icon-persist.test.ts +10 -2
- package/src/icon-persist.ts +19 -44
- package/src/index.ts +2 -16
- package/src/modal-frame.ts +2 -6
- package/src/progress.ts +1 -5
- package/src/renderers.ts +115 -18
- package/src/types-diff.d.ts +1 -5
- package/src/types.ts +3 -12
- package/src/utils.test.ts +3 -11
- package/src/utils.ts +6 -23
- package/src/commands/pretty.ts +0 -135
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 {
|
package/src/gate-overlay.test.ts
CHANGED
|
@@ -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
|
}),
|
package/src/gate-overlay.ts
CHANGED
|
@@ -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
|
|
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 = `${
|
|
91
|
+
const k = `${hljsLang}\0${code}`;
|
|
95
92
|
const hit = _cache.get(k);
|
|
96
93
|
if (hit) return _touch(k, hit);
|
|
97
94
|
|
package/src/icon-catalog.ts
CHANGED
|
@@ -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 `/
|
|
14
|
-
* command (
|
|
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 /
|
|
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 /
|
|
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
|
|
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
|
|
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 /
|
|
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] : "";
|
package/src/icon-persist.test.ts
CHANGED
|
@@ -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
|
|
28
|
-
expect(loadIconMode()).
|
|
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)", () => {
|
package/src/icon-persist.ts
CHANGED
|
@@ -1,37 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* icon-persist.ts — disk persistence for the global icon mode.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* /
|
|
6
|
-
* catalog resolver stays pure (no fs) and trivially
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
30
|
+
/** Persist the icon mode to pix.json (`pretty.icons`). */
|
|
43
31
|
export function saveIconMode(mode: IconMode): void {
|
|
44
32
|
try {
|
|
45
|
-
|
|
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
|
|
66
|
-
*
|
|
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 →
|
|
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
|
|
35
|
-
//
|
|
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.
|
package/src/modal-frame.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
|
86
|
-
export function renderTree(text: 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
|
//
|
package/src/types-diff.d.ts
CHANGED
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
|
});
|