pi-soly 1.11.2 → 1.12.0
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 +60 -14
- package/artifact/index.ts +241 -0
- package/artifact/render.ts +239 -0
- package/artifact/server.ts +384 -0
- package/artifact/session.ts +368 -0
- package/ask/README.md +20 -8
- package/ask/index.ts +100 -36
- package/ask/picker.ts +345 -29
- package/commands.ts +246 -30
- package/config.ts +124 -8
- package/core.ts +48 -236
- package/deck/deck.ts +386 -0
- package/deck/index.ts +113 -0
- package/index.ts +153 -37
- package/iteration.ts +1 -9
- package/mcp/commands.ts +12 -0
- package/mcp/direct-tools.ts +19 -2
- package/mcp/index.ts +144 -2
- package/mcp/init.ts +11 -0
- package/mcp/lifecycle.ts +9 -2
- package/mcp/server-manager.ts +30 -18
- package/mcp/state.ts +7 -0
- package/mcp/tool-cache.ts +135 -0
- package/nudge.ts +20 -3
- package/package.json +10 -3
- package/skills/soly-framework/SKILL.md +64 -37
- package/tool-hints.ts +62 -0
- package/tools.ts +28 -100
- package/util.ts +250 -0
- package/visual/chrome.ts +166 -0
- package/visual/colors.ts +24 -0
- package/visual/data.ts +62 -0
- package/visual/footer.ts +129 -0
- package/visual/format.ts +73 -0
- package/visual/glyphs.ts +35 -0
- package/visual/gradient.ts +122 -0
- package/visual/index.ts +18 -0
- package/visual/list-panel.ts +207 -0
- package/visual/segments.ts +136 -0
- package/visual/style.ts +34 -0
- package/visual/topbar.ts +55 -0
- package/visual/welcome.ts +265 -0
- package/visual/working.ts +66 -0
- package/workflows/execute.ts +17 -7
- package/workflows/index.ts +91 -44
- package/workflows/inspect.ts +23 -26
- package/workflows/migrate.ts +85 -0
- package/workflows/parser.ts +4 -2
- package/workflows/planning.ts +9 -8
- package/workflows/verify.ts +194 -0
- package/workflows-data/discuss-phase.md +10 -6
- package/agents-install.ts +0 -106
- package/ask/prompt.ts +0 -41
- package/ask/tests/prompt.test.ts +0 -54
package/visual/format.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/format.ts — pure formatting helpers for the soly chrome
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Small, dependency-free, fully testable string helpers used by the footer,
|
|
6
|
+
// top bar and working indicator. No ANSI, no pi imports — just text in / out.
|
|
7
|
+
// Width-aware rendering (visibleWidth/truncateToWidth) lives in segments.ts.
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format a token count for compact display, mirroring pi's native footer:
|
|
14
|
+
* `<1000` exact, `<10k` one decimal, `<1M` rounded k, then M.
|
|
15
|
+
*/
|
|
16
|
+
export function formatTokens(count: number): string {
|
|
17
|
+
if (!Number.isFinite(count) || count <= 0) return "0";
|
|
18
|
+
if (count < 1000) return String(Math.round(count));
|
|
19
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
20
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
21
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
22
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Replace the home prefix with `~`. Mirrors pi's `formatCwdForFooter` so our
|
|
27
|
+
* footer matches the native one when the path is short.
|
|
28
|
+
*/
|
|
29
|
+
export function formatCwd(cwd: string, home: string | undefined): string {
|
|
30
|
+
if (!home) return cwd;
|
|
31
|
+
const rel = path.relative(path.resolve(home), path.resolve(cwd));
|
|
32
|
+
const insideHome = rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
33
|
+
if (!insideHome) return cwd;
|
|
34
|
+
return rel === "" ? "~" : `~${path.sep}${rel}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Shrink a path to fit `maxWidth` visible columns through three tiers:
|
|
39
|
+
* 1. full `~/src/stbl/pi-soly.framework`
|
|
40
|
+
* 2. elided middle `~/…/pi-soly.framework`
|
|
41
|
+
* 3. basename `pi-soly.framework`
|
|
42
|
+
* Returns the widest tier that fits, or the basename truncated if even that
|
|
43
|
+
* is too wide. `maxWidth <= 0` returns an empty string (caller drops it).
|
|
44
|
+
*/
|
|
45
|
+
export function fitPath(cwd: string, home: string | undefined, maxWidth: number): string {
|
|
46
|
+
if (maxWidth <= 0) return "";
|
|
47
|
+
const full = formatCwd(cwd, home);
|
|
48
|
+
if (full.length <= maxWidth) return full;
|
|
49
|
+
|
|
50
|
+
const base = path.basename(cwd) || cwd;
|
|
51
|
+
const head = full.startsWith("~") ? "~" : path.parse(full).root.replace(/[\\/]+$/, "");
|
|
52
|
+
const elided = `${head}${path.sep}…${path.sep}${base}`;
|
|
53
|
+
if (elided.length <= maxWidth) return elided;
|
|
54
|
+
|
|
55
|
+
if (base.length <= maxWidth) return base;
|
|
56
|
+
return base.slice(0, Math.max(1, maxWidth - 1)) + "…";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compact elapsed label: `8s` under a minute, `9m 50s` under an hour, `1h 02m`
|
|
61
|
+
* beyond. Components are space-separated so the digits don't run together.
|
|
62
|
+
* Seconds/minutes are zero-padded to two digits so the right edge doesn't
|
|
63
|
+
* jump each tick on a live-updating counter. Clamped at 0.
|
|
64
|
+
*/
|
|
65
|
+
export function formatElapsed(ms: number): string {
|
|
66
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
67
|
+
if (total < 60) return `${total}s`;
|
|
68
|
+
const h = Math.floor(total / 3600);
|
|
69
|
+
const m = Math.floor((total % 3600) / 60);
|
|
70
|
+
const s = total % 60;
|
|
71
|
+
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
|
|
72
|
+
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
73
|
+
}
|
package/visual/glyphs.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/glyphs.ts — Nerd-Font glyphs with ASCII fallbacks
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Each glyph has a Nerd-Font form and a plain-ASCII form. The chrome picks
|
|
6
|
+
// per-glyph based on a single `ascii` flag (from soly config). Kept tiny and
|
|
7
|
+
// pure so the components stay declarative.
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
export type GlyphName = "ctx" | "git" | "model" | "enter";
|
|
11
|
+
|
|
12
|
+
const NERD: Record<GlyphName, string> = {
|
|
13
|
+
ctx: "◐",
|
|
14
|
+
git: "⎇",
|
|
15
|
+
model: "⊙",
|
|
16
|
+
enter: "↵",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ASCII: Record<GlyphName, string> = {
|
|
20
|
+
ctx: "",
|
|
21
|
+
git: "git:",
|
|
22
|
+
model: "",
|
|
23
|
+
enter: "enter",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Return the glyph for `name`, using the ASCII form when `ascii` is true. */
|
|
27
|
+
export function glyph(name: GlyphName, ascii: boolean): string {
|
|
28
|
+
return ascii ? ASCII[name] : NERD[name];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Prefix `text` with a glyph + space, omitting the space when the glyph is empty. */
|
|
32
|
+
export function withGlyph(name: GlyphName, text: string, ascii: boolean): string {
|
|
33
|
+
const g = glyph(name, ascii);
|
|
34
|
+
return g ? `${g} ${text}` : text;
|
|
35
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/gradient.ts — truecolor gradient helpers for the banner ("переливы")
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Pure RGB math + ANSI emission, mode-aware:
|
|
6
|
+
// - truecolor → 24-bit `\x1b[38;2;r;g;bm`
|
|
7
|
+
// - 256color → nearest xterm-256 `\x1b[38;5;Nm`
|
|
8
|
+
// - none → no codes (plain text; used in tests / NO_COLOR / ASCII)
|
|
9
|
+
//
|
|
10
|
+
// Used by welcome.ts to paint the full-width wave band and the "soly" wordmark
|
|
11
|
+
// with a multi-stop color sweep. No pi imports → trivially unit-testable.
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
|
|
16
|
+
export type RGB = { r: number; g: number; b: number };
|
|
17
|
+
export type ColorMode = "truecolor" | "256color" | "none";
|
|
18
|
+
|
|
19
|
+
export const RESET = "\x1b[0m";
|
|
20
|
+
|
|
21
|
+
/** Parse `#rgb` / `#rrggbb` → RGB, or null when malformed. */
|
|
22
|
+
export function hexToRgb(hex: string): RGB | null {
|
|
23
|
+
const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
|
|
24
|
+
if (!m?.[1]) return null;
|
|
25
|
+
const h = m[1].length === 3 ? m[1].split("").map((c) => c + c).join("") : m[1];
|
|
26
|
+
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const clamp255 = (n: number): number => Math.max(0, Math.min(255, Math.round(n)));
|
|
30
|
+
|
|
31
|
+
/** Linear interpolate between two colors. `t` in [0,1]. */
|
|
32
|
+
export function lerp(a: RGB, b: RGB, t: number): RGB {
|
|
33
|
+
return { r: clamp255(a.r + (b.r - a.r) * t), g: clamp255(a.g + (b.g - a.g) * t), b: clamp255(a.b + (b.b - a.b) * t) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Produce `n` colors evenly sampled across a multi-stop gradient. */
|
|
37
|
+
export function gradient(stops: RGB[], n: number): RGB[] {
|
|
38
|
+
if (n <= 0 || stops.length === 0) return [];
|
|
39
|
+
if (stops.length === 1 || n === 1) return Array.from({ length: n }, () => stops[0] as RGB);
|
|
40
|
+
const out: RGB[] = [];
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
const pos = (i / (n - 1)) * (stops.length - 1);
|
|
43
|
+
const idx = Math.min(stops.length - 2, Math.floor(pos));
|
|
44
|
+
out.push(lerp(stops[idx] as RGB, stops[idx + 1] as RGB, pos - idx));
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Nearest xterm-256 index for an RGB color (6×6×6 cube + grayscale ramp). */
|
|
50
|
+
export function rgbTo256(c: RGB): number {
|
|
51
|
+
if (Math.abs(c.r - c.g) < 8 && Math.abs(c.g - c.b) < 8) {
|
|
52
|
+
if (c.r < 8) return 16;
|
|
53
|
+
if (c.r > 248) return 231;
|
|
54
|
+
return 232 + Math.round(((c.r - 8) / 247) * 24);
|
|
55
|
+
}
|
|
56
|
+
const q = (v: number): number => Math.round((v / 255) * 5);
|
|
57
|
+
return 16 + 36 * q(c.r) + 6 * q(c.g) + q(c.b);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CUBE = [0, 95, 135, 175, 215, 255];
|
|
61
|
+
const BASIC16: RGB[] = [
|
|
62
|
+
{ r: 0, g: 0, b: 0 }, { r: 128, g: 0, b: 0 }, { r: 0, g: 128, b: 0 }, { r: 128, g: 128, b: 0 },
|
|
63
|
+
{ r: 0, g: 0, b: 128 }, { r: 128, g: 0, b: 128 }, { r: 0, g: 128, b: 128 }, { r: 192, g: 192, b: 192 },
|
|
64
|
+
{ r: 128, g: 128, b: 128 }, { r: 255, g: 0, b: 0 }, { r: 0, g: 255, b: 0 }, { r: 255, g: 255, b: 0 },
|
|
65
|
+
{ r: 0, g: 0, b: 255 }, { r: 255, g: 0, b: 255 }, { r: 0, g: 255, b: 255 }, { r: 255, g: 255, b: 255 },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** Convert an xterm-256 index back to approximate RGB (inverse of rgbTo256). */
|
|
69
|
+
export function xterm256ToRgb(n: number): RGB {
|
|
70
|
+
if (n < 16) return BASIC16[n] ?? { r: 0, g: 0, b: 0 };
|
|
71
|
+
if (n >= 232) {
|
|
72
|
+
const v = clamp255(8 + (n - 232) * 10);
|
|
73
|
+
return { r: v, g: v, b: v };
|
|
74
|
+
}
|
|
75
|
+
const i = n - 16;
|
|
76
|
+
return {
|
|
77
|
+
r: CUBE[Math.floor(i / 36) % 6] ?? 0,
|
|
78
|
+
g: CUBE[Math.floor(i / 6) % 6] ?? 0,
|
|
79
|
+
b: CUBE[i % 6] ?? 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Extract an RGB from a theme fg ANSI string (`38;2;r;g;b` or `38;5;n`). */
|
|
84
|
+
export function parseAnsiColor(ansi: string): RGB | null {
|
|
85
|
+
const tc = /38;2;(\d{1,3});(\d{1,3});(\d{1,3})/.exec(ansi);
|
|
86
|
+
if (tc) return { r: clamp255(+(tc[1] ?? 0)), g: clamp255(+(tc[2] ?? 0)), b: clamp255(+(tc[3] ?? 0)) };
|
|
87
|
+
const idx = /38;5;(\d{1,3})/.exec(ansi);
|
|
88
|
+
if (idx) return xterm256ToRgb(+(idx[1] ?? 0));
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Variations around a base color: darker → base → lighter. Used for accent-derived banners. */
|
|
93
|
+
export function variations(base: RGB): RGB[] {
|
|
94
|
+
return [lerp(base, { r: 0, g: 0, b: 0 }, 0.45), base, lerp(base, { r: 255, g: 255, b: 255 }, 0.5)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Foreground ANSI for a color in the given mode (empty string when none). */
|
|
98
|
+
export function fgAnsi(c: RGB, mode: ColorMode): string {
|
|
99
|
+
if (mode === "truecolor") return `\x1b[38;2;${c.r};${c.g};${c.b}m`;
|
|
100
|
+
if (mode === "256color") return `\x1b[38;5;${rgbTo256(c)}m`;
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Color `text` column-by-column from `colors` (indexed by visible column).
|
|
106
|
+
* Spaces are left unstyled. Returns text unchanged when mode is "none".
|
|
107
|
+
*/
|
|
108
|
+
export function colorizeColumns(text: string, colors: RGB[], mode: ColorMode): string {
|
|
109
|
+
if (mode === "none" || colors.length === 0) return text;
|
|
110
|
+
let out = "";
|
|
111
|
+
let col = 0;
|
|
112
|
+
for (const ch of Array.from(text)) {
|
|
113
|
+
if (ch === " ") {
|
|
114
|
+
out += ch;
|
|
115
|
+
} else {
|
|
116
|
+
const color = colors[Math.min(colors.length - 1, col)] as RGB;
|
|
117
|
+
out += fgAnsi(color, mode) + ch;
|
|
118
|
+
}
|
|
119
|
+
col += visibleWidth(ch);
|
|
120
|
+
}
|
|
121
|
+
return out + RESET;
|
|
122
|
+
}
|
package/visual/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/index.ts — public surface of the soly chrome (Phase 2: visual)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A flexible, native, dependency-free status system for pi: two width-aware
|
|
6
|
+
// "polosy" (a top bar above the editor + a custom footer) plus a configurable
|
|
7
|
+
// working spinner with live telemetry. Built only on documented pi UI APIs
|
|
8
|
+
// (setWidget / setFooter / setWorkingIndicator / setWorkingMessage).
|
|
9
|
+
//
|
|
10
|
+
// index.ts (the extension entry) creates one Chrome via createChrome(), keeps
|
|
11
|
+
// its `data` snapshot up to date on lifecycle events, and calls poke() to
|
|
12
|
+
// re-render. Everything else is internal.
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export { createChrome, type Chrome, type ChromeConfig } from "./chrome.ts";
|
|
16
|
+
export { type ChromeData, emptyChromeData } from "./data.ts";
|
|
17
|
+
export { SPINNER_FRAMES, SPINNER_INTERVAL_MS } from "./working.ts";
|
|
18
|
+
export { readWelcomeMeta, type WelcomeInput } from "./welcome.ts";
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/list-panel.ts — generic focused list modal (rules / docs / …)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A reusable overlay panel (shown via ctx.ui.custom) on the same pattern as the
|
|
6
|
+
// MCP panel: a fuzzy-filterable list with a live preview pane and key actions,
|
|
7
|
+
// instead of dumping everything into the chat. Monochrome by default — dim
|
|
8
|
+
// borders, muted rows, bold for the selected row (no decorative color).
|
|
9
|
+
//
|
|
10
|
+
// The caller supplies items (+ a `refresh` to re-read them after an action) and
|
|
11
|
+
// optional actions (key → run). `/` enters search mode; Esc exits search, then
|
|
12
|
+
// closes. Pure-ish: rendering uses only pi-tui width helpers + the theme.
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
16
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "../mcp/panel-keys.ts";
|
|
19
|
+
|
|
20
|
+
/** One row in the panel. `body` is shown in the preview pane when selected. */
|
|
21
|
+
export type ListItem = { id: string; marker: string; label: string; meta?: string; body?: string };
|
|
22
|
+
|
|
23
|
+
/** A key-triggered action over the selected item (e.g. enable/disable/reload). */
|
|
24
|
+
export type ListAction = { key: string; hint: string; run: (item: ListItem) => void };
|
|
25
|
+
|
|
26
|
+
export type ListPanelProps = {
|
|
27
|
+
tui: TUI;
|
|
28
|
+
theme: Theme;
|
|
29
|
+
keybindings?: PanelKeybindings;
|
|
30
|
+
done: () => void;
|
|
31
|
+
title: string;
|
|
32
|
+
/** Right-aligned header text (e.g. counts / token budget). */
|
|
33
|
+
headerRight?: string;
|
|
34
|
+
items: ListItem[];
|
|
35
|
+
actions?: ListAction[];
|
|
36
|
+
/** Re-read items after an action mutates state. */
|
|
37
|
+
refresh?: () => ListItem[];
|
|
38
|
+
/** Fired on Enter for the selected item; the panel then closes. When set,
|
|
39
|
+
* the footer shows an "⏎ open" hint. */
|
|
40
|
+
onSelect?: (item: ListItem) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const MAX_ROWS = 12; // list window height (render() has no viewport height)
|
|
44
|
+
const PREVIEW_LINES = 4;
|
|
45
|
+
|
|
46
|
+
/** Subsequence fuzzy match: substring scores highest, then in-order chars. */
|
|
47
|
+
function fuzzyScore(query: string, text: string): number {
|
|
48
|
+
const q = query.toLowerCase();
|
|
49
|
+
const t = text.toLowerCase();
|
|
50
|
+
if (!q) return 1;
|
|
51
|
+
if (t.includes(q)) return 100 + q.length / t.length;
|
|
52
|
+
let qi = 0;
|
|
53
|
+
for (let i = 0; i < t.length && qi < q.length; i++) if (t[i] === q[qi]) qi++;
|
|
54
|
+
return qi === q.length ? 1 : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ListPanel implements Component {
|
|
58
|
+
private readonly p: ListPanelProps;
|
|
59
|
+
private readonly keys: PanelKeys;
|
|
60
|
+
private items: ListItem[];
|
|
61
|
+
private query = "";
|
|
62
|
+
private searching = false;
|
|
63
|
+
private selected = 0;
|
|
64
|
+
|
|
65
|
+
constructor(props: ListPanelProps) {
|
|
66
|
+
this.p = props;
|
|
67
|
+
this.items = props.items;
|
|
68
|
+
this.keys = createPanelKeys(props.keybindings);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
invalidate(): void {
|
|
72
|
+
/* stateless cache */
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private filtered(): ListItem[] {
|
|
76
|
+
if (!this.query) return this.items;
|
|
77
|
+
return this.items
|
|
78
|
+
.map((it) => ({ it, s: fuzzyScore(this.query, `${it.label} ${it.meta ?? ""}`) }))
|
|
79
|
+
.filter((x) => x.s > 0)
|
|
80
|
+
.sort((a, b) => b.s - a.s)
|
|
81
|
+
.map((x) => x.it);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private clamp(list: ListItem[]): void {
|
|
85
|
+
if (this.selected >= list.length) this.selected = Math.max(0, list.length - 1);
|
|
86
|
+
if (this.selected < 0) this.selected = 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
handleInput(data: string): void {
|
|
90
|
+
const list = this.filtered();
|
|
91
|
+
this.clamp(list);
|
|
92
|
+
|
|
93
|
+
if (this.searching) {
|
|
94
|
+
if (matchesKey(data, "escape") || matchesKey(data, "return")) {
|
|
95
|
+
this.searching = false;
|
|
96
|
+
} else if (matchesKey(data, "backspace")) {
|
|
97
|
+
this.query = this.query.slice(0, -1);
|
|
98
|
+
} else if (data.length === 1 && data >= " ") {
|
|
99
|
+
this.query += data;
|
|
100
|
+
this.selected = 0;
|
|
101
|
+
}
|
|
102
|
+
this.p.tui.requestRender();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (matchesKey(data, "escape")) {
|
|
107
|
+
this.p.done();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.keys.selectUp(data)) {
|
|
111
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
112
|
+
} else if (this.keys.selectDown(data)) {
|
|
113
|
+
this.selected = Math.min(list.length - 1, this.selected + 1);
|
|
114
|
+
} else if (data === "/") {
|
|
115
|
+
this.searching = true;
|
|
116
|
+
} else if (matchesKey(data, "return")) {
|
|
117
|
+
const current = list[this.selected];
|
|
118
|
+
if (this.p.onSelect && current) {
|
|
119
|
+
this.p.onSelect(current);
|
|
120
|
+
this.p.done();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
const action = this.p.actions?.find((a) => a.key === data);
|
|
125
|
+
const current = list[this.selected];
|
|
126
|
+
if (action && current) {
|
|
127
|
+
action.run(current);
|
|
128
|
+
if (this.p.refresh) this.items = this.p.refresh();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
this.p.tui.requestRender();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
render(width: number): string[] {
|
|
135
|
+
const theme = this.p.theme;
|
|
136
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
137
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
138
|
+
const inner = Math.max(20, width - 4);
|
|
139
|
+
const list = this.filtered();
|
|
140
|
+
this.clamp(list);
|
|
141
|
+
|
|
142
|
+
const out: string[] = [];
|
|
143
|
+
out.push(this.headerLine(inner, dim, muted));
|
|
144
|
+
out.push(this.searchLine(inner, dim, muted));
|
|
145
|
+
out.push(this.frame("", inner, dim));
|
|
146
|
+
|
|
147
|
+
// Windowed list around the selection.
|
|
148
|
+
const start = Math.max(0, Math.min(this.selected - Math.floor(MAX_ROWS / 2), Math.max(0, list.length - MAX_ROWS)));
|
|
149
|
+
const window = list.slice(start, start + MAX_ROWS);
|
|
150
|
+
if (window.length === 0) out.push(this.frame(dim(" (no matches)"), inner, dim));
|
|
151
|
+
for (let i = 0; i < window.length; i++) out.push(this.rowLine(window[i] as ListItem, start + i === this.selected, inner, dim, muted));
|
|
152
|
+
|
|
153
|
+
out.push(this.ruleLine("preview", inner, dim));
|
|
154
|
+
for (const line of this.previewLines(list[this.selected], inner)) out.push(this.frame(" " + muted(line), inner, dim));
|
|
155
|
+
|
|
156
|
+
out.push(this.footerLine(inner, dim));
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private frame(content: string, inner: number, dim: (s: string) => string): string {
|
|
161
|
+
const pad = Math.max(0, inner - visibleWidth(content));
|
|
162
|
+
return dim("│ ") + content + " ".repeat(pad) + dim(" │");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private headerLine(inner: number, dim: (s: string) => string, muted: (s: string) => string): string {
|
|
166
|
+
const left = ` ${this.p.title} `;
|
|
167
|
+
const right = this.p.headerRight ? ` ${this.p.headerRight} ` : "";
|
|
168
|
+
const fillN = Math.max(1, inner + 2 - visibleWidth(left) - visibleWidth(right));
|
|
169
|
+
return dim("┌") + muted(left) + dim("─".repeat(fillN)) + muted(right) + dim("┐");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private searchLine(inner: number, dim: (s: string) => string, muted: (s: string) => string): string {
|
|
173
|
+
const text = this.searching || this.query ? `/ ${this.query}${this.searching ? "▏" : ""}` : muted("/ to search");
|
|
174
|
+
return this.frame(text, inner, dim);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private rowLine(it: ListItem, selected: boolean, inner: number, dim: (s: string) => string, muted: (s: string) => string): string {
|
|
178
|
+
const cursor = selected ? muted("❯ ") : " ";
|
|
179
|
+
const label = selected ? this.p.theme.bold(it.label) : muted(it.label);
|
|
180
|
+
const head = `${cursor}${dim(it.marker)} ${label}`;
|
|
181
|
+
const meta = it.meta ? dim(it.meta) : "";
|
|
182
|
+
const gap = Math.max(1, inner - visibleWidth(head) - visibleWidth(meta));
|
|
183
|
+
const line = head + " ".repeat(gap) + meta;
|
|
184
|
+
return this.frame(visibleWidth(line) > inner ? truncateToWidth(line, inner) : line, inner, dim);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A full-width "── label ───" rule inside the panel border. */
|
|
188
|
+
private ruleLine(label: string, inner: number, dim: (s: string) => string): string {
|
|
189
|
+
const head = `── ${label} `;
|
|
190
|
+
const dashes = Math.max(0, inner - visibleWidth(head));
|
|
191
|
+
return dim(`│ ${head}${"─".repeat(dashes)} │`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private previewLines(it: ListItem | undefined, inner: number): string[] {
|
|
195
|
+
const body = (it?.body ?? "").replace(/\s+/g, " ").trim();
|
|
196
|
+
if (!body) return ["(no preview)"];
|
|
197
|
+
return wrapTextWithAnsi(body, inner - 2).slice(0, PREVIEW_LINES);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private footerLine(inner: number, dim: (s: string) => string): string {
|
|
201
|
+
const acts = (this.p.actions ?? []).map((a) => `${a.key} ${a.hint}`).join(" · ");
|
|
202
|
+
const open = this.p.onSelect ? "⏎ open · " : "";
|
|
203
|
+
const hint = ` ↑↓ move · ${open}/ search${acts ? " · " + acts : ""} · esc `;
|
|
204
|
+
const fillN = Math.max(1, inner + 2 - visibleWidth(hint));
|
|
205
|
+
return dim("└") + dim(hint) + dim("─".repeat(fillN)) + dim("┘");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/segments.ts — width-aware segment composition for the soly chrome
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A bar is two ordered lists of segments (left + right). Each segment carries
|
|
6
|
+
// a `priority` (lower = dropped first when the terminal is too narrow). The
|
|
7
|
+
// composer drops the lowest-priority segments until the bar fits, then joins
|
|
8
|
+
// left/right with a separator and fills the gap with a rule (e.g. `─`).
|
|
9
|
+
//
|
|
10
|
+
// Segment `text` may already contain ANSI color codes; all width math uses
|
|
11
|
+
// pi's `visibleWidth`/`truncateToWidth`, which ignore ANSI and handle wide
|
|
12
|
+
// glyphs, so coloring never breaks the layout (same approach as pi's native
|
|
13
|
+
// footer). The composer itself is pure: pass an identity `styleFill` in tests
|
|
14
|
+
// to assert exact column widths.
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
+
|
|
19
|
+
/** One unit of a bar. `text` is the final (possibly ANSI-styled) string. */
|
|
20
|
+
export type Segment = {
|
|
21
|
+
/** Stable id (for debugging / future per-segment config). */
|
|
22
|
+
id: string;
|
|
23
|
+
/** Rendered text, may contain ANSI codes. */
|
|
24
|
+
text: string;
|
|
25
|
+
/** Lower priority is dropped first when space runs out. */
|
|
26
|
+
priority: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ComposeOptions = {
|
|
30
|
+
left: Segment[];
|
|
31
|
+
right: Segment[];
|
|
32
|
+
width: number;
|
|
33
|
+
/** Separator between segments on the same side. Default `" · "`. */
|
|
34
|
+
sep?: string;
|
|
35
|
+
/** Fill character for the gap between left and right. Default `"─"`. */
|
|
36
|
+
fillChar?: string;
|
|
37
|
+
/** Styles the gap fill (e.g. dim). Identity by default. */
|
|
38
|
+
styleFill?: (s: string) => string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Sum of segment widths joined by `sep`. */
|
|
42
|
+
function barWidth(segs: Segment[], sep: string): number {
|
|
43
|
+
if (segs.length === 0) return 0;
|
|
44
|
+
const sepW = visibleWidth(sep) * (segs.length - 1);
|
|
45
|
+
return segs.reduce((w, s) => w + visibleWidth(s.text), 0) + sepW;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Min columns between the left group and a non-empty right group. */
|
|
49
|
+
const MIN_GAP = 2;
|
|
50
|
+
|
|
51
|
+
function joinSide(segs: Segment[], sep: string): string {
|
|
52
|
+
return segs.map((s) => s.text).join(sep);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drop the lowest-priority segments (from either side) until the bar fits
|
|
57
|
+
* `width`. Ties break toward dropping right-side segments first, so the
|
|
58
|
+
* left identity/context stays put longest. Returns the surviving lists.
|
|
59
|
+
*/
|
|
60
|
+
function dropToFit(left: Segment[], right: Segment[], width: number, sep: string): {
|
|
61
|
+
left: Segment[];
|
|
62
|
+
right: Segment[];
|
|
63
|
+
} {
|
|
64
|
+
let l = [...left];
|
|
65
|
+
let r = [...right];
|
|
66
|
+
const fits = () => {
|
|
67
|
+
const gap = r.length > 0 ? MIN_GAP : 0;
|
|
68
|
+
return barWidth(l, sep) + barWidth(r, sep) + gap <= width;
|
|
69
|
+
};
|
|
70
|
+
while (!fits() && l.length + r.length > 0) {
|
|
71
|
+
const lowL = l.reduce<Segment | null>((m, s) => (m && m.priority <= s.priority ? m : s), null);
|
|
72
|
+
const lowR = r.reduce<Segment | null>((m, s) => (m && m.priority <= s.priority ? m : s), null);
|
|
73
|
+
// Prefer dropping the right side on ties (keep left identity).
|
|
74
|
+
if (lowR && (!lowL || lowR.priority <= lowL.priority)) {
|
|
75
|
+
r = r.filter((s) => s.id !== lowR.id);
|
|
76
|
+
} else if (lowL) {
|
|
77
|
+
l = l.filter((s) => s.id !== lowL.id);
|
|
78
|
+
} else {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { left: l, right: r };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compose a single full-width bar line. Drops by priority to fit, joins each
|
|
87
|
+
* side with `sep`, and fills the middle gap with a styled rule. When the two
|
|
88
|
+
* sides still don't fit after dropping (single dominant segment), the right
|
|
89
|
+
* side is truncated, then the left, mirroring pi's native footer.
|
|
90
|
+
*/
|
|
91
|
+
export function composeBar(opts: ComposeOptions): string {
|
|
92
|
+
const sep = opts.sep ?? " · ";
|
|
93
|
+
const fillChar = opts.fillChar ?? "─";
|
|
94
|
+
const styleFill = opts.styleFill ?? ((s) => s);
|
|
95
|
+
const width = Math.max(0, opts.width);
|
|
96
|
+
|
|
97
|
+
const kept = dropToFit(opts.left, opts.right, width, sep);
|
|
98
|
+
let leftStr = joinSide(kept.left, sep);
|
|
99
|
+
let rightStr = joinSide(kept.right, sep);
|
|
100
|
+
let wl = visibleWidth(leftStr);
|
|
101
|
+
let wr = visibleWidth(rightStr);
|
|
102
|
+
|
|
103
|
+
if (rightStr.length === 0) {
|
|
104
|
+
return wl > width ? truncateToWidth(leftStr, width, "…") : leftStr;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (wl + MIN_GAP + wr > width) {
|
|
108
|
+
// Truncate right, then left, to guarantee a single line.
|
|
109
|
+
const availRight = width - wl - MIN_GAP;
|
|
110
|
+
rightStr = availRight > 0 ? truncateToWidth(rightStr, availRight, "") : "";
|
|
111
|
+
if (rightStr.length === 0) return truncateToWidth(leftStr, width, "…");
|
|
112
|
+
wr = visibleWidth(rightStr);
|
|
113
|
+
const pad = Math.max(0, width - wl - wr);
|
|
114
|
+
return leftStr + " ".repeat(pad) + rightStr;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const gap = width - wl - wr;
|
|
118
|
+
const fill = gap >= MIN_GAP ? styleFill(` ${fillChar.repeat(gap - MIN_GAP)} `) : " ".repeat(gap);
|
|
119
|
+
return leftStr + fill + rightStr;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Join a flat list of prioritized parts into a single string no wider than
|
|
124
|
+
* `width`, dropping the lowest-priority parts first. Used for the working
|
|
125
|
+
* indicator message. Pure; no fill, no padding.
|
|
126
|
+
*/
|
|
127
|
+
export function fitParts(parts: Segment[], width: number, sep = " · "): string {
|
|
128
|
+
let kept = [...parts];
|
|
129
|
+
const total = () => barWidth(kept, sep);
|
|
130
|
+
while (total() > width && kept.length > 1) {
|
|
131
|
+
const low = kept.reduce((m, s) => (m.priority <= s.priority ? m : s));
|
|
132
|
+
kept = kept.filter((s) => s.id !== low.id);
|
|
133
|
+
}
|
|
134
|
+
const joined = joinSide(kept, sep);
|
|
135
|
+
return visibleWidth(joined) > width ? truncateToWidth(joined, width, "…") : joined;
|
|
136
|
+
}
|
package/visual/style.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// visual/style.ts — styling indirection so render logic stays testable
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Components style segment text through a ChromeStyler rather than touching a
|
|
6
|
+
// pi Theme directly. Production wraps the real Theme; tests pass `identityStyler`
|
|
7
|
+
// so assertions can compare exact, un-ANSI'd columns.
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { ChromeColor } from "./colors.ts";
|
|
12
|
+
|
|
13
|
+
/** Minimal styling surface used by the chrome. */
|
|
14
|
+
export type ChromeStyler = {
|
|
15
|
+
fg(color: ChromeColor, text: string): string;
|
|
16
|
+
dim(text: string): string;
|
|
17
|
+
bold(text: string): string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Wrap a real pi Theme. ChromeColor is a subset of ThemeColor, so this is sound. */
|
|
21
|
+
export function themeStyler(theme: Theme): ChromeStyler {
|
|
22
|
+
return {
|
|
23
|
+
fg: (color, text) => theme.fg(color, text),
|
|
24
|
+
dim: (text) => theme.fg("dim", text),
|
|
25
|
+
bold: (text) => theme.bold(text),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** No-op styler for tests — returns text unchanged so widths are predictable. */
|
|
30
|
+
export const identityStyler: ChromeStyler = {
|
|
31
|
+
fg: (_color, text) => text,
|
|
32
|
+
dim: (text) => text,
|
|
33
|
+
bold: (text) => text,
|
|
34
|
+
};
|