pi-powerline 0.5.1 → 0.6.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/footer.ts CHANGED
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import type { AssistantMessage } from '@earendil-works/pi-ai';
17
17
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
18
18
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
19
+ import { hasNerdFonts, hexFg, withIcon } from './breadcrumb.ts';
19
20
  import { readPowerlineSettings } from './settings.ts';
20
21
 
21
22
  // ═══════════════════════════════════════════════════════════════════════════
@@ -54,24 +55,12 @@ function formatTokens(count: number): string {
54
55
  }
55
56
 
56
57
  // ═══════════════════════════════════════════════════════════════════════════
57
- // think level display (mirrors widget.ts style)
58
+ // think level display
58
59
  // ═══════════════════════════════════════════════════════════════════════════
59
60
 
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
61
  const ICON_THINK = hasNerdFonts() ? '' : '';
69
62
  const ICON_GIT = hasNerdFonts() ? '' : '⎇';
70
63
 
71
- function withIcon(icon: string, text: string): string {
72
- return icon ? `${icon} ${text}` : text;
73
- }
74
-
75
64
  const THINK_LABELS: Record<string, string> = {
76
65
  minimal: 'min',
77
66
  low: 'low',
@@ -127,15 +116,6 @@ let autoCompactEnabled = true;
127
116
  // footer renderer
128
117
  // ═══════════════════════════════════════════════════════════════════════════
129
118
 
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
119
  /** Sanitize text for single-line status display. */
140
120
  function sanitizeStatusText(text: string): string {
141
121
  return text
@@ -256,48 +236,25 @@ function createFooterRenderer(ctx: ExtensionContext) {
256
236
  const rightWidth = visibleWidth(rightSidePlain);
257
237
 
258
238
  const minPad = 2;
239
+ const thinkToken = THINK_COLORS[liveThinkLevel || 'off'] ?? 'thinkingOff';
240
+ const coloredRight = rightSidePlain ? theme.fg(thinkToken, rightSidePlain) : '';
259
241
  let statsLine: string;
260
242
 
261
243
  const totalBase = gitFullWidth + statsLeftWidth + minPad + rightWidth;
262
244
  if (totalBase <= width) {
263
- // everything fits
264
245
  const pad = width - gitFullWidth - statsLeftWidth - rightWidth;
265
246
  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
247
  statsLine = gitFull + dimLeft + dimPadding + coloredRight;
272
248
  } else if (gitFullWidth + minPad + rightWidth <= width) {
273
- // drop git → fit statsLeft
274
249
  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
- }
250
+ const statsTrimmed = availStats > 0 ? truncateToWidth(statsLeft, availStats, '') : '';
251
+ const statsTrimmedWidth = visibleWidth(statsTrimmed);
284
252
  const pad = width - gitFullWidth - statsTrimmedWidth - rightWidth;
285
253
  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
254
  statsLine = gitFull + theme.fg('dim', statsTrimmed) + dimPadding + coloredRight;
292
255
  } else {
293
- // drop git, drop right → only stats
294
256
  const availStats = width - minPad;
295
- let statsTrimmed: string;
296
- if (availStats > 0) {
297
- statsTrimmed = truncateToWidth(statsLeft, availStats, '');
298
- } else {
299
- statsTrimmed = '';
300
- }
257
+ const statsTrimmed = availStats > 0 ? truncateToWidth(statsLeft, availStats, '') : '';
301
258
  statsLine = theme.fg('dim', statsTrimmed);
302
259
  }
303
260
 
package/header.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Shows a gradient-colored PI logo.
5
5
  * Controlled by .pi/settings.json → header (boolean, default true).
6
6
  */
7
- import { existsSync, readFileSync, statSync } from 'node:fs';
7
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { dirname, join, relative, resolve } from 'node:path';
10
10
  import type {
@@ -52,6 +52,10 @@ function gradientLine(line: string): string {
52
52
 
53
53
  const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
54
54
 
55
+ function getHomeDir(): string {
56
+ return process.env.HOME ?? homedir();
57
+ }
58
+
55
59
  function visibleLength(line: string): number {
56
60
  return line.replace(ANSI_PATTERN, '').length;
57
61
  }
@@ -131,14 +135,13 @@ function formatReasonStatus(theme: Theme, reason: SessionStartEvent['reason']):
131
135
  }
132
136
 
133
137
  interface HeaderInfo {
134
- themeName: string;
135
- cwd: string;
136
- commands: string[];
137
138
  prompts: string[];
138
139
  skills: string[];
139
140
  extensions: string[];
141
+ packages: string[];
140
142
  contextItems: string[];
141
143
  contextCount: number;
144
+ packagesCount: number;
142
145
  themesCount: number;
143
146
  skillsCount: number;
144
147
  promptsCount: number;
@@ -192,12 +195,13 @@ function renderLogo(
192
195
 
193
196
  const counts = [
194
197
  `context: ${info.contextCount}`,
195
- `themes: ${info.themesCount}`,
198
+ `packages: ${info.packagesCount}`,
199
+ `tools: ${info.toolsCount}`,
196
200
  `skills: ${info.skillsCount}`,
197
201
  `prompts: ${info.promptsCount}`,
198
- `extensions: ${info.extensionsCount}`,
199
202
  `commands: ${info.commandsCount}`,
200
- `tools: ${info.toolsCount}`,
203
+ `extensions: ${info.extensionsCount}`,
204
+ `themes: ${info.themesCount}`,
201
205
  ].join(' | ');
202
206
 
203
207
  return [
@@ -207,6 +211,8 @@ function renderLogo(
207
211
  '',
208
212
  ...renderInfoSection(theme, 'Context', info.contextItems, width),
209
213
  '',
214
+ ...renderInfoSection(theme, 'Packages', info.packages, width),
215
+ '',
210
216
  ...renderInfoSection(theme, 'Tools', info.tools, width),
211
217
  '',
212
218
  ...renderInfoSection(theme, 'Skills', info.skills, width),
@@ -249,9 +255,9 @@ function formatRelativePath(cwd: string, filePath: string): string {
249
255
  }
250
256
 
251
257
  function formatDisplayPath(cwd: string, filePath: string): string {
252
- const home = homedir();
258
+ const home = getHomeDir();
253
259
  const rel = relative(cwd, filePath);
254
- if (!rel || (!rel.startsWith('..') && !rel.startsWith('/'))) return rel || '.';
260
+ if (!rel || !rel.startsWith('..')) return rel || '.';
255
261
  if (filePath === home) return '~';
256
262
  if (filePath.startsWith(`${home}/`)) return `~/${relative(home, filePath)}`;
257
263
  return filePath;
@@ -328,57 +334,224 @@ function readPackageLabel(startPath: string): string | undefined {
328
334
  return undefined;
329
335
  }
330
336
 
331
- function getExtensionItems(cwd: string, commands: SlashCommandInfo[]): string[] {
332
- const extensions = new Map<string, string>();
337
+ function getNpmRoot(): string | undefined {
338
+ if (_npmRootResolved) return _npmRoot;
339
+ _npmRootResolved = true;
340
+
341
+ const home = getHomeDir();
342
+
343
+ // NVM: ~/.nvm/versions/node/<version>/lib/node_modules
344
+ if (process.env.NVM_DIR) {
345
+ const versionsDir = join(process.env.NVM_DIR, 'versions', 'node');
346
+ try {
347
+ if (existsSync(versionsDir)) {
348
+ for (const v of readdirSync(versionsDir).sort().reverse()) {
349
+ const dir = join(versionsDir, v, 'lib', 'node_modules');
350
+ if (existsSync(dir)) {
351
+ _npmRoot = dir;
352
+ return _npmRoot;
353
+ }
354
+ }
355
+ }
356
+ } catch {
357
+ /* ignore */
358
+ }
359
+ }
360
+
361
+ // Bun: ~/.bun/install/global/node_modules
362
+ const bunDir = join(home, '.bun', 'install', 'global', 'node_modules');
363
+ if (existsSync(bunDir)) {
364
+ _npmRoot = bunDir;
365
+ return _npmRoot;
366
+ }
367
+
368
+ // Fallbacks
369
+ for (const dir of ['/usr/local/lib/node_modules', '/usr/lib/node_modules']) {
370
+ if (existsSync(dir)) {
371
+ _npmRoot = dir;
372
+ return _npmRoot;
373
+ }
374
+ }
375
+
376
+ return undefined;
377
+ }
378
+
379
+ let _npmRoot: string | undefined;
380
+ let _npmRootResolved = false;
381
+
382
+ // ── shared: read array field from settings.json ──
383
+
384
+ function readSettingsArray(path: string, key: string): string[] {
385
+ if (!existsSync(path)) return [];
386
+ try {
387
+ const value = JSON.parse(readFileSync(path, 'utf-8'))[key];
388
+ return Array.isArray(value) ? value.map(String) : [];
389
+ } catch {
390
+ return [];
391
+ }
392
+ }
393
+
394
+ // ── shared: read raw package sources from settings.json ──
395
+
396
+ interface PackageSource {
397
+ source: string;
398
+ scope: 'project' | 'user';
399
+ }
400
+
401
+ interface ExtensionSource {
402
+ source: string;
403
+ baseDir: string;
404
+ }
405
+
406
+ function readPackageSources(cwd: string, home = getHomeDir()): PackageSource[] {
407
+ const globalPkgs = readSettingsArray(join(home, '.pi', 'agent', 'settings.json'), 'packages');
408
+ const projectPkgs = readSettingsArray(join(cwd, '.pi', 'settings.json'), 'packages');
333
409
 
334
- for (const command of commands) {
335
- if (command.source !== 'extension') continue;
410
+ // dedupe: project wins over global
411
+ const seen = new Set<string>();
412
+ const all: PackageSource[] = [];
413
+ for (const s of projectPkgs) {
414
+ if (seen.has(s)) continue;
415
+ seen.add(s);
416
+ all.push({ source: s, scope: 'project' });
417
+ }
418
+ for (const s of globalPkgs) {
419
+ if (seen.has(s)) continue;
420
+ seen.add(s);
421
+ all.push({ source: s, scope: 'user' });
422
+ }
423
+ return all;
424
+ }
336
425
 
337
- const sourcePath = command.sourceInfo?.baseDir ?? command.sourceInfo?.path;
338
- const key = sourcePath || command.sourceInfo?.source || command.name;
339
- const label =
340
- (sourcePath ? readPackageLabel(sourcePath) : undefined) ??
341
- (sourcePath ? formatDisplayPath(cwd, sourcePath) : undefined) ??
342
- command.sourceInfo?.source ??
343
- command.name;
344
- extensions.set(key, label);
426
+ // ── resolve a package source to its directory path ──
427
+
428
+ function resolvePackageDir(source: string, cwd: string, home = getHomeDir()): string | undefined {
429
+ if (source.startsWith('npm:')) {
430
+ const name = source.slice(4);
431
+ const npmRoot = getNpmRoot();
432
+ // project-scoped install (.pi/npm/node_modules/<name>)
433
+ const projectDir = join(cwd, '.pi', 'npm', 'node_modules', name);
434
+ if (existsSync(projectDir)) return projectDir;
435
+ // user-scoped install (~/.pi/agent/node_modules/<name>)
436
+ const userDir = join(home, '.pi', 'agent', 'node_modules', name);
437
+ if (existsSync(userDir)) return userDir;
438
+ // global npm root
439
+ if (npmRoot) {
440
+ const globalDir = join(npmRoot, name);
441
+ if (existsSync(globalDir)) return globalDir;
442
+ }
443
+ return undefined;
345
444
  }
346
445
 
347
- return Array.from(extensions.values()).sort((a, b) => a.localeCompare(b));
446
+ return source.startsWith('~')
447
+ ? join(home, source.slice(source.startsWith('~/') ? 2 : 1))
448
+ : resolve(cwd, source);
449
+ }
450
+
451
+ // ── getPackages: name+version from each configured package ──
452
+
453
+ function getPackages(cwd: string, home = getHomeDir()): string[] {
454
+ const sources = readPackageSources(cwd, home);
455
+ const results: string[] = [];
456
+
457
+ for (const { source, scope } of sources) {
458
+ const pkgDir = resolvePackageDir(source, cwd, home);
459
+ const scopeTag = scope === 'project' ? ' [l]' : ' [g]';
460
+ if (!pkgDir) {
461
+ // fallback: show npm package name or raw source
462
+ const name = source.startsWith('npm:') ? source.slice(4) : source;
463
+ results.push(name + scopeTag);
464
+ continue;
465
+ }
466
+ const label = readPackageLabel(pkgDir) ?? source;
467
+ results.push(label + scopeTag);
468
+ }
469
+
470
+ return results.sort((a, b) => a.localeCompare(b));
471
+ }
472
+
473
+ // ── getExtensionItems: scan .ts files from settings.json extensions dirs ──
474
+
475
+ function readExtensionSources(cwd: string, home = getHomeDir()): ExtensionSource[] {
476
+ const projectBaseDir = join(cwd, '.pi');
477
+ const globalBaseDir = join(home, '.pi', 'agent');
478
+ const projectExts = readSettingsArray(join(projectBaseDir, 'settings.json'), 'extensions');
479
+ const globalExts = readSettingsArray(join(globalBaseDir, 'settings.json'), 'extensions');
480
+
481
+ return [
482
+ ...projectExts.map((source) => ({ source, baseDir: projectBaseDir })),
483
+ ...globalExts.map((source) => ({ source, baseDir: globalBaseDir })),
484
+ ];
485
+ }
486
+
487
+ function resolveSettingsSource(source: string, baseDir: string, home = getHomeDir()): string {
488
+ return source.startsWith('~')
489
+ ? join(home, source.slice(source.startsWith('~/') ? 2 : 1))
490
+ : resolve(baseDir, source);
491
+ }
492
+
493
+ function getExtensionItems(cwd: string, home = getHomeDir()): string[] {
494
+ const results: string[] = [];
495
+ const seenFiles = new Set<string>();
496
+
497
+ function addFile(filePath: string) {
498
+ const key = resolve(filePath);
499
+ if (seenFiles.has(key)) return;
500
+ seenFiles.add(key);
501
+ results.push(formatDisplayPath(cwd, filePath));
502
+ }
503
+
504
+ for (const { source, baseDir } of readExtensionSources(cwd, home)) {
505
+ const resolved = resolveSettingsSource(source, baseDir, home);
506
+
507
+ if (!existsSync(resolved)) continue;
508
+
509
+ try {
510
+ const s = statSync(resolved);
511
+ if (s.isDirectory()) {
512
+ for (const f of readdirSync(resolved).sort()) {
513
+ if (f.endsWith('.ts')) addFile(join(resolved, f));
514
+ }
515
+ } else {
516
+ addFile(resolved);
517
+ }
518
+ } catch {
519
+ // ignore unreadable paths
520
+ }
521
+ }
522
+
523
+ return results;
348
524
  }
349
525
 
350
526
  function shouldShowHeaderInfo(ctx: ExtensionContext, reason: SessionStartEvent['reason']): boolean {
351
527
  if (reason !== 'startup' && reason !== 'reload') return false;
352
528
  const settings = readPowerlineSettings(ctx.cwd);
353
- if (!settings.quietStartup) return false;
354
- return settings['header-info'];
529
+ return settings.quietStartup && settings['header-info'];
355
530
  }
356
531
 
357
532
  function collectHeaderInfo(
358
533
  pi: ExtensionAPI,
359
534
  ctx: ExtensionContext,
360
- theme: Theme,
361
535
  contextItems: string[],
362
536
  skillItems: string[],
363
537
  ): HeaderInfo {
364
538
  const commands = typeof pi.getCommands === 'function' ? pi.getCommands() : [];
365
539
  const allThemes = typeof ctx.ui.getAllThemes === 'function' ? ctx.ui.getAllThemes() : [];
366
- const themeName = theme.name ?? ctx.ui.theme?.name ?? 'current';
367
- const extensions = getExtensionItems(ctx.cwd, commands);
540
+ const extensions = getExtensionItems(ctx.cwd);
541
+ const packages = getPackages(ctx.cwd);
368
542
  const activeTools =
369
543
  typeof pi.getActiveTools === 'function'
370
544
  ? pi.getActiveTools().sort((a, b) => a.localeCompare(b))
371
545
  : [];
372
546
 
373
547
  return {
374
- themeName,
375
- cwd: ctx.cwd,
376
- commands: getCommandNames(commands),
377
548
  prompts: getCommandNames(commands, 'prompt'),
378
549
  skills: skillItems,
379
550
  extensions,
551
+ packages,
380
552
  contextItems,
381
553
  contextCount: contextItems.length,
554
+ packagesCount: packages.length,
382
555
  themesCount: allThemes.length,
383
556
  skillsCount: skillItems.length,
384
557
  promptsCount: countUniqueSources(commands, 'prompt'),
@@ -407,7 +580,7 @@ export function registerHeader(pi: ExtensionAPI) {
407
580
  reason,
408
581
  width,
409
582
  shouldShowHeaderInfo(ctx, reason)
410
- ? collectHeaderInfo(pi, ctx, theme, contextItems, skillItems)
583
+ ? collectHeaderInfo(pi, ctx, contextItems, skillItems)
411
584
  : undefined,
412
585
  );
413
586
  },
package/index.ts CHANGED
@@ -143,58 +143,33 @@ export default function (pi: ExtensionAPI) {
143
143
 
144
144
  const ns = arg.slice(0, colonIdx);
145
145
  const val = arg.slice(colonIdx + 1);
146
- let msg = '';
147
146
 
148
- switch (ns) {
149
- case 'breadcrumb': {
150
- if (!['hide', 'top', 'inner'].includes(val)) {
151
- ctx.ui.notify('breadcrumb must be: hide, top, or inner', 'warning');
152
- return;
153
- }
154
- writePowerlineSetting(ctx.cwd, 'breadcrumb', val);
155
- pi.events.emit('powerline_settings_changed', ctx);
156
- msg = `breadcrumb → ${val}`;
157
- break;
158
- }
159
- case 'footer': {
160
- if (val !== 'on' && val !== 'off') {
161
- ctx.ui.notify('footer must be: on or off', 'warning');
162
- return;
163
- }
164
- writePowerlineSetting(ctx.cwd, 'footer', val === 'on');
165
- pi.events.emit('powerline_settings_changed', ctx);
166
- msg = `footer → ${val}`;
167
- break;
168
- }
169
- case 'header': {
170
- if (val !== 'on' && val !== 'off') {
171
- ctx.ui.notify('header must be: on or off', 'warning');
172
- return;
173
- }
174
- writePowerlineSetting(ctx.cwd, 'header', val === 'on');
175
- pi.events.emit('powerline_settings_changed', ctx);
176
- msg = `header → ${val}`;
177
- break;
178
- }
179
- case 'header-info': {
180
- if (val !== 'on' && val !== 'off') {
181
- ctx.ui.notify('header-info must be: on or off', 'warning');
182
- return;
183
- }
184
- writePowerlineSetting(ctx.cwd, 'header-info', val === 'on');
185
- pi.events.emit('powerline_settings_changed', ctx);
186
- msg = `header-info → ${val}`;
187
- break;
147
+ if (ns === 'breadcrumb') {
148
+ if (!['hide', 'top', 'inner'].includes(val)) {
149
+ ctx.ui.notify('breadcrumb must be: hide, top, or inner', 'warning');
150
+ return;
188
151
  }
189
- default:
190
- ctx.ui.notify(
191
- 'Usage: /powerline <breadcrumb:hide|top|inner|footer:on|off|header:on|off|header-info:on|off>',
192
- 'warning',
193
- );
152
+ writePowerlineSetting(ctx.cwd, 'breadcrumb', val);
153
+ pi.events.emit('powerline_settings_changed', ctx);
154
+ ctx.ui.notify(`breadcrumb ${val}`, 'info');
155
+ return;
156
+ }
157
+
158
+ if (ns === 'footer' || ns === 'header' || ns === 'header-info') {
159
+ if (val !== 'on' && val !== 'off') {
160
+ ctx.ui.notify(`${ns} must be: on or off`, 'warning');
194
161
  return;
162
+ }
163
+ writePowerlineSetting(ctx.cwd, ns, val === 'on');
164
+ pi.events.emit('powerline_settings_changed', ctx);
165
+ ctx.ui.notify(`${ns} → ${val}`, 'info');
166
+ return;
195
167
  }
196
168
 
197
- ctx.ui.notify(msg, 'info');
169
+ ctx.ui.notify(
170
+ 'Usage: /powerline <breadcrumb:hide|top|inner|footer:on|off|header:on|off|header-info:on|off>',
171
+ 'warning',
172
+ );
198
173
  },
199
174
  });
200
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-powerline",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Powerline-style UI extensions for pi coding agent (custom editor, breadcrumb, footer, header)",
5
5
  "homepage": "https://github.com/jwu/pi-powerline#readme",
6
6
  "repository": {
package/settings.ts CHANGED
@@ -43,18 +43,11 @@ function readSettingsFile(settingsPath: string): Record<string, unknown> {
43
43
  }
44
44
  }
45
45
 
46
- function mergeSettings(
47
- globalSettings: Record<string, unknown>,
48
- projectSettings: Record<string, unknown>,
49
- ): Record<string, unknown> {
50
- return { ...globalSettings, ...projectSettings };
51
- }
52
-
53
46
  export function readSettings(cwd: string = process.cwd()): Record<string, unknown> {
54
- return mergeSettings(
55
- readSettingsFile(getSettingsPath()),
56
- readSettingsFile(getProjectSettingsPath(cwd)),
57
- );
47
+ return {
48
+ ...readSettingsFile(getSettingsPath()),
49
+ ...readSettingsFile(getProjectSettingsPath(cwd)),
50
+ };
58
51
  }
59
52
 
60
53
  function writeSettings(cwd: string, settings: Record<string, unknown>): void {