pi-powerline 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/README.md ADDED
@@ -0,0 +1,214 @@
1
+ # pi-powerline
2
+
3
+ Powerline-style UI extensions for [pi](https://github.com/badlogic/pi-mono) coding agent: custom editor, breadcrumb widget, footer, and header.
4
+
5
+ ![pi-powerline screenshot](https://github.com/user-attachments/assets/9ee65cd5-8501-4502-ba69-0209b19e0499)
6
+
7
+ ## Features
8
+
9
+ **Custom editor** — Always-on bordered input area with a `❯` prompt prefix. Switches to bash-mode coloring when the prompt starts with `!`. Breadcrumb info (model → directory) can be embedded in the top border.
10
+
11
+ **Breadcrumb widget** — Displays current model → working directory above the editor, shown only when breadcrumb mode is `top`.
12
+
13
+ **Custom footer** — A compact status bar showing token usage (`↑input ↓output` + cache read/write), context usage % with auto-compact indicator, session cost, thinking level, git branch, and extension statuses. Updates in real-time during streaming.
14
+
15
+ **Custom header** — A gradient-colored PI logo rendered with ANSI 256-color codes, replacing the built-in header.
16
+
17
+ > Highly inspired by [nicobailon/pi-powerline-footer](https://github.com/nicobailon/pi-powerline-footer).
18
+
19
+ ## Installation
20
+
21
+ ### Local development
22
+
23
+ Clone the repository and use pi's `--extension` flag:
24
+
25
+ ```bash
26
+ git clone <repo-url> pi-powerline
27
+ cd pi-powerline
28
+ pi -e ./index.ts
29
+ ```
30
+
31
+ Or add it to your project's `.pi/settings.json`:
32
+
33
+ ```json
34
+ {
35
+ "extensions": ["./index.ts"]
36
+ }
37
+ ```
38
+
39
+ ### From npm (after publishing)
40
+
41
+ ```bash
42
+ pi install npm:pi-powerline
43
+ ```
44
+
45
+ Restart pi to activate.
46
+
47
+ ## Usage
48
+
49
+ All extensions activate automatically on session start. Each can be configured via the `/powerline` command.
50
+
51
+ ### Settings
52
+
53
+ Configure in `.pi/settings.json`:
54
+
55
+ ```json
56
+ {
57
+ "breadcrumb": "inner",
58
+ "footer": true,
59
+ "header": true
60
+ }
61
+ ```
62
+
63
+ | Setting | Type | Default | Description |
64
+ |---------|------|---------|-------------|
65
+ | `breadcrumb` | `"hide"` \| `"top"` \| `"inner"` | `"inner"` | Breadcrumb display mode |
66
+ | `footer` | `boolean` | `true` | Enable custom footer |
67
+ | `header` | `boolean` | `true` | Enable gradient-logo header |
68
+
69
+ **Breadcrumb modes:**
70
+
71
+ - `hide` — No breadcrumb display
72
+ - `top` — Breadcrumb as a widget above the editor
73
+ - `inner` — Breadcrumb embedded in the editor's top border
74
+
75
+ ### Commands
76
+
77
+ | Command | Description |
78
+ |---------|-------------|
79
+ | `/powerline` | Show current powerline settings |
80
+ | `/powerline breadcrumb:hide` | Disable breadcrumb |
81
+ | `/powerline breadcrumb:top` | Breadcrumb as top widget |
82
+ | `/powerline breadcrumb:inner` | Breadcrumb in editor border |
83
+ | `/powerline footer:on` | Enable custom footer |
84
+ | `/powerline footer:off` | Disable custom footer |
85
+ | `/powerline header:on` | Enable custom header |
86
+ | `/powerline header:off` | Disable custom header |
87
+
88
+ ### Nerd Fonts
89
+
90
+ The breadcrumb and footer use Nerd Font icons when a compatible terminal is detected (iTerm, WezTerm, Kitty, Ghostty, Alacritty). Set `POWERLINE_NERD_FONTS=1` or `POWERLINE_NERD_FONTS=0` to explicitly enable/disable.
91
+
92
+ ## Development
93
+
94
+ ### Project structure
95
+
96
+ ```
97
+ .
98
+ ├── index.ts # Single entry point (default export)
99
+ ├── editor.ts # Custom editor with prompt prefix
100
+ ├── breadcrumb.ts # Shared breadcrumb data & rendering helpers
101
+ ├── widget.ts # Top widget (shown when breadcrumb=top)
102
+ ├── footer.ts # Custom footer (token stats, git, thinking level)
103
+ ├── header.ts # Gradient-logo header
104
+ ├── settings.ts # Shared .pi/settings.json read/write helpers
105
+ ├── tests/
106
+ │ ├── editor.test.ts
107
+ │ ├── footer.test.ts
108
+ │ ├── header.test.ts
109
+ │ └── widget.test.ts
110
+ ├── .pi/
111
+ │ ├── settings.json
112
+ │ ├── APPEND_SYSTEM.md
113
+ │ └── extensions/
114
+ │ └── auto-format.ts # Auto prettier on edit/write
115
+ ├── .husky/
116
+ │ └── pre-commit # prettier check + bun test
117
+ ├── .editorconfig
118
+ ├── .prettierrc
119
+ ├── .prettierignore
120
+ ├── package.json
121
+ └── tsconfig.json # gitignored
122
+ ```
123
+
124
+ ### Architecture
125
+
126
+ `index.ts` is the single entry point registered in `package.json` → `"pi": { "extensions": ["./index.ts"] }`. It registers four extensions:
127
+
128
+ ```ts
129
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
130
+ import { registerEditor } from './editor.ts';
131
+ import { registerFooter } from './footer.ts';
132
+ import { registerHeader } from './header.ts';
133
+ import { registerWidget } from './widget.ts';
134
+
135
+ export default function (pi: ExtensionAPI) {
136
+ registerEditor(pi);
137
+ registerFooter(pi);
138
+ registerHeader(pi);
139
+ registerWidget(pi);
140
+ }
141
+ ```
142
+
143
+ Settings are managed via `settings.ts` — a shared module that reads/writes `.pi/settings.json`. When `/powerline` changes a setting, it emits a `powerline_settings_changed` event that all modules listen to for live reconfiguration.
144
+
145
+ ### Code quality
146
+
147
+ - **Formatting**: `.pi/extensions/auto-format.ts` runs prettier automatically after edit/write tools touch `.ts` files. Prettier config: single quotes, semicolons, trailing commas, 2-space indent, 100 char width.
148
+ - **Pre-commit**: `.husky/pre-commit` runs `prettier --check` + `bun test` before every commit.
149
+ - Use `bun run format` to format all files, `bun run format:check` to verify.
150
+
151
+ ### Editor setup
152
+
153
+ Neovim's tsserver can't resolve `@mariozechner/pi-*` imports because those packages are bundled inside pi, not in `node_modules`. Create a `tsconfig.json` with path mappings pointing to the global pi installation:
154
+
155
+ ```bash
156
+ # Find the pi install path
157
+ ls -d $(dirname $(which pi))/../lib/node_modules/@mariozechner/pi-coding-agent
158
+ ```
159
+
160
+ Then copy the example below and adjust paths to match your system:
161
+
162
+ ```json
163
+ {
164
+ "compilerOptions": {
165
+ "target": "ESNext",
166
+ "module": "ESNext",
167
+ "moduleResolution": "bundler",
168
+ "strict": true,
169
+ "noEmit": true,
170
+ "allowImportingTsExtensions": true,
171
+ "baseUrl": ".",
172
+ "paths": {
173
+ "@mariozechner/pi-coding-agent": [
174
+ "/path/to/.nvm/versions/node/vXX/lib/node_modules/@mariozechner/pi-coding-agent/dist"
175
+ ],
176
+ "@mariozechner/pi-ai": [
177
+ "/path/to/.nvm/.../pi-coding-agent/node_modules/@mariozechner/pi-ai/dist"
178
+ ],
179
+ "@mariozechner/pi-tui": [
180
+ "/path/to/.nvm/.../pi-coding-agent/node_modules/@mariozechner/pi-tui/dist"
181
+ ]
182
+ }
183
+ },
184
+ "include": ["*.ts", "tests/**/*.ts"]
185
+ }
186
+ ```
187
+
188
+ `tsconfig.json` is gitignored — each developer creates their own.
189
+
190
+ ### Running tests
191
+
192
+ ```bash
193
+ bun test
194
+ # or via npm:
195
+ npm run test:bun
196
+ ```
197
+
198
+ Tests use bun's built-in test runner (compatible with `node:test`). Run `npm run test` for the Node.js variant.
199
+
200
+ ### Testing a single extension
201
+
202
+ ```bash
203
+ pi -e ./index.ts
204
+ ```
205
+
206
+ Then verify:
207
+ - **Header**: startup screen → should show gradient-colored PI logo
208
+ - **Editor**: type text → should see `❯` prefix with `─` borders; type `!command` → bash-mode coloring
209
+ - **Breadcrumb**: check top border or widget → should show model name and folder
210
+ - **Footer**: check bottom bar → should show model, token stats, git branch, thinking level
211
+
212
+ ## License
213
+
214
+ MIT
package/breadcrumb.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared breadcrumb display helpers
3
+ *
4
+ * Export nerd font detection, icons, and helper functions used by
5
+ * widget.ts and editor.ts to render the model→folder breadcrumb.
6
+ */
7
+ import { basename } from 'node:path';
8
+ import type { ExtensionContext, Theme } from '@mariozechner/pi-coding-agent';
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // nerd font detection
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ export function hasNerdFonts(): boolean {
15
+ if (process.env.POWERLINE_NERD_FONTS === '1') return true;
16
+ if (process.env.POWERLINE_NERD_FONTS === '0') return false;
17
+ if (process.env.GHOSTTY_RESOURCES_DIR) return true;
18
+ const term = (process.env.TERM_PROGRAM || '').toLowerCase();
19
+ return ['iterm', 'wezterm', 'kitty', 'ghostty', 'alacritty'].some((t) => term.includes(t));
20
+ }
21
+
22
+ const NERD = hasNerdFonts();
23
+
24
+ export const ICON_MODEL = NERD ? '\uF4BC' : '';
25
+ export const ICON_FOLDER = NERD ? '\uF115' : 'dir';
26
+ export const SEP = NERD ? '\uE0B1' : '|';
27
+
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+ // helpers
30
+ // ═══════════════════════════════════════════════════════════════════════════
31
+
32
+ export function withIcon(icon: string, text: string): string {
33
+ return icon ? `${icon} ${text}` : text;
34
+ }
35
+
36
+ /** hex → ANSI true color (model/folder use hex, not pi theme tokens) */
37
+ export function hexFg(hex: string, text: string): string {
38
+ const h = hex.replace('#', '');
39
+ const r = parseInt(h.slice(0, 2), 16);
40
+ const g = parseInt(h.slice(2, 4), 16);
41
+ const b = parseInt(h.slice(4, 6), 16);
42
+ return `\x1b[38;2;${r};${g};${b}m${text}`;
43
+ }
44
+
45
+ // ═══════════════════════════════════════════════════════════════════════════
46
+ // breadcrumb data
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+
49
+ export interface BreadcrumbData {
50
+ modelName: string;
51
+ folder: string;
52
+ modelText: string; // icon + modelName
53
+ folderText: string; // icon + folder
54
+ }
55
+
56
+ export function getBreadcrumbData(ctx: ExtensionContext | null): BreadcrumbData {
57
+ const cwd = ctx?.cwd ?? process.cwd();
58
+ const folder = basename(cwd) || cwd;
59
+ const modelName = ctx?.model?.name || ctx?.model?.id || 'no-model';
60
+
61
+ return {
62
+ modelName,
63
+ folder,
64
+ modelText: withIcon(ICON_MODEL, modelName),
65
+ folderText: withIcon(ICON_FOLDER, folder),
66
+ };
67
+ }
68
+
69
+ // ═══════════════════════════════════════════════════════════════════════════
70
+ // breadcrumb info renderer (model → folder, colored)
71
+ // ═══════════════════════════════════════════════════════════════════════════
72
+
73
+ /** Render the "model→folder" breadcrumb info string. Optionally append ANSI reset. */
74
+ export function renderBreadcrumbInfo(data: BreadcrumbData, theme: Theme, reset = false): string {
75
+ const line =
76
+ hexFg('#d787af', data.modelText) +
77
+ theme.fg('dim', ` ${SEP} `) +
78
+ hexFg('#00afaf', data.folderText);
79
+ return reset ? line + '\x1b[0m' : line;
80
+ }
package/editor.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Custom Editor Extension
3
+ *
4
+ * Replaces the default editor with a bordered input area using a ❯ prompt prefix.
5
+ * Switches to bash-mode coloring when the prompt starts with !.
6
+ * Editor is always enabled; breadcrumb mode controls whether widget info is embedded.
7
+ */
8
+ import { type EditorTheme, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
9
+ import {
10
+ CustomEditor,
11
+ type ExtensionAPI,
12
+ type ExtensionContext,
13
+ type Theme,
14
+ type ThemeColor,
15
+ } from '@mariozechner/pi-coding-agent';
16
+ import { getBreadcrumbData, renderBreadcrumbInfo } from './breadcrumb.ts';
17
+ import { readPowerlineSettings } from './settings.ts';
18
+
19
+ /** Pure transform: add > prompt prefix and borders to rendered editor lines. */
20
+ function renderPromptPrefix(
21
+ lines: string[],
22
+ width: number,
23
+ borderChar: string,
24
+ prefixChar: string,
25
+ indentChar: string,
26
+ ): string[] {
27
+ if (lines.length < 3) return lines;
28
+
29
+ let bottomIdx = lines.length - 1;
30
+ for (let i = lines.length - 1; i >= 1; i--) {
31
+ const stripped = (lines[i] ?? '').replace(/\x1b\[[0-9;]*m/g, '');
32
+ if (stripped.length > 0 && /^─{3,}/.test(stripped)) {
33
+ bottomIdx = i;
34
+ break;
35
+ }
36
+ }
37
+
38
+ const result: string[] = [];
39
+
40
+ result.push(borderChar.repeat(width));
41
+
42
+ for (let i = 1; i < bottomIdx; i++) {
43
+ if (i === 1) {
44
+ result.push(prefixChar + ' ' + (lines[i] ?? ''));
45
+ } else {
46
+ result.push(indentChar + ' ' + (lines[i] ?? ''));
47
+ }
48
+ }
49
+
50
+ if (bottomIdx === 1) {
51
+ result.push(prefixChar + ' ' + ' '.repeat(width - 2));
52
+ }
53
+
54
+ result.push(borderChar.repeat(width));
55
+
56
+ for (let i = bottomIdx + 1; i < lines.length; i++) {
57
+ result.push(lines[i] ?? '');
58
+ }
59
+
60
+ return result;
61
+ }
62
+
63
+ // live state, updated on enable / model_select
64
+ let liveCtx: ExtensionContext | null = null;
65
+ let liveEditorTui: any = null;
66
+ let breadcrumbMode: string = 'inner';
67
+
68
+ let currentTheme: Theme | null = null;
69
+
70
+ /** Maps each editor element to a pi theme color token. @example PromptPrefixEditor.colorTokens.prefix = "success"; */
71
+ export interface PromptPrefixColorTokens {
72
+ border?: ThemeColor;
73
+ prefix?: ThemeColor;
74
+ indent?: ThemeColor;
75
+ }
76
+
77
+ /** Custom editor with a ❯ prompt prefix. Colors use `PromptPrefixColorTokens`. */
78
+ export class PromptPrefixEditor extends CustomEditor {
79
+ static colorTokens: PromptPrefixColorTokens = {
80
+ border: 'borderAccent',
81
+ prefix: 'dim',
82
+ indent: 'border',
83
+ };
84
+
85
+ render(width: number): string[] {
86
+ const contentWidth = Math.max(1, width - 2);
87
+ const lines = super.render(contentWidth);
88
+ if (lines.length < 3) return lines;
89
+
90
+ const theme = currentTheme;
91
+ const color = (token: ThemeColor | undefined, text: string) =>
92
+ !theme || !token ? text : theme.fg(token, text);
93
+
94
+ // Bash mode: when text starts with !, switch to bashMode coloring
95
+ const isBash = this.getText().trimStart().startsWith('!');
96
+ const tokens = isBash
97
+ ? {
98
+ border: 'bashMode' as ThemeColor,
99
+ prefix: 'bashMode' as ThemeColor,
100
+ indent: 'bashMode' as ThemeColor,
101
+ }
102
+ : PromptPrefixEditor.colorTokens;
103
+
104
+ const result = renderPromptPrefix(
105
+ lines,
106
+ width,
107
+ color(tokens.border, '─'),
108
+ color(tokens.prefix, '❯'),
109
+ tokens.indent ? color(tokens.indent, ' ') : ' ',
110
+ );
111
+
112
+ // Embed widget info (model + folder) into the top border when mode is "inner"
113
+ if (breadcrumbMode === 'inner') {
114
+ const ctx = liveCtx;
115
+ if (ctx && theme) {
116
+ const data = getBreadcrumbData(ctx);
117
+ const infoPart = renderBreadcrumbInfo(data, theme, false);
118
+
119
+ const infoWidth = visibleWidth(infoPart);
120
+ // '─ ' (2) + info + ' ' (1) + dashes → total width
121
+ let paddingLen = width - 3 - infoWidth;
122
+ let displayInfo = infoPart;
123
+
124
+ if (paddingLen < 2) {
125
+ // info too wide or not enough dashes, truncate with ellipsis, keep at least 2 dashes
126
+ const minDashes = 2;
127
+ const availForInfo = width - 3 - minDashes;
128
+ if (availForInfo > 0) {
129
+ displayInfo = truncateToWidth(infoPart, availForInfo, '...');
130
+ paddingLen = width - 3 - visibleWidth(displayInfo);
131
+ }
132
+ }
133
+
134
+ if (paddingLen >= 0) {
135
+ const borderColored = color(tokens.border, '─');
136
+ result[0] =
137
+ borderColored + ' ' + displayInfo + ' ' + color(tokens.border, '─'.repeat(paddingLen));
138
+ }
139
+ }
140
+ }
141
+
142
+ return result;
143
+ }
144
+ }
145
+
146
+ export function updateTheme(theme: Theme): void {
147
+ currentTheme = theme;
148
+ }
149
+
150
+ /** Register the custom editor extension. Editor is always enabled; breadcrumb mode controls info embedding. */
151
+ export function registerEditor(pi: ExtensionAPI) {
152
+ function createEditorFactory() {
153
+ return (tui: any, theme: EditorTheme, keybindings: any) => {
154
+ liveEditorTui = tui;
155
+ return new PromptPrefixEditor(tui, theme, keybindings);
156
+ };
157
+ }
158
+
159
+ function enable(ctx: ExtensionContext) {
160
+ liveCtx = ctx;
161
+ currentTheme = ctx.ui.theme;
162
+ breadcrumbMode = readPowerlineSettings(ctx.cwd).breadcrumb;
163
+ ctx.ui.setEditorComponent(createEditorFactory());
164
+ }
165
+
166
+ // always enable on session start
167
+ pi.on('session_start', (_event, ctx) => {
168
+ enable(ctx);
169
+ });
170
+
171
+ // keep widget info in sync when model/cwd changes
172
+ pi.on('model_select', (_event, ctx) => {
173
+ liveCtx = ctx;
174
+ breadcrumbMode = readPowerlineSettings(ctx.cwd).breadcrumb;
175
+ liveEditorTui?.requestRender();
176
+ });
177
+
178
+ // re-render on /powerline command (settings changed)
179
+ pi.events.on('powerline_settings_changed', (ctx) => {
180
+ const c = ctx as ExtensionContext;
181
+ breadcrumbMode = readPowerlineSettings(c.cwd).breadcrumb;
182
+ liveCtx = c;
183
+ liveEditorTui?.requestRender();
184
+ });
185
+ }
package/footer.ts ADDED
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Custom Footer Extension
3
+ *
4
+ * Mirrors the built-in footer layout: pwd line, stats line, extension statuses line.
5
+ *
6
+ * Token stats and context usage come from ctx.sessionManager/ctx.model/ctx.getContextUsage().
7
+ * Git branch, provider count, extension statuses come from footerData.
8
+ * Thinking level comes from pi.getThinkingLevel() + pi.on(thinking_level_select).
9
+ *
10
+ * Controlled by .pi/settings.json → footer (boolean, default true).
11
+ * Toggle at runtime via /powerline footer:on / footer:off.
12
+ */
13
+
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import type { AssistantMessage } from '@mariozechner/pi-ai';
17
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
18
+ import { truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
19
+ import { readPowerlineSettings } from './settings.ts';
20
+
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+ // auto-compact detection (nested under compaction.enabled, not powerline)
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+ function readAutoCompactEnabled(cwd: string): boolean {
25
+ const settingsPath = join(cwd, '.pi', 'settings.json');
26
+ if (existsSync(settingsPath)) {
27
+ try {
28
+ const content = readFileSync(settingsPath, 'utf-8');
29
+ const settings = JSON.parse(content || '{}');
30
+ if (
31
+ settings.compaction &&
32
+ typeof settings.compaction === 'object' &&
33
+ 'enabled' in (settings.compaction as Record<string, unknown>)
34
+ ) {
35
+ return !!(settings.compaction as Record<string, unknown>).enabled;
36
+ }
37
+ } catch {
38
+ // ignore parse errors
39
+ }
40
+ }
41
+ return true;
42
+ }
43
+
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+ // token formatting (mirrors built-in footer)
46
+ // ═══════════════════════════════════════════════════════════════════════════
47
+
48
+ function formatTokens(count: number): string {
49
+ if (count < 1000) return count.toString();
50
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
51
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
52
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
53
+ return `${Math.round(count / 1000000)}M`;
54
+ }
55
+
56
+ // ═══════════════════════════════════════════════════════════════════════════
57
+ // think level display (mirrors widget.ts style)
58
+ // ═══════════════════════════════════════════════════════════════════════════
59
+
60
+ function hasNerdFonts(): boolean {
61
+ if (process.env.POWERLINE_NERD_FONTS === '1') return true;
62
+ if (process.env.POWERLINE_NERD_FONTS === '0') return false;
63
+ if (process.env.GHOSTTY_RESOURCES_DIR) return true;
64
+ const term = (process.env.TERM_PROGRAM || '').toLowerCase();
65
+ return ['iterm', 'wezterm', 'kitty', 'ghostty', 'alacritty'].some((t) => term.includes(t));
66
+ }
67
+
68
+ const ICON_THINK = hasNerdFonts() ? '' : '';
69
+ const ICON_GIT = hasNerdFonts() ? '' : '⎇';
70
+
71
+ function withIcon(icon: string, text: string): string {
72
+ return icon ? `${icon} ${text}` : text;
73
+ }
74
+
75
+ const THINK_LABELS: Record<string, string> = {
76
+ minimal: 'min',
77
+ low: 'low',
78
+ medium: 'med',
79
+ high: 'high',
80
+ xhigh: 'xhi',
81
+ };
82
+
83
+ const THINK_COLORS: Record<string, string> = {
84
+ high: 'thinkingHigh',
85
+ xhigh: 'thinkingXhigh',
86
+ minimal: 'thinkingMinimal',
87
+ low: 'thinkingLow',
88
+ medium: 'thinkingMedium',
89
+ };
90
+
91
+ // ═══════════════════════════════════════════════════════════════════════════
92
+ // usage helpers (for fusing live streaming data with persisted entries)
93
+ // ═══════════════════════════════════════════════════════════════════════════
94
+
95
+ type SessionAssistantUsage = AssistantMessage['usage'];
96
+
97
+ function getUsageTokenTotal(usage: SessionAssistantUsage): number {
98
+ return (
99
+ ('totalTokens' in usage && typeof usage.totalTokens === 'number' ? usage.totalTokens : 0) ||
100
+ usage.input + usage.output + usage.cacheRead + usage.cacheWrite
101
+ );
102
+ }
103
+
104
+ function isSessionAssistantMessage(value: unknown): value is AssistantMessage {
105
+ return (
106
+ typeof value === 'object' &&
107
+ value !== null &&
108
+ 'role' in value &&
109
+ (value as any).role === 'assistant' &&
110
+ 'usage' in value &&
111
+ typeof (value as any).usage?.input === 'number' &&
112
+ typeof (value as any).usage?.output === 'number'
113
+ );
114
+ }
115
+
116
+ // ═══════════════════════════════════════════════════════════════════════════
117
+ // live state (updated by events)
118
+ // ═══════════════════════════════════════════════════════════════════════════
119
+
120
+ let liveThinkLevel = 'off';
121
+ let liveTui: any = null;
122
+ let isStreaming = false;
123
+ let liveAssistantUsage: SessionAssistantUsage | null = null;
124
+ let autoCompactEnabled = true;
125
+
126
+ // ═══════════════════════════════════════════════════════════════════════════
127
+ // footer renderer
128
+ // ═══════════════════════════════════════════════════════════════════════════
129
+
130
+ // hex → ANSI true color (for git branch, not using pi theme tokens)
131
+ function hexFg(hex: string, text: string): string {
132
+ const h = hex.replace('#', '');
133
+ const r = parseInt(h.slice(0, 2), 16);
134
+ const g = parseInt(h.slice(2, 4), 16);
135
+ const b = parseInt(h.slice(4, 6), 16);
136
+ return `\x1b[38;2;${r};${g};${b}m${text}`;
137
+ }
138
+
139
+ /** Sanitize text for single-line status display. */
140
+ function sanitizeStatusText(text: string): string {
141
+ return text
142
+ .replace(/[\r\n\t]/g, ' ')
143
+ .replace(/ +/g, ' ')
144
+ .trim();
145
+ }
146
+
147
+ function createFooterRenderer(ctx: ExtensionContext) {
148
+ return (tui: any, theme: any, footerData: any) => {
149
+ liveTui = tui;
150
+ const unsubBranch = footerData.onBranchChange(() => tui.requestRender());
151
+
152
+ return {
153
+ dispose() {
154
+ liveTui = null;
155
+ unsubBranch();
156
+ },
157
+ invalidate() {},
158
+ render(width: number): string[] {
159
+ // ── cumulative token stats from persisted entries + live streaming ──
160
+ let totalInput = 0,
161
+ totalOutput = 0,
162
+ totalCacheRead = 0,
163
+ totalCacheWrite = 0,
164
+ totalCost = 0;
165
+ let lastPersistedAssistant: AssistantMessage | undefined;
166
+ for (const e of ctx.sessionManager.getEntries()) {
167
+ if (e.type === 'message' && e.message.role === 'assistant') {
168
+ const m = e.message as AssistantMessage;
169
+ if (m.stopReason === 'error' || m.stopReason === 'aborted') continue;
170
+ totalInput += m.usage.input;
171
+ totalOutput += m.usage.output;
172
+ totalCacheRead += m.usage.cacheRead;
173
+ totalCacheWrite += m.usage.cacheWrite;
174
+ totalCost += m.usage.cost.total;
175
+ if (getUsageTokenTotal(m.usage) > 0) {
176
+ lastPersistedAssistant = m;
177
+ }
178
+ }
179
+ }
180
+
181
+ // fuse live streaming usage (not yet persisted) on top of persisted totals
182
+ const latestUsage = isStreaming
183
+ ? (liveAssistantUsage ?? lastPersistedAssistant?.usage)
184
+ : lastPersistedAssistant?.usage;
185
+ if (isStreaming && liveAssistantUsage) {
186
+ totalInput += liveAssistantUsage.input;
187
+ totalOutput += liveAssistantUsage.output;
188
+ totalCacheRead += liveAssistantUsage.cacheRead;
189
+ totalCacheWrite += liveAssistantUsage.cacheWrite;
190
+ totalCost += liveAssistantUsage.cost.total;
191
+ }
192
+
193
+ // ── context usage ──
194
+ // During streaming, ctx.getContextUsage() may be stale; estimate from usage.
195
+ const coreContextUsage = isStreaming && liveAssistantUsage ? null : ctx.getContextUsage();
196
+ const contextTokens =
197
+ coreContextUsage?.tokens ?? (latestUsage ? getUsageTokenTotal(latestUsage) : null);
198
+ const contextWindow = coreContextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
199
+ const contextPercent =
200
+ contextTokens !== null ? ((contextTokens / contextWindow) * 100).toFixed(1) : '?';
201
+
202
+ // ── git branch (leftmost, before stats) ──
203
+ const branch = footerData.getGitBranch();
204
+ const gitSegment = branch ? hexFg('#5faf5f', withIcon(ICON_GIT, branch)) : '';
205
+ const gitFull = gitSegment ? gitSegment + ' ' : '';
206
+ const gitFullWidth = gitSegment ? visibleWidth(gitSegment) + 1 : 0;
207
+
208
+ // ── stats + model ──
209
+ const statsParts: string[] = [];
210
+
211
+ // context % with threshold coloring (always first)
212
+ const contextPercentNum =
213
+ contextTokens !== null && contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
214
+ const contextPercentDisplay =
215
+ contextPercent === '?'
216
+ ? `?/${formatTokens(contextWindow)}`
217
+ : `${contextPercent}%/${formatTokens(contextWindow)}${autoCompactEnabled ? ' (auto)' : ''}`;
218
+ let contextPercentStr: string;
219
+ if (contextPercentNum > 90) {
220
+ contextPercentStr = theme.fg('error', contextPercentDisplay);
221
+ } else if (contextPercentNum > 70) {
222
+ contextPercentStr = theme.fg('warning', contextPercentDisplay);
223
+ } else {
224
+ contextPercentStr = contextPercentDisplay;
225
+ }
226
+ statsParts.push(contextPercentStr);
227
+
228
+ if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
229
+ if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
230
+ if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
231
+ if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
232
+
233
+ const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
234
+ if (totalCost || usingSubscription) {
235
+ const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? ' (sub)' : ''}`;
236
+ statsParts.push(costStr);
237
+ }
238
+
239
+ let statsLeft = statsParts.join(' ');
240
+ let statsLeftWidth = visibleWidth(statsLeft);
241
+ if (statsLeftWidth > width) {
242
+ statsLeft = truncateToWidth(statsLeft, width, '...');
243
+ statsLeftWidth = visibleWidth(statsLeft);
244
+ }
245
+
246
+ // ── stats line layout: git (green) + left (dim) + padding (dim) + right (colored think level) ──
247
+ const dimLeft = theme.fg('dim', statsLeft);
248
+
249
+ // right side: think level only, colored (omitted when model lacks reasoning)
250
+ let rightSidePlain = '';
251
+ if (ctx.model?.reasoning) {
252
+ const tl = liveThinkLevel || 'off';
253
+ const label = THINK_LABELS[tl] ?? tl;
254
+ rightSidePlain = withIcon(ICON_THINK, label);
255
+ }
256
+ const rightWidth = visibleWidth(rightSidePlain);
257
+
258
+ const minPad = 2;
259
+ let statsLine: string;
260
+
261
+ const totalBase = gitFullWidth + statsLeftWidth + minPad + rightWidth;
262
+ if (totalBase <= width) {
263
+ // everything fits
264
+ const pad = width - gitFullWidth - statsLeftWidth - rightWidth;
265
+ const dimPadding = pad > 0 ? theme.fg('dim', ' '.repeat(pad)) : '';
266
+ let coloredRight = '';
267
+ if (rightSidePlain) {
268
+ const tl = liveThinkLevel || 'off';
269
+ coloredRight = theme.fg(THINK_COLORS[tl] ?? 'thinkingOff', rightSidePlain);
270
+ }
271
+ statsLine = gitFull + dimLeft + dimPadding + coloredRight;
272
+ } else if (gitFullWidth + minPad + rightWidth <= width) {
273
+ // drop git → fit statsLeft
274
+ const availStats = width - gitFullWidth - minPad - rightWidth;
275
+ let statsTrimmed: string;
276
+ let statsTrimmedWidth: number;
277
+ if (availStats > 0) {
278
+ statsTrimmed = truncateToWidth(statsLeft, availStats, '');
279
+ statsTrimmedWidth = visibleWidth(statsTrimmed);
280
+ } else {
281
+ statsTrimmed = '';
282
+ statsTrimmedWidth = 0;
283
+ }
284
+ const pad = width - gitFullWidth - statsTrimmedWidth - rightWidth;
285
+ const dimPadding = pad > 0 ? theme.fg('dim', ' '.repeat(pad)) : '';
286
+ let coloredRight = '';
287
+ if (rightSidePlain) {
288
+ const tl = liveThinkLevel || 'off';
289
+ coloredRight = theme.fg(THINK_COLORS[tl] ?? 'thinkingOff', rightSidePlain);
290
+ }
291
+ statsLine = gitFull + theme.fg('dim', statsTrimmed) + dimPadding + coloredRight;
292
+ } else {
293
+ // drop git, drop right → only stats
294
+ const availStats = width - minPad;
295
+ let statsTrimmed: string;
296
+ if (availStats > 0) {
297
+ statsTrimmed = truncateToWidth(statsLeft, availStats, '');
298
+ } else {
299
+ statsTrimmed = '';
300
+ }
301
+ statsLine = theme.fg('dim', statsTrimmed);
302
+ }
303
+
304
+ const lines = [statsLine];
305
+
306
+ // ── line 3: extension statuses ──
307
+ const extensionStatuses = footerData.getExtensionStatuses() as Map<string, string>;
308
+ if (extensionStatuses.size > 0) {
309
+ const sorted = Array.from(extensionStatuses.entries())
310
+ .sort(([a], [b]) => a.localeCompare(b))
311
+ .map(([, text]) => sanitizeStatusText(text));
312
+ const statusLine = sorted.join(' ');
313
+ lines.push(truncateToWidth(statusLine, width, theme.fg('dim', '...')));
314
+ }
315
+
316
+ return lines;
317
+ },
318
+ };
319
+ };
320
+ }
321
+
322
+ // ═══════════════════════════════════════════════════════════════════════════
323
+ // module registration
324
+ // ═══════════════════════════════════════════════════════════════════════════
325
+
326
+ export function registerFooter(pi: ExtensionAPI) {
327
+ let enabled = false;
328
+
329
+ function enable(ctx: ExtensionContext) {
330
+ enabled = true;
331
+ liveThinkLevel = pi.getThinkingLevel();
332
+ ctx.ui.setFooter(createFooterRenderer(ctx));
333
+ }
334
+
335
+ function disable(ctx: ExtensionContext) {
336
+ enabled = false;
337
+ liveTui = null;
338
+ ctx.ui.setFooter(undefined);
339
+ }
340
+
341
+ // enable on session start if footer setting is true
342
+ pi.on('session_start', (_event, ctx) => {
343
+ autoCompactEnabled = readAutoCompactEnabled(ctx.cwd);
344
+ if (readPowerlineSettings(ctx.cwd).footer) {
345
+ enable(ctx);
346
+ }
347
+ });
348
+
349
+ // track thinking level changes for footer display
350
+ pi.on('thinking_level_select', (event) => {
351
+ if (!enabled) return;
352
+ liveThinkLevel = event.level;
353
+ liveTui?.requestRender();
354
+ });
355
+
356
+ // model switch may affect reasoning support / provider count
357
+ pi.on('model_select', (_event, ctx) => {
358
+ const show = readPowerlineSettings(ctx.cwd).footer;
359
+ if (show && !enabled) {
360
+ enable(ctx);
361
+ } else if (!show && enabled) {
362
+ disable(ctx);
363
+ } else if (enabled) {
364
+ liveThinkLevel = pi.getThinkingLevel();
365
+ liveTui?.requestRender();
366
+ }
367
+ });
368
+
369
+ // re-evaluate on /powerline command (settings changed)
370
+ pi.events.on('powerline_settings_changed', (ctx) => {
371
+ const c = ctx as ExtensionContext;
372
+ const show = readPowerlineSettings(c.cwd).footer;
373
+ if (show && !enabled) {
374
+ enable(c);
375
+ } else if (!show && enabled) {
376
+ disable(c);
377
+ }
378
+ });
379
+
380
+ // ── real-time token updates during streaming ──
381
+
382
+ pi.on('agent_start', () => {
383
+ isStreaming = true;
384
+ liveAssistantUsage = null;
385
+ });
386
+
387
+ pi.on('message_update', (event) => {
388
+ if (!enabled) return;
389
+ if (isSessionAssistantMessage(event.message)) {
390
+ liveAssistantUsage = event.message.usage;
391
+ liveTui?.requestRender();
392
+ }
393
+ });
394
+
395
+ pi.on('message_end', (event) => {
396
+ isStreaming = false;
397
+ if (!enabled) return;
398
+ if (isSessionAssistantMessage(event.message)) {
399
+ liveAssistantUsage =
400
+ event.message.stopReason === 'error' || event.message.stopReason === 'aborted'
401
+ ? null
402
+ : event.message.usage;
403
+ }
404
+ liveTui?.requestRender();
405
+ });
406
+ }
package/header.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Custom Header Extension
3
+ *
4
+ * Shows a gradient-colored PI logo.
5
+ * Controlled by .pi/settings.json → header (boolean, default true).
6
+ */
7
+ import type { ExtensionAPI, ExtensionContext, Theme } from '@mariozechner/pi-coding-agent';
8
+ import { VERSION } from '@mariozechner/pi-coding-agent';
9
+ import { readPowerlineSettings } from './settings.ts';
10
+
11
+ /** Left-to-right ANSI gradient coloring. Spaces are left uncolored. */
12
+ const GRADIENT_COLORS = [
13
+ '\x1b[38;5;199m',
14
+ '\x1b[38;5;171m',
15
+ '\x1b[38;5;135m',
16
+ '\x1b[38;5;99m',
17
+ '\x1b[38;5;75m',
18
+ '\x1b[38;5;51m',
19
+ ];
20
+
21
+ function gradientLine(line: string): string {
22
+ const reset = '\x1b[0m';
23
+ let result = '';
24
+ let colorIdx = 0;
25
+ const step = Math.max(1, Math.floor(line.length / GRADIENT_COLORS.length));
26
+
27
+ for (let i = 0; i < line.length; i++) {
28
+ if (i > 0 && i % step === 0 && colorIdx < GRADIENT_COLORS.length - 1) {
29
+ colorIdx++;
30
+ }
31
+
32
+ const char = line[i];
33
+ if (char !== ' ') {
34
+ result += GRADIENT_COLORS[colorIdx] + char + reset;
35
+ } else {
36
+ result += char;
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
42
+ const PI_LOGO = [
43
+ '██████████ ',
44
+ '████ ████ ',
45
+ '████ ████ ',
46
+ '████████ ████',
47
+ '████ ████',
48
+ '████ ████',
49
+ ];
50
+
51
+ function renderLogo(theme: Theme): string[] {
52
+ const lines = PI_LOGO.map((line) => ' ' + gradientLine(line) + '\x1b[0m');
53
+ const subtitle = `${theme.fg('muted', ' pi agent')}${theme.fg('dim', ` v${VERSION}`)}`;
54
+ return ['', ...lines, subtitle];
55
+ }
56
+
57
+ /** Register the custom header extension. */
58
+ export function registerHeader(pi: ExtensionAPI) {
59
+ let headerEnabled = false;
60
+
61
+ function createHeaderComponent() {
62
+ return (_tui: any, theme: Theme) => ({
63
+ render(_width: number): string[] {
64
+ return renderLogo(theme);
65
+ },
66
+ invalidate() {},
67
+ });
68
+ }
69
+
70
+ function enable(ctx: ExtensionContext) {
71
+ headerEnabled = true;
72
+ ctx.ui.setHeader(createHeaderComponent());
73
+ }
74
+
75
+ function disable(ctx: ExtensionContext) {
76
+ headerEnabled = false;
77
+ ctx.ui.setHeader(undefined);
78
+ }
79
+
80
+ // auto-enable on session start if header setting is true
81
+ pi.on('session_start', (_event, ctx) => {
82
+ if (!ctx.hasUI) return;
83
+ if (readPowerlineSettings(ctx.cwd).header) {
84
+ enable(ctx);
85
+ }
86
+ });
87
+
88
+ // re-evaluate on model switch
89
+ pi.on('model_select', (_event, ctx) => {
90
+ const show = readPowerlineSettings(ctx.cwd).header;
91
+ if (show && !headerEnabled) {
92
+ enable(ctx);
93
+ } else if (!show && headerEnabled) {
94
+ disable(ctx);
95
+ }
96
+ });
97
+
98
+ // re-evaluate on /powerline command (settings changed)
99
+ pi.events.on('powerline_settings_changed', (ctx) => {
100
+ const c = ctx as ExtensionContext;
101
+ const show = readPowerlineSettings(c.cwd).header;
102
+ if (show && !headerEnabled) {
103
+ enable(c);
104
+ } else if (!show && headerEnabled) {
105
+ disable(c);
106
+ }
107
+ });
108
+ }
package/index.ts ADDED
@@ -0,0 +1,150 @@
1
+ import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
2
+ import type { AutocompleteItem } from '@mariozechner/pi-tui';
3
+ import { registerEditor } from './editor.ts';
4
+ import { registerFooter } from './footer.ts';
5
+ import { registerHeader } from './header.ts';
6
+ import { registerWidget } from './widget.ts';
7
+ import { readPowerlineSettings, writePowerlineSetting } from './settings.ts';
8
+
9
+ export default function (pi: ExtensionAPI) {
10
+ // flags
11
+ pi.registerFlag('breadcrumb', {
12
+ description: 'Breadcrumb display mode: hide, top, inner',
13
+ type: 'string',
14
+ default: 'inner',
15
+ });
16
+
17
+ pi.registerFlag('footer', {
18
+ description: 'Enable custom footer with token stats',
19
+ type: 'boolean',
20
+ default: true,
21
+ });
22
+
23
+ pi.registerFlag('header', {
24
+ description: 'Enable custom gradient-logo header',
25
+ type: 'boolean',
26
+ default: true,
27
+ });
28
+
29
+ // register all sub-extensions
30
+ registerEditor(pi);
31
+ registerFooter(pi);
32
+ registerHeader(pi);
33
+ registerWidget(pi);
34
+
35
+ // unified /powerline command
36
+ pi.registerCommand('powerline', {
37
+ description: 'Configure powerline: breadcrumb, footer, header',
38
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
39
+ const items: AutocompleteItem[] = [
40
+ {
41
+ value: 'breadcrumb:hide',
42
+ label: 'breadcrumb:hide',
43
+ description: 'No breadcrumb display',
44
+ },
45
+ {
46
+ value: 'breadcrumb:top',
47
+ label: 'breadcrumb:top',
48
+ description: 'Breadcrumb as a widget above the editor',
49
+ },
50
+ {
51
+ value: 'breadcrumb:inner',
52
+ label: 'breadcrumb:inner',
53
+ description: 'Breadcrumb embedded in editor top border',
54
+ },
55
+ {
56
+ value: 'footer:on',
57
+ label: 'footer:on',
58
+ description: 'Enable custom footer',
59
+ },
60
+ {
61
+ value: 'footer:off',
62
+ label: 'footer:off',
63
+ description: 'Disable custom footer',
64
+ },
65
+ {
66
+ value: 'header:on',
67
+ label: 'header:on',
68
+ description: 'Enable custom header',
69
+ },
70
+ {
71
+ value: 'header:off',
72
+ label: 'header:off',
73
+ description: 'Disable custom header',
74
+ },
75
+ ];
76
+ if (!prefix) return items;
77
+ return items.filter((i) => i.value.startsWith(prefix));
78
+ },
79
+ handler: async (args, ctx) => {
80
+ const arg = args?.trim().toLowerCase();
81
+
82
+ // no args: show status
83
+ if (!arg) {
84
+ const { breadcrumb, footer, header } = readPowerlineSettings(ctx.cwd);
85
+ const lines = [
86
+ `breadcrumb: ${breadcrumb}`,
87
+ `footer: ${footer ? 'on' : 'off'}`,
88
+ `header: ${header ? 'on' : 'off'}`,
89
+ ];
90
+ ctx.ui.notify(lines.join('\n'), 'info');
91
+ return;
92
+ }
93
+
94
+ // parse namespace:value
95
+ const colonIdx = arg.indexOf(':');
96
+ if (colonIdx === -1) {
97
+ ctx.ui.notify(
98
+ 'Usage: /powerline <breadcrumb:hide|top|inner|footer:on|off|header:on|off>',
99
+ 'warning',
100
+ );
101
+ return;
102
+ }
103
+
104
+ const ns = arg.slice(0, colonIdx);
105
+ const val = arg.slice(colonIdx + 1);
106
+ let msg = '';
107
+
108
+ switch (ns) {
109
+ case 'breadcrumb': {
110
+ if (!['hide', 'top', 'inner'].includes(val)) {
111
+ ctx.ui.notify('breadcrumb must be: hide, top, or inner', 'warning');
112
+ return;
113
+ }
114
+ writePowerlineSetting(ctx.cwd, 'breadcrumb', val);
115
+ pi.events.emit('powerline_settings_changed', ctx);
116
+ msg = `breadcrumb → ${val}`;
117
+ break;
118
+ }
119
+ case 'footer': {
120
+ if (val !== 'on' && val !== 'off') {
121
+ ctx.ui.notify('footer must be: on or off', 'warning');
122
+ return;
123
+ }
124
+ writePowerlineSetting(ctx.cwd, 'footer', val === 'on');
125
+ pi.events.emit('powerline_settings_changed', ctx);
126
+ msg = `footer → ${val}`;
127
+ break;
128
+ }
129
+ case 'header': {
130
+ if (val !== 'on' && val !== 'off') {
131
+ ctx.ui.notify('header must be: on or off', 'warning');
132
+ return;
133
+ }
134
+ writePowerlineSetting(ctx.cwd, 'header', val === 'on');
135
+ pi.events.emit('powerline_settings_changed', ctx);
136
+ msg = `header → ${val}`;
137
+ break;
138
+ }
139
+ default:
140
+ ctx.ui.notify(
141
+ 'Usage: /powerline <breadcrumb:hide|top|inner|footer:on|off|header:on|off>',
142
+ 'warning',
143
+ );
144
+ return;
145
+ }
146
+
147
+ ctx.ui.notify(msg, 'info');
148
+ },
149
+ });
150
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "pi-powerline",
3
+ "version": "0.1.0",
4
+ "description": "Powerline-style UI extensions for pi coding agent (custom editor, breadcrumb, footer, header)",
5
+ "type": "module",
6
+ "files": [
7
+ "index.ts",
8
+ "editor.ts",
9
+ "breadcrumb.ts",
10
+ "widget.ts",
11
+ "footer.ts",
12
+ "header.ts",
13
+ "settings.ts"
14
+ ],
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi",
18
+ "coding-agent",
19
+ "powerline",
20
+ "extension"
21
+ ],
22
+ "author": "",
23
+ "license": "MIT",
24
+ "scripts": {
25
+ "test": "node --experimental-strip-types --test tests/**/*.test.ts",
26
+ "test:bun": "bun test",
27
+ "format": "prettier --write '**/*.ts'",
28
+ "format:check": "prettier --check '**/*.ts'",
29
+ "prepare": "husky"
30
+ },
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ],
35
+ "image": "https://github.com/user-attachments/assets/9ee65cd5-8501-4502-ba69-0209b19e0499"
36
+ },
37
+ "peerDependencies": {
38
+ "@mariozechner/pi-coding-agent": "*",
39
+ "@mariozechner/pi-ai": "*",
40
+ "@mariozechner/pi-tui": "*"
41
+ },
42
+ "devDependencies": {
43
+ "husky": "^9.1.7",
44
+ "prettier": "^3.8.3",
45
+ "typescript": "^6.0.3"
46
+ }
47
+ }
package/settings.ts ADDED
@@ -0,0 +1,56 @@
1
+ // shared settings read/write helpers for pi-powerline
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export type BreadcrumbMode = 'hide' | 'top' | 'inner';
6
+
7
+ export interface PowerlineSettings {
8
+ breadcrumb: BreadcrumbMode;
9
+ footer: boolean;
10
+ header: boolean;
11
+ }
12
+
13
+ const DEFAULTS: PowerlineSettings = {
14
+ breadcrumb: 'inner',
15
+ footer: true,
16
+ header: true,
17
+ };
18
+
19
+ function readSettings(cwd: string): Record<string, unknown> {
20
+ const settingsPath = join(cwd, '.pi', 'settings.json');
21
+ if (!existsSync(settingsPath)) return {};
22
+ try {
23
+ return JSON.parse(readFileSync(settingsPath, 'utf-8'));
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ function writeSettings(cwd: string, settings: Record<string, unknown>): void {
30
+ const settingsDir = join(cwd, '.pi');
31
+ if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
32
+ writeFileSync(join(settingsDir, 'settings.json'), JSON.stringify(settings, null, 2) + '\n');
33
+ }
34
+
35
+ /** Read powerline settings, validating and applying defaults. */
36
+ export function readPowerlineSettings(cwd: string): PowerlineSettings {
37
+ const s = readSettings(cwd);
38
+ return {
39
+ breadcrumb: (['hide', 'top', 'inner'].includes(s.breadcrumb as string)
40
+ ? s.breadcrumb
41
+ : DEFAULTS.breadcrumb) as BreadcrumbMode,
42
+ footer: typeof s.footer === 'boolean' ? s.footer : DEFAULTS.footer,
43
+ header: typeof s.header === 'boolean' ? s.header : DEFAULTS.header,
44
+ };
45
+ }
46
+
47
+ /** Write a single powerline setting key, preserving other settings.json keys. */
48
+ export function writePowerlineSetting(
49
+ cwd: string,
50
+ key: keyof PowerlineSettings,
51
+ value: string | boolean,
52
+ ): void {
53
+ const s = readSettings(cwd);
54
+ s[key] = value;
55
+ writeSettings(cwd, s);
56
+ }
package/widget.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Custom Widget Extension
3
+ *
4
+ * Powerline-style status widget displayed above the input editor.
5
+ * Shows: model → current folder.
6
+ * Only active when breadcrumb mode is "top" in .pi/settings.json.
7
+ */
8
+ import type { ExtensionAPI, ExtensionContext, Theme } from '@mariozechner/pi-coding-agent';
9
+ import { truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
10
+ import { getBreadcrumbData, renderBreadcrumbInfo } from './breadcrumb.ts';
11
+ import { readPowerlineSettings } from './settings.ts';
12
+
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ // live state
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+
17
+ let liveCtx: ExtensionContext | null = null;
18
+ let liveTui: any = null;
19
+ let widgetEnabled = false;
20
+
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+ // widget renderer
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+
25
+ function createWidgetRenderer() {
26
+ return (_tui: any, theme: Theme) => {
27
+ liveTui = _tui;
28
+ return {
29
+ dispose() {
30
+ liveTui = null;
31
+ },
32
+ invalidate() {},
33
+ render(width: number): string[] {
34
+ const ctx = liveCtx;
35
+ const data = getBreadcrumbData(ctx);
36
+ const line = renderBreadcrumbInfo(data, theme, true);
37
+
38
+ const visLen = visibleWidth(line);
39
+ if (visLen > width) {
40
+ return [truncateToWidth(line, width, '...')];
41
+ }
42
+ return [line + ' '.repeat(width - visLen)];
43
+ },
44
+ };
45
+ };
46
+ }
47
+
48
+ // ═══════════════════════════════════════════════════════════════════════════
49
+ // module registration
50
+ // ═══════════════════════════════════════════════════════════════════════════
51
+
52
+ export function registerWidget(pi: ExtensionAPI) {
53
+ function enable(ctx: ExtensionContext) {
54
+ widgetEnabled = true;
55
+ liveCtx = ctx;
56
+ ctx.ui.setWidget('powerline-status', createWidgetRenderer(), {
57
+ placement: 'aboveEditor',
58
+ });
59
+ }
60
+
61
+ function disable(ctx: ExtensionContext) {
62
+ widgetEnabled = false;
63
+ liveCtx = null;
64
+ ctx.ui.setWidget('powerline-status', undefined);
65
+ }
66
+
67
+ // enable only when breadcrumb mode is "top"
68
+ pi.on('session_start', (_event, ctx) => {
69
+ if (!ctx.hasUI) return;
70
+ const { breadcrumb } = readPowerlineSettings(ctx.cwd);
71
+ if (breadcrumb === 'top') {
72
+ enable(ctx);
73
+ }
74
+ });
75
+
76
+ // re-evaluate on model switch (breadcrumb setting may have changed)
77
+ pi.on('model_select', (_event, ctx) => {
78
+ const { breadcrumb } = readPowerlineSettings(ctx.cwd);
79
+ if (breadcrumb === 'top' && !widgetEnabled) {
80
+ enable(ctx);
81
+ } else if (breadcrumb !== 'top' && widgetEnabled) {
82
+ disable(ctx);
83
+ } else if (widgetEnabled) {
84
+ liveCtx = ctx;
85
+ liveTui?.requestRender();
86
+ }
87
+ });
88
+
89
+ // re-evaluate on /powerline command (settings changed)
90
+ pi.events.on('powerline_settings_changed', (ctx) => {
91
+ const c = ctx as ExtensionContext;
92
+ const { breadcrumb } = readPowerlineSettings(c.cwd);
93
+ if (breadcrumb === 'top' && !widgetEnabled) {
94
+ enable(c);
95
+ } else if (breadcrumb !== 'top' && widgetEnabled) {
96
+ disable(c);
97
+ }
98
+ });
99
+ }