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/index.ts ADDED
@@ -0,0 +1,412 @@
1
+ import type { ExtensionAPI, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
2
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
3
+ import { visibleWidth } from "@mariozechner/pi-tui";
4
+
5
+ import type { SegmentContext, StatusLinePreset } from "./types.js";
6
+ import { getPreset, PRESETS } from "./presets.js";
7
+ import { getSeparator } from "./separators.js";
8
+ import { renderSegment } from "./segments.js";
9
+ import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
10
+ import { ansi, getFgAnsiCode } from "./colors.js";
11
+
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+ // Configuration
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ interface PowerlineConfig {
17
+ preset: StatusLinePreset;
18
+ }
19
+
20
+ let config: PowerlineConfig = {
21
+ preset: "default",
22
+ };
23
+
24
+ // ═══════════════════════════════════════════════════════════════════════════
25
+ // Status Line Builder (for top border)
26
+ // ═══════════════════════════════════════════════════════════════════════════
27
+
28
+ /** Build just the status content (segments with separators, no borders) */
29
+ function buildStatusContent(ctx: SegmentContext, presetDef: ReturnType<typeof getPreset>): string {
30
+ const separatorDef = getSeparator(presetDef.separator);
31
+ const sepAnsi = getFgAnsiCode("sep");
32
+
33
+ // Collect visible segment contents
34
+ const leftParts: string[] = [];
35
+ for (const segId of presetDef.leftSegments) {
36
+ const rendered = renderSegment(segId, ctx);
37
+ if (rendered.visible && rendered.content) {
38
+ leftParts.push(rendered.content);
39
+ }
40
+ }
41
+
42
+ const rightParts: string[] = [];
43
+ for (const segId of presetDef.rightSegments) {
44
+ const rendered = renderSegment(segId, ctx);
45
+ if (rendered.visible && rendered.content) {
46
+ rightParts.push(rendered.content);
47
+ }
48
+ }
49
+
50
+ if (leftParts.length === 0 && rightParts.length === 0) {
51
+ return "";
52
+ }
53
+
54
+ // Build content with powerline separators (no background)
55
+ const sep = separatorDef.left;
56
+ const allParts = [...leftParts, ...rightParts];
57
+ return " " + allParts.join(` ${sepAnsi}${sep}${ansi.reset} `) + ansi.reset + " ";
58
+ }
59
+
60
+ // ═══════════════════════════════════════════════════════════════════════════
61
+ // Extension
62
+ // ═══════════════════════════════════════════════════════════════════════════
63
+
64
+ export default function powerlineFooter(pi: ExtensionAPI) {
65
+ let enabled = true;
66
+ let sessionStartTime = Date.now();
67
+ let currentCtx: any = null;
68
+ let footerDataRef: ReadonlyFooterDataProvider | null = null;
69
+ let getThinkingLevelFn: (() => string) | null = null;
70
+ let isStreaming = false;
71
+ let tuiRef: any = null; // Store TUI reference for forcing re-renders
72
+
73
+ // Track session start
74
+ pi.on("session_start", async (_event, ctx) => {
75
+ sessionStartTime = Date.now();
76
+ currentCtx = ctx;
77
+
78
+ // Store thinking level getter if available
79
+ if (typeof ctx.getThinkingLevel === 'function') {
80
+ getThinkingLevelFn = () => ctx.getThinkingLevel();
81
+ }
82
+
83
+ if (enabled && ctx.hasUI) {
84
+ setupCustomEditor(ctx);
85
+ }
86
+ });
87
+
88
+ // Check if a bash command might change git branch
89
+ const mightChangeGitBranch = (cmd: string): boolean => {
90
+ const gitBranchPatterns = [
91
+ /\bgit\s+(checkout|switch|branch\s+-[dDmM]|merge|rebase|pull|reset|worktree)/,
92
+ /\bgit\s+stash\s+(pop|apply)/,
93
+ ];
94
+ return gitBranchPatterns.some(p => p.test(cmd));
95
+ };
96
+
97
+ // Invalidate git status on file changes, trigger re-render on potential branch changes
98
+ pi.on("tool_result", async (event, _ctx) => {
99
+ if (event.toolName === "write" || event.toolName === "edit") {
100
+ invalidateGitStatus();
101
+ }
102
+ // Check for bash commands that might change git branch
103
+ if (event.toolName === "bash" && event.input?.command) {
104
+ const cmd = String(event.input.command);
105
+ if (mightChangeGitBranch(cmd)) {
106
+ // Invalidate caches since working tree state changes with branch
107
+ invalidateGitStatus();
108
+ invalidateGitBranch();
109
+ // Small delay to let git update, then re-render
110
+ setTimeout(() => tuiRef?.requestRender(), 100);
111
+ }
112
+ }
113
+ });
114
+
115
+ // Also catch user escape commands (! prefix)
116
+ // Note: This fires BEFORE execution, so we use a longer delay and multiple re-renders
117
+ // to ensure we catch the update after the command completes.
118
+ pi.on("user_bash", async (event, _ctx) => {
119
+ if (mightChangeGitBranch(event.command)) {
120
+ // Invalidate immediately so next render fetches fresh data
121
+ invalidateGitStatus();
122
+ invalidateGitBranch();
123
+ // Multiple staggered re-renders to catch fast and slow commands
124
+ setTimeout(() => tuiRef?.requestRender(), 100);
125
+ setTimeout(() => tuiRef?.requestRender(), 300);
126
+ setTimeout(() => tuiRef?.requestRender(), 500);
127
+ }
128
+ });
129
+
130
+ // Track streaming state (footer only shows status during streaming)
131
+ pi.on("stream_start", async () => {
132
+ isStreaming = true;
133
+ });
134
+
135
+ pi.on("stream_end", async () => {
136
+ isStreaming = false;
137
+ });
138
+
139
+ // Command to toggle/configure
140
+ pi.registerCommand("powerline", {
141
+ description: "Configure powerline status (toggle, preset)",
142
+ handler: async (args, ctx) => {
143
+ // Update context reference (command ctx may have more methods)
144
+ currentCtx = ctx;
145
+
146
+ if (!args) {
147
+ // Toggle
148
+ enabled = !enabled;
149
+ if (enabled) {
150
+ setupCustomEditor(ctx);
151
+ ctx.ui.notify("Powerline status enabled", "info");
152
+ } else {
153
+ // setFooter(undefined) internally calls the old footer's dispose()
154
+ ctx.ui.setEditorComponent(undefined);
155
+ ctx.ui.setFooter(undefined);
156
+ footerDataRef = null;
157
+ tuiRef = null;
158
+ ctx.ui.notify("Default editor restored", "info");
159
+ }
160
+ return;
161
+ }
162
+
163
+ // Check if args is a preset name
164
+ const preset = args.trim().toLowerCase() as StatusLinePreset;
165
+ if (preset in PRESETS) {
166
+ config.preset = preset;
167
+ if (enabled) {
168
+ setupCustomEditor(ctx);
169
+ }
170
+ ctx.ui.notify(`Preset set to: ${preset}`, "info");
171
+ return;
172
+ }
173
+
174
+ // Show available presets
175
+ const presetList = Object.keys(PRESETS).join(", ");
176
+ ctx.ui.notify(`Available presets: ${presetList}`, "info");
177
+ },
178
+ });
179
+
180
+ function buildSegmentContext(ctx: any, width: number): SegmentContext {
181
+ const presetDef = getPreset(config.preset);
182
+
183
+ // Build usage stats and get thinking level from session
184
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
185
+ let lastAssistant: AssistantMessage | undefined;
186
+ let thinkingLevelFromSession = "off";
187
+
188
+ const sessionEvents = ctx.sessionManager?.getBranch?.() ?? [];
189
+ for (const e of sessionEvents) {
190
+ // Check for thinking level change entries
191
+ if (e.type === "thinking_level_change" && e.thinkingLevel) {
192
+ thinkingLevelFromSession = e.thinkingLevel;
193
+ }
194
+ if (e.type === "message" && e.message.role === "assistant") {
195
+ const m = e.message as AssistantMessage;
196
+ if (m.stopReason === "error" || m.stopReason === "aborted") {
197
+ continue;
198
+ }
199
+ input += m.usage.input;
200
+ output += m.usage.output;
201
+ cacheRead += m.usage.cacheRead;
202
+ cacheWrite += m.usage.cacheWrite;
203
+ cost += m.usage.cost.total;
204
+ lastAssistant = m;
205
+ }
206
+ }
207
+
208
+ // Calculate context percentage (total tokens used in last turn)
209
+ const contextTokens = lastAssistant
210
+ ? lastAssistant.usage.input + lastAssistant.usage.output +
211
+ lastAssistant.usage.cacheRead + lastAssistant.usage.cacheWrite
212
+ : 0;
213
+ const contextWindow = ctx.model?.contextWindow || 0;
214
+ const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
215
+
216
+ // Get git status (cached)
217
+ const gitBranch = footerDataRef?.getGitBranch() ?? null;
218
+ const gitStatus = getGitStatus(gitBranch);
219
+
220
+ // Check if using OAuth subscription
221
+ const usingSubscription = ctx.model
222
+ ? ctx.modelRegistry?.isUsingOAuth?.(ctx.model) ?? false
223
+ : false;
224
+
225
+ return {
226
+ model: ctx.model,
227
+ thinkingLevel: thinkingLevelFromSession || getThinkingLevelFn?.() || "off",
228
+ sessionId: ctx.sessionManager?.getSessionId?.(),
229
+ usageStats: { input, output, cacheRead, cacheWrite, cost },
230
+ contextPercent,
231
+ contextWindow,
232
+ autoCompactEnabled: ctx.settingsManager?.getCompactionSettings?.()?.enabled ?? true,
233
+ usingSubscription,
234
+ sessionStartTime,
235
+ git: gitStatus,
236
+ extensionStatuses: footerDataRef?.getExtensionStatuses() ?? new Map(),
237
+ options: presetDef.segmentOptions ?? {},
238
+ width,
239
+ };
240
+ }
241
+
242
+ function setupCustomEditor(ctx: any) {
243
+ // Import CustomEditor dynamically and create wrapper
244
+ import("@mariozechner/pi-coding-agent").then(({ CustomEditor }) => {
245
+ ctx.ui.setEditorComponent((tui: any, theme: any, keybindings: any) => {
246
+ // Create custom editor that overrides render for status in top border
247
+ const editor = new CustomEditor(theme, keybindings);
248
+
249
+ // Store original render
250
+ const originalRender = editor.render.bind(editor);
251
+
252
+ // Override render to match oh-my-pi design with rounded box:
253
+ // ╭─ status content ────────────────────╮
254
+ // │ input text here │
255
+ // ╰─ ─╯
256
+ // + autocomplete items (if showing)
257
+ editor.render = (width: number): string[] => {
258
+ const bc = (s: string) => `${getFgAnsiCode("border")}${s}${ansi.reset}`;
259
+
260
+ // Box drawing chars
261
+ const topLeft = bc("╭─");
262
+ const topRight = bc("─╮");
263
+ const bottomLeft = bc("╰─");
264
+ const bottomRight = bc("─╯");
265
+ const vertical = bc("│");
266
+
267
+ // Content area is width - 6 (3 chars border on each side)
268
+ const contentWidth = Math.max(1, width - 6);
269
+ const lines = originalRender(contentWidth);
270
+
271
+ if (lines.length === 0 || !currentCtx) return lines;
272
+
273
+ // Find where the bottom border is (last line that's all ─ chars)
274
+ // Lines after it are autocomplete items
275
+ let bottomBorderIndex = lines.length - 1;
276
+ for (let i = lines.length - 1; i >= 1; i--) {
277
+ const stripped = lines[i]?.replace(/\x1b\[[0-9;]*m/g, "") || "";
278
+ if (stripped.length > 0 && /^─+$/.test(stripped)) {
279
+ bottomBorderIndex = i;
280
+ break;
281
+ }
282
+ }
283
+
284
+ const result: string[] = [];
285
+
286
+ // Top border: ╭─ status ────────────╮
287
+ const presetDef = getPreset(config.preset);
288
+ const segmentCtx = buildSegmentContext(currentCtx, width);
289
+ const statusContent = buildStatusContent(segmentCtx, presetDef);
290
+ const statusWidth = visibleWidth(statusContent);
291
+ const topFillWidth = width - 4; // Reserve 4 for corners (╭─ and ─╮)
292
+
293
+ if (statusWidth <= topFillWidth) {
294
+ const fillWidth = topFillWidth - statusWidth;
295
+ result.push(topLeft + statusContent + bc("─".repeat(fillWidth)) + topRight);
296
+ } else {
297
+ // Status too wide - truncate by removing segments from the end
298
+ // Build progressively shorter content until it fits
299
+ const allSegments = [...presetDef.leftSegments, ...presetDef.rightSegments];
300
+ let truncatedContent = "";
301
+
302
+ for (let numSegments = allSegments.length - 1; numSegments >= 1; numSegments--) {
303
+ const limitedPreset = {
304
+ ...presetDef,
305
+ leftSegments: presetDef.leftSegments.slice(0, numSegments),
306
+ rightSegments: [],
307
+ };
308
+ truncatedContent = buildStatusContent(segmentCtx, limitedPreset);
309
+ const truncWidth = visibleWidth(truncatedContent);
310
+ if (truncWidth <= topFillWidth - 1) { // -1 for ellipsis
311
+ truncatedContent += "…";
312
+ break;
313
+ }
314
+ }
315
+
316
+ const truncWidth = visibleWidth(truncatedContent);
317
+ if (truncWidth <= topFillWidth) {
318
+ const fillWidth = topFillWidth - truncWidth;
319
+ result.push(topLeft + truncatedContent + bc("─".repeat(fillWidth)) + topRight);
320
+ } else {
321
+ // Still too wide, show minimal
322
+ result.push(topLeft + bc("─".repeat(Math.max(0, topFillWidth))) + topRight);
323
+ }
324
+ }
325
+
326
+ // Content lines (between top border at 0 and bottom border)
327
+ for (let i = 1; i < bottomBorderIndex; i++) {
328
+ const line = lines[i] || "";
329
+ const lineWidth = visibleWidth(line);
330
+ const padding = " ".repeat(Math.max(0, contentWidth - lineWidth));
331
+
332
+ const isLastContent = i === bottomBorderIndex - 1;
333
+ if (isLastContent) {
334
+ // Last content line: ╰─ content ─╯
335
+ result.push(`${bottomLeft} ${line}${padding} ${bottomRight}`);
336
+ } else {
337
+ // Middle lines: │ content │
338
+ result.push(`${vertical} ${line}${padding} ${vertical}`);
339
+ }
340
+ }
341
+
342
+ // If only had top/bottom borders (empty editor), add the bottom
343
+ if (bottomBorderIndex === 1) {
344
+ const padding = " ".repeat(contentWidth);
345
+ result.push(`${bottomLeft} ${padding} ${bottomRight}`);
346
+ }
347
+
348
+ // Append any autocomplete lines that come after the bottom border
349
+ for (let i = bottomBorderIndex + 1; i < lines.length; i++) {
350
+ result.push(lines[i] || "");
351
+ }
352
+
353
+ return result;
354
+ };
355
+
356
+ return editor;
357
+ });
358
+
359
+ // Also set up footer data provider access via a minimal footer
360
+ ctx.ui.setFooter((tui: any, _theme: any, footerData: ReadonlyFooterDataProvider) => {
361
+ footerDataRef = footerData;
362
+ tuiRef = tui; // Store TUI reference for re-renders on git branch changes
363
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
364
+
365
+ return {
366
+ dispose: unsub,
367
+ invalidate() {
368
+ // Re-render when thinking level or other settings change
369
+ tui.requestRender();
370
+ },
371
+ render(width: number): string[] {
372
+ // Only show status in footer during streaming (editor hidden)
373
+ // When editor is visible, status shows in editor top border instead
374
+ if (!isStreaming || !currentCtx) return [];
375
+
376
+ const presetDef = getPreset(config.preset);
377
+ const segmentCtx = buildSegmentContext(currentCtx, width);
378
+ const statusContent = buildStatusContent(segmentCtx, presetDef);
379
+
380
+ if (!statusContent) return [];
381
+
382
+ // Single line with status content, padded/truncated to width
383
+ const statusWidth = visibleWidth(statusContent);
384
+ if (statusWidth <= width) {
385
+ return [statusContent + " ".repeat(width - statusWidth)];
386
+ } else {
387
+ // Truncate by removing segments (same logic as editor)
388
+ const allSegments = [...presetDef.leftSegments, ...presetDef.rightSegments];
389
+ let truncatedContent = "";
390
+
391
+ for (let numSegments = allSegments.length - 1; numSegments >= 1; numSegments--) {
392
+ const limitedPreset = {
393
+ ...presetDef,
394
+ leftSegments: presetDef.leftSegments.slice(0, numSegments),
395
+ rightSegments: [],
396
+ };
397
+ truncatedContent = buildStatusContent(segmentCtx, limitedPreset);
398
+ const truncWidth = visibleWidth(truncatedContent);
399
+ if (truncWidth <= width - 1) {
400
+ truncatedContent += "…";
401
+ break;
402
+ }
403
+ }
404
+
405
+ return [truncatedContent];
406
+ }
407
+ },
408
+ };
409
+ });
410
+ });
411
+ }
412
+ }
package/install.mjs ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, copyFileSync, readdirSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { homedir } from "node:os";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const targetDir = join(homedir(), ".pi", "agent", "extensions", "powerline-footer");
10
+
11
+ // Files to copy (TypeScript sources + docs)
12
+ const files = readdirSync(__dirname).filter(
13
+ (f) => f.endsWith(".ts") || f === "README.md" || f === "CHANGELOG.md"
14
+ );
15
+
16
+ // Create target directory
17
+ mkdirSync(targetDir, { recursive: true });
18
+
19
+ // Copy files
20
+ let copied = 0;
21
+ for (const file of files) {
22
+ const src = join(__dirname, file);
23
+ const dest = join(targetDir, file);
24
+ copyFileSync(src, dest);
25
+ copied++;
26
+ }
27
+
28
+ console.log(`✓ Installed pi-powerline-footer to ${targetDir}`);
29
+ console.log(` Copied ${copied} files`);
30
+ console.log(`\nRestart pi to activate the extension.`);
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "pi-powerline-footer",
3
+ "version": "0.2.2",
4
+ "description": "Powerline-style status bar extension for pi coding agent",
5
+ "type": "module",
6
+ "bin": {
7
+ "pi-powerline-footer": "./install.mjs"
8
+ },
9
+ "files": [
10
+ "*.ts",
11
+ "*.md",
12
+ "install.mjs"
13
+ ],
14
+ "keywords": [
15
+ "pi",
16
+ "coding-agent",
17
+ "powerline",
18
+ "status-bar",
19
+ "extension"
20
+ ],
21
+ "author": "Nico Bailon",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/nicobailon/pi-powerline-footer"
26
+ }
27
+ }
package/presets.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { PresetDef, StatusLinePreset } from "./types.js";
2
+
3
+ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
4
+ default: {
5
+ leftSegments: ["pi", "model", "thinking", "path", "git", "context_pct", "token_total", "cost", "extension_statuses"],
6
+ rightSegments: [],
7
+ separator: "powerline-thin",
8
+ segmentOptions: {
9
+ model: { showThinkingLevel: false },
10
+ path: { mode: "basename" },
11
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
12
+ },
13
+ },
14
+
15
+ minimal: {
16
+ leftSegments: ["path", "git"],
17
+ rightSegments: ["context_pct"],
18
+ separator: "slash",
19
+ segmentOptions: {
20
+ path: { mode: "basename" },
21
+ git: { showBranch: true, showStaged: false, showUnstaged: false, showUntracked: false },
22
+ },
23
+ },
24
+
25
+ compact: {
26
+ leftSegments: ["model", "git"],
27
+ rightSegments: ["cost", "context_pct"],
28
+ separator: "powerline-thin",
29
+ segmentOptions: {
30
+ model: { showThinkingLevel: false },
31
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: false },
32
+ },
33
+ },
34
+
35
+ full: {
36
+ leftSegments: ["pi", "hostname", "model", "thinking", "path", "git", "subagents"],
37
+ rightSegments: ["token_in", "token_out", "cache_read", "cost", "context_pct", "time_spent", "time", "extension_statuses"],
38
+ separator: "powerline",
39
+ segmentOptions: {
40
+ model: { showThinkingLevel: false },
41
+ path: { mode: "abbreviated", maxLength: 50 },
42
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
43
+ time: { format: "24h", showSeconds: false },
44
+ },
45
+ },
46
+
47
+ nerd: {
48
+ leftSegments: ["pi", "hostname", "model", "thinking", "path", "git", "session", "subagents"],
49
+ rightSegments: ["token_in", "token_out", "cache_read", "cache_write", "cost", "context_pct", "context_total", "time_spent", "time", "extension_statuses"],
50
+ separator: "powerline",
51
+ segmentOptions: {
52
+ model: { showThinkingLevel: false },
53
+ path: { mode: "abbreviated", maxLength: 60 },
54
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
55
+ time: { format: "24h", showSeconds: true },
56
+ },
57
+ },
58
+
59
+ ascii: {
60
+ leftSegments: ["model", "path", "git"],
61
+ rightSegments: ["token_total", "cost", "context_pct"],
62
+ separator: "ascii",
63
+ segmentOptions: {
64
+ model: { showThinkingLevel: true },
65
+ path: { mode: "abbreviated", maxLength: 40 },
66
+ git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
67
+ },
68
+ },
69
+
70
+ custom: {
71
+ leftSegments: ["model", "path", "git"],
72
+ rightSegments: ["token_total", "cost", "context_pct"],
73
+ separator: "powerline-thin",
74
+ segmentOptions: {},
75
+ },
76
+ };
77
+
78
+ export function getPreset(name: StatusLinePreset): PresetDef {
79
+ return PRESETS[name] ?? PRESETS.default;
80
+ }