@narumitw/pi-statusline 0.49.3 → 0.49.4

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,357 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ReadonlyFooterDataProvider,
5
+ Theme,
6
+ ThemeColor,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { formatDirectoryPath } from "./directory.js";
9
+ import {
10
+ type ExtensionStatusRuntime,
11
+ formatExtensionStatuses,
12
+ wrapExtensionStatusline,
13
+ } from "./extension-status.js";
14
+ import { formatGitBranchValue, type GitStatusSummary } from "./git-status.js";
15
+ import { renderPowerlineStatusline } from "./powerline.js";
16
+ import { sanitizeTerminalText } from "./terminal.js";
17
+ import {
18
+ LINE_BREAK_SEGMENT_NAME,
19
+ type PowerlineBlockName,
20
+ type RenderItem,
21
+ type RenderSegment,
22
+ type SegmentName,
23
+ type StatuslineConfig,
24
+ type TruncationDirection,
25
+ } from "./types.js";
26
+ import { type FooterUsageSummary, summarizeFooterUsage } from "./usage.js";
27
+
28
+ type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
29
+ export interface RuntimeState extends ExtensionStatusRuntime {
30
+ homeDir?: string;
31
+ turnCount: number;
32
+ activeTools: Map<string, number>;
33
+ isStreaming: boolean;
34
+ thinkingLevel: ThinkingLevel;
35
+ gitStatus?: GitStatusSummary;
36
+ requestRender?: () => void;
37
+ }
38
+ const GITHUB_PR_KEY = "github-pr";
39
+ const GITHUB_PR_STATUS_KEYS = new Set([GITHUB_PR_KEY]);
40
+ export function renderStatusline(
41
+ width: number,
42
+ ctx: ExtensionContext,
43
+ footerData: ReadonlyFooterDataProvider,
44
+ _theme: Theme,
45
+ config: StatuslineConfig,
46
+ runtime: RuntimeState,
47
+ ): string {
48
+ if (width <= 0) return "";
49
+
50
+ const usageSummary = summarizeFooterUsage(ctx.sessionManager.getEntries());
51
+ const rows: Array<{ configuredSegments: number; segments: RenderSegment[] }> = [
52
+ { configuredSegments: 0, segments: [] },
53
+ ];
54
+ for (const name of config.segments) {
55
+ if (name === LINE_BREAK_SEGMENT_NAME) {
56
+ rows.push({ configuredSegments: 0, segments: [] });
57
+ continue;
58
+ }
59
+
60
+ const row = rows.at(-1);
61
+ if (!row) continue;
62
+ row.configuredSegments += 1;
63
+ const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary);
64
+ if (rendered && rendered.text.length > 0) row.segments.push(rendered);
65
+ }
66
+
67
+ const segments: RenderItem[] = [];
68
+ const renderedRows = rows.filter(
69
+ (row) => row.configuredSegments === 0 || row.segments.length > 0,
70
+ );
71
+ for (const [index, row] of renderedRows.entries()) {
72
+ if (index > 0) segments.push({ name: LINE_BREAK_SEGMENT_NAME });
73
+ segments.push(...row.segments);
74
+ }
75
+
76
+ return renderPowerlineStatusline(width, segments, config);
77
+ }
78
+
79
+ export function renderExtensionStatusline(
80
+ width: number,
81
+ footerData: ReadonlyFooterDataProvider,
82
+ theme: Theme,
83
+ config: StatuslineConfig,
84
+ runtime: RuntimeState,
85
+ mainLine: string,
86
+ ): string[] {
87
+ const statuses = footerData.getExtensionStatuses();
88
+ const prContext = prContextFromStatuses(statuses);
89
+ const rendersPrInline = prContext !== undefined && mainLine.includes(prContext);
90
+ const status = formatExtensionStatuses(
91
+ statuses,
92
+ theme,
93
+ config,
94
+ runtime,
95
+ rendersPrInline ? GITHUB_PR_STATUS_KEYS : undefined,
96
+ );
97
+ return wrapExtensionStatusline(status, width);
98
+ }
99
+
100
+ function buildSegment(
101
+ name: SegmentName,
102
+ ctx: ExtensionContext,
103
+ footerData: ReadonlyFooterDataProvider,
104
+ config: StatuslineConfig,
105
+ runtime: RuntimeState,
106
+ usageSummary: FooterUsageSummary,
107
+ ): RenderSegment | undefined {
108
+ switch (name) {
109
+ case "brand":
110
+ return segment(name, "π", config, "accent", "header", true);
111
+ case "provider":
112
+ return segment(name, ctx.model?.provider ?? "no-provider", config, "accent", "header");
113
+ case "model": {
114
+ const presentation = config.segmentText.model;
115
+ const model = truncateModel(
116
+ shortenModel(ctx.model?.id ?? "no-model"),
117
+ presentation.truncationLength,
118
+ presentation.truncationSymbol,
119
+ presentation.truncationDirection,
120
+ );
121
+ return segment(name, model, config, "accent", "header");
122
+ }
123
+ case "thinking":
124
+ return segment(
125
+ name,
126
+ runtime.thinkingLevel,
127
+ config,
128
+ thinkingColor(runtime.thinkingLevel),
129
+ "header",
130
+ );
131
+ case "branch": {
132
+ const branch = footerData.getGitBranch();
133
+ const pr = branch ? prContextFromStatuses(footerData.getExtensionStatuses()) : undefined;
134
+ return segment(
135
+ name,
136
+ formatGitBranchValue(branch, runtime.gitStatus, pr),
137
+ config,
138
+ "accent",
139
+ "git",
140
+ );
141
+ }
142
+ case "cwd":
143
+ return segment(
144
+ name,
145
+ formatDirectoryPath(ctx.cwd, runtime.homeDir, runtime.gitStatus?.root),
146
+ config,
147
+ "accent",
148
+ "directory",
149
+ );
150
+ case "tools": {
151
+ const activity = formatToolActivity(runtime);
152
+ return activity ? segment(name, activity, config, "accent", "runtime") : undefined;
153
+ }
154
+ case "context": {
155
+ const usage = ctx.getContextUsage();
156
+ const percentage =
157
+ usage?.percent === null || usage?.percent === undefined
158
+ ? "?"
159
+ : `${usage.percent.toFixed(1)}%`;
160
+ const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
161
+ return segment(
162
+ name,
163
+ `${percentage}/${formatCount(contextWindow)}`,
164
+ config,
165
+ contextColor(usage?.percent),
166
+ "runtime",
167
+ );
168
+ }
169
+ case "tokens": {
170
+ const value =
171
+ usageSummary.input === 0 && usageSummary.output === 0
172
+ ? "tok 0"
173
+ : `↑${formatCount(usageSummary.input)} ↓${formatCount(usageSummary.output)}`;
174
+ return segment(name, value, config, "accent", "runtime");
175
+ }
176
+ case "cache": {
177
+ if (usageSummary.cacheRead === 0 && usageSummary.cacheWrite === 0) return undefined;
178
+ const values: string[] = [];
179
+ if (usageSummary.cacheRead > 0) values.push(`R${formatCount(usageSummary.cacheRead)}`);
180
+ if (usageSummary.cacheWrite > 0) values.push(`W${formatCount(usageSummary.cacheWrite)}`);
181
+ if (usageSummary.latestCacheHitRate !== undefined) {
182
+ values.push(`CH${usageSummary.latestCacheHitRate.toFixed(1)}%`);
183
+ }
184
+ return segment(name, values.join(" "), config, "accent", "runtime");
185
+ }
186
+ case "cost": {
187
+ const subscription = isSubscriptionBacked(ctx) ? " (sub)" : "";
188
+ return segment(
189
+ name,
190
+ `${usageSummary.cost.toFixed(usageSummary.cost >= 1 ? 2 : 3)}${subscription}`,
191
+ config,
192
+ "accent",
193
+ "meter",
194
+ );
195
+ }
196
+ case "time":
197
+ return segment(name, formatTime(), config, "accent", "meter");
198
+ case "turn":
199
+ return segment(name, `${runtime.turnCount}`, config, "accent", "meter");
200
+ }
201
+ }
202
+
203
+ function segment(
204
+ name: SegmentName,
205
+ value: string,
206
+ config: StatuslineConfig,
207
+ color: RenderSegment["color"],
208
+ block: PowerlineBlockName,
209
+ emphasis = false,
210
+ ): RenderSegment {
211
+ return { name, text: formatConfiguredSegment(name, value, config), color, block, emphasis };
212
+ }
213
+
214
+ export function formatConfiguredSegment(
215
+ name: SegmentName,
216
+ value: string,
217
+ config: Pick<StatuslineConfig, "segmentText">,
218
+ ): string {
219
+ const presentation = config.segmentText[name];
220
+ return `${presentation.prefix}${value}${presentation.suffix}`;
221
+ }
222
+
223
+ function thinkingColor(level: ThinkingLevel): ThemeColor {
224
+ switch (level as string) {
225
+ case "off":
226
+ return "dim";
227
+ case "minimal":
228
+ return "thinkingMinimal";
229
+ case "low":
230
+ return "thinkingLow";
231
+ case "medium":
232
+ return "thinkingMedium";
233
+ case "high":
234
+ return "thinkingHigh";
235
+ case "xhigh":
236
+ return "thinkingXhigh";
237
+ case "max":
238
+ return "thinkingMax" as ThemeColor;
239
+ default:
240
+ return "dim";
241
+ }
242
+ }
243
+
244
+ export function contextColor(percent: number | null | undefined): ThemeColor {
245
+ if (percent === null || percent === undefined) return "dim";
246
+ if (percent >= 90) return "error";
247
+ if (percent >= 70) return "warning";
248
+ return "success";
249
+ }
250
+
251
+ export function formatToolActivity(runtime: RuntimeState): string | undefined {
252
+ const active = [...runtime.activeTools.entries()];
253
+ if (active.length > 0) {
254
+ const [name, count] = active[0] ?? ["tool", 1];
255
+ const suffix = count > 1 ? `×${count}` : active.length > 1 ? `+${active.length - 1}` : "";
256
+ return `⚙ ${name}${suffix}`;
257
+ }
258
+
259
+ return runtime.isStreaming ? "💭 thinking" : undefined;
260
+ }
261
+
262
+ export function prLinkFromStatuses(statuses: ReadonlyMap<string, string>): string | undefined {
263
+ const value = statuses.get(GITHUB_PR_KEY);
264
+ if (!value) return undefined;
265
+ // Extract the OSC 8 hyperlink span (the clickable "#123"); skip non-PR states
266
+ // like "PR gh missing" that carry no link. github-pr emits exactly one link, so the
267
+ // first OSC 8 span is the PR number.
268
+ const open = value.indexOf("\x1b]8;;");
269
+ if (open === -1) return undefined;
270
+ const closeMarker = "\x1b]8;;\x07";
271
+ const close = value.indexOf(closeMarker, open + 1);
272
+ return close === -1 ? undefined : value.slice(open, close + closeMarker.length);
273
+ }
274
+
275
+ export function prContextFromStatuses(statuses: ReadonlyMap<string, string>): string | undefined {
276
+ const value = statuses.get(GITHUB_PR_KEY);
277
+ const link = prLinkFromStatuses(statuses);
278
+ if (!value || !link) return undefined;
279
+
280
+ const state = compactPrState(value.replace(link, ""));
281
+ return state ? `${link} · ${state}` : undefined;
282
+ }
283
+
284
+ function compactPrState(value: string): string | undefined {
285
+ if (/:\s*merged\s*$/.test(value)) return "merged";
286
+ if (/:\s*closed\s*$/.test(value)) return "closed";
287
+ if (/\bdraft\b/.test(value)) return "draft";
288
+
289
+ const failing = /\bchecks failing \((\d+)\)/.exec(value);
290
+ if (failing) return `${failing[1]} failing`;
291
+ if (/\bchanges requested\b/.test(value)) return "changes requested";
292
+
293
+ const pending = /\bchecks pending \((\d+)\)/.exec(value);
294
+ if (pending) return `${pending[1]} pending`;
295
+ if (/\bapproved\b/.test(value)) return "approved";
296
+ if (/\breview required\b/.test(value)) return "review required";
297
+ if (/\bchecks passing\b/.test(value)) return "checks passing";
298
+ if (/\bno checks\b/.test(value)) return "no checks";
299
+ return undefined;
300
+ }
301
+
302
+ function isSubscriptionBacked(ctx: ExtensionContext): boolean {
303
+ const model = ctx.model;
304
+ return (
305
+ model !== undefined &&
306
+ (model.provider === "kimi-coding" || ctx.modelRegistry.isUsingOAuth(model))
307
+ );
308
+ }
309
+
310
+ export function formatCount(value: number): string {
311
+ if (value < 1000) return `${value}`;
312
+ if (value < 1_000_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k`;
313
+ return `${(value / 1_000_000).toFixed(1)}m`;
314
+ }
315
+
316
+ function formatTime(): string {
317
+ const now = new Date();
318
+ const hours = now.getHours().toString().padStart(2, "0");
319
+ const minutes = now.getMinutes().toString().padStart(2, "0");
320
+ return `${hours}:${minutes}`;
321
+ }
322
+
323
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
324
+
325
+ export function truncateModel(
326
+ model: string,
327
+ length: number,
328
+ symbol: string,
329
+ direction: TruncationDirection,
330
+ ): string {
331
+ const safeModel = sanitizeTerminalText(model);
332
+ if (length === 0) return safeModel;
333
+ const graphemes = [...graphemeSegmenter.segment(safeModel)].map(({ segment }) => segment);
334
+ if (graphemes.length <= length) return safeModel;
335
+ const safeSymbol = sanitizeTerminalText(symbol);
336
+
337
+ switch (direction) {
338
+ case "start":
339
+ return `${safeSymbol}${graphemes.slice(-length).join("")}`;
340
+ case "middle": {
341
+ const headLength = Math.ceil(length / 2);
342
+ const tailLength = Math.floor(length / 2);
343
+ const tail = tailLength > 0 ? graphemes.slice(-tailLength).join("") : "";
344
+ return `${graphemes.slice(0, headLength).join("")}${safeSymbol}${tail}`;
345
+ }
346
+ case "end":
347
+ return `${graphemes.slice(0, length).join("")}${safeSymbol}`;
348
+ }
349
+ }
350
+
351
+ export function shortenModel(model: string): string {
352
+ return model
353
+ .replace(/^claude-/, "")
354
+ .replace(/^gpt-/, "gpt ")
355
+ .replace(/-20\d{6}$/, "")
356
+ .replace(/-latest$/, "");
357
+ }