@alasano/pi-panels 0.0.1

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,44 @@
1
+ # pi-panels
2
+
3
+ ![pi-panels screenshot](assets/screenshot.png)
4
+
5
+ Responsive status panels rendered below the editor in [pi](https://pi.dev). Three panels ship out of the box, each independently toggleable:
6
+
7
+ - **GIT** - worktree name, branch, upstream tracking (shown only when non-default), ahead/behind counts
8
+ - **INFO** - LLM context usage bar (color-coded from green to red as you approach the context window limit), active model and thinking level
9
+ - **NOW PLAYING** - Spotify track, artist, and progress bar with animated fill (hidden automatically when Spotify is not running)
10
+
11
+ Panels auto-size to their content, render side-by-side when terminal width allows, and fall back to a stacked layout on narrow terminals.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pi install npm:@alasano/pi-panels
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ | Command | Description |
22
+ | ------------------------ | ----------------------------------------------------- |
23
+ | `/status-panels` | Open the settings overlay to toggle individual panels |
24
+ | `/status-panels on\|off` | Enable or disable all panels |
25
+
26
+ ## Settings overlay
27
+
28
+ Running `/status-panels` with no arguments opens a centered overlay where you can enable or disable each panel individually using checkbox toggles. Typing any character dismisses the overlay and passes the keystroke to the editor.
29
+
30
+ ## Preferences
31
+
32
+ Panel visibility preferences are persisted at `~/.pi/agent/state/extensions/status-panels/config.json` and restored on session start. Default behavior on first run is all panels enabled.
33
+
34
+ ## Refresh behavior
35
+
36
+ - Git info refreshes every 5 seconds and immediately after each agent turn
37
+ - LLM context and model info update on turn end and model switch
38
+ - Spotify polls every 1 second while playing, every 2.5 seconds when idle
39
+ - The rendering loop ticks every 250ms to keep the Spotify progress bar smooth
40
+
41
+ ## Requirements
42
+
43
+ - macOS (Spotify integration uses osascript/AppleScript)
44
+ - Pi interactive mode (panels use the widget API which is unavailable in print/RPC mode)
Binary file
@@ -0,0 +1,83 @@
1
+ import { GREEN_DARK_FG, GREEN_FG } from './utils';
2
+ import { truncateToWidth } from '@mariozechner/pi-tui';
3
+ import {
4
+ computePanelWidths,
5
+ framePanelBody,
6
+ renderRow,
7
+ SEPARATOR_WIDTH,
8
+ type BuiltPanel,
9
+ } from './panel';
10
+
11
+ export type GitInfo = {
12
+ inRepo: boolean;
13
+ worktree: string;
14
+ branch: string;
15
+ tracking: string;
16
+ ahead: number;
17
+ behind: number;
18
+ };
19
+
20
+ export const EMPTY_GIT_STATE: GitInfo = {
21
+ inRepo: false,
22
+ worktree: '-',
23
+ branch: '-',
24
+ tracking: '(no repository)',
25
+ ahead: 0,
26
+ behind: 0,
27
+ };
28
+
29
+ type GitRow = {
30
+ label: string;
31
+ value: string;
32
+ labelColor?: string;
33
+ };
34
+
35
+ export function buildGitPanel(snapshot: GitInfo, maxInner: number): BuiltPanel {
36
+ const worktreeValue = snapshot.inRepo ? snapshot.worktree : '(not a git repository)';
37
+ const branchValue = snapshot.inRepo ? snapshot.branch : '-';
38
+ const trackingValue = snapshot.inRepo ? snapshot.tracking : '-';
39
+
40
+ const expectedTracking = `origin/${branchValue}`;
41
+ const shouldShowTracking =
42
+ snapshot.inRepo && (trackingValue === '(no upstream)' || trackingValue !== expectedTracking);
43
+
44
+ const rows: GitRow[] = shouldShowTracking
45
+ ? [
46
+ { label: 'worktree', value: worktreeValue, labelColor: GREEN_FG },
47
+ { label: 'branch', value: branchValue, labelColor: GREEN_DARK_FG },
48
+ { label: 'tracking', value: trackingValue, labelColor: GREEN_DARK_FG },
49
+ ]
50
+ : [
51
+ { label: 'worktree', value: worktreeValue, labelColor: GREEN_FG },
52
+ { label: 'branch', value: branchValue, labelColor: GREEN_DARK_FG },
53
+ ];
54
+
55
+ const labelWidth = rows.reduce((max, row) => Math.max(max, row.label.length), 0);
56
+
57
+ const right = `↑${snapshot.ahead} ↓${snapshot.behind}`;
58
+
59
+ const naturalContentWidth = rows.reduce(
60
+ (max, row) => Math.max(max, labelWidth + SEPARATOR_WIDTH + row.value.length),
61
+ 0,
62
+ );
63
+
64
+ const { inner, contentWidth } = computePanelWidths({
65
+ title: 'GIT',
66
+ rightText: right,
67
+ naturalContentWidth,
68
+ maxInner,
69
+ minInner: 24,
70
+ });
71
+
72
+ return framePanelBody({
73
+ title: 'GIT',
74
+ rightText: right,
75
+ bodyLines: rows.map((entry) =>
76
+ renderRow(entry.label, entry.labelColor, labelWidth, contentWidth, (vw) =>
77
+ truncateToWidth(entry.value, vw, '…', true),
78
+ ),
79
+ ),
80
+ inner,
81
+ contentWidth,
82
+ });
83
+ }
@@ -0,0 +1,667 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
4
+ import { getAgentDir, getSettingsListTheme } from '@mariozechner/pi-coding-agent';
5
+ import {
6
+ decodeKittyPrintable,
7
+ matchesKey,
8
+ parseKey,
9
+ type SettingItem,
10
+ SettingsList,
11
+ truncateToWidth,
12
+ } from '@mariozechner/pi-tui';
13
+ import { buildGitPanel, EMPTY_GIT_STATE, type GitInfo } from './git';
14
+ import { buildInfoPanel, type InfoSnapshot } from './info';
15
+ import { framePanelBody } from './panel';
16
+ import { buildNowPlayingPanel, type PlayerState, type SpotifyInfo } from './now-playing';
17
+ import { maxVisibleWidth, padVisible, visibleWidth } from './utils';
18
+
19
+ const SETTINGS_OVERLAY_MAX_INNER = 56;
20
+
21
+ function computeSettingsOverlayInner(bodyLines: string[], availableWidth: number): number {
22
+ const maxInner = Math.max(24, Math.min(availableWidth - 2, SETTINGS_OVERLAY_MAX_INNER));
23
+ return Math.max(
24
+ 24,
25
+ Math.min(maxInner, Math.max(maxVisibleWidth(bodyLines), visibleWidth('─ STATUS PANELS ')) + 2),
26
+ );
27
+ }
28
+
29
+ function getPrintableTypingKey(data: string): string | undefined {
30
+ const kittyPrintable = decodeKittyPrintable(data);
31
+ if (kittyPrintable && kittyPrintable !== ' ') {
32
+ return kittyPrintable;
33
+ }
34
+
35
+ const parsed = parseKey(data);
36
+ if (parsed && parsed.length === 1 && parsed !== ' ') {
37
+ return parsed;
38
+ }
39
+
40
+ if (data.length === 1 && data !== ' ' && /^[\x21-\x7E]$/.test(data)) {
41
+ return data;
42
+ }
43
+
44
+ return undefined;
45
+ }
46
+
47
+ const WIDGET_ID = 'status-panels';
48
+ const REFRESH_MS = 5000;
49
+ const NOW_PLAYING_FETCH_PLAYING_MS = 1000;
50
+ const NOW_PLAYING_FETCH_IDLE_MS = 2500;
51
+ const TICK_MS = 250;
52
+ const GAP = ' ';
53
+ const CONFIG_PATH = join(getAgentDir(), 'state', 'extensions', 'status-panels', 'config.json');
54
+
55
+ const PANEL_DEFS = [
56
+ { id: 'git', label: 'Git', defaultEnabled: true },
57
+ { id: 'info', label: 'Info', defaultEnabled: true },
58
+ { id: 'nowPlaying', label: 'Spotify', defaultEnabled: true },
59
+ ] as const;
60
+
61
+ type PanelId = (typeof PANEL_DEFS)[number]['id'];
62
+ type PanelState = Record<PanelId, boolean>;
63
+
64
+ type StatusPanelsConfig = {
65
+ enabled: boolean;
66
+ panels: PanelState;
67
+ };
68
+
69
+ function createPanelState(enabled: boolean): PanelState {
70
+ return PANEL_DEFS.reduce((panels, panel) => {
71
+ panels[panel.id] = enabled;
72
+ return panels;
73
+ }, {} as PanelState);
74
+ }
75
+
76
+ function createDefaultConfig(): StatusPanelsConfig {
77
+ return {
78
+ enabled: true,
79
+ panels: createPanelState(true),
80
+ };
81
+ }
82
+
83
+ function normalizeConfig(raw: unknown): StatusPanelsConfig {
84
+ const defaults = createDefaultConfig();
85
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
86
+ return defaults;
87
+ }
88
+
89
+ const input = raw as { enabled?: unknown; panels?: Record<string, unknown> };
90
+ const panels = { ...defaults.panels };
91
+
92
+ for (const panel of PANEL_DEFS) {
93
+ const value = input.panels?.[panel.id];
94
+ if (typeof value === 'boolean') {
95
+ panels[panel.id] = value;
96
+ }
97
+ }
98
+
99
+ return {
100
+ enabled: typeof input.enabled === 'boolean' ? input.enabled : defaults.enabled,
101
+ panels,
102
+ };
103
+ }
104
+
105
+ function loadConfig(): StatusPanelsConfig {
106
+ if (!existsSync(CONFIG_PATH)) {
107
+ return createDefaultConfig();
108
+ }
109
+
110
+ try {
111
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
112
+ return normalizeConfig(raw);
113
+ } catch (error) {
114
+ console.error(`Failed to load status panels config from ${CONFIG_PATH}:`, error);
115
+ return createDefaultConfig();
116
+ }
117
+ }
118
+
119
+ function saveConfig(config: StatusPanelsConfig): boolean {
120
+ try {
121
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
122
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
123
+ return true;
124
+ } catch (error) {
125
+ console.error(`Failed to save status panels config to ${CONFIG_PATH}:`, error);
126
+ return false;
127
+ }
128
+ }
129
+
130
+ function parseCount(raw: string): { behind: number; ahead: number } {
131
+ const [behindRaw, aheadRaw] = raw.trim().split(/\s+/);
132
+ return {
133
+ behind: Number.parseInt(behindRaw || '0', 10) || 0,
134
+ ahead: Number.parseInt(aheadRaw || '0', 10) || 0,
135
+ };
136
+ }
137
+
138
+ function combineSideBySide(left: string[], leftWidth: number, right: string[]): string[] {
139
+ const rows = Math.max(left.length, right.length);
140
+ const output: string[] = [];
141
+
142
+ for (let i = 0; i < rows; i++) {
143
+ const l = left[i] ?? ' '.repeat(leftWidth);
144
+ const r = right[i] ?? '';
145
+ output.push(`${padVisible(l, leftWidth)}${GAP}${r}`);
146
+ }
147
+
148
+ return output;
149
+ }
150
+
151
+ function emptySpotifyState(): SpotifyInfo {
152
+ return {
153
+ running: false,
154
+ state: 'stopped',
155
+ track: '',
156
+ artist: '',
157
+ positionSec: 0,
158
+ durationMs: 0,
159
+ };
160
+ }
161
+
162
+ export default function statusPanelsExtension(pi: ExtensionAPI) {
163
+ let ctxRef: ExtensionContext | null = null;
164
+ let timer: ReturnType<typeof setInterval> | null = null;
165
+ let config = loadConfig();
166
+ let fetchingSpotify = false;
167
+ let lastGitRefreshAt = 0;
168
+ let lastSpotifyFetchAt = 0;
169
+ let gradientPhase = 0;
170
+ let gradientTick = 0;
171
+
172
+ let gitState: GitInfo = EMPTY_GIT_STATE;
173
+
174
+ let infoState: InfoSnapshot = {
175
+ percent: null,
176
+ tokens: null,
177
+ contextWindow: 0,
178
+ modelText: '(no model)',
179
+ };
180
+
181
+ let spotifyState: SpotifyInfo = emptySpotifyState();
182
+
183
+ function isPanelEnabled(panelId: PanelId): boolean {
184
+ return config.panels[panelId];
185
+ }
186
+
187
+ function checkboxValue(enabled: boolean): string {
188
+ return enabled ? '[x]' : '[ ]';
189
+ }
190
+
191
+ function persistConfig(ctx?: ExtensionContext): boolean {
192
+ const ok = saveConfig(config);
193
+ if (!ok && ctx?.hasUI) {
194
+ ctx.ui.notify('Failed to save status panels preferences', 'error');
195
+ }
196
+ return ok;
197
+ }
198
+
199
+ function applyConfig(ctx?: ExtensionContext) {
200
+ if (ctx) ctxRef = ctx;
201
+
202
+ if (!isPanelEnabled('nowPlaying')) {
203
+ spotifyState = emptySpotifyState();
204
+ }
205
+
206
+ if (!config.enabled) {
207
+ stop();
208
+ return;
209
+ }
210
+
211
+ stop();
212
+ start();
213
+ }
214
+
215
+ function setMasterEnabled(nextEnabled: boolean, ctx?: ExtensionContext) {
216
+ config = {
217
+ enabled: nextEnabled,
218
+ panels: createPanelState(nextEnabled),
219
+ };
220
+ persistConfig(ctx);
221
+ applyConfig(ctx);
222
+ }
223
+
224
+ function setPanelEnabled(panelId: PanelId, nextEnabled: boolean, ctx?: ExtensionContext) {
225
+ config = {
226
+ ...config,
227
+ panels: {
228
+ ...config.panels,
229
+ [panelId]: nextEnabled,
230
+ },
231
+ };
232
+ persistConfig(ctx);
233
+ applyConfig(ctx);
234
+ }
235
+
236
+ async function runGit(args: string[]): Promise<string | undefined> {
237
+ try {
238
+ const result = await pi.exec('git', args, { timeout: 2000 });
239
+ if (result.code !== 0) return undefined;
240
+ const value = result.stdout.trim();
241
+ return value || undefined;
242
+ } catch {
243
+ return undefined;
244
+ }
245
+ }
246
+
247
+ async function readGitInfo(): Promise<GitInfo> {
248
+ const inside = await runGit(['rev-parse', '--is-inside-work-tree']);
249
+ if (inside !== 'true') {
250
+ return EMPTY_GIT_STATE;
251
+ }
252
+
253
+ const topLevel = (await runGit(['rev-parse', '--show-toplevel'])) || '-';
254
+ const worktree = topLevel.split('/').filter(Boolean).pop() || topLevel;
255
+
256
+ const branch =
257
+ (await runGit(['branch', '--show-current'])) ||
258
+ (await runGit(['rev-parse', '--short', 'HEAD'])) ||
259
+ '(detached)';
260
+
261
+ const upstream = await runGit([
262
+ 'rev-parse',
263
+ '--abbrev-ref',
264
+ '--symbolic-full-name',
265
+ '@{upstream}',
266
+ ]);
267
+
268
+ if (!upstream) {
269
+ return {
270
+ inRepo: true,
271
+ worktree,
272
+ branch,
273
+ tracking: '(no upstream)',
274
+ ahead: 0,
275
+ behind: 0,
276
+ };
277
+ }
278
+
279
+ const countsRaw = await runGit(['rev-list', '--left-right', '--count', `${upstream}...HEAD`]);
280
+ const { behind, ahead } = parseCount(countsRaw || '0 0');
281
+
282
+ return {
283
+ inRepo: true,
284
+ worktree,
285
+ branch,
286
+ tracking: upstream,
287
+ ahead,
288
+ behind,
289
+ };
290
+ }
291
+
292
+ function readInfoState(ctx: ExtensionContext): InfoSnapshot {
293
+ const usage = ctx.getContextUsage();
294
+
295
+ const modelId = ctx.model?.id || '(no model)';
296
+ const thinking = pi.getThinkingLevel();
297
+
298
+ return {
299
+ percent: usage?.percent ?? null,
300
+ tokens: usage?.tokens ?? null,
301
+ contextWindow: usage?.contextWindow ?? 0,
302
+ modelText: `${modelId} • ${thinking}`,
303
+ };
304
+ }
305
+
306
+ async function readSpotify(): Promise<SpotifyInfo> {
307
+ const script = `
308
+ if application "Spotify" is running then
309
+ tell application "Spotify"
310
+ set ps to player state as string
311
+ if ps is "stopped" then
312
+ return "RUNNING\nstopped\n\n\n0\n0"
313
+ end if
314
+ set tName to name of current track
315
+ set tArtist to artist of current track
316
+ set tPos to player position
317
+ set tDur to duration of current track
318
+ return "RUNNING\n" & ps & "\n" & tName & "\n" & tArtist & "\n" & (tPos as string) & "\n" & (tDur as string)
319
+ end tell
320
+ else
321
+ return "NOT_RUNNING"
322
+ end if
323
+ `.trim();
324
+
325
+ try {
326
+ const result = await pi.exec('osascript', ['-e', script], { timeout: 2000 });
327
+ if (result.code !== 0) {
328
+ return emptySpotifyState();
329
+ }
330
+
331
+ const output = result.stdout.trim();
332
+ if (output === 'NOT_RUNNING') {
333
+ return emptySpotifyState();
334
+ }
335
+
336
+ const lines = output.split('\n');
337
+ const stateRaw = (lines[1] || 'stopped').trim();
338
+ const state: PlayerState =
339
+ stateRaw === 'playing' ? 'playing' : stateRaw === 'paused' ? 'paused' : 'stopped';
340
+
341
+ const positionSec = Number.parseFloat((lines[4] || '0').replace(',', '.'));
342
+ const durationMs = Number.parseInt(lines[5] || '0', 10);
343
+
344
+ return {
345
+ running: true,
346
+ state,
347
+ track: lines[2] || '',
348
+ artist: lines[3] || '',
349
+ positionSec: Number.isFinite(positionSec) ? positionSec : 0,
350
+ durationMs: Number.isFinite(durationMs) ? durationMs : 0,
351
+ };
352
+ } catch {
353
+ return emptySpotifyState();
354
+ }
355
+ }
356
+
357
+ function buildTopBlock(safeWidth: number): string[] {
358
+ const panels: Array<{ lines: string[]; width: number }> = [];
359
+
360
+ if (isPanelEnabled('git')) {
361
+ panels.push(buildGitPanel(gitState, safeWidth - 2));
362
+ }
363
+
364
+ if (isPanelEnabled('info')) {
365
+ panels.push(buildInfoPanel(infoState, safeWidth - 2));
366
+ }
367
+
368
+ if (panels.length === 0) {
369
+ return [];
370
+ }
371
+
372
+ if (panels.length === 1) {
373
+ return panels[0]!.lines;
374
+ }
375
+
376
+ const [first, second] = panels;
377
+ const naturalCombined = first!.width + visibleWidth(GAP) + second!.width;
378
+ if (naturalCombined <= safeWidth) {
379
+ return combineSideBySide(first!.lines, first!.width, second!.lines);
380
+ }
381
+
382
+ const leftOuterTarget = Math.max(28, Math.floor((safeWidth - visibleWidth(GAP)) * 0.55));
383
+ const rightOuterTarget = Math.max(28, safeWidth - visibleWidth(GAP) - leftOuterTarget);
384
+
385
+ const gitCompact = buildGitPanel(gitState, Math.max(24, leftOuterTarget - 2));
386
+ const infoCompact = buildInfoPanel(infoState, Math.max(24, rightOuterTarget - 2));
387
+
388
+ const compactCombined = gitCompact.width + visibleWidth(GAP) + infoCompact.width;
389
+ if (compactCombined <= safeWidth) {
390
+ return combineSideBySide(gitCompact.lines, gitCompact.width, infoCompact.lines);
391
+ }
392
+
393
+ return [...first!.lines, ...second!.lines];
394
+ }
395
+
396
+ function renderPanels() {
397
+ if (!config.enabled || !ctxRef?.hasUI) return;
398
+
399
+ ctxRef.ui.setWidget(
400
+ WIDGET_ID,
401
+ (_tui, _theme) => ({
402
+ invalidate() {},
403
+ render(width: number) {
404
+ const safeWidth = Math.max(1, width);
405
+ const clampLines = (lines: string[]) =>
406
+ lines.map((line) => truncateToWidth(line, safeWidth));
407
+
408
+ const topBlock = buildTopBlock(safeWidth);
409
+ if (!isPanelEnabled('nowPlaying')) {
410
+ return clampLines(topBlock);
411
+ }
412
+
413
+ const nowPlayingNatural = buildNowPlayingPanel(
414
+ spotifyState,
415
+ gradientPhase,
416
+ safeWidth - 2,
417
+ );
418
+ if (!nowPlayingNatural) {
419
+ return clampLines(topBlock);
420
+ }
421
+
422
+ if (topBlock.length === 0) {
423
+ return clampLines(nowPlayingNatural.lines);
424
+ }
425
+
426
+ const topWidth = maxVisibleWidth(topBlock);
427
+ const gapWidth = visibleWidth(GAP);
428
+
429
+ if (topWidth + gapWidth + nowPlayingNatural.width <= safeWidth) {
430
+ return clampLines(combineSideBySide(topBlock, topWidth, nowPlayingNatural.lines));
431
+ }
432
+
433
+ const availableForNowPlaying = safeWidth - topWidth - gapWidth;
434
+ if (availableForNowPlaying >= 30) {
435
+ const nowPlayingCompact = buildNowPlayingPanel(
436
+ spotifyState,
437
+ gradientPhase,
438
+ Math.max(28, availableForNowPlaying - 2),
439
+ );
440
+
441
+ if (nowPlayingCompact && topWidth + gapWidth + nowPlayingCompact.width <= safeWidth) {
442
+ return clampLines(combineSideBySide(topBlock, topWidth, nowPlayingCompact.lines));
443
+ }
444
+ }
445
+
446
+ return clampLines([...topBlock, ...nowPlayingNatural.lines]);
447
+ },
448
+ }),
449
+ { placement: 'belowEditor' },
450
+ );
451
+ }
452
+
453
+ async function refreshCore(force = false) {
454
+ if (!ctxRef) return;
455
+ const now = Date.now();
456
+ if (!force && now - lastGitRefreshAt < REFRESH_MS) return;
457
+
458
+ gitState = await readGitInfo();
459
+ infoState = readInfoState(ctxRef);
460
+ lastGitRefreshAt = now;
461
+ }
462
+
463
+ async function refreshSpotify(force = false) {
464
+ if (!ctxRef || !isPanelEnabled('nowPlaying') || fetchingSpotify) return;
465
+ const now = Date.now();
466
+ const interval =
467
+ spotifyState.state === 'playing' ? NOW_PLAYING_FETCH_PLAYING_MS : NOW_PLAYING_FETCH_IDLE_MS;
468
+
469
+ if (!force && now - lastSpotifyFetchAt < interval) return;
470
+
471
+ fetchingSpotify = true;
472
+ spotifyState = await readSpotify();
473
+ fetchingSpotify = false;
474
+ lastSpotifyFetchAt = now;
475
+ }
476
+
477
+ async function tick(forceCore = false, forceSpotify = false) {
478
+ if (!config.enabled || !ctxRef?.hasUI) return;
479
+
480
+ gradientTick = (gradientTick + 1) % 2;
481
+ if (gradientTick === 0) {
482
+ gradientPhase = (gradientPhase + 1) % 2;
483
+ }
484
+
485
+ await refreshCore(forceCore);
486
+ await refreshSpotify(forceSpotify);
487
+ renderPanels();
488
+ }
489
+
490
+ function start() {
491
+ if (!config.enabled || !ctxRef?.hasUI) return;
492
+ if (timer) return;
493
+
494
+ void tick(true, true);
495
+ timer = setInterval(() => {
496
+ void tick(false, false);
497
+ }, TICK_MS);
498
+ }
499
+
500
+ function stop() {
501
+ if (timer) {
502
+ clearInterval(timer);
503
+ timer = null;
504
+ }
505
+
506
+ if (ctxRef?.hasUI) {
507
+ ctxRef.ui.setWidget(WIDGET_ID, undefined);
508
+ }
509
+ }
510
+
511
+ async function showSettingsOverlay(ctx: ExtensionContext): Promise<void> {
512
+ ctxRef = ctx;
513
+
514
+ const items: SettingItem[] = [
515
+ {
516
+ id: 'enabled',
517
+ label: 'Show all panels',
518
+ currentValue: checkboxValue(config.enabled),
519
+ values: ['[x]', '[ ]'],
520
+ },
521
+ ...PANEL_DEFS.map((panel) => ({
522
+ id: panel.id,
523
+ label: panel.label,
524
+ currentValue: checkboxValue(isPanelEnabled(panel.id)),
525
+ values: ['[x]', '[ ]'],
526
+ })),
527
+ ];
528
+
529
+ const settingsTheme = getSettingsListTheme();
530
+ const maxVisibleItems = Math.min(items.length + 2, 10);
531
+ const probeList = new SettingsList(
532
+ items,
533
+ maxVisibleItems,
534
+ settingsTheme,
535
+ () => {},
536
+ () => {},
537
+ );
538
+ const probeLines = probeList.render(Math.max(8, SETTINGS_OVERLAY_MAX_INNER - 2));
539
+ const overlayBodyLines = ['Choose which panels are visible', '', ...probeLines];
540
+ const overlayWidth =
541
+ computeSettingsOverlayInner(overlayBodyLines, SETTINGS_OVERLAY_MAX_INNER + 2) + 2;
542
+
543
+ await ctx.ui.custom(
544
+ (_tui, theme, _kb, done) => {
545
+ const settingsList = new SettingsList(
546
+ items,
547
+ maxVisibleItems,
548
+ settingsTheme,
549
+ (id, newValue) => {
550
+ const nextEnabled = newValue === '[x]';
551
+ if (id === 'enabled') {
552
+ setMasterEnabled(nextEnabled, ctx);
553
+ for (const panel of PANEL_DEFS) {
554
+ settingsList.updateValue(panel.id, checkboxValue(nextEnabled));
555
+ }
556
+ return;
557
+ }
558
+
559
+ setPanelEnabled(id as PanelId, nextEnabled, ctx);
560
+ },
561
+ () => done(undefined),
562
+ );
563
+
564
+ return {
565
+ render(width: number) {
566
+ const safeWidth = Math.max(24, width);
567
+ const provisionalInner = Math.max(
568
+ 24,
569
+ Math.min(safeWidth - 2, SETTINGS_OVERLAY_MAX_INNER),
570
+ );
571
+ const listLines = settingsList.render(Math.max(8, provisionalInner - 2));
572
+ const bodyLines = [
573
+ theme.fg('muted', 'Choose which panels are visible'),
574
+ '',
575
+ ...listLines,
576
+ ];
577
+ const naturalInner = computeSettingsOverlayInner(bodyLines, safeWidth);
578
+
579
+ return framePanelBody({
580
+ title: 'STATUS PANELS',
581
+ bodyLines,
582
+ inner: naturalInner,
583
+ }).lines;
584
+ },
585
+ invalidate() {
586
+ settingsList.invalidate();
587
+ },
588
+ handleInput(data: string) {
589
+ const printableKey = getPrintableTypingKey(data);
590
+ if (
591
+ printableKey &&
592
+ !matchesKey(data, 'escape') &&
593
+ !matchesKey(data, 'return') &&
594
+ !matchesKey(data, 'up') &&
595
+ !matchesKey(data, 'down') &&
596
+ !matchesKey(data, 'left') &&
597
+ !matchesKey(data, 'right')
598
+ ) {
599
+ done(undefined);
600
+ queueMicrotask(() => ctx.ui.pasteToEditor(printableKey));
601
+ return;
602
+ }
603
+
604
+ settingsList.handleInput?.(data);
605
+ },
606
+ };
607
+ },
608
+ {
609
+ overlay: true,
610
+ overlayOptions: {
611
+ anchor: 'center',
612
+ width: overlayWidth,
613
+ },
614
+ },
615
+ );
616
+ }
617
+
618
+ pi.registerCommand('status-panels', {
619
+ description: 'Open status panel settings, or use /status-panels [on|off]',
620
+ handler: async (args, ctx) => {
621
+ ctxRef = ctx;
622
+ const mode = (args || '').trim().toLowerCase();
623
+
624
+ if (mode === '' || mode === 'settings') {
625
+ await showSettingsOverlay(ctx);
626
+ return;
627
+ }
628
+
629
+ if (!['on', 'off'].includes(mode)) {
630
+ ctx.ui.notify('Usage: /status-panels [on|off]', 'warning');
631
+ return;
632
+ }
633
+
634
+ const nextEnabled = mode === 'on';
635
+ setMasterEnabled(nextEnabled, ctx);
636
+ ctx.ui.notify(nextEnabled ? 'Status panels visible' : 'Status panels hidden', 'info');
637
+ },
638
+ });
639
+
640
+ pi.on('session_start', async (_event, ctx) => {
641
+ config = loadConfig();
642
+ applyConfig(ctx);
643
+ });
644
+
645
+ pi.on('session_switch', async (_event, ctx) => {
646
+ config = loadConfig();
647
+ applyConfig(ctx);
648
+ });
649
+
650
+ pi.on('turn_end', async (_event, ctx) => {
651
+ ctxRef = ctx;
652
+ if (!config.enabled) return;
653
+ void tick(true, true);
654
+ });
655
+
656
+ pi.on('model_select', async (_event, ctx) => {
657
+ ctxRef = ctx;
658
+ if (!config.enabled) return;
659
+ infoState = readInfoState(ctx);
660
+ renderPanels();
661
+ });
662
+
663
+ pi.on('session_shutdown', async (_event, ctx) => {
664
+ ctxRef = ctx;
665
+ stop();
666
+ });
667
+ }
@@ -0,0 +1,93 @@
1
+ import { truncateToWidth } from '@mariozechner/pi-tui';
2
+ import {
3
+ GREEN_DARK_FG,
4
+ GREEN_FG,
5
+ clamp,
6
+ formatCompactTokens,
7
+ tint,
8
+ usageColor,
9
+ visibleWidth,
10
+ } from './utils';
11
+ import {
12
+ computePanelWidths,
13
+ framePanelBody,
14
+ renderRow,
15
+ SEPARATOR_WIDTH,
16
+ type BuiltPanel,
17
+ } from './panel';
18
+
19
+ export type InfoSnapshot = {
20
+ percent: number | null;
21
+ tokens: number | null;
22
+ contextWindow: number;
23
+ modelText: string;
24
+ };
25
+
26
+ type InfoRow = {
27
+ label: string;
28
+ labelColor: string;
29
+ renderValue: (valueWidth: number) => string;
30
+ measure: number;
31
+ };
32
+
33
+ function renderBar(percent: number | null, valueWidth: number): string {
34
+ const safePercent = clamp(percent ?? 0, 0, 100);
35
+ const pctText = `${Math.round(safePercent)}%`;
36
+
37
+ const desiredBar = 20;
38
+ const minBar = 6;
39
+ const barWidth = Math.max(minBar, Math.min(desiredBar, valueWidth - pctText.length - 1));
40
+ const filled = Math.round((safePercent / 100) * barWidth);
41
+
42
+ const color = usageColor(percent);
43
+ const fill = tint('█'.repeat(Math.max(0, filled)), color);
44
+ const empty = '░'.repeat(Math.max(0, barWidth - filled));
45
+
46
+ const composed = `${fill}${empty} ${tint(pctText, color)}`;
47
+ const deficit = valueWidth - visibleWidth(composed);
48
+ return deficit > 0 ? `${composed}${' '.repeat(deficit)}` : composed;
49
+ }
50
+
51
+ export function buildInfoPanel(snapshot: InfoSnapshot, maxInner: number): BuiltPanel {
52
+ const contextTopRight = formatCompactTokens(snapshot.contextWindow);
53
+
54
+ const rows: InfoRow[] = [
55
+ {
56
+ label: 'context',
57
+ labelColor: GREEN_FG,
58
+ measure: 20 + 1 + `${Math.round(snapshot.percent ?? 0)}%`.length,
59
+ renderValue: (valueWidth) => renderBar(snapshot.percent, valueWidth),
60
+ },
61
+ {
62
+ label: 'model',
63
+ labelColor: GREEN_DARK_FG,
64
+ measure: snapshot.modelText.length,
65
+ renderValue: (valueWidth) => truncateToWidth(snapshot.modelText, valueWidth, '…', true),
66
+ },
67
+ ];
68
+
69
+ const labelWidth = rows.reduce((max, row) => Math.max(max, row.label.length), 0);
70
+
71
+ const naturalContentWidth = rows.reduce(
72
+ (max, row) => Math.max(max, labelWidth + SEPARATOR_WIDTH + row.measure),
73
+ 0,
74
+ );
75
+
76
+ const { inner, contentWidth } = computePanelWidths({
77
+ title: 'INFO',
78
+ rightText: contextTopRight,
79
+ naturalContentWidth,
80
+ maxInner,
81
+ minInner: 24,
82
+ });
83
+
84
+ return framePanelBody({
85
+ title: 'INFO',
86
+ rightText: contextTopRight,
87
+ bodyLines: rows.map((entry) =>
88
+ renderRow(entry.label, entry.labelColor, labelWidth, contentWidth, entry.renderValue),
89
+ ),
90
+ inner,
91
+ contentWidth,
92
+ });
93
+ }
@@ -0,0 +1,114 @@
1
+ import { truncateToWidth } from '@mariozechner/pi-tui';
2
+ import { clamp, GREEN_DARK_FG, GREEN_FG, visibleWidth } from './utils';
3
+ import {
4
+ computePanelWidths,
5
+ framePanelBody,
6
+ renderRow,
7
+ SEPARATOR_WIDTH,
8
+ type BuiltPanel,
9
+ } from './panel';
10
+
11
+ const ORANGE_LIGHT_FG = '\x1b[38;2;242;145;62m';
12
+ const ORANGE_RED_FG = '\x1b[38;2;222;92;44m';
13
+
14
+ export type PlayerState = 'playing' | 'paused' | 'stopped';
15
+
16
+ export type SpotifyInfo = {
17
+ running: boolean;
18
+ state: PlayerState;
19
+ track: string;
20
+ artist: string;
21
+ positionSec: number;
22
+ durationMs: number;
23
+ };
24
+
25
+ function formatTime(seconds: number): string {
26
+ const safe = Math.max(0, Math.floor(seconds));
27
+ const h = Math.floor(safe / 3600);
28
+ const m = Math.floor((safe % 3600) / 60);
29
+ const s = safe % 60;
30
+
31
+ if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
32
+ return `${m}:${String(s).padStart(2, '0')}`;
33
+ }
34
+
35
+ function renderOrangeFill(count: number, blinkPhase: number): string {
36
+ if (count <= 0) return '';
37
+
38
+ let out = '';
39
+ for (let i = 0; i < count; i++) {
40
+ const isNewestTick = i === count - 1;
41
+ const color = isNewestTick && blinkPhase % 2 === 0 ? ORANGE_LIGHT_FG : ORANGE_RED_FG;
42
+ out += `${color}█\x1b[39m`;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ export function buildNowPlayingPanel(
48
+ spotify: SpotifyInfo,
49
+ phase: number,
50
+ maxInner: number,
51
+ ): BuiltPanel | undefined {
52
+ if (!spotify.running) return undefined;
53
+
54
+ const icon = spotify.state === 'playing' ? '▶' : '⏸';
55
+ const right = `Spotify ${icon}`;
56
+
57
+ const songValueRaw = spotify.artist
58
+ ? `${spotify.track || '(unknown)'} • ${spotify.artist}`
59
+ : spotify.track || '(unknown)';
60
+
61
+ const durationSec = spotify.durationMs > 0 ? spotify.durationMs / 1000 : 0;
62
+ const ratio = durationSec > 0 ? clamp(spotify.positionSec / durationSec, 0, 1) : 0;
63
+ const timeTextRaw = `${formatTime(spotify.positionSec)} / ${formatTime(durationSec)}`;
64
+
65
+ const labels = ['song', 'time'];
66
+ const labelWidth = Math.max(...labels.map((l) => l.length));
67
+ const desiredBarWidth = 20;
68
+ const timeMeasure = desiredBarWidth + 1 + timeTextRaw.length;
69
+ const naturalContentWidth = Math.max(
70
+ labelWidth + SEPARATOR_WIDTH + songValueRaw.length,
71
+ labelWidth + SEPARATOR_WIDTH + timeMeasure,
72
+ );
73
+
74
+ const { inner, contentWidth } = computePanelWidths({
75
+ title: 'NOW PLAYING',
76
+ rightText: right,
77
+ naturalContentWidth,
78
+ maxInner,
79
+ minInner: 28,
80
+ });
81
+
82
+ const renderTimeValue = (valueWidth: number) => {
83
+ const availableForBar = valueWidth - (timeTextRaw.length + 1);
84
+ const barWidth = availableForBar > 0 ? Math.min(20, availableForBar) : 0;
85
+ const formattedTime =
86
+ barWidth > 0 ? timeTextRaw : truncateToWidth(timeTextRaw, valueWidth, '…');
87
+
88
+ const filled = barWidth > 0 ? Math.round(ratio * barWidth) : 0;
89
+ const empty = Math.max(0, barWidth - filled);
90
+
91
+ const fillPart =
92
+ spotify.state === 'playing' ? renderOrangeFill(filled, phase) : '█'.repeat(filled);
93
+
94
+ const emptyPart = '░'.repeat(empty);
95
+ const separator = barWidth > 0 ? ' ' : '';
96
+
97
+ const shown = `${fillPart}${emptyPart}${separator}${formattedTime}`;
98
+ const deficit = valueWidth - visibleWidth(shown);
99
+ return deficit > 0 ? `${shown}${' '.repeat(deficit)}` : shown;
100
+ };
101
+
102
+ return framePanelBody({
103
+ title: 'NOW PLAYING',
104
+ rightText: right,
105
+ bodyLines: [
106
+ renderRow('song', GREEN_FG, labelWidth, contentWidth, (vw) =>
107
+ truncateToWidth(songValueRaw, vw, '…', true),
108
+ ),
109
+ renderRow('time', GREEN_DARK_FG, labelWidth, contentWidth, renderTimeValue),
110
+ ],
111
+ inner,
112
+ contentWidth,
113
+ });
114
+ }
@@ -0,0 +1,87 @@
1
+ import { gold, padVisible, tint, visibleWidth } from './utils';
2
+
3
+ export type BuiltPanel = {
4
+ lines: string[];
5
+ width: number;
6
+ };
7
+
8
+ const SEPARATOR = ' │ ';
9
+ export const SEPARATOR_WIDTH = 3;
10
+
11
+ type PanelWidthOptions = {
12
+ title: string;
13
+ rightText?: string;
14
+ naturalContentWidth: number;
15
+ maxInner: number;
16
+ minInner?: number;
17
+ };
18
+
19
+ type PanelFrameOptions = {
20
+ title: string;
21
+ bodyLines: string[];
22
+ inner: number;
23
+ contentWidth?: number;
24
+ rightText?: string;
25
+ };
26
+
27
+ function panelHeaderLeft(title: string): string {
28
+ return `─ ${title} `;
29
+ }
30
+
31
+ export function computePanelWidths({
32
+ title,
33
+ rightText,
34
+ naturalContentWidth,
35
+ maxInner,
36
+ minInner = 24,
37
+ }: PanelWidthOptions): { inner: number; contentWidth: number } {
38
+ const leftHeader = panelHeaderLeft(title);
39
+ const rightSegment = rightText ? ` ${rightText} ` : '';
40
+ const minHeaderInner = leftHeader.length + rightSegment.length + 1;
41
+ const naturalInner = Math.max(minHeaderInner, naturalContentWidth + 2);
42
+ const inner = Math.max(minInner, Math.min(maxInner, naturalInner));
43
+ const contentWidth = Math.max(8, inner - 2);
44
+
45
+ return { inner, contentWidth };
46
+ }
47
+
48
+ export function framePanelBody({
49
+ title,
50
+ bodyLines,
51
+ inner,
52
+ contentWidth: passedContentWidth,
53
+ rightText,
54
+ }: PanelFrameOptions): BuiltPanel {
55
+ const leftHeader = panelHeaderLeft(title);
56
+ const rightSegment = rightText ? ` ${rightText} ` : '';
57
+ const fill = Math.max(1, inner - leftHeader.length - rightSegment.length);
58
+
59
+ const top =
60
+ gold('╭') +
61
+ gold(leftHeader) +
62
+ gold('─'.repeat(fill)) +
63
+ (rightSegment ? gold(rightSegment) : '') +
64
+ gold('╮');
65
+
66
+ const bottom = gold('╰') + gold('─'.repeat(inner)) + gold('╯');
67
+ const contentWidth = passedContentWidth ?? Math.max(8, inner - 2);
68
+
69
+ const framedBody = bodyLines.map(
70
+ (line) => gold('│ ') + padVisible(line, contentWidth) + gold(' │'),
71
+ );
72
+ const lines = [top, ...framedBody, bottom];
73
+ return { lines, width: visibleWidth(top) };
74
+ }
75
+
76
+ export function renderRow(
77
+ label: string,
78
+ labelColor: string | undefined,
79
+ labelWidth: number,
80
+ contentWidth: number,
81
+ renderValue: (valueWidth: number) => string,
82
+ ): string {
83
+ const paddedLabel = label.padEnd(labelWidth, ' ');
84
+ const coloredLabel = labelColor ? tint(paddedLabel, labelColor) : paddedLabel;
85
+ const valueWidth = Math.max(1, contentWidth - (labelWidth + SEPARATOR_WIDTH));
86
+ return `${coloredLabel}${SEPARATOR}${renderValue(valueWidth)}`;
87
+ }
@@ -0,0 +1,46 @@
1
+ import { visibleWidth } from '@mariozechner/pi-tui';
2
+
3
+ export { visibleWidth };
4
+
5
+ export const GOLD_FG = '\x1b[38;2;212;162;46m';
6
+ export const GREEN_FG = '\x1b[38;2;96;176;88m';
7
+ export const GREEN_DARK_FG = '\x1b[38;2;62;124;66m';
8
+ export const RESET_FG = '\x1b[39m';
9
+
10
+ export function tint(text: string, color: string): string {
11
+ return `${color}${text}${RESET_FG}`;
12
+ }
13
+
14
+ export function gold(text: string): string {
15
+ return tint(text, GOLD_FG);
16
+ }
17
+
18
+ export function padVisible(text: string, width: number): string {
19
+ const deficit = width - visibleWidth(text);
20
+ if (deficit <= 0) return text;
21
+ return `${text}${' '.repeat(deficit)}`;
22
+ }
23
+
24
+ export function clamp(value: number, min: number, max: number): number {
25
+ return Math.max(min, Math.min(value, max));
26
+ }
27
+
28
+ export function formatCompactTokens(tokens: number | null | undefined): string {
29
+ if (tokens == null || Number.isNaN(tokens)) return '?';
30
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
31
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}k`;
32
+ return `${tokens}`;
33
+ }
34
+
35
+ export function usageColor(percent: number | null): string {
36
+ if (percent == null) return '\x1b[38;2;160;160;160m';
37
+ if (percent < 40) return '\x1b[38;2;88;172;98m'; // green
38
+ if (percent < 60) return '\x1b[38;2;145;182;78m'; // lime
39
+ if (percent < 75) return '\x1b[38;2;213;176;68m'; // yellow
40
+ if (percent < 90) return '\x1b[38;2;214;140;52m'; // orange
41
+ return '\x1b[38;2;200;84;74m'; // red
42
+ }
43
+
44
+ export function maxVisibleWidth(lines: string[]): number {
45
+ return lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
46
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@alasano/pi-panels",
3
+ "version": "0.0.1",
4
+ "description": "Responsive status panels for pi - git info, LLM context usage, and Spotify now-playing below the editor",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/alasano/house-of-pi",
12
+ "directory": "packages/pi-panels"
13
+ },
14
+ "type": "module",
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "pi": {
19
+ "extensions": [
20
+ "./extensions"
21
+ ],
22
+ "image": "https://raw.githubusercontent.com/alasano/house-of-pi/master/packages/pi-panels/assets/screenshot.png"
23
+ },
24
+ "files": [
25
+ "extensions",
26
+ "assets",
27
+ "README.md"
28
+ ],
29
+ "peerDependencies": {
30
+ "@mariozechner/pi-coding-agent": "*",
31
+ "@mariozechner/pi-tui": "*",
32
+ "@mariozechner/pi-ai": "*",
33
+ "@sinclair/typebox": "*"
34
+ }
35
+ }