pi-powerline-footer 0.2.2

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/segments.ts ADDED
@@ -0,0 +1,440 @@
1
+ import { hostname as osHostname } from "node:os";
2
+ import { basename } from "node:path";
3
+ import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types.js";
4
+ import { fgOnly, rainbow } from "./colors.js";
5
+ import { getIcons, SEP_DOT, getThinkingText } from "./icons.js";
6
+
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ // Helpers
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ function withIcon(icon: string, text: string): string {
12
+ return icon ? `${icon} ${text}` : text;
13
+ }
14
+
15
+ function formatTokens(n: number): string {
16
+ if (n < 1000) return n.toString();
17
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
18
+ if (n < 1000000) return `${Math.round(n / 1000)}k`;
19
+ if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
20
+ return `${Math.round(n / 1000000)}M`;
21
+ }
22
+
23
+ function formatDuration(ms: number): string {
24
+ const seconds = Math.floor(ms / 1000);
25
+ const minutes = Math.floor(seconds / 60);
26
+ const hours = Math.floor(minutes / 60);
27
+
28
+ if (hours > 0) return `${hours}h${minutes % 60}m`;
29
+ if (minutes > 0) return `${minutes}m${seconds % 60}s`;
30
+ return `${seconds}s`;
31
+ }
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // Segment Implementations
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ const piSegment: StatusLineSegment = {
38
+ id: "pi",
39
+ render(_ctx) {
40
+ const icons = getIcons();
41
+ if (!icons.pi) return { content: "", visible: false };
42
+ const content = `${icons.pi} `;
43
+ return { content: fgOnly("accent", content), visible: true };
44
+ },
45
+ };
46
+
47
+ const modelSegment: StatusLineSegment = {
48
+ id: "model",
49
+ render(ctx) {
50
+ const icons = getIcons();
51
+ const opts = ctx.options.model ?? {};
52
+
53
+ let modelName = ctx.model?.name || ctx.model?.id || "no-model";
54
+ // Strip "Claude " prefix for brevity
55
+ if (modelName.startsWith("Claude ")) {
56
+ modelName = modelName.slice(7);
57
+ }
58
+
59
+ let content = withIcon(icons.model, modelName);
60
+
61
+ // Add thinking level with dot separator
62
+ if (opts.showThinkingLevel !== false && ctx.model?.reasoning) {
63
+ const level = ctx.thinkingLevel || "off";
64
+ if (level !== "off") {
65
+ const thinkingText = getThinkingText(level);
66
+ if (thinkingText) {
67
+ content += `${SEP_DOT}${thinkingText}`;
68
+ }
69
+ }
70
+ }
71
+
72
+ return { content: fgOnly("model", content), visible: true };
73
+ },
74
+ };
75
+
76
+ const pathSegment: StatusLineSegment = {
77
+ id: "path",
78
+ render(ctx) {
79
+ const icons = getIcons();
80
+ const opts = ctx.options.path ?? {};
81
+ const mode = opts.mode ?? "basename";
82
+
83
+ let pwd = process.cwd();
84
+ const home = process.env.HOME || process.env.USERPROFILE;
85
+
86
+ if (mode === "basename") {
87
+ // Just the last directory component (cross-platform)
88
+ pwd = basename(pwd) || pwd;
89
+ } else {
90
+ // Abbreviate home directory for abbreviated/full modes
91
+ if (home && pwd.startsWith(home)) {
92
+ pwd = `~${pwd.slice(home.length)}`;
93
+ }
94
+
95
+ // Strip /work/ prefix (common in containers)
96
+ if (pwd.startsWith("/work/")) {
97
+ pwd = pwd.slice(6);
98
+ }
99
+
100
+ // Truncate if too long (only for abbreviated mode)
101
+ if (mode === "abbreviated") {
102
+ const maxLen = opts.maxLength ?? 40;
103
+ if (pwd.length > maxLen) {
104
+ pwd = `…${pwd.slice(-(maxLen - 1))}`;
105
+ }
106
+ }
107
+ }
108
+
109
+ const content = withIcon(icons.folder, pwd);
110
+ return { content: fgOnly("path", content), visible: true };
111
+ },
112
+ };
113
+
114
+ const gitSegment: StatusLineSegment = {
115
+ id: "git",
116
+ render(ctx) {
117
+ const icons = getIcons();
118
+ const opts = ctx.options.git ?? {};
119
+ const { branch, staged, unstaged, untracked } = ctx.git;
120
+ const gitStatus = (staged > 0 || unstaged > 0 || untracked > 0)
121
+ ? { staged, unstaged, untracked }
122
+ : null;
123
+
124
+ if (!branch && !gitStatus) return { content: "", visible: false };
125
+
126
+ const isDirty = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0 || gitStatus.untracked > 0);
127
+ const showBranch = opts.showBranch !== false;
128
+
129
+ // Build content
130
+ let content = "";
131
+ if (showBranch && branch) {
132
+ content = withIcon(icons.branch, branch);
133
+ }
134
+
135
+ // Add status indicators
136
+ if (gitStatus) {
137
+ const indicators: string[] = [];
138
+ if (opts.showUnstaged !== false && gitStatus.unstaged > 0) {
139
+ indicators.push(fgOnly("unstaged", `*${gitStatus.unstaged}`));
140
+ }
141
+ if (opts.showStaged !== false && gitStatus.staged > 0) {
142
+ indicators.push(fgOnly("staged", `+${gitStatus.staged}`));
143
+ }
144
+ if (opts.showUntracked !== false && gitStatus.untracked > 0) {
145
+ indicators.push(fgOnly("untracked", `?${gitStatus.untracked}`));
146
+ }
147
+ if (indicators.length > 0) {
148
+ const indicatorText = indicators.join(" ");
149
+ if (!content && showBranch === false) {
150
+ content = withIcon(icons.git, indicatorText);
151
+ } else {
152
+ content += content ? ` ${indicatorText}` : indicatorText;
153
+ }
154
+ }
155
+ }
156
+
157
+ if (!content) return { content: "", visible: false };
158
+
159
+ // Wrap entire content in branch color
160
+ const colorName = isDirty ? "gitDirty" : "gitClean";
161
+ return { content: fgOnly(colorName, content), visible: true };
162
+ },
163
+ };
164
+
165
+ const thinkingSegment: StatusLineSegment = {
166
+ id: "thinking",
167
+ render(ctx) {
168
+ const level = ctx.thinkingLevel || "off";
169
+
170
+ // Text label for each level
171
+ const levelText: Record<string, string> = {
172
+ off: "off",
173
+ minimal: "min",
174
+ low: "low",
175
+ medium: "med",
176
+ high: "high",
177
+ xhigh: "xhigh",
178
+ };
179
+ const label = levelText[level] || level;
180
+ const content = `thinking:${label}`;
181
+
182
+ // Use rainbow effect for high/xhigh (like Claude Code ultrathink)
183
+ if (level === "high" || level === "xhigh") {
184
+ return { content: rainbow(content), visible: true };
185
+ }
186
+
187
+ // Use dedicated thinking colors (gradient: gray → purple → blue → teal)
188
+ const colorMap: Record<string, "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium"> = {
189
+ off: "thinkingOff",
190
+ minimal: "thinkingMinimal",
191
+ low: "thinkingLow",
192
+ medium: "thinkingMedium",
193
+ };
194
+ const color = colorMap[level] || "thinkingOff";
195
+
196
+ return { content: fgOnly(color, content), visible: true };
197
+ },
198
+ };
199
+
200
+ const subagentsSegment: StatusLineSegment = {
201
+ id: "subagents",
202
+ render(_ctx) {
203
+ // Note: pi-mono doesn't have subagent tracking built-in
204
+ // This would require extension state management
205
+ // For now, return not visible
206
+ return { content: "", visible: false };
207
+ },
208
+ };
209
+
210
+ const tokenInSegment: StatusLineSegment = {
211
+ id: "token_in",
212
+ render(ctx) {
213
+ const icons = getIcons();
214
+ const { input } = ctx.usageStats;
215
+ if (!input) return { content: "", visible: false };
216
+
217
+ const content = withIcon(icons.input, formatTokens(input));
218
+ return { content: fgOnly("spend", content), visible: true };
219
+ },
220
+ };
221
+
222
+ const tokenOutSegment: StatusLineSegment = {
223
+ id: "token_out",
224
+ render(ctx) {
225
+ const icons = getIcons();
226
+ const { output } = ctx.usageStats;
227
+ if (!output) return { content: "", visible: false };
228
+
229
+ const content = withIcon(icons.output, formatTokens(output));
230
+ return { content: fgOnly("output", content), visible: true };
231
+ },
232
+ };
233
+
234
+ const tokenTotalSegment: StatusLineSegment = {
235
+ id: "token_total",
236
+ render(ctx) {
237
+ const icons = getIcons();
238
+ const { input, output, cacheRead, cacheWrite } = ctx.usageStats;
239
+ const total = input + output + cacheRead + cacheWrite;
240
+ if (!total) return { content: "", visible: false };
241
+
242
+ const content = withIcon(icons.tokens, formatTokens(total));
243
+ return { content: fgOnly("spend", content), visible: true };
244
+ },
245
+ };
246
+
247
+ const costSegment: StatusLineSegment = {
248
+ id: "cost",
249
+ render(ctx) {
250
+ const { cost } = ctx.usageStats;
251
+ const usingSubscription = ctx.usingSubscription;
252
+
253
+ if (!cost && !usingSubscription) {
254
+ return { content: "", visible: false };
255
+ }
256
+
257
+ const costDisplay = usingSubscription ? "(sub)" : `$${cost.toFixed(2)}`;
258
+ return { content: fgOnly("cost", costDisplay), visible: true };
259
+ },
260
+ };
261
+
262
+ const contextPctSegment: StatusLineSegment = {
263
+ id: "context_pct",
264
+ render(ctx) {
265
+ const icons = getIcons();
266
+ const pct = ctx.contextPercent;
267
+ const window = ctx.contextWindow;
268
+
269
+ const autoIcon = ctx.autoCompactEnabled && icons.auto ? ` ${icons.auto}` : "";
270
+ const text = `${pct.toFixed(1)}%/${formatTokens(window)}${autoIcon}`;
271
+
272
+ // Icon outside color, text inside
273
+ let content: string;
274
+ if (pct > 90) {
275
+ content = withIcon(icons.context, fgOnly("error", text));
276
+ } else if (pct > 70) {
277
+ content = withIcon(icons.context, fgOnly("warning", text));
278
+ } else {
279
+ content = withIcon(icons.context, fgOnly("context", text));
280
+ }
281
+
282
+ return { content, visible: true };
283
+ },
284
+ };
285
+
286
+ const contextTotalSegment: StatusLineSegment = {
287
+ id: "context_total",
288
+ render(ctx) {
289
+ const icons = getIcons();
290
+ const window = ctx.contextWindow;
291
+ if (!window) return { content: "", visible: false };
292
+
293
+ return {
294
+ content: fgOnly("context", withIcon(icons.context, formatTokens(window))),
295
+ visible: true,
296
+ };
297
+ },
298
+ };
299
+
300
+ const timeSpentSegment: StatusLineSegment = {
301
+ id: "time_spent",
302
+ render(ctx) {
303
+ const icons = getIcons();
304
+ const elapsed = Date.now() - ctx.sessionStartTime;
305
+ if (elapsed < 1000) return { content: "", visible: false };
306
+
307
+ // No explicit color
308
+ return { content: withIcon(icons.time, formatDuration(elapsed)), visible: true };
309
+ },
310
+ };
311
+
312
+ const timeSegment: StatusLineSegment = {
313
+ id: "time",
314
+ render(ctx) {
315
+ const icons = getIcons();
316
+ const opts = ctx.options.time ?? {};
317
+ const now = new Date();
318
+
319
+ let hours = now.getHours();
320
+ let suffix = "";
321
+ if (opts.format === "12h") {
322
+ suffix = hours >= 12 ? "pm" : "am";
323
+ hours = hours % 12 || 12;
324
+ }
325
+
326
+ const mins = now.getMinutes().toString().padStart(2, "0");
327
+ let timeStr = `${hours}:${mins}`;
328
+ if (opts.showSeconds) {
329
+ timeStr += `:${now.getSeconds().toString().padStart(2, "0")}`;
330
+ }
331
+ timeStr += suffix;
332
+
333
+ // No explicit color
334
+ return { content: withIcon(icons.time, timeStr), visible: true };
335
+ },
336
+ };
337
+
338
+ const sessionSegment: StatusLineSegment = {
339
+ id: "session",
340
+ render(ctx) {
341
+ const icons = getIcons();
342
+ const sessionId = ctx.sessionId;
343
+ const display = sessionId?.slice(0, 8) || "new";
344
+
345
+ // No explicit color
346
+ return { content: withIcon(icons.session, display), visible: true };
347
+ },
348
+ };
349
+
350
+ const hostnameSegment: StatusLineSegment = {
351
+ id: "hostname",
352
+ render(_ctx) {
353
+ const icons = getIcons();
354
+ const name = osHostname().split(".")[0];
355
+ // No explicit color
356
+ return { content: withIcon(icons.host, name), visible: true };
357
+ },
358
+ };
359
+
360
+ const cacheReadSegment: StatusLineSegment = {
361
+ id: "cache_read",
362
+ render(ctx) {
363
+ const icons = getIcons();
364
+ const { cacheRead } = ctx.usageStats;
365
+ if (!cacheRead) return { content: "", visible: false };
366
+
367
+ // Space-separated parts
368
+ const parts = [icons.cache, icons.input, formatTokens(cacheRead)].filter(Boolean);
369
+ const content = parts.join(" ");
370
+ return { content: fgOnly("spend", content), visible: true };
371
+ },
372
+ };
373
+
374
+ const cacheWriteSegment: StatusLineSegment = {
375
+ id: "cache_write",
376
+ render(ctx) {
377
+ const icons = getIcons();
378
+ const { cacheWrite } = ctx.usageStats;
379
+ if (!cacheWrite) return { content: "", visible: false };
380
+
381
+ // Space-separated parts
382
+ const parts = [icons.cache, icons.output, formatTokens(cacheWrite)].filter(Boolean);
383
+ const content = parts.join(" ");
384
+ return { content: fgOnly("output", content), visible: true };
385
+ },
386
+ };
387
+
388
+ const extensionStatusesSegment: StatusLineSegment = {
389
+ id: "extension_statuses",
390
+ render(ctx) {
391
+ const statuses = ctx.extensionStatuses;
392
+ if (!statuses || statuses.size === 0) return { content: "", visible: false };
393
+
394
+ // Join all extension statuses with a separator
395
+ const parts: string[] = [];
396
+ for (const [_key, value] of statuses) {
397
+ if (value) parts.push(value);
398
+ }
399
+
400
+ if (parts.length === 0) return { content: "", visible: false };
401
+
402
+ // Statuses already have their own styling applied by the extensions
403
+ const content = parts.join(` ${SEP_DOT} `);
404
+ return { content, visible: true };
405
+ },
406
+ };
407
+
408
+ // ═══════════════════════════════════════════════════════════════════════════
409
+ // Segment Registry
410
+ // ═══════════════════════════════════════════════════════════════════════════
411
+
412
+ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
413
+ pi: piSegment,
414
+ model: modelSegment,
415
+ path: pathSegment,
416
+ git: gitSegment,
417
+ thinking: thinkingSegment,
418
+ subagents: subagentsSegment,
419
+ token_in: tokenInSegment,
420
+ token_out: tokenOutSegment,
421
+ token_total: tokenTotalSegment,
422
+ cost: costSegment,
423
+ context_pct: contextPctSegment,
424
+ context_total: contextTotalSegment,
425
+ time_spent: timeSpentSegment,
426
+ time: timeSegment,
427
+ session: sessionSegment,
428
+ hostname: hostnameSegment,
429
+ cache_read: cacheReadSegment,
430
+ cache_write: cacheWriteSegment,
431
+ extension_statuses: extensionStatusesSegment,
432
+ };
433
+
434
+ export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
435
+ const segment = SEGMENTS[id];
436
+ if (!segment) {
437
+ return { content: "", visible: false };
438
+ }
439
+ return segment.render(ctx);
440
+ }
package/separators.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { SeparatorDef, StatusLineSeparatorStyle } from "./types.js";
2
+ import { getSeparatorChars } from "./icons.js";
3
+
4
+ export function getSeparator(style: StatusLineSeparatorStyle): SeparatorDef {
5
+ const chars = getSeparatorChars();
6
+
7
+ switch (style) {
8
+ case "powerline":
9
+ return {
10
+ left: chars.powerlineLeft,
11
+ right: chars.powerlineRight,
12
+ endCaps: {
13
+ left: chars.powerlineRight,
14
+ right: chars.powerlineLeft,
15
+ useBgAsFg: true,
16
+ },
17
+ };
18
+
19
+ case "powerline-thin":
20
+ return {
21
+ left: chars.powerlineThinLeft,
22
+ right: chars.powerlineThinRight,
23
+ endCaps: {
24
+ left: chars.powerlineRight,
25
+ right: chars.powerlineLeft,
26
+ useBgAsFg: true,
27
+ },
28
+ };
29
+
30
+ case "slash":
31
+ return { left: ` ${chars.slash} `, right: ` ${chars.slash} ` };
32
+
33
+ case "pipe":
34
+ return { left: ` ${chars.pipe} `, right: ` ${chars.pipe} ` };
35
+
36
+ case "block":
37
+ return { left: chars.block, right: chars.block };
38
+
39
+ case "none":
40
+ return { left: chars.space, right: chars.space };
41
+
42
+ case "ascii":
43
+ return { left: chars.asciiLeft, right: chars.asciiRight };
44
+
45
+ case "dot":
46
+ return { left: chars.dot, right: chars.dot };
47
+
48
+ case "chevron":
49
+ return { left: "›", right: "‹" };
50
+
51
+ case "star":
52
+ return { left: "✦", right: "✦" };
53
+
54
+ default:
55
+ return getSeparator("powerline-thin");
56
+ }
57
+ }
package/types.ts ADDED
@@ -0,0 +1,129 @@
1
+ // Segment identifiers
2
+ export type StatusLineSegmentId =
3
+ | "pi"
4
+ | "model"
5
+ | "path"
6
+ | "git"
7
+ | "subagents"
8
+ | "token_in"
9
+ | "token_out"
10
+ | "token_total"
11
+ | "cost"
12
+ | "context_pct"
13
+ | "context_total"
14
+ | "time_spent"
15
+ | "time"
16
+ | "session"
17
+ | "hostname"
18
+ | "cache_read"
19
+ | "cache_write"
20
+ | "thinking"
21
+ | "extension_statuses";
22
+
23
+ // Separator styles
24
+ export type StatusLineSeparatorStyle =
25
+ | "powerline"
26
+ | "powerline-thin"
27
+ | "slash"
28
+ | "pipe"
29
+ | "block"
30
+ | "none"
31
+ | "ascii"
32
+ | "dot"
33
+ | "chevron"
34
+ | "star";
35
+
36
+ // Preset names
37
+ export type StatusLinePreset =
38
+ | "default"
39
+ | "minimal"
40
+ | "compact"
41
+ | "full"
42
+ | "nerd"
43
+ | "ascii"
44
+ | "custom";
45
+
46
+ // Per-segment options
47
+ export interface StatusLineSegmentOptions {
48
+ model?: { showThinkingLevel?: boolean };
49
+ path?: {
50
+ mode?: "basename" | "abbreviated" | "full";
51
+ maxLength?: number;
52
+ };
53
+ git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean };
54
+ time?: { format?: "12h" | "24h"; showSeconds?: boolean };
55
+ }
56
+
57
+ // Preset definition
58
+ export interface PresetDef {
59
+ leftSegments: StatusLineSegmentId[];
60
+ rightSegments: StatusLineSegmentId[];
61
+ separator: StatusLineSeparatorStyle;
62
+ segmentOptions?: StatusLineSegmentOptions;
63
+ }
64
+
65
+ // Separator definition
66
+ export interface SeparatorDef {
67
+ left: string;
68
+ right: string;
69
+ endCaps?: {
70
+ left: string;
71
+ right: string;
72
+ useBgAsFg: boolean;
73
+ };
74
+ }
75
+
76
+ // Git status data
77
+ export interface GitStatus {
78
+ branch: string | null;
79
+ staged: number;
80
+ unstaged: number;
81
+ untracked: number;
82
+ }
83
+
84
+ // Usage statistics
85
+ export interface UsageStats {
86
+ input: number;
87
+ output: number;
88
+ cacheRead: number;
89
+ cacheWrite: number;
90
+ cost: number;
91
+ }
92
+
93
+ // Context passed to segment render functions
94
+ export interface SegmentContext {
95
+ // From pi-mono
96
+ model: { id: string; name?: string; reasoning?: boolean; contextWindow?: number } | undefined;
97
+ thinkingLevel: string;
98
+ sessionId: string | undefined;
99
+
100
+ // Computed
101
+ usageStats: UsageStats;
102
+ contextPercent: number;
103
+ contextWindow: number;
104
+ autoCompactEnabled: boolean;
105
+ usingSubscription: boolean;
106
+ sessionStartTime: number;
107
+
108
+ // Git
109
+ git: GitStatus;
110
+
111
+ // Extension statuses
112
+ extensionStatuses: ReadonlyMap<string, string>;
113
+
114
+ // Options
115
+ options: StatusLineSegmentOptions;
116
+ width: number;
117
+ }
118
+
119
+ // Rendered segment output
120
+ export interface RenderedSegment {
121
+ content: string;
122
+ visible: boolean;
123
+ }
124
+
125
+ // Segment definition
126
+ export interface StatusLineSegment {
127
+ id: StatusLineSegmentId;
128
+ render(ctx: SegmentContext): RenderedSegment;
129
+ }