@narumitw/pi-statusline 0.13.0 → 0.14.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/render.ts ADDED
@@ -0,0 +1,257 @@
1
+ import { basename } from "node:path";
2
+ import process from "node:process";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ ReadonlyFooterDataProvider,
7
+ Theme,
8
+ ThemeColor,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import { truncateToWidth } from "@earendil-works/pi-tui";
11
+ import { renderClassicStatusline } from "../presets/classic.js";
12
+ import { renderTokyoNightStatusline } from "../presets/tokyo-night.js";
13
+ import type {
14
+ PaletteName,
15
+ RenderSegment,
16
+ SegmentName,
17
+ StatuslineConfig,
18
+ TokyoNightBlockName,
19
+ } from "../presets/types.js";
20
+ import {
21
+ formatExtensionStatuses,
22
+ type ExtensionStatusRuntime,
23
+ wrapExtensionStatusline,
24
+ } from "./extension-status.js";
25
+ import { formatGitBranchText, type GitStatusSummary } from "./git-status.js";
26
+
27
+ type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
28
+ export interface RuntimeState extends ExtensionStatusRuntime {
29
+ turnCount: number;
30
+ activeTools: Map<string, number>;
31
+ lastTool?: string;
32
+ lastCompletedTool?: string;
33
+ isStreaming: boolean;
34
+ thinkingLevel: ThinkingLevel;
35
+ gitStatus?: GitStatusSummary;
36
+ requestRender?: () => void;
37
+ }
38
+ interface TokenTotals {
39
+ input: number;
40
+ output: number;
41
+ cost: number;
42
+ }
43
+ const GITHUB_PR_KEY = "github-pr";
44
+ const PALETTES: Record<PaletteName, ThemeColor[]> = {
45
+ ocean: ["accent", "muted", "success", "warning"],
46
+ sunset: ["warning", "accent", "success", "muted"],
47
+ forest: ["success", "accent", "muted", "warning"],
48
+ candy: ["accent", "warning", "success", "muted"],
49
+ neon: ["accent", "success", "warning", "error"],
50
+ mono: ["muted", "dim"],
51
+ };
52
+
53
+ export function renderStatusline(
54
+ width: number,
55
+ ctx: ExtensionContext,
56
+ footerData: ReadonlyFooterDataProvider,
57
+ theme: Theme,
58
+ config: StatuslineConfig,
59
+ runtime: RuntimeState,
60
+ ): string {
61
+ if (width <= 0) return "";
62
+
63
+ const segments = config.segments
64
+ .map((segment, index) => buildSegment(segment, index, ctx, footerData, config, runtime))
65
+ .filter(
66
+ (segment): segment is RenderSegment => segment !== undefined && segment.text.length > 0,
67
+ );
68
+
69
+ if (segments.length === 0) return truncateToWidth(theme.fg("dim", "pi-statusline"), width);
70
+
71
+ switch (config.preset) {
72
+ case "classic":
73
+ return renderClassicStatusline(width, segments, theme, config);
74
+ case "tokyo-night":
75
+ return renderTokyoNightStatusline(width, segments);
76
+ }
77
+ }
78
+
79
+ export function renderExtensionStatusline(
80
+ width: number,
81
+ footerData: ReadonlyFooterDataProvider,
82
+ theme: Theme,
83
+ config: StatuslineConfig,
84
+ runtime: RuntimeState,
85
+ ): string[] {
86
+ const status = formatExtensionStatuses(footerData.getExtensionStatuses(), theme, config, runtime);
87
+ return wrapExtensionStatusline(status, width);
88
+ }
89
+
90
+ function buildSegment(
91
+ name: SegmentName,
92
+ index: number,
93
+ ctx: ExtensionContext,
94
+ footerData: ReadonlyFooterDataProvider,
95
+ config: StatuslineConfig,
96
+ runtime: RuntimeState,
97
+ ): RenderSegment | undefined {
98
+ const color = pickColor(config, index);
99
+
100
+ switch (name) {
101
+ case "brand":
102
+ return segment(name, "π", "accent", "header", true);
103
+ case "model":
104
+ return segment(name, `🤖 ${shortenModel(ctx.model?.id ?? "no-model")}`, color, "header");
105
+ case "thinking":
106
+ return segment(
107
+ name,
108
+ `🧠 ${runtime.thinkingLevel}`,
109
+ thinkingColor(runtime.thinkingLevel),
110
+ "header",
111
+ );
112
+ case "branch": {
113
+ const branch = footerData.getGitBranch();
114
+ const pr = branch ? prLinkFromStatuses(footerData.getExtensionStatuses()) : undefined;
115
+ return segment(name, formatGitBranchText(branch, runtime.gitStatus, pr), color, "git");
116
+ }
117
+ case "cwd":
118
+ return segment(name, `📁 ${basename(ctx.cwd) || ctx.cwd}`, color, "directory");
119
+ case "tools":
120
+ return segment(name, formatToolActivity(runtime), color, "runtime");
121
+ case "context": {
122
+ const usage = ctx.getContextUsage();
123
+ const value =
124
+ usage?.percent === null || usage?.percent === undefined
125
+ ? "🪟 ctx ?"
126
+ : `🪟 ctx ${usage.percent.toFixed(0)}%`;
127
+ return segment(name, value, contextColor(usage?.percent), "runtime");
128
+ }
129
+ case "tokens": {
130
+ const totals = getTokenTotals(ctx);
131
+ if (totals.input === 0 && totals.output === 0)
132
+ return segment(name, "🔢 tok 0", color, "runtime");
133
+ return segment(
134
+ name,
135
+ `🔢 ↑${formatCount(totals.input)} ↓${formatCount(totals.output)}`,
136
+ color,
137
+ "runtime",
138
+ );
139
+ }
140
+ case "cost": {
141
+ const totals = getTokenTotals(ctx);
142
+ return segment(name, `💸 $${totals.cost.toFixed(totals.cost >= 1 ? 2 : 3)}`, color, "meter");
143
+ }
144
+ case "time":
145
+ return segment(name, `🕒 ${formatTime()}`, color, "meter");
146
+ case "turn":
147
+ return segment(name, `🔁 #${runtime.turnCount}`, color, "meter");
148
+ }
149
+ }
150
+
151
+ function segment(
152
+ name: SegmentName,
153
+ text: string,
154
+ color: ThemeColor,
155
+ block: TokyoNightBlockName,
156
+ emphasis = false,
157
+ ): RenderSegment {
158
+ return { name, text, color, block, emphasis };
159
+ }
160
+
161
+ function pickColor(config: StatuslineConfig, index: number): ThemeColor {
162
+ const palette = PALETTES[config.palette];
163
+ return palette[index % palette.length] ?? "muted";
164
+ }
165
+
166
+ function thinkingColor(level: ThinkingLevel): ThemeColor {
167
+ switch (level) {
168
+ case "off":
169
+ return "dim";
170
+ case "minimal":
171
+ return "thinkingMinimal";
172
+ case "low":
173
+ return "thinkingLow";
174
+ case "medium":
175
+ return "thinkingMedium";
176
+ case "high":
177
+ return "thinkingHigh";
178
+ case "xhigh":
179
+ return "thinkingXhigh";
180
+ }
181
+ }
182
+
183
+ export function contextColor(percent: number | null | undefined): ThemeColor {
184
+ if (percent === null || percent === undefined) return "dim";
185
+ if (percent >= 90) return "error";
186
+ if (percent >= 70) return "warning";
187
+ return "success";
188
+ }
189
+
190
+ export function formatToolActivity(runtime: RuntimeState): string {
191
+ const active = [...runtime.activeTools.entries()];
192
+ if (active.length > 0) {
193
+ const [name, count] = active[0] ?? ["tool", 1];
194
+ const suffix = count > 1 ? `×${count}` : active.length > 1 ? `+${active.length - 1}` : "";
195
+ return `⚙ ${name}${suffix}`;
196
+ }
197
+
198
+ if (runtime.isStreaming) return "💭 thinking";
199
+ if (runtime.lastCompletedTool) return `✅ ${runtime.lastCompletedTool}`;
200
+ return "💤 idle";
201
+ }
202
+
203
+ export function prLinkFromStatuses(statuses: ReadonlyMap<string, string>): string | undefined {
204
+ const value = statuses.get(GITHUB_PR_KEY);
205
+ if (!value) return undefined;
206
+ // Extract the OSC 8 hyperlink span (the clickable "#123"); skip non-PR states
207
+ // like "PR gh missing" that carry no link. github-pr emits exactly one link, so the
208
+ // first OSC 8 span is the PR number.
209
+ const open = value.indexOf("\x1b]8;;");
210
+ if (open === -1) return undefined;
211
+ const closeMarker = "\x1b]8;;\x07";
212
+ const close = value.indexOf(closeMarker, open + 1);
213
+ return close === -1 ? undefined : value.slice(open, close + closeMarker.length);
214
+ }
215
+
216
+ function getTokenTotals(ctx: ExtensionContext): TokenTotals {
217
+ const totals: TokenTotals = { input: 0, output: 0, cost: 0 };
218
+
219
+ for (const entry of ctx.sessionManager.getBranch()) {
220
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
221
+
222
+ const usage = entry.message.usage as
223
+ | {
224
+ input?: number;
225
+ output?: number;
226
+ cost?: { total?: number };
227
+ }
228
+ | undefined;
229
+
230
+ totals.input += usage?.input ?? 0;
231
+ totals.output += usage?.output ?? 0;
232
+ totals.cost += usage?.cost?.total ?? 0;
233
+ }
234
+
235
+ return totals;
236
+ }
237
+
238
+ export function formatCount(value: number): string {
239
+ if (value < 1000) return `${value}`;
240
+ if (value < 1_000_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k`;
241
+ return `${(value / 1_000_000).toFixed(1)}m`;
242
+ }
243
+
244
+ function formatTime(): string {
245
+ const now = new Date();
246
+ const hours = now.getHours().toString().padStart(2, "0");
247
+ const minutes = now.getMinutes().toString().padStart(2, "0");
248
+ return `${hours}:${minutes}`;
249
+ }
250
+
251
+ export function shortenModel(model: string): string {
252
+ return model
253
+ .replace(/^claude-/, "")
254
+ .replace(/^gpt-/, "gpt ")
255
+ .replace(/-20\d{6}$/, "")
256
+ .replace(/-latest$/, "");
257
+ }
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import process from "node:process";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import type { SegmentName, StatuslineConfig, StatuslinePresetName } from "../presets/types.js";
6
+
7
+ const SETTINGS_FILE = "pi-statusline-settings.json";
8
+ const DEFAULT_PRESET: StatuslinePresetName = "tokyo-night";
9
+ const DEFAULT_SEGMENTS: SegmentName[] = [
10
+ "brand",
11
+ "model",
12
+ "thinking",
13
+ "cwd",
14
+ "branch",
15
+ "tools",
16
+ "context",
17
+ "tokens",
18
+ "cost",
19
+ "time",
20
+ ];
21
+
22
+ export interface StatuslineSettings {
23
+ extensionStatusIcons: Record<string, string>;
24
+ }
25
+
26
+ export function createDefaultConfig(): StatuslineConfig {
27
+ return {
28
+ preset: readStatuslinePreset(),
29
+ palette: "candy",
30
+ density: "compact",
31
+ separator: "dot",
32
+ showLabels: false,
33
+ segments: [...DEFAULT_SEGMENTS],
34
+ extensionStatusIcons: readStatuslineSettings().extensionStatusIcons,
35
+ };
36
+ }
37
+
38
+ export function readStatuslineSettings(
39
+ settingsPath = join(getAgentDir(), SETTINGS_FILE),
40
+ ): StatuslineSettings {
41
+ try {
42
+ return normalizeStatuslineSettings(JSON.parse(readFileSync(settingsPath, "utf8")));
43
+ } catch {
44
+ return { extensionStatusIcons: {} };
45
+ }
46
+ }
47
+
48
+ export function normalizeStatuslineSettings(value: unknown): StatuslineSettings {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { extensionStatusIcons: {} };
50
+ const icons = (value as { extensionStatusIcons?: unknown }).extensionStatusIcons;
51
+ if (!icons || typeof icons !== "object" || Array.isArray(icons)) {
52
+ return { extensionStatusIcons: {} };
53
+ }
54
+ return {
55
+ extensionStatusIcons: Object.fromEntries(
56
+ Object.entries(icons).filter(
57
+ (entry): entry is [string, string] => typeof entry[1] === "string",
58
+ ),
59
+ ),
60
+ };
61
+ }
62
+
63
+ function readStatuslinePreset(): StatuslinePresetName {
64
+ const value = process.env.PI_STATUSLINE_PRESET?.trim().toLowerCase();
65
+ return value === "classic" || value === "tokyo-night" ? value : DEFAULT_PRESET;
66
+ }