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.
Files changed (54) hide show
  1. package/README.md +60 -14
  2. package/artifact/index.ts +241 -0
  3. package/artifact/render.ts +239 -0
  4. package/artifact/server.ts +384 -0
  5. package/artifact/session.ts +368 -0
  6. package/ask/README.md +20 -8
  7. package/ask/index.ts +100 -36
  8. package/ask/picker.ts +345 -29
  9. package/commands.ts +246 -30
  10. package/config.ts +124 -8
  11. package/core.ts +48 -236
  12. package/deck/deck.ts +386 -0
  13. package/deck/index.ts +113 -0
  14. package/index.ts +153 -37
  15. package/iteration.ts +1 -9
  16. package/mcp/commands.ts +12 -0
  17. package/mcp/direct-tools.ts +19 -2
  18. package/mcp/index.ts +144 -2
  19. package/mcp/init.ts +11 -0
  20. package/mcp/lifecycle.ts +9 -2
  21. package/mcp/server-manager.ts +30 -18
  22. package/mcp/state.ts +7 -0
  23. package/mcp/tool-cache.ts +135 -0
  24. package/nudge.ts +20 -3
  25. package/package.json +10 -3
  26. package/skills/soly-framework/SKILL.md +64 -37
  27. package/tool-hints.ts +62 -0
  28. package/tools.ts +28 -100
  29. package/util.ts +250 -0
  30. package/visual/chrome.ts +166 -0
  31. package/visual/colors.ts +24 -0
  32. package/visual/data.ts +62 -0
  33. package/visual/footer.ts +129 -0
  34. package/visual/format.ts +73 -0
  35. package/visual/glyphs.ts +35 -0
  36. package/visual/gradient.ts +122 -0
  37. package/visual/index.ts +18 -0
  38. package/visual/list-panel.ts +207 -0
  39. package/visual/segments.ts +136 -0
  40. package/visual/style.ts +34 -0
  41. package/visual/topbar.ts +55 -0
  42. package/visual/welcome.ts +265 -0
  43. package/visual/working.ts +66 -0
  44. package/workflows/execute.ts +17 -7
  45. package/workflows/index.ts +91 -44
  46. package/workflows/inspect.ts +23 -26
  47. package/workflows/migrate.ts +85 -0
  48. package/workflows/parser.ts +4 -2
  49. package/workflows/planning.ts +9 -8
  50. package/workflows/verify.ts +194 -0
  51. package/workflows-data/discuss-phase.md +10 -6
  52. package/agents-install.ts +0 -106
  53. package/ask/prompt.ts +0 -41
  54. package/ask/tests/prompt.test.ts +0 -54
package/util.ts ADDED
@@ -0,0 +1,250 @@
1
+ // =============================================================================
2
+ // util.ts — shared leaf utilities (frontmatter parsers, fs/glob/format helpers)
3
+ // =============================================================================
4
+ //
5
+ // Extracted from core.ts to keep it focused. These are dependency-light helpers
6
+ // used across the extension; core.ts re-exports them so existing
7
+ // `import { ... } from "./core.ts"` call sites keep working. Only type-only
8
+ // imports point back at core.ts (erased at runtime — no import cycle).
9
+ // =============================================================================
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import type { ProgressInfo, RuleFrontmatter } from "./core.ts";
14
+
15
+ // ============================================================================
16
+ // Frontmatter parsers
17
+ // ============================================================================
18
+
19
+ // Simple parser for .soly/rules/ frontmatter.
20
+ export function parseRuleFrontmatter(raw: string): {
21
+ meta: RuleFrontmatter;
22
+ body: string;
23
+ } {
24
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
25
+ if (!match) return { meta: {}, body: raw };
26
+
27
+ const yamlText = match[1];
28
+ const body = match[2];
29
+ const meta: RuleFrontmatter = {};
30
+
31
+ for (const line of yamlText.split(/\r?\n/)) {
32
+ const trimmed = line.trim();
33
+ if (!trimmed || trimmed.startsWith("#")) continue;
34
+
35
+ const colonIdx = trimmed.indexOf(":");
36
+ if (colonIdx === -1) continue;
37
+
38
+ const key = trimmed.slice(0, colonIdx).trim();
39
+ let value: string | string[] | boolean = trimmed.slice(colonIdx + 1).trim();
40
+
41
+ if (
42
+ typeof value === "string" &&
43
+ ((value.startsWith('"') && value.endsWith('"')) ||
44
+ (value.startsWith("'") && value.endsWith("'")))
45
+ ) {
46
+ value = value.slice(1, -1);
47
+ }
48
+
49
+ if (
50
+ typeof value === "string" &&
51
+ value.startsWith("[") &&
52
+ value.endsWith("]")
53
+ ) {
54
+ const inner = value.slice(1, -1).trim();
55
+ value =
56
+ inner.length === 0
57
+ ? []
58
+ : inner.split(",").map((v) => v.trim().replace(/^["']|["']$/g, ""));
59
+ } else if (value === "true") {
60
+ value = true;
61
+ } else if (value === "false") {
62
+ value = false;
63
+ }
64
+
65
+ (meta as Record<string, unknown>)[key] = value;
66
+ }
67
+
68
+ return { meta, body };
69
+ }
70
+
71
+ // YAML-ish parser for .soly/STATE.md. Handles 2-level nested objects (for `progress:`).
72
+ export function parseStateFrontmatter(yaml: string): {
73
+ meta: Record<string, unknown>;
74
+ progress: ProgressInfo;
75
+ } {
76
+ const root: Record<string, unknown> = {};
77
+ const stack: { indent: number; obj: Record<string, unknown> }[] = [
78
+ { indent: -1, obj: root },
79
+ ];
80
+
81
+ for (const rawLine of yaml.split(/\r?\n/)) {
82
+ const line = rawLine.replace(/\s+$/, "");
83
+ if (!line.trim() || line.trim().startsWith("#")) continue;
84
+
85
+ const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
86
+ const content = line.trim();
87
+
88
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
89
+ stack.pop();
90
+ }
91
+ const parent = stack[stack.length - 1].obj as Record<string, unknown>;
92
+
93
+ const colonIdx = content.indexOf(":");
94
+ if (colonIdx === -1) continue;
95
+ const key = content.slice(0, colonIdx).trim();
96
+ let value: unknown = content.slice(colonIdx + 1).trim();
97
+
98
+ if (value === "") {
99
+ const newObj: Record<string, unknown> = {};
100
+ parent[key] = newObj;
101
+ stack.push({ indent, obj: newObj });
102
+ continue;
103
+ }
104
+
105
+ if (typeof value === "string") {
106
+ if (
107
+ (value.startsWith('"') && value.endsWith('"')) ||
108
+ (value.startsWith("'") && value.endsWith("'"))
109
+ ) {
110
+ value = value.slice(1, -1);
111
+ } else if (value === "true") {
112
+ value = true;
113
+ } else if (value === "false") {
114
+ value = false;
115
+ } else if (/^-?\d+(\.\d+)?$/.test(value)) {
116
+ const n = Number(value);
117
+ if (!Number.isNaN(n)) value = n;
118
+ }
119
+ }
120
+ parent[key] = value;
121
+ }
122
+
123
+ const progressObj =
124
+ (root.progress as Record<string, unknown> | undefined) ?? {};
125
+ return {
126
+ meta: root,
127
+ progress: {
128
+ totalPhases: Number(progressObj.total_phases ?? 0),
129
+ completedPhases: Number(progressObj.completed_phases ?? 0),
130
+ totalPlans: Number(progressObj.total_plans ?? 0),
131
+ completedPlans: Number(progressObj.completed_plans ?? 0),
132
+ percent: Number(progressObj.percent ?? 0),
133
+ },
134
+ };
135
+ }
136
+
137
+ export function splitFrontmatter(
138
+ raw: string,
139
+ ): { yaml: string; body: string } | null {
140
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
141
+ return m ? { yaml: m[1], body: m[2] } : null;
142
+ }
143
+
144
+ // ============================================================================
145
+ // File helpers
146
+ // ============================================================================
147
+
148
+ export function readIfExists(p: string): string | null {
149
+ try {
150
+ return fs.readFileSync(p, "utf-8");
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Atomic file write: write to a tmp file in the same directory, then rename
158
+ * to the target. Avoids partial writes when concurrent readers/processes
159
+ * (hot reload, another tool, git status) would otherwise see a half-written
160
+ * file. Best-effort: if rename fails, falls back to a direct write.
161
+ */
162
+ export function atomicWriteFileSync(
163
+ target: string,
164
+ content: string,
165
+ encoding: BufferEncoding = "utf-8",
166
+ ): void {
167
+ const dir = path.dirname(target);
168
+ const base = path.basename(target);
169
+ const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
170
+ try {
171
+ fs.writeFileSync(tmp, content, encoding);
172
+ fs.renameSync(tmp, target);
173
+ } catch {
174
+ // Fallback: direct write (e.g. cross-device rename on some systems)
175
+ try {
176
+ fs.writeFileSync(target, content, encoding);
177
+ } catch {
178
+ // best effort — caller handles errors
179
+ }
180
+ }
181
+ }
182
+
183
+ export function findMarkdownFiles(dir: string, basePath = ""): string[] {
184
+ const results: string[] = [];
185
+ if (!fs.existsSync(dir)) return results;
186
+
187
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
188
+ for (const entry of entries) {
189
+ if (entry.name.startsWith(".")) continue;
190
+ if (entry.name === "node_modules") continue;
191
+ const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
192
+ const fullPath = path.join(dir, entry.name);
193
+ if (entry.isDirectory()) {
194
+ results.push(...findMarkdownFiles(fullPath, relPath));
195
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
196
+ results.push(relPath);
197
+ }
198
+ }
199
+ return results;
200
+ }
201
+
202
+ export function globToRegExp(glob: string): RegExp {
203
+ let re = "";
204
+ for (let i = 0; i < glob.length; i++) {
205
+ const c = glob[i];
206
+ if (c === "*") {
207
+ if (glob[i + 1] === "*") {
208
+ re += ".*";
209
+ i++;
210
+ if (glob[i + 1] === "/") i++;
211
+ } else {
212
+ re += "[^/]*";
213
+ }
214
+ } else if (c === "?") {
215
+ re += "[^/]";
216
+ } else if (".()+|^${}\\".includes(c)) {
217
+ re += "\\" + c;
218
+ } else {
219
+ re += c;
220
+ }
221
+ }
222
+ return new RegExp("^" + re + "$");
223
+ }
224
+
225
+ export function matchesGlob(pathStr: string, glob: string): boolean {
226
+ return globToRegExp(glob).test(pathStr);
227
+ }
228
+
229
+ export function extractFilePathsFromPrompt(prompt: string): string[] {
230
+ // Only match paths that look like real file references: must contain a slash
231
+ // or start with ./ and end with a short extension. Avoids catching "1.5",
232
+ // "i.e.", etc.
233
+ const matches =
234
+ prompt.match(
235
+ /(?:\.{0,2}\/)?(?:[A-Za-z0-9_\-]+\/)+[A-Za-z0-9_\-.]+\.[A-Za-z0-9]{1,5}/g,
236
+ ) ||
237
+ prompt.match(/[A-Za-z0-9_\-.]+\.[a-z]{1,5}/g) ||
238
+ [];
239
+ return matches;
240
+ }
241
+
242
+ export function estimateTokens(text: string): number {
243
+ return Math.ceil(text.length / 4);
244
+ }
245
+
246
+ export function formatTok(n: number): string {
247
+ if (n <= 0) return "0";
248
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
249
+ return String(n);
250
+ }
@@ -0,0 +1,166 @@
1
+ // =============================================================================
2
+ // visual/chrome.ts — controller that wires the soly chrome into pi's UI
3
+ // =============================================================================
4
+ //
5
+ // Owns one ChromeData and installs three pieces through documented APIs only:
6
+ // - ctx.ui.setFooter → SolyFooter (bottom polosa, replaces native)
7
+ // - ctx.ui.setWidget(above) → SolyTopBar (top polosa, soly workflow state)
8
+ // - ctx.ui.setWorkingIndicator + setWorkingMessage → native snowflake spinner
9
+ // with a live telemetry message (elapsed · ↑↓ tokens · tok/s)
10
+ //
11
+ // No terminal monkey-patching, no runtime deps. The telemetry message is
12
+ // driven by a 1s timer (elapsed) plus message_update pokes (output tokens),
13
+ // and is torn down on agent_end / shutdown. install() is idempotent.
14
+ // =============================================================================
15
+
16
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
17
+ import type { TUI } from "@earendil-works/pi-tui";
18
+ import { type ChromeData, emptyChromeData } from "./data.ts";
19
+ import { SolyFooter } from "./footer.ts";
20
+ import { SolyTopBar } from "./topbar.ts";
21
+ import { SolyHeader, type WelcomeInput } from "./welcome.ts";
22
+ import { buildWorkingMessage } from "./working.ts";
23
+
24
+ /** Subset of soly config that controls the chrome (see config.ts `chrome`). */
25
+ export type ChromeConfig = {
26
+ enabled: boolean;
27
+ ascii: boolean;
28
+ spinnerFrames: string[];
29
+ spinnerIntervalMs: number;
30
+ telemetry: boolean;
31
+ /** Hex gradient stops for the welcome banner (empty → accent color). */
32
+ bannerColors: string[];
33
+ };
34
+
35
+ const TOPBAR_KEY = "soly-chrome-top";
36
+ const WORKING_LABEL = "Working";
37
+ /** Columns reserved for the spinner glyph and pi's own "(esc to interrupt)" hint. */
38
+ const WORKING_MARGIN = 18;
39
+
40
+ type WorkingState = {
41
+ ui: ExtensionUIContext;
42
+ startMs: number;
43
+ inputTokens: number;
44
+ outputTokens: number;
45
+ timer: ReturnType<typeof setInterval> | null;
46
+ };
47
+
48
+ /** The chrome controller. One per extension instance. */
49
+ export type Chrome = {
50
+ /** Mutable snapshot the components render from; index.ts updates it in place. */
51
+ readonly data: ChromeData;
52
+ /** Install footer/top-bar/spinner/header. Safe to call repeatedly (session_start). */
53
+ install(ui: ExtensionUIContext): void;
54
+ /** Provide the startup-header snapshot (version, state, recent changes). */
55
+ setWelcome(input: WelcomeInput): void;
56
+ /** Force a re-render of footer + top bar after data changes. */
57
+ poke(): void;
58
+ /** Begin the working telemetry line (agent_start). */
59
+ startWorking(ui: ExtensionUIContext): void;
60
+ /** Update generated-token count during streaming (message_update). */
61
+ updateWorking(ui: ExtensionUIContext, outputTokens: number): void;
62
+ /** Stop the telemetry line and restore the default message (agent_end). */
63
+ stopWorking(ui: ExtensionUIContext): void;
64
+ /** Restore pi's native footer/widgets/indicator (session_shutdown / disable). */
65
+ dispose(ui?: ExtensionUIContext): void;
66
+ };
67
+
68
+ /** Create a chrome controller. `getConfig` is read live so /reload picks up changes. */
69
+ export function createChrome(getConfig: () => ChromeConfig): Chrome {
70
+ const data = emptyChromeData();
71
+ let welcome: WelcomeInput | null = null;
72
+ let tui: TUI | null = null;
73
+ let working: WorkingState | null = null;
74
+ const ascii = () => getConfig().ascii;
75
+
76
+ const workingWidth = (): number => {
77
+ const cols = typeof process !== "undefined" ? process.stdout?.columns : undefined;
78
+ return Math.max(20, (cols ?? 80) - WORKING_MARGIN);
79
+ };
80
+
81
+ const renderWorking = (): void => {
82
+ if (!working || !getConfig().telemetry) return;
83
+ const message = buildWorkingMessage(
84
+ {
85
+ label: WORKING_LABEL,
86
+ elapsedMs: Date.now() - working.startMs,
87
+ inputTokens: working.inputTokens,
88
+ outputTokens: working.outputTokens,
89
+ },
90
+ workingWidth(),
91
+ );
92
+ try { working.ui.setWorkingMessage(message); } catch { /* session may have ended */ }
93
+ };
94
+
95
+ const clearWorking = (): void => {
96
+ if (working?.timer) clearInterval(working.timer);
97
+ working = null;
98
+ };
99
+
100
+ return {
101
+ data,
102
+
103
+ install(ui): void {
104
+ if (!getConfig().enabled) return;
105
+ ui.setWorkingIndicator({ frames: getConfig().spinnerFrames, intervalMs: getConfig().spinnerIntervalMs });
106
+ ui.setFooter((t, theme, footerData) => {
107
+ tui = t;
108
+ return new SolyFooter(data, footerData, theme, ascii);
109
+ });
110
+ ui.setWidget(
111
+ TOPBAR_KEY,
112
+ (t, theme) => {
113
+ tui = t;
114
+ return new SolyTopBar(data, theme, ascii);
115
+ },
116
+ { placement: "aboveEditor" },
117
+ );
118
+ ui.setHeader((t, theme) => {
119
+ tui = t;
120
+ return new SolyHeader(() => welcome, theme, ascii, () => getConfig().bannerColors);
121
+ });
122
+ },
123
+
124
+ setWelcome(input): void {
125
+ welcome = input;
126
+ try { tui?.requestRender(); } catch { /* not mounted yet */ }
127
+ },
128
+
129
+ poke(): void {
130
+ try { tui?.requestRender(); } catch { /* not mounted yet */ }
131
+ },
132
+
133
+ startWorking(ui): void {
134
+ if (!getConfig().enabled || !getConfig().telemetry) return;
135
+ clearWorking();
136
+ working = { ui, startMs: Date.now(), inputTokens: data.ctxTokens ?? 0, outputTokens: 0, timer: null };
137
+ renderWorking();
138
+ working.timer = setInterval(renderWorking, 1000);
139
+ },
140
+
141
+ updateWorking(ui, outputTokens): void {
142
+ if (!working) return;
143
+ working.ui = ui;
144
+ working.outputTokens = Math.max(working.outputTokens, outputTokens);
145
+ renderWorking();
146
+ },
147
+
148
+ stopWorking(ui): void {
149
+ clearWorking();
150
+ try { ui.setWorkingMessage(); } catch { /* ignore */ }
151
+ },
152
+
153
+ dispose(ui): void {
154
+ clearWorking();
155
+ tui = null;
156
+ if (!ui) return;
157
+ try {
158
+ ui.setFooter(undefined);
159
+ ui.setWidget(TOPBAR_KEY, undefined);
160
+ ui.setHeader(undefined);
161
+ ui.setWorkingIndicator();
162
+ ui.setWorkingMessage();
163
+ } catch { /* session already torn down */ }
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,24 @@
1
+ // =============================================================================
2
+ // visual/colors.ts — theme color-role helpers for the soly chrome
3
+ // =============================================================================
4
+ //
5
+ // We only use a small subset of pi's ThemeColor union. Declaring it locally
6
+ // (a) avoids importing pi's internal type and (b) keeps the literals narrow
7
+ // so `theme.fg(role, text)` type-checks (the subset is assignable to
8
+ // ThemeColor). Pure: maps data → color-role name, no ANSI here.
9
+ // =============================================================================
10
+
11
+ /** Subset of pi's ThemeColor used by the chrome. Assignable to ThemeColor. */
12
+ export type ChromeColor = "text" | "muted" | "dim" | "accent" | "warning" | "error" | "success";
13
+
14
+ /**
15
+ * Context-usage color by percentage, matching pi's native footer thresholds:
16
+ * `> 90%` error, `> 70%` warning, otherwise muted. `null` (unknown, e.g. just
17
+ * after compaction) is muted.
18
+ */
19
+ export function ctxColor(percent: number | null): ChromeColor {
20
+ if (percent === null || !Number.isFinite(percent)) return "muted";
21
+ if (percent > 90) return "error";
22
+ if (percent > 70) return "warning";
23
+ return "muted";
24
+ }
package/visual/data.ts ADDED
@@ -0,0 +1,62 @@
1
+ // =============================================================================
2
+ // visual/data.ts — shared mutable snapshot read by the chrome components
3
+ // =============================================================================
4
+ //
5
+ // index.ts owns one ChromeData object and mutates its fields on lifecycle
6
+ // events (session_start, before_agent_start, turn_end, message_update,
7
+ // model_select). The footer/top-bar components read it live at render time
8
+ // (the same "read fresh each render" pattern pi's own FooterComponent uses),
9
+ // so there is no per-render allocation and no stale closure over `ctx`.
10
+ // =============================================================================
11
+
12
+ /** Live values the chrome renders from. All optional/nullable → segment hidden. */
13
+ export type ChromeData = {
14
+ /** Current working directory. */
15
+ cwd: string;
16
+ /** Home dir for `~` substitution (os.homedir()). */
17
+ home: string | undefined;
18
+ /** Active model id (e.g. "claude-opus-4-8"), null if none. */
19
+ modelId: string | null;
20
+ /** Active model provider (e.g. "anthropic"). */
21
+ modelProvider: string | null;
22
+ /** Whether the model supports extended thinking. */
23
+ reasoning: boolean;
24
+ /** Current thinking level, e.g. "high"; null/"off" hides it. */
25
+ thinkingLevel: string | null;
26
+ /** Context usage percent (0–100), or null when unknown (post-compaction). */
27
+ ctxPercent: number | null;
28
+ /** Estimated context tokens, or null when unknown. */
29
+ ctxTokens: number | null;
30
+ /** Cumulative context window size in tokens. */
31
+ contextWindow: number | null;
32
+ /** Dirty file count from soly's git context (footerData only exposes branch). */
33
+ gitDirty: number;
34
+ /** Number of active soly rules (always-on + glob), 0 = no segment. */
35
+ rulesActive: number;
36
+ /** Compact phase label, e.g. "plan 2/5"; null when no active project. */
37
+ phaseLabel: string | null;
38
+ /** Active workflow verb, e.g. "execute"; null when idle. */
39
+ verbLabel: string | null;
40
+ /** html_artifacts created this session (0 = no segment). */
41
+ artifactCount: number;
42
+ };
43
+
44
+ /** A fresh ChromeData with everything empty/idle. */
45
+ export function emptyChromeData(): ChromeData {
46
+ return {
47
+ cwd: "",
48
+ home: undefined,
49
+ modelId: null,
50
+ modelProvider: null,
51
+ reasoning: false,
52
+ thinkingLevel: null,
53
+ ctxPercent: null,
54
+ ctxTokens: null,
55
+ contextWindow: null,
56
+ gitDirty: 0,
57
+ rulesActive: 0,
58
+ phaseLabel: null,
59
+ verbLabel: null,
60
+ artifactCount: 0,
61
+ };
62
+ }
@@ -0,0 +1,129 @@
1
+ // =============================================================================
2
+ // visual/footer.ts — soly's custom footer (the bottom "polosa")
3
+ // =============================================================================
4
+ //
5
+ // Installed via ctx.ui.setFooter, this replaces pi's built-in footer. It is a
6
+ // single width-aware bar:
7
+ //
8
+ // ◐ 34% · ⎇ master *3 ─────────── ~/…/pi-soly · ↑12.4k · ↵ send
9
+ // └─ left: context% · git · foreign ext statuses
10
+ // right: [model] · pwd · tokens · keys ─┘
11
+ //
12
+ // `model` appears here only when the top bar is hidden (no active phase/verb),
13
+ // so it is always visible somewhere but never duplicated. Foreign extension
14
+ // statuses (e.g. the MCP icons) are folded in; our own legacy "soly" status
15
+ // key is excluded to avoid echoing it. Layout/drop logic lives in segments.ts.
16
+ // =============================================================================
17
+
18
+ import type { Component } from "@earendil-works/pi-tui";
19
+ import type { Theme } from "@earendil-works/pi-coding-agent";
20
+ import { composeBar, type Segment } from "./segments.ts";
21
+ import { ctxColor } from "./colors.ts";
22
+ import { fitPath, formatTokens } from "./format.ts";
23
+ import { withGlyph } from "./glyphs.ts";
24
+ import type { ChromeData } from "./data.ts";
25
+ import { themeStyler, type ChromeStyler } from "./style.ts";
26
+
27
+ /** Structural subset of pi's ReadonlyFooterDataProvider we consume. */
28
+ export type FooterData = {
29
+ getGitBranch(): string | null;
30
+ getExtensionStatuses(): ReadonlyMap<string, string>;
31
+ getAvailableProviderCount(): number;
32
+ };
33
+
34
+ export type FooterOpts = {
35
+ ascii: boolean;
36
+ styler: ChromeStyler;
37
+ /** Override the keys hint segment; defaults to "↵ send". */
38
+ keysHint?: string;
39
+ };
40
+
41
+ /** Max columns a pwd segment may occupy before it elides (see fitPath tiers). */
42
+ const PWD_MAX = 28;
43
+
44
+ /** Glyph for the active-rules count (Unicode form; ASCII falls back to "N rules"). */
45
+ const RULES_GLYPH = "≡";
46
+
47
+ /** Model id (+ thinking level when supported), prefixed with the model glyph. */
48
+ export function modelText(data: ChromeData, ascii: boolean): string {
49
+ const id = data.modelId ?? "no-model";
50
+ const showThink = data.reasoning && data.thinkingLevel !== null && data.thinkingLevel !== "off";
51
+ const label = showThink ? `${id} · ${data.thinkingLevel}` : id;
52
+ return withGlyph("model", label, ascii);
53
+ }
54
+
55
+ /** Build the full-width footer line. Pure given a styler (identity in tests). */
56
+ export function buildFooterLine(data: ChromeData, fd: FooterData, width: number, opts: FooterOpts): string {
57
+ const { ascii, styler } = opts;
58
+ const left: Segment[] = [];
59
+ const right: Segment[] = [];
60
+
61
+ // Phase leads the footer (monochrome — ctx% keeps the only functional color).
62
+ if (data.phaseLabel) left.push({ id: "phase", text: styler.fg("muted", data.phaseLabel), priority: 8 });
63
+
64
+ const pct = data.ctxPercent;
65
+ const ctxText = withGlyph("ctx", pct === null ? "?" : `${Math.round(pct)}%`, ascii);
66
+ left.push({ id: "ctx", text: styler.fg(ctxColor(pct), ctxText), priority: 9 });
67
+
68
+ const branch = fd.getGitBranch();
69
+ if (branch) {
70
+ const dirty = data.gitDirty > 0 ? ` *${data.gitDirty}` : "";
71
+ left.push({ id: "git", text: styler.fg("muted", withGlyph("git", `${branch}${dirty}`, ascii)), priority: 7 });
72
+ }
73
+
74
+ if (data.rulesActive > 0) {
75
+ const word = data.rulesActive === 1 ? "rule" : "rules";
76
+ const rulesText = ascii ? `${data.rulesActive} ${word}` : `${RULES_GLYPH} ${data.rulesActive}`;
77
+ left.push({ id: "rules", text: styler.dim(rulesText), priority: 5 });
78
+ }
79
+
80
+ if (data.artifactCount > 0) {
81
+ // BMP glyph only — an astral emoji (🖼) is measured as 1 col here but drawn
82
+ // as 2 by terminals, overflowing the width-fitted bar and wrapping it.
83
+ const artText = ascii ? `${data.artifactCount} art` : `▦ ${data.artifactCount}`;
84
+ left.push({ id: "artifacts", text: styler.dim(artText), priority: 4 });
85
+ }
86
+
87
+ const exts = [...fd.getExtensionStatuses().entries()]
88
+ .filter(([key]) => key !== "soly")
89
+ .sort(([a], [b]) => a.localeCompare(b))
90
+ .map(([, value]) => value.replace(/[\r\n\t]+/g, " ").trim())
91
+ .filter((value) => value.length > 0);
92
+ if (exts.length > 0) left.push({ id: "ext", text: styler.dim(exts.join(" · ")), priority: 3 });
93
+
94
+ // The top bar now carries only the active verb; phase lives here. So the
95
+ // model belongs in the footer whenever no verb is active (top bar hidden).
96
+ const topbarHidden = data.verbLabel === null;
97
+ if (topbarHidden && data.modelId) {
98
+ right.push({ id: "model", text: styler.fg("muted", modelText(data, ascii)), priority: 6 });
99
+ }
100
+
101
+ const pwd = fitPath(data.cwd, data.home, PWD_MAX);
102
+ if (pwd) right.push({ id: "pwd", text: styler.dim(pwd), priority: 4 });
103
+
104
+ if (data.ctxTokens !== null && data.ctxTokens > 0) {
105
+ right.push({ id: "tokens", text: styler.dim(`↑${formatTokens(data.ctxTokens)}`), priority: 5 });
106
+ }
107
+
108
+ right.push({ id: "keys", text: styler.dim(opts.keysHint ?? withGlyph("enter", "send", ascii)), priority: 2 });
109
+
110
+ return composeBar({ left, right, width, styleFill: styler.dim });
111
+ }
112
+
113
+ /** pi Component wrapping the footer line; reads live ChromeData each render. */
114
+ export class SolyFooter implements Component {
115
+ constructor(
116
+ private readonly data: ChromeData,
117
+ private readonly fd: FooterData,
118
+ private readonly theme: Theme,
119
+ private readonly getAscii: () => boolean,
120
+ ) {}
121
+
122
+ invalidate(): void {
123
+ /* stateless — render() always reads fresh data */
124
+ }
125
+
126
+ render(width: number): string[] {
127
+ return [buildFooterLine(this.data, this.fd, width, { ascii: this.getAscii(), styler: themeStyler(this.theme) })];
128
+ }
129
+ }