@sentiolabs/pi-scriptable-statusline 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial package skeleton for `@sentiolabs/pi-scriptable-statusline`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sentio Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # `@sentiolabs/pi-scriptable-statusline`
2
+
3
+ Scriptable footer and statusline UI package for Pi.
4
+
5
+ ## What it does
6
+
7
+ `@sentiolabs/pi-scriptable-statusline` owns Pi's footer with `ctx.ui.setFooter()` and can render scriptable widgets above or below the editor with `ctx.ui.setWidget()`.
8
+
9
+ This package is for users who want to customize the whole footer/statusline experience with code instead of selecting from fixed presets.
10
+
11
+ > **Footer ownership:** this package replaces Pi's footer. Disable other footer replacement packages such as `pi-powerline-footer` when using it. Status entries from non-footer extensions are still available through `input.extensionStatuses`.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pi install npm:@sentiolabs/pi-scriptable-statusline
17
+ ```
18
+
19
+ For local development from this monorepo:
20
+
21
+ ```bash
22
+ pi -e ./packages/pi-scriptable-statusline
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```text
28
+ /statusline init
29
+ /statusline doctor
30
+ ```
31
+
32
+ Edit:
33
+
34
+ ```text
35
+ ~/.pi/agent/scriptable-statusline/render.ts
36
+ ```
37
+
38
+ ## Renderer API
39
+
40
+ ```typescript
41
+ import type { StatuslineRenderer } from "@sentiolabs/pi-scriptable-statusline";
42
+
43
+ const render: StatuslineRenderer = async (input) => ({
44
+ footer: [
45
+ `${input.model.label} · ${input.repo.name} · ${input.git.branch ?? "no-git"}`,
46
+ `ctx ${input.context.percent ?? "?"}% · ${input.tokens.totalLabel} · ${input.cost.totalLabel}`,
47
+ ],
48
+ widgets: {
49
+ belowEditor: input.extensionStatuses.length
50
+ ? [`statuses: ${input.extensionStatuses.map((status) => status.text).join(" · ")}`]
51
+ : [],
52
+ },
53
+ });
54
+
55
+ export default render;
56
+ ```
57
+
58
+ ## Commands
59
+
60
+ - `/statusline init` creates the global renderer if missing.
61
+ - `/statusline reload` clears the renderer cache and rerenders.
62
+ - `/statusline doctor` prints renderer diagnostics.
63
+ - `/statusline disable` clears footer/widgets for the current session.
64
+ - `/statusline enable` re-registers footer/widgets for the current session.
65
+
66
+ ## Natural-language setup
67
+
68
+ Use `/statusline <request>` to delegate your layout request to the `statusline-setup` workflow.
69
+
70
+ Example request:
71
+
72
+ ```text
73
+ /statusline show context on the first footer line, daily and weekly limits on the second line, and model/repo/branch below the editor.
74
+ ```
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ npm test --workspace @sentiolabs/pi-scriptable-statusline
80
+ npm run pack:dry-run --workspace @sentiolabs/pi-scriptable-statusline
81
+ ```
@@ -0,0 +1,111 @@
1
+ import type { StatuslineSurface } from "../index.d.ts";
2
+ import { normalizeRenderResult, type NormalizedStatuslineRenderResult } from "./render-result.ts";
3
+
4
+ export interface RenderCacheOptions {
5
+ loadRenderer: () => Promise<any>;
6
+ buildInput: (surface: StatuslineSurface, width: number, context?: unknown) => any;
7
+ requestRender: () => void;
8
+ fallbackLines?: (surface: StatuslineSurface) => string[];
9
+ }
10
+
11
+ interface RenderCacheEntry {
12
+ lines: string[];
13
+ stale: boolean;
14
+ generation: number;
15
+ pending?: Promise<void>;
16
+ lastError?: Error;
17
+ lastRenderTime?: number;
18
+ }
19
+
20
+ function cacheKey(surface: StatuslineSurface, width: number): string {
21
+ return `${surface}:${width}`;
22
+ }
23
+
24
+ function errorValue(error: unknown): Error {
25
+ return error instanceof Error ? error : new Error(String(error));
26
+ }
27
+
28
+ function linesForSurface(result: NormalizedStatuslineRenderResult, surface: StatuslineSurface): string[] {
29
+ if (surface === "footer") return result.footer;
30
+ if (surface === "aboveEditor") return result.widgets.aboveEditor;
31
+ return result.widgets.belowEditor;
32
+ }
33
+
34
+ export function createRenderCache(options: RenderCacheOptions) {
35
+ const entries = new Map<string, RenderCacheEntry>();
36
+ let generation = 0;
37
+ let lastError: Error | undefined;
38
+ let lastRenderTime: number | undefined;
39
+
40
+ function fallback(surface: StatuslineSurface): string[] {
41
+ return [...(options.fallbackLines?.(surface) ?? [])];
42
+ }
43
+
44
+ function refresh(key: string, entry: RenderCacheEntry, surface: StatuslineSurface, width: number, context?: unknown) {
45
+ if (entry.pending) return;
46
+
47
+ const refreshGeneration = generation;
48
+ entry.pending = Promise.resolve()
49
+ .then(async () => {
50
+ const renderer = await options.loadRenderer();
51
+ const input = options.buildInput(surface, width, context);
52
+ const result = await renderer(input);
53
+ const normalized = normalizeRenderResult(result, surface);
54
+ const renderedLines = linesForSurface(normalized, surface);
55
+
56
+ if (refreshGeneration !== generation) {
57
+ entry.stale = true;
58
+ return;
59
+ }
60
+
61
+ entry.lines = [...renderedLines];
62
+ entry.stale = false;
63
+ entry.generation = refreshGeneration;
64
+ entry.lastError = undefined;
65
+ entry.lastRenderTime = Date.now();
66
+ lastError = undefined;
67
+ lastRenderTime = entry.lastRenderTime;
68
+ entries.set(key, entry);
69
+ })
70
+ .catch((error) => {
71
+ const normalizedError = errorValue(error);
72
+ entry.lastError = normalizedError;
73
+ entry.stale = refreshGeneration !== generation;
74
+ lastError = normalizedError;
75
+ })
76
+ .finally(() => {
77
+ entry.pending = undefined;
78
+ options.requestRender();
79
+ });
80
+ }
81
+
82
+ return {
83
+ render(surface: StatuslineSurface, width: number, context?: unknown): string[] {
84
+ const key = cacheKey(surface, width);
85
+ let entry = entries.get(key);
86
+
87
+ if (!entry) {
88
+ entry = { lines: fallback(surface), stale: true, generation };
89
+ entries.set(key, entry);
90
+ }
91
+
92
+ if (entry.stale) {
93
+ refresh(key, entry, surface, width, context);
94
+ }
95
+
96
+ return [...entry.lines];
97
+ },
98
+ invalidate() {
99
+ generation += 1;
100
+ for (const entry of entries.values()) {
101
+ entry.stale = true;
102
+ }
103
+ },
104
+ getLastError(): Error | undefined {
105
+ return lastError;
106
+ },
107
+ getLastRenderTime(): number | undefined {
108
+ return lastRenderTime;
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,67 @@
1
+ import type { StatuslineRenderResult, StatuslineRendererResult, StatuslineSurface } from "../index.d.ts";
2
+
3
+ export interface NormalizedStatuslineRenderResult {
4
+ footer: string[];
5
+ widgets: {
6
+ aboveEditor: string[];
7
+ belowEditor: string[];
8
+ };
9
+ status?: string;
10
+ }
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function normalizeLines(value: unknown, path: string): string[] {
17
+ if (value === undefined) return [];
18
+ if (!Array.isArray(value)) {
19
+ throw new Error(`${path} must be an array of strings`);
20
+ }
21
+ return value.map((line, index) => {
22
+ if (typeof line !== "string") {
23
+ throw new Error(`${path}[${index}] must be a string`);
24
+ }
25
+ return line;
26
+ });
27
+ }
28
+
29
+ export function normalizeRenderResult(
30
+ result: StatuslineRendererResult,
31
+ surface: StatuslineSurface,
32
+ ): NormalizedStatuslineRenderResult {
33
+ if (Array.isArray(result)) {
34
+ const lines = normalizeLines(result, "result");
35
+ return {
36
+ footer: surface === "footer" ? lines : [],
37
+ widgets: {
38
+ aboveEditor: surface === "aboveEditor" ? lines : [],
39
+ belowEditor: surface === "belowEditor" ? lines : [],
40
+ },
41
+ };
42
+ }
43
+
44
+ if (!isRecord(result)) {
45
+ throw new Error("Renderer must return an array of strings or an object");
46
+ }
47
+
48
+ const widgets = isRecord(result.widgets) ? result.widgets : {};
49
+ const normalized: NormalizedStatuslineRenderResult = {
50
+ footer: normalizeLines(result.footer, "result.footer"),
51
+ widgets: {
52
+ aboveEditor: normalizeLines(widgets.aboveEditor, "result.widgets.aboveEditor"),
53
+ belowEditor: normalizeLines(widgets.belowEditor, "result.widgets.belowEditor"),
54
+ },
55
+ };
56
+
57
+ if (result.status !== undefined) {
58
+ if (typeof result.status !== "string") {
59
+ throw new Error("result.status must be a string when provided");
60
+ }
61
+ normalized.status = result.status;
62
+ }
63
+
64
+ return normalized;
65
+ }
66
+
67
+ export type { StatuslineRenderResult, StatuslineRendererResult, StatuslineSurface };
@@ -0,0 +1,49 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { StatuslineRenderer } from "../index.d.ts";
5
+
6
+ export type ImportModule = (specifier: string) => Promise<{ default?: unknown }>;
7
+
8
+ export interface RendererLoaderOptions {
9
+ rendererPath?: string;
10
+ homeDir?: string;
11
+ importModule?: ImportModule;
12
+ now?: () => number;
13
+ }
14
+
15
+ export function defaultRendererPath(homeDir = homedir()): string {
16
+ return `${homeDir}/.pi/agent/scriptable-statusline/render.ts`;
17
+ }
18
+
19
+ export function createRendererLoader(options: RendererLoaderOptions = {}) {
20
+ const rendererPath = options.rendererPath ?? defaultRendererPath(options.homeDir);
21
+ const importModule = options.importModule ?? ((specifier) => import(specifier));
22
+ const now = options.now ?? Date.now;
23
+ let version = `${now()}-0`;
24
+ let invalidations = 0;
25
+ let cached: Promise<StatuslineRenderer> | undefined;
26
+
27
+ return {
28
+ rendererPath,
29
+ invalidate() {
30
+ invalidations += 1;
31
+ version = `${now()}-${invalidations}`;
32
+ cached = undefined;
33
+ },
34
+ async load(): Promise<StatuslineRenderer> {
35
+ if (!existsSync(rendererPath)) {
36
+ throw new Error(`Renderer file not found: ${rendererPath}`);
37
+ }
38
+
39
+ cached ??= importModule(`${pathToFileURL(rendererPath).href}?v=${version}`).then((module) => {
40
+ if (typeof module.default !== "function") {
41
+ throw new Error("Statusline renderer default export must be a function");
42
+ }
43
+ return module.default as StatuslineRenderer;
44
+ });
45
+
46
+ return cached;
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,196 @@
1
+ import { basename } from "node:path";
2
+ import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
3
+ import type { StatuslineRenderInput, StatuslineSurface } from "../index.d.ts";
4
+
5
+ export interface SnapshotOptions {
6
+ surface: StatuslineSurface;
7
+ width: number;
8
+ ctx: any;
9
+ footerData?: { getGitBranch?: () => string | null; getExtensionStatuses?: () => ReadonlyMap<string, string> };
10
+ turn: number;
11
+ repoRoot?: string | null;
12
+ }
13
+
14
+ type UnknownRecord = Record<string, unknown>;
15
+
16
+ function isRecord(value: unknown): value is UnknownRecord {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ function callOptional<T>(fn: unknown): T | undefined {
21
+ if (typeof fn !== "function") return undefined;
22
+ try {
23
+ return fn() as T;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ function stringValue(value: unknown): string | undefined {
30
+ return typeof value === "string" && value.length > 0 ? value : undefined;
31
+ }
32
+
33
+ function numberValue(value: unknown): number | undefined {
34
+ if (typeof value === "number" && Number.isFinite(value)) return value;
35
+ if (typeof value === "string" && value.trim().length > 0) {
36
+ const parsed = Number(value);
37
+ if (Number.isFinite(parsed)) return parsed;
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function recordNumber(record: unknown, keys: string[]): number | undefined {
43
+ if (!isRecord(record)) return undefined;
44
+ for (const key of keys) {
45
+ const value = numberValue(record[key]);
46
+ if (value !== undefined) return value;
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function recordString(record: unknown, keys: string[]): string | undefined {
52
+ if (!isRecord(record)) return undefined;
53
+ for (const key of keys) {
54
+ const value = stringValue(record[key]);
55
+ if (value !== undefined) return value;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ export function formatCount(value: number): string {
61
+ if (value < 1000) return String(value);
62
+ if (value < 1_000_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k`;
63
+ return `${(value / 1_000_000).toFixed(value < 10_000_000 ? 1 : 0)}M`;
64
+ }
65
+
66
+ function formatCost(value: number): string {
67
+ return `$${value.toFixed(3)}`;
68
+ }
69
+
70
+ function modelSnapshot(ctx: any) {
71
+ const model = ctx?.model;
72
+ if (typeof model === "string") {
73
+ return { provider: null, id: model, label: model };
74
+ }
75
+
76
+ const provider = recordString(model, ["provider", "providerId"]);
77
+ const id = recordString(model, ["id", "model", "modelId", "name"]);
78
+ const label = recordString(model, ["label", "displayName", "name", "id", "model", "modelId"]) ?? "unknown";
79
+ return { provider: provider ?? null, id: id ?? null, label };
80
+ }
81
+
82
+ function contextSnapshot(ctx: any) {
83
+ const usage = callOptional<unknown>(ctx?.getContextUsage?.bind?.(ctx)) ?? ctx?.contextUsage;
84
+ const tokens = recordNumber(usage, ["tokens", "used", "current"]) ?? 0;
85
+ const window = recordNumber(usage, ["window", "contextWindow", "limit", "max"]) ?? 0;
86
+ const explicitPercent = recordNumber(usage, ["percent", "percentage"]);
87
+ const percent = explicitPercent ?? (window > 0 ? Math.round((tokens / window) * 100) : null);
88
+
89
+ return { tokens, window, percent };
90
+ }
91
+
92
+ function aggregateUsageFromSession(ctx: any): { input: number; output: number; cost: number } | null {
93
+ const entries = callOptional<unknown>(ctx?.sessionManager?.getBranch?.bind?.(ctx?.sessionManager));
94
+ if (!Array.isArray(entries)) return null;
95
+
96
+ let input = 0;
97
+ let output = 0;
98
+ let cost = 0;
99
+ let found = false;
100
+
101
+ for (const entry of entries) {
102
+ if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message) || entry.message.role !== "assistant") {
103
+ continue;
104
+ }
105
+
106
+ const usage = isRecord(entry.message.usage) ? entry.message.usage : {};
107
+ input += recordNumber(usage, ["input", "inputTokens", "prompt", "promptTokens"]) ?? 0;
108
+ output += recordNumber(usage, ["output", "outputTokens", "completion", "completionTokens"]) ?? 0;
109
+ cost += recordNumber(usage.cost, ["total", "totalUsd", "usd"]) ?? 0;
110
+ found = true;
111
+ }
112
+
113
+ return found ? { input, output, cost } : null;
114
+ }
115
+
116
+ function tokenSnapshot(ctx: any) {
117
+ const fromSession = aggregateUsageFromSession(ctx);
118
+ if (fromSession) {
119
+ const total = fromSession.input + fromSession.output;
120
+ return { input: fromSession.input, output: fromSession.output, total, totalLabel: formatCount(total) };
121
+ }
122
+
123
+ const source = ctx?.tokens ?? ctx?.tokenUsage ?? ctx?.usage;
124
+ const input = recordNumber(source, ["input", "inputTokens", "prompt", "promptTokens"]) ?? 0;
125
+ const output = recordNumber(source, ["output", "outputTokens", "completion", "completionTokens"]) ?? 0;
126
+ const total = recordNumber(source, ["total", "totalTokens"]) ?? input + output;
127
+
128
+ return { input, output, total, totalLabel: formatCount(total) };
129
+ }
130
+
131
+ function costSnapshot(ctx: any) {
132
+ const fromSession = aggregateUsageFromSession(ctx);
133
+ if (fromSession) {
134
+ return { total: fromSession.cost, totalLabel: formatCost(fromSession.cost) };
135
+ }
136
+
137
+ const source = ctx?.cost ?? ctx?.costs;
138
+ const total = recordNumber(source, ["total", "totalUsd", "usd"]) ?? 0;
139
+ const totalLabel = recordString(source, ["totalLabel", "label"]) ?? formatCost(total);
140
+
141
+ return { total, totalLabel };
142
+ }
143
+
144
+ function extensionStatuses(footerData: SnapshotOptions["footerData"]) {
145
+ const statuses = callOptional<ReadonlyMap<string, string>>(footerData?.getExtensionStatuses?.bind?.(footerData));
146
+ if (!statuses) return [];
147
+
148
+ return Array.from(statuses.entries()).map(([key, text]) => ({ key, text }));
149
+ }
150
+
151
+ function sessionId(ctx: any): string | null {
152
+ return (
153
+ stringValue(ctx?.sessionId) ??
154
+ recordString(ctx?.session, ["id"]) ??
155
+ callOptional<string>(ctx?.sessionManager?.getSessionFile?.bind?.(ctx?.sessionManager)) ??
156
+ null
157
+ );
158
+ }
159
+
160
+ export function buildStatuslineSnapshot(options: SnapshotOptions): StatuslineRenderInput {
161
+ const ctx = options.ctx ?? {};
162
+ const cwd = stringValue(ctx.cwd) ?? process.cwd();
163
+ const repoRoot = options.repoRoot ?? null;
164
+ const repoName = basename(repoRoot ?? cwd) || basename(cwd) || cwd;
165
+ const footerBranch = callOptional<string | null>(options.footerData?.getGitBranch?.bind?.(options.footerData));
166
+ const themeSource = ctx.theme ?? ctx.ui?.theme;
167
+
168
+ return {
169
+ surface: options.surface,
170
+ width: options.width,
171
+ cwd,
172
+ model: modelSnapshot(ctx),
173
+ repo: { name: repoName, root: repoRoot },
174
+ git: { branch: footerBranch ?? null },
175
+ context: contextSnapshot(ctx),
176
+ tokens: tokenSnapshot(ctx),
177
+ cost: costSnapshot(ctx),
178
+ limits: { daily: null, weekly: null, source: null },
179
+ extensionStatuses: extensionStatuses(options.footerData),
180
+ session: { id: sessionId(ctx), turn: options.turn },
181
+ theme: {
182
+ fg: (color: string, text: string) => {
183
+ if (typeof themeSource?.fg === "function") return themeSource.fg(color, text);
184
+ return text;
185
+ },
186
+ bg: (color: string, text: string) => {
187
+ if (typeof themeSource?.bg === "function") return themeSource.bg(color, text);
188
+ return text;
189
+ },
190
+ },
191
+ utils: {
192
+ visibleWidth,
193
+ truncate: truncateToWidth,
194
+ },
195
+ };
196
+ }
@@ -0,0 +1,388 @@
1
+ import { copyFileSync, existsSync, mkdirSync, watch } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import { createRenderCache } from "./render-cache.ts";
5
+ import { createRendererLoader, defaultRendererPath } from "./renderer-loader.ts";
6
+ import { buildStatuslineSnapshot } from "./snapshot.ts";
7
+
8
+ const ABOVE_WIDGET_KEY = "scriptable-statusline-above";
9
+ const BELOW_WIDGET_KEY = "scriptable-statusline-below";
10
+
11
+ const DEFAULT_TEMPLATE = new URL("../templates/default-render.ts", import.meta.url);
12
+
13
+ type FooterDataLike = {
14
+ getGitBranch?: () => string | null;
15
+ getExtensionStatuses?: () => ReadonlyMap<string, string>;
16
+ onBranchChange?: (callback: () => void) => (() => void) | void;
17
+ };
18
+
19
+ type TuiLike = { requestRender?: () => void };
20
+
21
+ interface StatuslineCommandRuntime {
22
+ rendererPath?: string;
23
+ templatePath?: string | URL;
24
+ loader?: { rendererPath?: string; invalidate: () => void };
25
+ cache?: { invalidate: () => void; getLastError?: () => Error | undefined; getLastRenderTime?: () => number | undefined };
26
+ enable?: (ctx: ExtensionContext) => void;
27
+ disable?: (ctx: ExtensionContext) => void;
28
+ requestRender?: () => void;
29
+ isEnabled?: () => boolean;
30
+ onInit?: () => void;
31
+ }
32
+
33
+ interface StatuslineControllerOptions {
34
+ cache: {
35
+ render: (surface: "footer" | "aboveEditor" | "belowEditor", width: number, context?: unknown) => string[];
36
+ invalidate?: () => void;
37
+ };
38
+ onEnable?: (ctx: ExtensionContext) => void;
39
+ setFooterDataContext: (context: { footerData?: FooterDataLike } | undefined) => void;
40
+ getFooterDataContext: () => { footerData?: FooterDataLike } | undefined;
41
+ requestRender: () => void;
42
+ registerTui: (tui: TuiLike | undefined) => void;
43
+ }
44
+
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ function numericWidth(value: unknown, fallback = 80): number {
50
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return Math.floor(value);
51
+ if (typeof value === "string") {
52
+ const parsed = Number(value);
53
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
54
+ }
55
+ if (isRecord(value)) {
56
+ const width = numericWidth(value.width, 0);
57
+ if (width > 0) return width;
58
+ const columns = numericWidth(value.columns, 0);
59
+ if (columns > 0) return columns;
60
+ }
61
+ return fallback;
62
+ }
63
+
64
+ function isFooterData(value: unknown): value is FooterDataLike {
65
+ return isRecord(value) && (typeof value.getGitBranch === "function" || typeof value.getExtensionStatuses === "function");
66
+ }
67
+
68
+ function footerContext(first: unknown, second: unknown): { footerData?: FooterDataLike } | undefined {
69
+ if (isFooterData(second)) return { footerData: second };
70
+ if (isFooterData(first)) return { footerData: first };
71
+ if (isRecord(first) && isFooterData(first.footerData)) return { footerData: first.footerData };
72
+ if (isRecord(second) && isFooterData(second.footerData)) return { footerData: second.footerData };
73
+ return undefined;
74
+ }
75
+
76
+ function renderWidth(first: unknown, second?: unknown): number {
77
+ const firstWidth = numericWidth(first, 0);
78
+ if (firstWidth > 0) return firstWidth;
79
+ const secondWidth = numericWidth(second, 0);
80
+ if (secondWidth > 0) return secondWidth;
81
+ return 80;
82
+ }
83
+
84
+ function notify(ctx: ExtensionContext | undefined, message: string, level: "info" | "warning" | "error" = "info") {
85
+ if (!ctx || ctx.hasUI === false) return;
86
+ ctx.ui.notify(message, level);
87
+ }
88
+
89
+ function commandAction(args: string): string {
90
+ return args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "doctor";
91
+ }
92
+
93
+ function isOperationalStatuslineCommand(args: string): boolean {
94
+ const action = commandAction(args);
95
+ return action === "init" || action === "reload" || action === "doctor" || action === "disable" || action === "enable";
96
+ }
97
+
98
+ function delegationMessage(request: string): string {
99
+ return `Use the statusline-setup skill to configure @sentiolabs/pi-scriptable-statusline for this request: ${request}`;
100
+ }
101
+
102
+ function clearStatuslineUi(ctx: ExtensionContext | undefined) {
103
+ if (!ctx) return;
104
+ ctx.ui.setFooter(undefined);
105
+ ctx.ui.setWidget(ABOVE_WIDGET_KEY, undefined);
106
+ ctx.ui.setWidget(BELOW_WIDGET_KEY, undefined);
107
+ }
108
+
109
+ function initializeRenderer(rendererPath: string, templatePath: string | URL = DEFAULT_TEMPLATE): string {
110
+ if (existsSync(rendererPath)) return `Statusline renderer already exists: ${rendererPath}`;
111
+
112
+ mkdirSync(dirname(rendererPath), { recursive: true });
113
+ copyFileSync(templatePath, rendererPath);
114
+ return `Created statusline renderer: ${rendererPath}`;
115
+ }
116
+
117
+ export function runStatuslineCommand(args: string, ctx: ExtensionContext, runtime: StatuslineCommandRuntime = {}): string {
118
+ const action = commandAction(args);
119
+ const rendererPath = runtime.rendererPath ?? runtime.loader?.rendererPath ?? defaultRendererPath();
120
+
121
+ if (action === "init") {
122
+ const message = initializeRenderer(rendererPath, runtime.templatePath);
123
+ runtime.loader?.invalidate();
124
+ runtime.cache?.invalidate();
125
+ runtime.requestRender?.();
126
+ runtime.onInit?.();
127
+ return message;
128
+ }
129
+
130
+ if (action === "reload") {
131
+ runtime.loader?.invalidate();
132
+ runtime.cache?.invalidate();
133
+ runtime.requestRender?.();
134
+ return "Statusline renderer reloaded.";
135
+ }
136
+
137
+ if (action === "disable") {
138
+ if (runtime.disable) runtime.disable(ctx);
139
+ else clearStatuslineUi(ctx);
140
+ return "Statusline disabled.";
141
+ }
142
+
143
+ if (action === "enable") {
144
+ runtime.enable?.(ctx);
145
+ runtime.requestRender?.();
146
+ return "Statusline enabled.";
147
+ }
148
+
149
+ if (action === "doctor") {
150
+ const lastError = runtime.cache?.getLastError?.();
151
+ const lastRenderTime = runtime.cache?.getLastRenderTime?.();
152
+ return [
153
+ "Statusline doctor",
154
+ `enabled: ${runtime.isEnabled?.() ?? true}`,
155
+ `renderer: ${rendererPath}`,
156
+ `rendererExists: ${existsSync(rendererPath)}`,
157
+ `lastRenderTime: ${lastRenderTime === undefined ? "never" : new Date(lastRenderTime).toISOString()}`,
158
+ `lastError: ${lastError?.message ?? "none"}`,
159
+ ].join("\n");
160
+ }
161
+
162
+ return "Usage: /statusline init|reload|doctor|disable|enable";
163
+ }
164
+
165
+ function statusSignature(footerData?: FooterDataLike): string {
166
+ if (!footerData) return "none";
167
+ const branch = typeof footerData.getGitBranch === "function" ? footerData.getGitBranch() ?? "" : "";
168
+ const statuses = typeof footerData.getExtensionStatuses === "function" ? footerData.getExtensionStatuses() : undefined;
169
+ const statusParts = statuses ? Array.from(statuses.entries()).map(([key, text]) => `${key}:${text}`) : [];
170
+ return `${branch}|${statusParts.join("|")}`;
171
+ }
172
+
173
+ function createStatuslineController(options: StatuslineControllerOptions) {
174
+ let footerUnsubscribe: (() => void) | undefined;
175
+ let footerSignature = "none";
176
+
177
+ function resetFooterSubscription() {
178
+ footerUnsubscribe?.();
179
+ footerUnsubscribe = undefined;
180
+ }
181
+
182
+ return {
183
+ enable(ctx: ExtensionContext) {
184
+ options.onEnable?.(ctx);
185
+ const ui = ctx.ui;
186
+
187
+ ui.setFooter((tui?: TuiLike, _theme?: unknown, footerData?: unknown) => {
188
+ options.registerTui(tui);
189
+ const context = footerContext(footerData, undefined);
190
+ options.setFooterDataContext(context);
191
+ const currentFooterData = context?.footerData;
192
+ footerSignature = statusSignature(currentFooterData);
193
+
194
+ resetFooterSubscription();
195
+ if (currentFooterData && typeof currentFooterData.onBranchChange === "function") {
196
+ const unsub = currentFooterData.onBranchChange(() => {
197
+ options.cache.invalidate?.();
198
+ options.requestRender();
199
+ });
200
+ if (typeof unsub === "function") footerUnsubscribe = unsub;
201
+ }
202
+
203
+ return {
204
+ dispose() {
205
+ resetFooterSubscription();
206
+ },
207
+ invalidate() {
208
+ options.cache.invalidate?.();
209
+ },
210
+ render(width: number) {
211
+ const nextSignature = statusSignature(currentFooterData);
212
+ if (nextSignature !== footerSignature) {
213
+ footerSignature = nextSignature;
214
+ options.cache.invalidate?.();
215
+ }
216
+ return options.cache.render("footer", renderWidth(width), context);
217
+ },
218
+ };
219
+ });
220
+ ui.setWidget(ABOVE_WIDGET_KEY, (tui?: TuiLike) => {
221
+ options.registerTui(tui);
222
+ return {
223
+ invalidate() {
224
+ options.cache.invalidate?.();
225
+ },
226
+ render(width: number) {
227
+ return options.cache.render("aboveEditor", renderWidth(width), options.getFooterDataContext());
228
+ },
229
+ };
230
+ });
231
+ ui.setWidget(
232
+ BELOW_WIDGET_KEY,
233
+ (tui?: TuiLike) => {
234
+ options.registerTui(tui);
235
+ return {
236
+ invalidate() {
237
+ options.cache.invalidate?.();
238
+ },
239
+ render(width: number) {
240
+ return options.cache.render("belowEditor", renderWidth(width), options.getFooterDataContext());
241
+ },
242
+ };
243
+ },
244
+ { placement: "belowEditor" },
245
+ );
246
+ },
247
+ disable(ctx: ExtensionContext) {
248
+ resetFooterSubscription();
249
+ options.setFooterDataContext(undefined);
250
+ clearStatuslineUi(ctx);
251
+ },
252
+ };
253
+ }
254
+
255
+ function findGitRoot(cwd: string | undefined): string | null {
256
+ if (!cwd) return null;
257
+
258
+ let current = cwd;
259
+ while (true) {
260
+ if (existsSync(`${current}/.git`)) return current;
261
+ const parent = dirname(current);
262
+ if (parent === current) return null;
263
+ current = parent;
264
+ }
265
+ }
266
+
267
+ export default function statuslineExtension(pi: ExtensionAPI) {
268
+ let enabled = true;
269
+ let turn = 0;
270
+ let currentCtx: ExtensionContext | undefined;
271
+ let repoRoot: string | null = null;
272
+ let watcher: ReturnType<typeof watch> | undefined;
273
+ let lastFooterContext: { footerData?: FooterDataLike } | undefined;
274
+ const tuiHandles = new Set<TuiLike>();
275
+
276
+ const requestRender = () => {
277
+ for (const tui of tuiHandles) {
278
+ tui.requestRender?.();
279
+ }
280
+ };
281
+
282
+ const loader = createRendererLoader();
283
+ const cache = createRenderCache({
284
+ loadRenderer: () => loader.load(),
285
+ buildInput: (surface, width, context) => {
286
+ const data = isRecord(context) ? context.footerData : undefined;
287
+ return buildStatuslineSnapshot({
288
+ surface,
289
+ width,
290
+ ctx: currentCtx ?? {},
291
+ footerData: isFooterData(data) ? data : undefined,
292
+ turn,
293
+ repoRoot,
294
+ });
295
+ },
296
+ requestRender,
297
+ fallbackLines: (surface) => (surface === "footer" ? ["statusline loading..."] : []),
298
+ });
299
+
300
+ const controller = createStatuslineController({
301
+ cache,
302
+ onEnable(ctx) {
303
+ currentCtx = ctx;
304
+ repoRoot = findGitRoot(ctx?.cwd);
305
+ },
306
+ setFooterDataContext(context) {
307
+ lastFooterContext = context;
308
+ },
309
+ getFooterDataContext() {
310
+ return lastFooterContext;
311
+ },
312
+ requestRender,
313
+ registerTui(tui) {
314
+ if (tui) tuiHandles.add(tui);
315
+ },
316
+ });
317
+
318
+ function restartWatcher() {
319
+ watcher?.close();
320
+ watcher = undefined;
321
+ if (!existsSync(loader.rendererPath)) return;
322
+
323
+ try {
324
+ watcher = watch(loader.rendererPath, { persistent: false }, () => {
325
+ loader.invalidate();
326
+ cache.invalidate();
327
+ requestRender();
328
+ });
329
+ } catch {
330
+ watcher = undefined;
331
+ }
332
+ }
333
+
334
+ pi.on("turn_start", () => {
335
+ turn += 1;
336
+ cache.invalidate();
337
+ });
338
+
339
+ pi.on("session_start", (_event, ctx) => {
340
+ currentCtx = ctx;
341
+ repoRoot = findGitRoot(ctx?.cwd);
342
+ if (enabled) controller.enable(ctx);
343
+ restartWatcher();
344
+ });
345
+
346
+ pi.registerCommand("statusline", {
347
+ description: "Manage the scriptable statusline renderer",
348
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
349
+ currentCtx = ctx;
350
+ repoRoot = findGitRoot(ctx?.cwd);
351
+
352
+ const trimmedArgs = args.trim();
353
+ if (trimmedArgs.length > 0 && !isOperationalStatuslineCommand(trimmedArgs)) {
354
+ const deliverAs = ctx.isIdle() === false ? "followUp" : undefined;
355
+ pi.sendUserMessage(delegationMessage(trimmedArgs), deliverAs ? { deliverAs } : undefined);
356
+ notify(ctx, "Delegating to statusline-setup for your requested layout.", "info");
357
+ return;
358
+ }
359
+
360
+ const message = runStatuslineCommand(args, ctx, {
361
+ rendererPath: loader.rendererPath,
362
+ loader,
363
+ cache,
364
+ enable: (commandCtx) => {
365
+ enabled = true;
366
+ controller.enable(commandCtx);
367
+ },
368
+ disable: (commandCtx) => {
369
+ enabled = false;
370
+ controller.disable(commandCtx);
371
+ },
372
+ requestRender,
373
+ isEnabled: () => enabled,
374
+ onInit: restartWatcher,
375
+ });
376
+
377
+ notify(ctx, message, "info");
378
+ },
379
+ });
380
+
381
+ pi.on("session_shutdown", () => {
382
+ watcher?.close();
383
+ watcher = undefined;
384
+ lastFooterContext = undefined;
385
+ tuiHandles.clear();
386
+ if (currentCtx) controller.disable(currentCtx);
387
+ });
388
+ }
package/index.d.ts ADDED
@@ -0,0 +1,102 @@
1
+ export type StatuslineSurface = "footer" | "aboveEditor" | "belowEditor";
2
+
3
+ export interface StatuslineTheme {
4
+ fg(color: string, text: string): string;
5
+ bg?(color: string, text: string): string;
6
+ }
7
+
8
+ export interface StatuslineUtils {
9
+ visibleWidth(text: string): number;
10
+ truncate(text: string, width: number, ellipsis?: string): string;
11
+ }
12
+
13
+ export interface StatuslineModelSnapshot {
14
+ provider: string | null;
15
+ id: string | null;
16
+ label: string;
17
+ }
18
+
19
+ export interface StatuslineRepoSnapshot {
20
+ name: string;
21
+ root: string | null;
22
+ }
23
+
24
+ export interface StatuslineGitSnapshot {
25
+ branch: string | null;
26
+ }
27
+
28
+ export interface StatuslineContextSnapshot {
29
+ tokens: number;
30
+ window: number;
31
+ percent: number | null;
32
+ }
33
+
34
+ export interface StatuslineTokenSnapshot {
35
+ input: number;
36
+ output: number;
37
+ total: number;
38
+ totalLabel: string;
39
+ }
40
+
41
+ export interface StatuslineCostSnapshot {
42
+ total: number;
43
+ totalLabel: string;
44
+ }
45
+
46
+ export interface StatuslineLimitWindowSnapshot {
47
+ used: number | null;
48
+ limit: number | null;
49
+ percent: number | null;
50
+ resetAt: string | null;
51
+ label: string;
52
+ }
53
+
54
+ export interface StatuslineLimitSnapshot {
55
+ daily: StatuslineLimitWindowSnapshot | null;
56
+ weekly: StatuslineLimitWindowSnapshot | null;
57
+ source: string | null;
58
+ }
59
+
60
+ export interface StatuslineExtensionStatusSnapshot {
61
+ key: string;
62
+ text: string;
63
+ }
64
+
65
+ export interface StatuslineSessionSnapshot {
66
+ id: string | null;
67
+ turn: number;
68
+ }
69
+
70
+ export interface StatuslineRenderInput {
71
+ surface: StatuslineSurface;
72
+ width: number;
73
+ cwd: string;
74
+ model: StatuslineModelSnapshot;
75
+ repo: StatuslineRepoSnapshot;
76
+ git: StatuslineGitSnapshot;
77
+ context: StatuslineContextSnapshot;
78
+ tokens: StatuslineTokenSnapshot;
79
+ cost: StatuslineCostSnapshot;
80
+ limits: StatuslineLimitSnapshot;
81
+ extensionStatuses: readonly StatuslineExtensionStatusSnapshot[];
82
+ session: StatuslineSessionSnapshot;
83
+ theme: StatuslineTheme;
84
+ utils: StatuslineUtils;
85
+ }
86
+
87
+ export interface StatuslineWidgetRenderResult {
88
+ aboveEditor?: readonly string[];
89
+ belowEditor?: readonly string[];
90
+ }
91
+
92
+ export interface StatuslineRenderResult {
93
+ footer?: readonly string[];
94
+ widgets?: StatuslineWidgetRenderResult;
95
+ status?: string;
96
+ }
97
+
98
+ export type StatuslineRendererResult = StatuslineRenderResult | readonly string[];
99
+
100
+ export type StatuslineRenderer = (
101
+ input: StatuslineRenderInput,
102
+ ) => StatuslineRendererResult | Promise<StatuslineRendererResult>;
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@sentiolabs/pi-scriptable-statusline",
3
+ "version": "0.1.0",
4
+ "description": "Scriptable footer and statusline UI package for Pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi-skill",
10
+ "statusline",
11
+ "footer",
12
+ "widgets"
13
+ ],
14
+ "license": "MIT",
15
+ "author": {
16
+ "name": "Sentio Labs",
17
+ "url": "https://github.com/sentiolabs"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+ssh://git@github.com/SentioLabs/pi-nexus.git",
22
+ "directory": "packages/pi-scriptable-statusline"
23
+ },
24
+ "homepage": "https://github.com/SentioLabs/pi-nexus/tree/main/packages/pi-scriptable-statusline#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/SentioLabs/pi-nexus/issues"
27
+ },
28
+ "engines": {
29
+ "node": ">=24.0.0"
30
+ },
31
+ "scripts": {
32
+ "test": "node --experimental-strip-types --test tests/*.test.mjs && npm run typecheck",
33
+ "typecheck": "tsc -p tsconfig.json",
34
+ "pack:dry-run": "npm pack --dry-run",
35
+ "prepublishOnly": "npm test && npm run pack:dry-run"
36
+ },
37
+ "files": [
38
+ "extensions/",
39
+ "skills/",
40
+ "prompts/",
41
+ "templates/",
42
+ "index.d.ts",
43
+ "README.md",
44
+ "CHANGELOG.md",
45
+ "LICENSE"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "devDependencies": {
51
+ "@mariozechner/pi-coding-agent": "^0.73.0",
52
+ "@mariozechner/pi-tui": "^0.73.0",
53
+ "@types/node": "^24.12.2",
54
+ "typescript": "^5.9.3"
55
+ },
56
+ "peerDependencies": {
57
+ "@mariozechner/pi-coding-agent": "*",
58
+ "@mariozechner/pi-tui": "*"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "@mariozechner/pi-coding-agent": {
62
+ "optional": true
63
+ },
64
+ "@mariozechner/pi-tui": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "pi": {
69
+ "extensions": [
70
+ "./extensions/statusline.ts"
71
+ ],
72
+ "skills": [
73
+ "./skills"
74
+ ],
75
+ "prompts": [
76
+ "./prompts/*.md"
77
+ ]
78
+ }
79
+ }
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Use the statusline-setup skill to customize @sentiolabs/pi-scriptable-statusline.
3
+ ---
4
+
5
+ Use the `statusline-setup` skill to configure `@sentiolabs/pi-scriptable-statusline` for this user's requested layout.
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: statusline-setup
3
+ description: Configure the scriptable Pi statusline renderer. Use when the user wants to customize Pi's footer, statusline, model/context display, git/repo display, provider limit display, or above/below-editor status widgets through @sentiolabs/pi-scriptable-statusline.
4
+ license: MIT
5
+ ---
6
+
7
+ # Statusline Setup
8
+
9
+ Configure `@sentiolabs/pi-scriptable-statusline` by creating or editing the user's renderer at `~/.pi/agent/scriptable-statusline/render.ts`.
10
+
11
+ ## Rules
12
+
13
+ - Treat `~/.pi/agent/scriptable-statusline/render.ts` as user-owned code.
14
+ - Read the existing renderer before editing it.
15
+ - Preserve custom helper functions and comments when practical.
16
+ - Ask a concise clarifying question only when the desired layout is ambiguous.
17
+ - Prefer simple JS/TS code over a generated framework.
18
+ - Use `import type { StatuslineRenderer } from "@sentiolabs/pi-scriptable-statusline";` for type hints.
19
+ - Return footer lines through `footer`, above-editor lines through `widgets.aboveEditor`, and below-editor lines through `widgets.belowEditor`.
20
+ - If the user wants this package to own the footer, remind them to disable other footer replacement packages such as `pi-powerline-footer`.
21
+ - After editing, tell the user to run `/statusline reload` if the extension does not reload automatically.
22
+
23
+ ## Renderer template
24
+
25
+ ```typescript
26
+ import type { StatuslineRenderer } from "@sentiolabs/pi-scriptable-statusline";
27
+
28
+ const render: StatuslineRenderer = async (input) => ({
29
+ footer: [
30
+ `${input.model.label} · ${input.repo.name} · ${input.git.branch ?? "no-git"}`,
31
+ `ctx ${input.context.percent ?? "?"}% · ${input.tokens.totalLabel} · ${input.cost.totalLabel}`,
32
+ ],
33
+ widgets: {
34
+ belowEditor: input.extensionStatuses.map((status) => status.text),
35
+ },
36
+ });
37
+
38
+ export default render;
39
+ ```
40
+
41
+ ## Verification
42
+
43
+ - The renderer exports a default function.
44
+ - The renderer returns strings only.
45
+ - The renderer handles `null` branch, context, and limits values.
46
+ - The renderer does not import project-local files unless the user explicitly asks for project-specific behavior.
@@ -0,0 +1,22 @@
1
+ import type { StatuslineRenderer } from "@sentiolabs/pi-scriptable-statusline";
2
+
3
+ const render: StatuslineRenderer = async (input) => {
4
+ const branch = input.git.branch ?? "no-git";
5
+ const context = input.context.percent === null ? "ctx ?" : `ctx ${input.context.percent}%`;
6
+ const daily = input.limits.daily?.label ?? "daily ?";
7
+ const weekly = input.limits.weekly?.label ?? "weekly ?";
8
+ const statuses = input.extensionStatuses.map((status) => status.text).filter(Boolean);
9
+
10
+ return {
11
+ footer: [
12
+ `${input.model.label} · ${input.repo.name} · ${branch}`,
13
+ `${context} · ${input.tokens.totalLabel} · ${input.cost.totalLabel} · ${daily} · ${weekly}`,
14
+ ],
15
+ widgets: {
16
+ belowEditor: statuses.length > 0 ? [`statuses: ${statuses.join(" · ")}`] : [],
17
+ },
18
+ status: `${context} · ${input.model.label}`,
19
+ };
20
+ };
21
+
22
+ export default render;