pi-powerline 0.5.1 → 0.6.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/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 {
@@ -131,14 +131,13 @@ function formatReasonStatus(theme: Theme, reason: SessionStartEvent['reason']):
131
131
  }
132
132
 
133
133
  interface HeaderInfo {
134
- themeName: string;
135
- cwd: string;
136
- commands: string[];
137
134
  prompts: string[];
138
135
  skills: string[];
139
136
  extensions: string[];
137
+ packages: string[];
140
138
  contextItems: string[];
141
139
  contextCount: number;
140
+ packagesCount: number;
142
141
  themesCount: number;
143
142
  skillsCount: number;
144
143
  promptsCount: number;
@@ -192,12 +191,13 @@ function renderLogo(
192
191
 
193
192
  const counts = [
194
193
  `context: ${info.contextCount}`,
195
- `themes: ${info.themesCount}`,
194
+ `packages: ${info.packagesCount}`,
195
+ `tools: ${info.toolsCount}`,
196
196
  `skills: ${info.skillsCount}`,
197
197
  `prompts: ${info.promptsCount}`,
198
- `extensions: ${info.extensionsCount}`,
199
198
  `commands: ${info.commandsCount}`,
200
- `tools: ${info.toolsCount}`,
199
+ `extensions: ${info.extensionsCount}`,
200
+ `themes: ${info.themesCount}`,
201
201
  ].join(' | ');
202
202
 
203
203
  return [
@@ -207,6 +207,8 @@ function renderLogo(
207
207
  '',
208
208
  ...renderInfoSection(theme, 'Context', info.contextItems, width),
209
209
  '',
210
+ ...renderInfoSection(theme, 'Packages', info.packages, width),
211
+ '',
210
212
  ...renderInfoSection(theme, 'Tools', info.tools, width),
211
213
  '',
212
214
  ...renderInfoSection(theme, 'Skills', info.skills, width),
@@ -251,7 +253,7 @@ function formatRelativePath(cwd: string, filePath: string): string {
251
253
  function formatDisplayPath(cwd: string, filePath: string): string {
252
254
  const home = homedir();
253
255
  const rel = relative(cwd, filePath);
254
- if (!rel || (!rel.startsWith('..') && !rel.startsWith('/'))) return rel || '.';
256
+ if (!rel || !rel.startsWith('..')) return rel || '.';
255
257
  if (filePath === home) return '~';
256
258
  if (filePath.startsWith(`${home}/`)) return `~/${relative(home, filePath)}`;
257
259
  return filePath;
@@ -328,57 +330,200 @@ function readPackageLabel(startPath: string): string | undefined {
328
330
  return undefined;
329
331
  }
330
332
 
331
- function getExtensionItems(cwd: string, commands: SlashCommandInfo[]): string[] {
332
- const extensions = new Map<string, string>();
333
+ function getNpmRoot(): string | undefined {
334
+ if (_npmRootResolved) return _npmRoot;
335
+ _npmRootResolved = true;
333
336
 
334
- for (const command of commands) {
335
- if (command.source !== 'extension') continue;
337
+ const home = homedir();
336
338
 
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);
339
+ // NVM: ~/.nvm/versions/node/<version>/lib/node_modules
340
+ if (process.env.NVM_DIR) {
341
+ const versionsDir = join(process.env.NVM_DIR, 'versions', 'node');
342
+ try {
343
+ if (existsSync(versionsDir)) {
344
+ for (const v of readdirSync(versionsDir).sort().reverse()) {
345
+ const dir = join(versionsDir, v, 'lib', 'node_modules');
346
+ if (existsSync(dir)) {
347
+ _npmRoot = dir;
348
+ return _npmRoot;
349
+ }
350
+ }
351
+ }
352
+ } catch {
353
+ /* ignore */
354
+ }
355
+ }
356
+
357
+ // Bun: ~/.bun/install/global/node_modules
358
+ const bunDir = join(home, '.bun', 'install', 'global', 'node_modules');
359
+ if (existsSync(bunDir)) {
360
+ _npmRoot = bunDir;
361
+ return _npmRoot;
362
+ }
363
+
364
+ // Fallbacks
365
+ for (const dir of ['/usr/local/lib/node_modules', '/usr/lib/node_modules']) {
366
+ if (existsSync(dir)) {
367
+ _npmRoot = dir;
368
+ return _npmRoot;
369
+ }
345
370
  }
346
371
 
347
- return Array.from(extensions.values()).sort((a, b) => a.localeCompare(b));
372
+ return undefined;
373
+ }
374
+
375
+ let _npmRoot: string | undefined;
376
+ let _npmRootResolved = false;
377
+
378
+ // ── shared: read array field from settings.json ──
379
+
380
+ function readSettingsArray(path: string, key: string): string[] {
381
+ if (!existsSync(path)) return [];
382
+ try {
383
+ const value = JSON.parse(readFileSync(path, 'utf-8'))[key];
384
+ return Array.isArray(value) ? value.map(String) : [];
385
+ } catch {
386
+ return [];
387
+ }
388
+ }
389
+
390
+ // ── shared: read raw package sources from settings.json ──
391
+
392
+ interface PackageSource {
393
+ source: string;
394
+ scope: 'project' | 'user';
395
+ }
396
+
397
+ function readPackageSources(cwd: string, home = homedir()): PackageSource[] {
398
+ const globalPkgs = readSettingsArray(join(home, '.pi', 'agent', 'settings.json'), 'packages');
399
+ const projectPkgs = readSettingsArray(join(cwd, '.pi', 'settings.json'), 'packages');
400
+
401
+ // dedupe: project wins over global
402
+ const seen = new Set<string>();
403
+ const all: PackageSource[] = [];
404
+ for (const s of projectPkgs) {
405
+ if (seen.has(s)) continue;
406
+ seen.add(s);
407
+ all.push({ source: s, scope: 'project' });
408
+ }
409
+ for (const s of globalPkgs) {
410
+ if (seen.has(s)) continue;
411
+ seen.add(s);
412
+ all.push({ source: s, scope: 'user' });
413
+ }
414
+ return all;
415
+ }
416
+
417
+ // ── resolve a package source to its directory path ──
418
+
419
+ function resolvePackageDir(source: string, cwd: string, home = homedir()): string | undefined {
420
+ if (source.startsWith('npm:')) {
421
+ const name = source.slice(4);
422
+ const npmRoot = getNpmRoot();
423
+ // project-scoped install (.pi/npm/node_modules/<name>)
424
+ const projectDir = join(cwd, '.pi', 'npm', 'node_modules', name);
425
+ if (existsSync(projectDir)) return projectDir;
426
+ // user-scoped install (~/.pi/agent/node_modules/<name>)
427
+ const userDir = join(home, '.pi', 'agent', 'node_modules', name);
428
+ if (existsSync(userDir)) return userDir;
429
+ // global npm root
430
+ if (npmRoot) {
431
+ const globalDir = join(npmRoot, name);
432
+ if (existsSync(globalDir)) return globalDir;
433
+ }
434
+ return undefined;
435
+ }
436
+
437
+ return source.startsWith('~')
438
+ ? join(home, source.slice(source.startsWith('~/') ? 2 : 1))
439
+ : resolve(cwd, source);
440
+ }
441
+
442
+ // ── getPackages: name+version from each configured package ──
443
+
444
+ function getPackages(cwd: string, home = homedir()): string[] {
445
+ const sources = readPackageSources(cwd, home);
446
+ const results: string[] = [];
447
+
448
+ for (const { source, scope } of sources) {
449
+ const pkgDir = resolvePackageDir(source, cwd, home);
450
+ const scopeTag = scope === 'project' ? ' [l]' : ' [g]';
451
+ if (!pkgDir) {
452
+ // fallback: show npm package name or raw source
453
+ const name = source.startsWith('npm:') ? source.slice(4) : source;
454
+ results.push(name + scopeTag);
455
+ continue;
456
+ }
457
+ const label = readPackageLabel(pkgDir) ?? source;
458
+ results.push(label + scopeTag);
459
+ }
460
+
461
+ return results.sort((a, b) => a.localeCompare(b));
462
+ }
463
+
464
+ // ── getExtensionItems: scan .ts files from settings.json extensions dirs ──
465
+
466
+ function getExtensionItems(cwd: string, home = homedir()): string[] {
467
+ const results: string[] = [];
468
+
469
+ const globalExts = readSettingsArray(join(home, '.pi', 'agent', 'settings.json'), 'extensions');
470
+ const projectExts = readSettingsArray(join(cwd, '.pi', 'settings.json'), 'extensions');
471
+
472
+ for (const ext of [...projectExts, ...globalExts]) {
473
+ const resolved = ext.startsWith('~')
474
+ ? join(home, ext.slice(ext.startsWith('~/') ? 2 : 1))
475
+ : resolve(cwd, ext);
476
+
477
+ if (!existsSync(resolved)) continue;
478
+
479
+ try {
480
+ const s = statSync(resolved);
481
+ if (s.isDirectory()) {
482
+ for (const f of readdirSync(resolved).sort()) {
483
+ if (f.endsWith('.ts')) {
484
+ results.push(formatDisplayPath(cwd, join(resolved, f)));
485
+ }
486
+ }
487
+ } else {
488
+ results.push(formatDisplayPath(cwd, resolved));
489
+ }
490
+ } catch {
491
+ // ignore unreadable paths
492
+ }
493
+ }
494
+
495
+ return results;
348
496
  }
349
497
 
350
498
  function shouldShowHeaderInfo(ctx: ExtensionContext, reason: SessionStartEvent['reason']): boolean {
351
499
  if (reason !== 'startup' && reason !== 'reload') return false;
352
500
  const settings = readPowerlineSettings(ctx.cwd);
353
- if (!settings.quietStartup) return false;
354
- return settings['header-info'];
501
+ return settings.quietStartup && settings['header-info'];
355
502
  }
356
503
 
357
504
  function collectHeaderInfo(
358
505
  pi: ExtensionAPI,
359
506
  ctx: ExtensionContext,
360
- theme: Theme,
361
507
  contextItems: string[],
362
508
  skillItems: string[],
363
509
  ): HeaderInfo {
364
510
  const commands = typeof pi.getCommands === 'function' ? pi.getCommands() : [];
365
511
  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);
512
+ const extensions = getExtensionItems(ctx.cwd);
513
+ const packages = getPackages(ctx.cwd);
368
514
  const activeTools =
369
515
  typeof pi.getActiveTools === 'function'
370
516
  ? pi.getActiveTools().sort((a, b) => a.localeCompare(b))
371
517
  : [];
372
518
 
373
519
  return {
374
- themeName,
375
- cwd: ctx.cwd,
376
- commands: getCommandNames(commands),
377
520
  prompts: getCommandNames(commands, 'prompt'),
378
521
  skills: skillItems,
379
522
  extensions,
523
+ packages,
380
524
  contextItems,
381
525
  contextCount: contextItems.length,
526
+ packagesCount: packages.length,
382
527
  themesCount: allThemes.length,
383
528
  skillsCount: skillItems.length,
384
529
  promptsCount: countUniqueSources(commands, 'prompt'),
@@ -407,7 +552,7 @@ export function registerHeader(pi: ExtensionAPI) {
407
552
  reason,
408
553
  width,
409
554
  shouldShowHeaderInfo(ctx, reason)
410
- ? collectHeaderInfo(pi, ctx, theme, contextItems, skillItems)
555
+ ? collectHeaderInfo(pi, ctx, contextItems, skillItems)
411
556
  : undefined,
412
557
  );
413
558
  },
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.0",
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 {