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 +214 -0
- package/breadcrumb.ts +80 -0
- package/editor.ts +185 -0
- package/footer.ts +406 -0
- package/header.ts +108 -0
- package/index.ts +150 -0
- package/package.json +47 -0
- package/settings.ts +56 -0
- package/widget.ts +99 -0
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
|
+

|
|
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
|
+
}
|