@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 +5 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/extensions/render-cache.ts +111 -0
- package/extensions/render-result.ts +67 -0
- package/extensions/renderer-loader.ts +49 -0
- package/extensions/snapshot.ts +196 -0
- package/extensions/statusline.ts +388 -0
- package/index.d.ts +102 -0
- package/package.json +79 -0
- package/prompts/statusline.md +5 -0
- package/skills/statusline-setup/SKILL.md +46 -0
- package/templates/default-render.ts +22 -0
package/CHANGELOG.md
ADDED
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,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;
|