@erichll/pi-statusline 0.0.0-stage → 0.2.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/src/git.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { GitState } from "./types.ts";
3
+
4
+ export function emptyGit(kind: GitState["kind"] = "unknown"): GitState {
5
+ return { kind, branch: null, detached: false, staged: 0, unstaged: 0, untracked: 0, conflicts: 0, ahead: 0, behind: 0 };
6
+ }
7
+ export function parseGitStatus(output: string): GitState {
8
+ const state = emptyGit("repository");
9
+ let oid = "";
10
+ const records = output.split("\0");
11
+ for (let i = 0; i < records.length; i++) {
12
+ const line = records[i]!;
13
+ if (line.startsWith("# branch.head ")) {
14
+ const name = line.slice(14);
15
+ state.detached = name === "(detached)";
16
+ if (!state.detached) state.branch = name;
17
+ } else if (line.startsWith("# branch.oid ")) oid = line.slice(13);
18
+ else if (line.startsWith("# branch.ab ")) {
19
+ const match = /^# branch.ab \+(\d+) -(\d+)$/.exec(line);
20
+ if (match) { state.ahead = Number(match[1]); state.behind = Number(match[2]); }
21
+ } else if (line.startsWith("? ")) state.untracked++;
22
+ else if (line.startsWith("u ")) state.conflicts++;
23
+ else if (line.startsWith("1 ") || line.startsWith("2 ")) {
24
+ const fields = line.split(" ", 4);
25
+ const xy = fields[1] ?? "..";
26
+ const sub = fields[2] ?? "N...";
27
+ if (xy[0] !== ".") state.staged++;
28
+ if (xy[1] !== "." || (sub.startsWith("S") && sub.slice(1) !== "...")) state.unstaged++;
29
+ if (line.startsWith("2 ")) i++; // Rename source is a separate NUL record, not another status.
30
+ }
31
+ }
32
+ if (state.detached) state.branch = oid && oid !== "(initial)" ? oid.slice(0, 7) : "HEAD";
33
+ return state;
34
+ }
35
+ export type GitReader = (cwd: string, signal: AbortSignal) => Promise<GitState>;
36
+ export const readGit: GitReader = (cwd, signal) => new Promise((resolve) => {
37
+ if (signal.aborted) { resolve(emptyGit()); return; }
38
+ const child = spawn("git", ["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z"], {
39
+ cwd, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
40
+ });
41
+ const chunks: Buffer[] = [];
42
+ let bytes = 0, stderr = "", stopped = false;
43
+ const stop = () => { stopped = true; child.kill("SIGKILL"); };
44
+ const timer = setTimeout(stop, 1000);
45
+ timer.unref();
46
+ signal.addEventListener("abort", stop, { once: true });
47
+ const finish = (state: GitState) => { clearTimeout(timer); signal.removeEventListener("abort", stop); resolve(state); };
48
+ child.stdout.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > 8 * 1024 * 1024) stop(); else chunks.push(chunk); });
49
+ child.stderr.on("data", (chunk: Buffer) => { if (stderr.length < 4096) stderr += chunk.toString(); });
50
+ child.on("error", () => finish(emptyGit()));
51
+ child.on("close", (code) => {
52
+ if (stopped || signal.aborted) finish(emptyGit());
53
+ else if (code === 0) finish(parseGitStatus(Buffer.concat(chunks).toString("utf8")));
54
+ else finish(emptyGit(stderr.includes("not a git repository") ? "none" : "unknown"));
55
+ });
56
+ });
57
+
58
+ /** One query at a time. Disposal cancels work and prevents stale completion callbacks. */
59
+ export class GitPoller {
60
+ private controller: AbortController | undefined;
61
+ private timer: ReturnType<typeof setInterval> | undefined;
62
+ private state = emptyGit();
63
+ private disposed = false;
64
+ private pending = false;
65
+ private lastStart = 0;
66
+ private deferred: ReturnType<typeof setTimeout> | undefined;
67
+ private readonly cwd: string;
68
+ private readonly changed: (state: GitState) => void;
69
+ private readonly reader: GitReader;
70
+ constructor(cwd: string, changed: (state: GitState) => void, reader: GitReader = readGit) {
71
+ this.cwd = cwd; this.changed = changed; this.reader = reader;
72
+ }
73
+ start(): void {
74
+ if (this.timer || this.disposed) return;
75
+ void this.refresh();
76
+ this.timer = setInterval(() => void this.refresh(), 2000);
77
+ this.timer.unref();
78
+ }
79
+ async refresh(): Promise<void> {
80
+ if (this.disposed) return;
81
+ if (this.controller) { this.pending = true; return; }
82
+ const delay = 250 - (Date.now() - this.lastStart);
83
+ if (delay > 0) {
84
+ this.deferred ??= setTimeout(() => { this.deferred = undefined; void this.refresh(); }, delay);
85
+ this.deferred.unref();
86
+ return;
87
+ }
88
+ this.lastStart = Date.now();
89
+ const controller = new AbortController();
90
+ this.controller = controller;
91
+ try {
92
+ const state = await this.reader(this.cwd, controller.signal);
93
+ if (!this.disposed && JSON.stringify(state) !== JSON.stringify(this.state)) { this.state = state; this.changed(state); }
94
+ } catch {
95
+ if (!this.disposed && this.state.kind !== "unknown") { this.state = emptyGit(); this.changed(this.state); }
96
+ } finally {
97
+ this.controller = undefined;
98
+ if (this.pending && !this.disposed) { this.pending = false; void this.refresh(); }
99
+ }
100
+ }
101
+ dispose(): void {
102
+ this.disposed = true;
103
+ clearInterval(this.timer); clearTimeout(this.deferred); this.controller?.abort();
104
+ }
105
+ }
package/src/index.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { join } from "node:path";
2
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { ConfigStore, presetConfig } from "./config.ts";
4
+ import { StatuslinePanel } from "./panel.ts";
5
+ import { sampleSnapshot } from "./render.ts";
6
+ import { FooterSession } from "./session.ts";
7
+
8
+ export default function statusline(pi: ExtensionAPI): void {
9
+ const store = new ConfigStore(join(getAgentDir(), "extensions", "pi-statusline", "config.json"));
10
+ let config = presetConfig();
11
+ let session: FooterSession | undefined;
12
+ let generation = 0;
13
+
14
+ pi.on("session_start", async (_event, ctx) => {
15
+ const token = ++generation;
16
+ session?.dispose(); session = undefined;
17
+ if (ctx.mode !== "tui") return;
18
+ const loaded = await store.load(config);
19
+ if (token !== generation) return;
20
+ config = loaded.config;
21
+ if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
22
+ session = new FooterSession(ctx, config);
23
+ session.refresh(true);
24
+ session.install();
25
+ });
26
+ pi.on("session_shutdown", () => { generation++; session?.dispose(); session = undefined; });
27
+ pi.on("agent_start", (_event, ctx) => { session?.setContext(ctx); session?.startRun(); session?.schedule(true); });
28
+ pi.on("turn_start", (_event, ctx) => { session?.setContext(ctx); session?.startTurn(); session?.schedule(true); });
29
+ pi.on("agent_before_settle", (event) => { session?.runtime.beforeSettle(event.outcome); });
30
+ pi.on("agent_settled", (_event, ctx) => { session?.setContext(ctx); session?.settle(); session?.schedule(true); });
31
+ pi.on("ui_prompt_start", () => { session?.runtime.uiStart(); session?.schedule(true); });
32
+ pi.on("ui_prompt_end", () => { session?.runtime.uiEnd(); session?.schedule(true); });
33
+ pi.on("tool_execution_start", (event, ctx) => { session?.setContext(ctx); session?.runtime.toolStart(event.toolCallId, event.toolName); session?.schedule(true); });
34
+ pi.on("session_before_compact", (_event, ctx) => {
35
+ session?.setContext(ctx); session?.compaction.start(); session?.runtime.compaction(true); session?.schedule(true);
36
+ });
37
+ pi.on("message_start", (event, ctx) => {
38
+ session?.setContext(ctx);
39
+ if (event.message.role === "assistant") { session?.setLive(undefined); session?.runtime.messageStart(event.message); }
40
+ session?.schedule(true);
41
+ });
42
+ pi.on("message_update", (event, ctx) => {
43
+ session?.setContext(ctx);
44
+ if (event.message.role === "assistant") { session?.setLive(event.message); session?.runtime.stream(event.assistantMessageEvent); session?.runtime.message(event.message); }
45
+ session?.schedule();
46
+ });
47
+ pi.on("message_end", (event, ctx) => {
48
+ session?.setContext(ctx);
49
+ if (event.message.role === "assistant") { session?.setLive(event.message); session?.runtime.message(event.message, true); }
50
+ session?.schedule(true);
51
+ });
52
+ const refresh = (_event: unknown, ctx: ExtensionContext) => { session?.setContext(ctx); session?.schedule(true); };
53
+ const resetLive = (event: unknown, ctx: ExtensionContext) => { session?.setLive(undefined); refresh(event, ctx); };
54
+ pi.on("agent_end", resetLive);
55
+ pi.on("turn_end", (event, ctx) => { if (event.message.role === "assistant") session?.runtime.message(event.message, true); session?.runtime.turnEnd(); refresh(event, ctx); });
56
+ pi.on("model_select", refresh);
57
+ pi.on("thinking_level_select", refresh);
58
+ pi.on("session_compact", (event, ctx) => {
59
+ session?.compaction.finish("success", event.compactionEntry.id); session?.runtime.compaction(false); resetLive(event, ctx);
60
+ });
61
+ pi.on("session_compact_failed", (event, ctx) => {
62
+ session?.compaction.finish(event.aborted ? "cancelled" : "failed", undefined, event.errorMessage); session?.runtime.compaction(false); refresh(event, ctx);
63
+ });
64
+ pi.on("session_tree", resetLive);
65
+ pi.on("tool_execution_end", (event, ctx) => { session?.setContext(ctx); session?.runtime.toolEnd(event.toolCallId); session?.requestGit(); session?.schedule(true); });
66
+ pi.on("user_bash", (_event, ctx) => { session?.setContext(ctx); session?.requestGit(); });
67
+
68
+ pi.registerCommand("statusline", {
69
+ description: "Configure the statusline, or on | off | reload",
70
+ handler: async (args, ctx) => {
71
+ if (ctx.mode !== "tui") { ctx.ui.notify("Statusline configuration requires Pi terminal mode.", "info"); return; }
72
+ const token = generation;
73
+ const current = session;
74
+ if (!current) return;
75
+ current.setContext(ctx);
76
+ const command = args.trim();
77
+ if (command === "off") { current.disable(); return; }
78
+ if (command === "on") { current.apply({ ...config, enabled: true }); return; }
79
+ if (command === "reload") {
80
+ const loaded = await store.load(config);
81
+ if (token !== generation) return;
82
+ config = loaded.config;
83
+ if (loaded.warning) ctx.ui.notify(loaded.warning, "warning");
84
+ current.dispose();
85
+ session = new FooterSession(ctx, config);
86
+ session.refresh(true); session.install();
87
+ return;
88
+ }
89
+ if (command) { ctx.ui.notify("Usage: /statusline [on|off|reload]", "info"); return; }
90
+ current.refresh(true);
91
+ await ctx.ui.custom<void>((tui, _theme, _keys, done) => new StatuslinePanel({
92
+ config, theme: () => ctx.ui.theme,
93
+ snapshot: () => {
94
+ const example = ctx.sessionManager.getLeafId() === null;
95
+ return { data: example ? sampleSnapshot() : current.snapshot(), example };
96
+ },
97
+ requestRender: () => tui.requestRender(), height: () => tui.terminal.rows,
98
+ done: () => done(),
99
+ save: async (next) => {
100
+ if (token !== generation) throw new Error("Session changed; reopen settings");
101
+ await store.save(next);
102
+ if (token !== generation) return;
103
+ config = next; current.apply(config);
104
+ },
105
+ }));
106
+ },
107
+ });
108
+ }
package/src/panel.ts ADDED
@@ -0,0 +1,202 @@
1
+ import { Input, matchesKey, SelectList, truncateToWidth, type Component, type Focusable, type SelectItem } from "@earendil-works/pi-tui";
2
+ import { COLORS, parseConfig, presetConfig } from "./config.ts";
3
+ import { cleanText, renderFooter, type RenderTheme } from "./render.ts";
4
+ import type { SegmentConfig, StatuslineConfig, StatuslineSnapshot } from "./types.ts";
5
+
6
+ interface PanelOptions {
7
+ config: StatuslineConfig;
8
+ theme: () => RenderTheme;
9
+ snapshot: () => { data: StatuslineSnapshot; example: boolean };
10
+ requestRender: () => void;
11
+ height: () => number;
12
+ save: (config: StatuslineConfig) => Promise<void>;
13
+ done: () => void;
14
+ }
15
+
16
+ /** All edits belong to a draft. Only a successful explicit Save changes the running footer. */
17
+ export class StatuslinePanel implements Component, Focusable {
18
+ private options: PanelOptions;
19
+ private draft: StatuslineConfig;
20
+ private list!: SelectList;
21
+ private editing: SegmentConfig | undefined;
22
+ private input: Input | undefined;
23
+ private inputLabel = "";
24
+ private error = "";
25
+ private saving = false;
26
+ private disposed = false;
27
+ private hasFocus = false;
28
+ private listHeight = 0;
29
+ private section: "runtime" | undefined;
30
+
31
+ constructor(options: PanelOptions) { this.options = options; this.draft = structuredClone(options.config); this.rebuild(); }
32
+ get focused(): boolean { return this.hasFocus; }
33
+ set focused(value: boolean) { this.hasFocus = value; if (this.input) this.input.focused = value; }
34
+ private paint(text: string, color: "text" | "accent" | "muted" | "error" = "text"): string {
35
+ const theme = this.options.theme(); return theme.style(text, { fg: theme.colors[color] });
36
+ }
37
+ private thinkingConfig(): SegmentConfig {
38
+ let thinking = this.draft.segments.find((segment) => segment.id === "thinking");
39
+ if (!thinking) { thinking = { id: "thinking", enabled: false }; this.draft.segments.push(thinking); }
40
+ return thinking;
41
+ }
42
+ private items(): SelectItem[] {
43
+ if (this.section) {
44
+ const labels: Record<string, string> = { enabled: "Runtime metrics", status: "Elapsed / compaction status", tools: "Tool activity", noContent: "No content warning", noContentSeconds: "No content threshold (seconds)", avg: "Average output speed", cache: "Cache read ratio", counts: "Turns and compactions", retainSummary: "Retain last run summary" };
45
+ return [...Object.entries(this.draft[this.section]).map(([key, value]) => ({ value: `option:${key}`, label: `${labels[key] ?? key}: ${value}` })), { value: "back", label: "Back to settings" }];
46
+ }
47
+ if (this.editing) {
48
+ const segment = this.editing;
49
+ return [
50
+ { value: "enabled", label: `Enabled: ${segment.enabled ? "yes" : "no"}` },
51
+ ...(["icon", "color", "iconColor", "background"] as const).map((key) => ({ value: key, label: `${key}: ${segment[key] ?? "(default)"}`, description: key === "icon" ? "Empty text hides the icon; :default restores it" : "Semantic color or #RRGGBB; empty restores default" })),
52
+ ...(segment.id === "model" ? [
53
+ { value: "thinking", label: `Thinking level: ${this.thinkingConfig().enabled ? "yes" : "no"}`, description: "Colored level in parentheses after the model name" },
54
+ { value: "thinkingColor", label: `Thinking color: ${this.thinkingConfig().color ?? "automatic"}`, description: "Automatic uses level colors; high/xhigh/max use rainbow" },
55
+ ] : []),
56
+ { value: "back", label: "Back to segments" },
57
+ ];
58
+ }
59
+ return [
60
+ { value: "defaultEnabled", label: `Enable on startup: ${this.draft.enabled ? "yes" : "no"}` },
61
+ { value: "icons", label: `Icons: ${this.draft.icons}` },
62
+ { value: "separator", label: `Separator: ${this.draft.separator}` },
63
+ { value: "pathMode", label: `Path: ${this.draft.pathMode}` },
64
+ { value: "modelDisplay", label: `Model display: ${this.draft.modelDisplay}`, description: "name: display name · last: final model ID component" },
65
+ { value: "contextDisplay", label: `Context display: ${this.draft.contextDisplay}`, description: "text: tokens/capacity(percent) · bar: progress and percent" },
66
+ ...this.draft.segments.filter((segment) => segment.id !== "thinking").map((segment) => ({ value: `segment:${segment.id}`, label: `[${segment.enabled ? "x" : " "}] ${segment.id}`, description: segment.id === "statuses" ? "Secondary row · Space toggles, Enter edits" : "Space toggles · Alt+Up/Down reorders · Enter edits" })),
67
+ { value: "runtime", label: "Runtime options" },
68
+ { value: "save", label: "Save and apply globally" },
69
+ { value: "reset", label: "Restore defaults in preview" },
70
+ { value: "cancel", label: "Cancel" },
71
+ ];
72
+ }
73
+ private rebuild(selected?: string): void {
74
+ const items = this.items();
75
+ this.listHeight = Math.max(1, this.options.height() - 12);
76
+ this.list = new SelectList(items, this.listHeight, {
77
+ selectedPrefix: (text) => this.paint(text, "accent"), selectedText: (text) => this.paint(text, "accent"),
78
+ description: (text) => this.paint(text, "muted"), scrollInfo: (text) => this.paint(text, "muted"), noMatch: (text) => this.paint(text, "muted"),
79
+ });
80
+ this.list.onSelect = (item) => this.activate(item.value);
81
+ this.list.onCancel = () => this.cancel();
82
+ if (selected) this.list.setSelectedIndex(Math.max(0, items.findIndex((item) => item.value === selected)));
83
+ }
84
+ private cancel(): void {
85
+ if (this.input) { this.input = undefined; this.error = ""; }
86
+ else if (this.section) { const section = this.section; this.section = undefined; this.rebuild(section); }
87
+ else if (this.editing) { const id = this.editing.id; this.editing = undefined; this.rebuild(`segment:${id}`); }
88
+ else this.options.done();
89
+ }
90
+ private activate(value: string): void {
91
+ this.error = "";
92
+ if (this.section) {
93
+ if (value === "back") { this.cancel(); return; }
94
+ const key = value.slice("option:".length);
95
+ if (key === "noContentSeconds") {
96
+ this.input = new Input(); this.input.focused = this.hasFocus; this.input.setValue(String(this.draft.runtime.noContentSeconds));
97
+ this.inputLabel = "No content threshold (1–3600 seconds)";
98
+ this.input.onEscape = () => this.cancel();
99
+ this.input.onSubmit = (text) => {
100
+ try { this.draft = parseConfig({ ...this.draft, runtime: { ...this.draft.runtime, noContentSeconds: Number(text) } }); this.input = undefined; this.error = ""; this.rebuild(value); }
101
+ catch (error) { this.error = error instanceof Error ? error.message : String(error); }
102
+ };
103
+ } else {
104
+ const group = this.draft[this.section] as unknown as Record<string, unknown>;
105
+ if (typeof group[key] === "boolean") group[key] = !group[key];
106
+ }
107
+ this.rebuild(value); return;
108
+ }
109
+ if (this.editing) {
110
+ if (value === "back") this.cancel();
111
+ else if (value === "enabled") { this.editing.enabled = !this.editing.enabled; this.rebuild(value); }
112
+ else if (value === "thinking") { const thinking = this.thinkingConfig(); thinking.enabled = !thinking.enabled; this.rebuild(value); }
113
+ else this.editProperty(value as "icon" | "color" | "iconColor" | "background" | "thinkingColor");
114
+ return;
115
+ }
116
+ const cycle = <T,>(current: T, options: readonly T[]): T => options[(options.indexOf(current) + 1) % options.length]!;
117
+ switch (value) {
118
+ case "defaultEnabled": this.draft.enabled = !this.draft.enabled; break;
119
+ case "icons": this.draft.icons = cycle(this.draft.icons, ["unicode", "nerd", "ascii"]); break;
120
+ case "separator": this.draft.separator = cycle(this.draft.separator, ["pipe", "dot", "space", "powerline"]); break;
121
+ case "pathMode": this.draft.pathMode = cycle(this.draft.pathMode, ["basename", "abbreviated", "full"]); break;
122
+ case "modelDisplay": this.draft.modelDisplay = cycle(this.draft.modelDisplay, ["name", "last"]); break;
123
+ case "contextDisplay": this.draft.contextDisplay = cycle(this.draft.contextDisplay, ["text", "bar"]); break;
124
+ case "runtime": this.section = value; this.rebuild(); return;
125
+ case "reset": this.draft = presetConfig(); break;
126
+ case "cancel": this.options.done(); return;
127
+ case "save": void this.save(); return;
128
+ default: this.editing = this.draft.segments.find((segment) => `segment:${segment.id}` === value); break;
129
+ }
130
+ this.rebuild(this.editing ? undefined : value);
131
+ }
132
+ private editProperty(property: "icon" | "color" | "iconColor" | "background" | "thinkingColor"): void {
133
+ const editingId = this.editing!.id;
134
+ const segment = property === "thinkingColor" ? this.thinkingConfig() : this.editing!;
135
+ const key = property === "thinkingColor" ? "color" : property;
136
+ const input = new Input();
137
+ input.setValue(segment[key] ?? (key === "icon" ? ":default" : ""));
138
+ input.focused = this.hasFocus;
139
+ this.input = input;
140
+ this.inputLabel = key === "icon" ? "Icon (empty hides; :default restores)" : `${key} (#RRGGBB or ${COLORS.slice(0, 7).join(", ")}; empty restores)`;
141
+ input.onEscape = () => this.cancel();
142
+ input.onSubmit = (text) => {
143
+ const candidate = structuredClone(this.draft);
144
+ const target = candidate.segments.find((item) => item.id === segment.id)!;
145
+ if ((key === "icon" && text === ":default") || (key !== "icon" && !text.trim())) delete target[key];
146
+ else (target as unknown as Record<string, unknown>)[key] = key === "icon" ? text : text.trim();
147
+ try {
148
+ this.draft = parseConfig(candidate);
149
+ this.editing = this.draft.segments.find((item) => item.id === editingId);
150
+ this.input = undefined; this.error = ""; this.rebuild(property);
151
+ } catch (error) { this.error = error instanceof Error ? error.message : String(error); }
152
+ };
153
+ }
154
+ private async save(): Promise<void> {
155
+ this.saving = true;
156
+ this.options.requestRender();
157
+ try { await this.options.save(structuredClone(this.draft)); if (!this.disposed) this.options.done(); }
158
+ catch (error) { if (!this.disposed) this.error = `Save failed: ${error instanceof Error ? error.message : String(error)}`; }
159
+ finally { this.saving = false; if (!this.disposed) this.options.requestRender(); }
160
+ }
161
+ handleInput(data: string): void {
162
+ if (this.saving || this.disposed) return;
163
+ if (this.input) this.input.handleInput(data);
164
+ else {
165
+ const selected = this.list.getSelectedItem()?.value;
166
+ const segmentIndex = this.draft.segments.findIndex((segment) => `segment:${segment.id}` === selected);
167
+ if (!this.editing && segmentIndex >= 0 && matchesKey(data, "space")) {
168
+ const segment = this.draft.segments[segmentIndex]!; segment.enabled = !segment.enabled; this.rebuild(selected);
169
+ } else if (!this.editing && segmentIndex >= 0 && (matchesKey(data, "alt+up") || matchesKey(data, "alt+down"))) {
170
+ const direction = matchesKey(data, "alt+up") ? -1 : 1;
171
+ let next = segmentIndex + direction;
172
+ while (this.draft.segments[next]?.id === "thinking") next += direction;
173
+ if (next >= 0 && next < this.draft.segments.length) {
174
+ [this.draft.segments[segmentIndex], this.draft.segments[next]] = [this.draft.segments[next]!, this.draft.segments[segmentIndex]!];
175
+ this.rebuild(selected);
176
+ }
177
+ } else if (this.section && matchesKey(data, "space") && selected?.startsWith("option:")) this.activate(selected);
178
+ else this.list.handleInput(data);
179
+ }
180
+ if (!this.disposed) this.options.requestRender();
181
+ }
182
+ invalidate(): void { this.list.invalidate(); this.input?.invalidate(); }
183
+ dispose(): void { this.disposed = true; }
184
+ render(width: number): string[] {
185
+ if (width <= 0) return [];
186
+ if (this.listHeight !== Math.max(1, this.options.height() - 12)) this.rebuild(this.list.getSelectedItem()?.value);
187
+ const { data, example } = this.options.snapshot();
188
+ const preview = renderFooter(width, data, this.draft, this.options.theme());
189
+ const lines = [
190
+ this.paint(`Statusline settings${this.editing ? ` / ${this.editing.id}` : this.section ? ` / ${this.section}` : ""}`, "accent"),
191
+ this.paint(example ? "Preview · example data" : "Preview · current session", "muted"),
192
+ ...(preview.length ? preview : [this.paint("(all segments hidden)", "muted")]),
193
+ this.paint("Changes are a draft until Save. Fees are Pi-reported USD.", "muted"),
194
+ "",
195
+ ...(this.input ? [this.paint(this.inputLabel, "accent"), ...this.input.render(width)] : this.list.render(width)),
196
+ "",
197
+ this.paint(this.saving ? "Saving…" : "↑↓ select · Space toggle · Alt+↑↓ reorder · Enter edit · Esc back/cancel", "muted"),
198
+ ...(this.error ? [this.paint(cleanText(this.error), "error")] : []),
199
+ ];
200
+ return lines.map((line) => truncateToWidth(line, width));
201
+ }
202
+ }
package/src/render.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { basename, isAbsolute, relative, sep } from "node:path";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { parseColor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { cleanText } from "./text.ts";
5
+ export { cleanText } from "./text.ts";
6
+ import type { ColorValue, SegmentConfig, SegmentId, StatuslineConfig, StatuslineSnapshot } from "./types.ts";
7
+ import { emptyGit } from "./git.ts";
8
+ import { emptyUsage } from "./usage.ts";
9
+ import { renderRuntime } from "./runtime-render.ts";
10
+
11
+ export type RenderTheme = Pick<Theme, "style" | "colors" | "appearance">;
12
+ // Keep rainbow hues independent of semantic theme colors, which can repeat.
13
+ const THINKING_RAINBOW = ["#c084fc", "#f472b6", "#fb923c", "#facc15", "#4ade80", "#22d3ee", "#60a5fa"].map((value) => parseColor(value));
14
+ const ICONS: Record<StatuslineConfig["icons"], Partial<Record<SegmentId, string>>> = {
15
+ unicode: { model: "", directory: "▸", git: "⑂", context: "◉", cost: "$", input: "↑", output: "↓", tokens: "Σ", cacheRead: "R", cacheWrite: "W" },
16
+ nerd: { model: "", directory: "󰉋", git: "", context: "󰆼", cost: "$", input: "↑", output: "↓", tokens: "󰊄", cacheRead: "R", cacheWrite: "W" },
17
+ ascii: { model: "", directory: "", git: "git:", context: "ctx:", cost: "$", input: "in:", output: "out:", tokens: "tok:", cacheRead: "R:", cacheWrite: "W:" },
18
+ };
19
+ const DROP_ORDER: SegmentId[] = ["cost", "cacheWrite", "cacheRead", "tokens", "output", "input", "thinking", "git", "directory"];
20
+ export function formatTokens(value: number): string {
21
+ if (value < 1000) return String(Math.round(value));
22
+ const divisor = value >= 1e6 ? 1e6 : 1000;
23
+ return `${Number((value / divisor).toFixed(1))}${divisor === 1e6 ? "M" : "k"}`;
24
+ }
25
+ function contextBar(percent: number | null, ascii: boolean): string {
26
+ const width = 10;
27
+ if (percent === null || !Number.isFinite(percent)) return `[${"?".repeat(width)}]?%`;
28
+ const fill = Math.max(0, Math.min(100, percent)) / 100 * width;
29
+ // Whole cells avoid the blank right side of fractional glyphs such as ▌.
30
+ // Keep small, non-zero usage visible; the percentage gives the precise value.
31
+ const full = fill > 0 ? Math.max(1, Math.round(fill)) : 0;
32
+ const bar = (ascii ? "#" : "█").repeat(full) + (ascii ? "-" : "░").repeat(width - full);
33
+ return `[${bar}]${Number(percent.toFixed(1))}%`;
34
+ }
35
+ function displayPath(data: StatuslineSnapshot, mode: StatuslineConfig["pathMode"]): string {
36
+ if (mode === "basename") return basename(data.cwd) || data.cwd;
37
+ const rel = data.home ? relative(data.home, data.cwd) : "..";
38
+ const inside = rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
39
+ const full = inside ? (rel ? `~${sep}${rel}` : "~") : data.cwd;
40
+ if (mode !== "abbreviated" || visibleWidth(full) <= 36) return full;
41
+ const parts = full.split(sep);
42
+ return `…${sep}${parts.slice(-2).join(sep)}`;
43
+ }
44
+ function gitText(data: StatuslineSnapshot, ascii: boolean): string | undefined {
45
+ const git = data.git;
46
+ if (git.kind === "none") return undefined;
47
+ if (git.kind === "unknown") return `${git.branch ?? ""} ?`.trim();
48
+ const bits = [git.branch ?? "HEAD"];
49
+ if (git.detached) bits.push(ascii ? "(detached)" : "detached");
50
+ if (git.conflicts) bits.push(`${ascii ? "!" : "⚠"}${git.conflicts}`);
51
+ else bits.push(git.staged + git.unstaged + git.untracked ? (ascii ? "*" : "●") : (ascii ? "ok" : "✓"));
52
+ if (git.staged) bits.push(`+${git.staged}`);
53
+ if (git.unstaged) bits.push(`*${git.unstaged}`);
54
+ if (git.untracked) bits.push(`?${git.untracked}`);
55
+ if (git.ahead) bits.push(`${ascii ? "^" : "↑"}${git.ahead}`);
56
+ if (git.behind) bits.push(`${ascii ? "v" : "↓"}${git.behind}`);
57
+ return bits.join(" ");
58
+ }
59
+ function segmentText(id: SegmentId, data: StatuslineSnapshot, config: StatuslineConfig): string | undefined {
60
+ const usage = data.usage;
61
+ switch (id) {
62
+ case "model": return (config.modelDisplay === "last" ? data.modelId.split("/").filter(Boolean).at(-1) : undefined) || data.model || "No model";
63
+ case "thinking": return undefined; // Its settings now control the model's suffix.
64
+ case "directory": return displayPath(data, config.pathMode);
65
+ case "git": return gitText(data, config.icons === "ascii");
66
+ case "context": {
67
+ const { tokens, percent, contextWindow } = data.context;
68
+ if (config.contextDisplay === "bar") return contextBar(percent, config.icons === "ascii");
69
+ const capacity = contextWindow > 0 ? formatTokens(contextWindow) : "?";
70
+ return `${tokens === null ? "?" : formatTokens(tokens)}/${capacity}(${percent === null ? "?" : Number(percent.toFixed(1))}%)`;
71
+ }
72
+ case "cost": return usage.cost.toFixed(3);
73
+ case "input": return formatTokens(usage.input);
74
+ case "output": return formatTokens(usage.output);
75
+ case "tokens": return formatTokens(usage.input + usage.output + usage.cacheRead + usage.cacheWrite);
76
+ case "cacheRead": return formatTokens(usage.cacheRead);
77
+ case "cacheWrite": return formatTokens(usage.cacheWrite);
78
+ case "statuses": return [...data.statuses.values()].map(cleanText).filter(Boolean).join(" · ") || undefined;
79
+ }
80
+ }
81
+ function defaultColor(id: SegmentId, data: StatuslineSnapshot): ColorValue {
82
+ if (id === "context") {
83
+ if ((data.context.percent ?? 0) > 90) return "error";
84
+ if ((data.context.percent ?? 0) > 70) return "warning";
85
+ }
86
+ if (id === "git") {
87
+ if (data.git.conflicts) return "error";
88
+ if (data.git.kind === "unknown" || data.git.staged + data.git.unstaged + data.git.untracked) return "warning";
89
+ return "success";
90
+ }
91
+ if (id === "thinking") {
92
+ const colors: Record<string, ColorValue> = { off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax" };
93
+ return colors[data.thinking] ?? "muted";
94
+ }
95
+ if (id === "model") return "accent";
96
+ if (id === "directory") return "success";
97
+ if (id === "context") return "#c678dd";
98
+ if (id === "cost") return "text";
99
+ return "muted";
100
+ }
101
+ function color(value: ColorValue, theme: RenderTheme) {
102
+ return value.startsWith("#") ? parseColor(value) : theme.colors[value as Exclude<ColorValue, `#${string}`>];
103
+ }
104
+ interface Part {
105
+ config: SegmentConfig;
106
+ text: string;
107
+ icon: string;
108
+ foreground: ColorValue;
109
+ background?: ColorValue;
110
+ thinking?: { text: string; foreground: ColorValue | "rainbow" };
111
+ }
112
+ function renderPart(part: Part, theme: RenderTheme): string {
113
+ const bg = part.background ? color(part.background, theme) : undefined;
114
+ const style = { fg: color(part.foreground, theme), ...(bg ? { bg } : {}) };
115
+ const icon = part.icon ? theme.style(`${part.icon} `, { ...style, fg: color(part.config.iconColor ?? part.foreground, theme) }) : "";
116
+ const text = theme.style(part.text, style);
117
+ let suffix = "";
118
+ if (part.thinking) {
119
+ const { text: level, foreground } = part.thinking;
120
+ const label = `(${level})`;
121
+ const characters = Array.from(label);
122
+ suffix = foreground === "rainbow"
123
+ ? characters.map((character, i) => theme.style(character, {
124
+ ...style,
125
+ fg: THINKING_RAINBOW[Math.round(i * (THINKING_RAINBOW.length - 1) / Math.max(1, characters.length - 1))]!,
126
+ })).join("")
127
+ : theme.style(label, { ...style, fg: color(foreground, theme) });
128
+ }
129
+ const content = `${icon}${text}${suffix}`;
130
+ return bg ? `${theme.style(" ", style)}${content}${theme.style(" ", style)}` : content;
131
+ }
132
+ function joinParts(parts: Part[], config: StatuslineConfig, theme: RenderTheme): string {
133
+ return parts.map((part, i) => {
134
+ let separator = "";
135
+ const previous = parts[i - 1];
136
+ if (previous) {
137
+ if (config.separator === "powerline" && config.icons !== "ascii") {
138
+ separator = theme.style("", {
139
+ fg: color(previous.background ?? previous.foreground, theme),
140
+ ...(part.background ? { bg: color(part.background, theme) } : {}),
141
+ });
142
+ } else {
143
+ const sep = config.separator === "space" ? " " : config.separator === "dot" && config.icons !== "ascii" ? " · " : " | ";
144
+ separator = theme.style(sep, { fg: theme.colors.dim });
145
+ }
146
+ }
147
+ return separator + renderPart(part, theme);
148
+ }).join("");
149
+ }
150
+
151
+ /** Pure terminal rendering: no disk reads, processes or session traversal. */
152
+ export function renderFooter(width: number, data: StatuslineSnapshot, config: StatuslineConfig, theme: RenderTheme): string[] {
153
+ width = Math.max(0, Math.floor(width));
154
+ if (width === 0) return [];
155
+ const parts: Part[] = [];
156
+ let secondary: Part | undefined;
157
+ // CLIProxyAPI's published `tps` status is display text only.
158
+ const tps = cleanText(data.statuses.get("tps") ?? "");
159
+ const elapsedStatus = config.segments.some((s) => s.id === "statuses" && s.enabled) && /^Elapsed \d+[dhms](?: \d+[hms])*$/.test(tps) ? tps : undefined;
160
+ const statuses = elapsedStatus ? new Map([...data.statuses].filter(([key]) => key !== "tps")) : data.statuses;
161
+ const thinking = config.segments.find((segment) => segment.id === "thinking");
162
+ for (const segment of config.segments) {
163
+ if (!segment.enabled) continue;
164
+ const raw = segmentText(segment.id, segment.id === "statuses" ? { ...data, statuses } : data, config);
165
+ if (raw === undefined) continue;
166
+ let text = cleanText(raw);
167
+ if (config.icons === "ascii") text = text.replaceAll(" · ", " / ").replaceAll("…", "...");
168
+ const background = segment.background ?? (config.preset === "powerline" ? (theme.appearance === "light" ? (parts.length % 2 ? "#e4e9f2" : "#d6dde9") : (parts.length % 2 ? "#283447" : "#202938")) : undefined);
169
+ const part: Part = { config: segment, text, icon: cleanText(segment.icon ?? ICONS[config.icons][segment.id] ?? ""), foreground: segment.color ?? defaultColor(segment.id, data), background };
170
+ if (segment.id === "model" && data.reasoning && thinking?.enabled) {
171
+ part.thinking = {
172
+ text: cleanText(data.thinking || "off"),
173
+ foreground: thinking.color ?? (["high", "xhigh", "max"].includes(data.thinking) ? "rainbow" : defaultColor("thinking", data)),
174
+ };
175
+ }
176
+ if (segment.id === "statuses") secondary = part;
177
+ else parts.push(part);
178
+ }
179
+ let line = joinParts(parts, config, theme);
180
+ const ellipsis = config.icons === "ascii" ? "..." : "…";
181
+ if (visibleWidth(line) > width) {
182
+ for (const id of ["directory", "model"] as const) {
183
+ const part = parts.find((p) => p.config.id === id);
184
+ if (part) part.text = truncateToWidth(part.text, Math.max(6, Math.min(24, Math.floor(width / 4))), ellipsis);
185
+ }
186
+ line = joinParts(parts, config, theme);
187
+ for (const id of DROP_ORDER) {
188
+ if (visibleWidth(line) <= width) break;
189
+ if (id === "thinking") {
190
+ const model = parts.find((p) => p.config.id === "model");
191
+ if (model) model.thinking = undefined;
192
+ } else {
193
+ const index = parts.findIndex((p) => p.config.id === id);
194
+ if (index !== -1) parts.splice(index, 1);
195
+ }
196
+ line = joinParts(parts, config, theme);
197
+ }
198
+ }
199
+ const lines: string[] = [];
200
+ if (parts.length) lines.push(truncateToWidth(line, width, ellipsis));
201
+ const secondLine = renderRuntime(width, data, config, theme, secondary ? renderPart(secondary, theme) : undefined, elapsedStatus);
202
+ if (secondLine) lines.push(secondLine);
203
+ return lines;
204
+ }
205
+
206
+ export function sampleSnapshot(): StatuslineSnapshot {
207
+ return {
208
+ model: "Example model", modelId: "example/provider/model", reasoning: true, thinking: "high", cwd: "/projects/pi-packages", home: "/home/example",
209
+ context: { tokens: 23800, contextWindow: 200000, percent: 11.9 },
210
+ usage: { ...emptyUsage(), input: 32000, output: 5400, cacheRead: 12000, cost: 0.12 },
211
+ git: { ...emptyGit("repository"), branch: "main", unstaged: 2, ahead: 2 },
212
+ statuses: new Map([["example", "Example extension status"]]),
213
+ runtime: { runId: "example", status: "Running", elapsedMs: 28000, turns: 3, toolCalls: 2, tools: [], awaitingInput: false, noContentMs: 0, usage: { ...emptyUsage(), output: 1260 }, avg: 45, cache: 0.86 },
214
+ compaction: { active: false, elapsedMs: 0, count: 1, showFailure: false },
215
+ };
216
+ }