@underactive/pi-topping-statusline 0.2.0 → 0.2.2

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/src/menu.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * ║ ╭── π ⬢ model ───────────────────── session ──╮ ║
17
17
  * ║ ║
18
18
  * ╟─ Global ─────────────────────────────────────────╢
19
- * ║ ▸ [■] Transparent Segments ON ║
19
+ * ║ > [■] Transparent Segments ON ║
20
20
  * ║ Separator ‹ powerline-thin › ║
21
21
  * ║ ║
22
22
  * ╟──────────────────────────────────────────────────╢
@@ -118,11 +118,6 @@ const PLAIN_ROW_PREFIX_LEN = 5;
118
118
  const CHECKBOX_ROW_PREFIX_LEN = 8;
119
119
 
120
120
  interface FlatItem {
121
- id: string;
122
- label: string;
123
- cycleValues?: readonly string[];
124
- cycleEnabledBy?: string;
125
- cycleDisabledValue?: string;
126
121
  item: MenuItem;
127
122
  sectionIndex: number;
128
123
  }
@@ -131,15 +126,7 @@ function flattenSections(sections: MenuSection[]): FlatItem[] {
131
126
  const flat: FlatItem[] = [];
132
127
  for (const [sectionIndex, section] of sections.entries()) {
133
128
  for (const item of section.items) {
134
- flat.push({
135
- id: item.id,
136
- label: item.label,
137
- cycleValues: item.cycleValues,
138
- cycleEnabledBy: item.cycleEnabledBy,
139
- cycleDisabledValue: item.cycleDisabledValue,
140
- item,
141
- sectionIndex,
142
- });
129
+ flat.push({ item, sectionIndex });
143
130
  }
144
131
  }
145
132
  return flat;
@@ -260,26 +247,26 @@ export class MenuComponent implements Component {
260
247
  [Key.space]: () => {
261
248
  const item = this.flat[this.cursor]!;
262
249
  if (item.item.action) {
263
- this.runAction(item.id);
250
+ this.runAction(item.item.id);
264
251
  return;
265
252
  }
266
253
  if (item.item.text) {
267
254
  this.beginEditing(item);
268
255
  return;
269
256
  }
270
- if (item.cycleValues && item.cycleEnabledBy) {
271
- this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
272
- if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) {
273
- this.values[item.id] = item.cycleDisabledValue;
257
+ if (item.item.cycleValues && item.item.cycleEnabledBy) {
258
+ this.values[item.item.cycleEnabledBy] = !this.values[item.item.cycleEnabledBy] as boolean;
259
+ if (!this.values[item.item.cycleEnabledBy] && item.item.cycleDisabledValue !== undefined) {
260
+ this.values[item.item.id] = item.item.cycleDisabledValue;
274
261
  }
275
- } else if (!item.cycleValues) this.values[item.id] = !this.values[item.id] as boolean;
262
+ } else if (!item.item.cycleValues) this.values[item.item.id] = !this.values[item.item.id] as boolean;
276
263
  this.invalidate();
277
264
  },
278
265
  // Enter opens an editor or fires an action on those rows, so applying
279
266
  // the whole menu stays available from any other row.
280
267
  [Key.enter]: () => {
281
268
  const item = this.flat[this.cursor]!;
282
- if (item.item.action) this.runAction(item.id);
269
+ if (item.item.action) this.runAction(item.item.id);
283
270
  else if (item.item.text) this.beginEditing(item);
284
271
  else this.done({ applied: true, values: { ...this.values } });
285
272
  },
@@ -297,7 +284,7 @@ export class MenuComponent implements Component {
297
284
  }
298
285
 
299
286
  private beginEditing(item: FlatItem): void {
300
- this.editing = { id: item.id, buffer: String(this.values[item.id] ?? "") };
287
+ this.editing = { id: item.item.id, buffer: String(this.values[item.item.id] ?? "") };
301
288
  this.invalidate();
302
289
  }
303
290
 
@@ -373,10 +360,10 @@ export class MenuComponent implements Component {
373
360
 
374
361
  private cycleCurrentValue(delta: number): void {
375
362
  const item = this.flat[this.cursor]!;
376
- if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
377
- const current = item.cycleValues.indexOf(this.values[item.id] as string);
378
- const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
379
- this.values[item.id] = item.cycleValues[index]!;
363
+ if (!item.item.cycleValues?.length || (item.item.cycleEnabledBy && !this.values[item.item.cycleEnabledBy])) return;
364
+ const current = item.item.cycleValues.indexOf(this.values[item.item.id] as string);
365
+ const index = (current + delta + item.item.cycleValues.length) % item.item.cycleValues.length;
366
+ this.values[item.item.id] = item.item.cycleValues[index]!;
380
367
  this.invalidate();
381
368
  }
382
369
 
@@ -386,7 +373,7 @@ export class MenuComponent implements Component {
386
373
  const result = this.previewFn(
387
374
  this.values,
388
375
  this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
389
- this.flat[this.cursor]?.id,
376
+ this.flat[this.cursor]?.item.id,
390
377
  innerWidth,
391
378
  );
392
379
  if (Array.isArray(result)) {
@@ -500,8 +487,8 @@ export class MenuComponent implements Component {
500
487
  }
501
488
 
502
489
  private availableRows(): number | undefined {
503
- const rows = (this.tui as (TUI & { terminal?: { rows?: number } }) | undefined)?.terminal?.rows;
504
- return typeof rows === "number" && Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : undefined;
490
+ const rows = this.tui?.terminal.rows;
491
+ return rows !== undefined && rows > 0 ? rows : undefined;
505
492
  }
506
493
 
507
494
  private buildLines(maxWidth: number, maxRows?: number): string[] {
@@ -597,15 +584,17 @@ export class MenuComponent implements Component {
597
584
  private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
598
585
  const th = this.theme;
599
586
  const value = this.values[item.id]!;
600
- const marker = selected ? "\u25b8" : " ";
587
+ const marker = selected ? ">" : " ";
601
588
  const markerColored = selected ? th.fg("accent", marker) : marker;
589
+ const rowBackground = (content: string) =>
590
+ selected ? th.bg("selectedBg", content.replaceAll("\x1b[0m", `\x1b[0m${th.getBgAnsi("selectedBg")}`)) : content;
602
591
 
603
592
  if (item.action) {
604
593
  const label = truncateToWidth(item.label, Math.max(0, innerWidth - 5));
605
594
  const leftPlain = ` ${marker} ${label}`;
606
595
  const pad = Math.max(0, innerWidth - visibleWidth(leftPlain));
607
596
  const content = ` ${markerColored} ${th.fg(selected ? "accent" : "muted", label)}${" ".repeat(pad)}`;
608
- return this.wrap("\u2551", content, "\u2551");
597
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
609
598
  }
610
599
 
611
600
  if (item.text) {
@@ -626,7 +615,7 @@ export class MenuComponent implements Component {
626
615
  const leftPlain = ` ${marker} ${label} ${fieldText}${caret}`;
627
616
  const pad = Math.max(0, innerWidth - visibleWidth(leftPlain) - 1);
628
617
  const content = ` ${markerColored} ${th.fg("dim", label)} ${fieldStyled}${editing ? th.fg("accent", caret) : ""}${" ".repeat(pad)} `;
629
- return this.wrap("\u2551", content, "\u2551");
618
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
630
619
  }
631
620
 
632
621
  if (item.cycleValues) {
@@ -638,7 +627,7 @@ export class MenuComponent implements Component {
638
627
  const leftPlain = ` ${marker} ${label}`;
639
628
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
640
629
  const content = ` ${markerColored} ${th.fg("text", label)}${" ".repeat(gap)}${th.fg("accent", stateWord)} `;
641
- return this.wrap("\u2551", content, "\u2551");
630
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
642
631
  }
643
632
  const enabled = this.values[item.cycleEnabledBy] as boolean;
644
633
  const maxLabelLen = Math.max(0, innerWidth - CHECKBOX_ROW_PREFIX_LEN - visibleWidth(stateWord) - 1);
@@ -646,7 +635,7 @@ export class MenuComponent implements Component {
646
635
  const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
647
636
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
648
637
  const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
649
- return this.wrap("\u2551", content, "\u2551");
638
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
650
639
  }
651
640
 
652
641
  const enabled = value as boolean;
@@ -658,7 +647,7 @@ export class MenuComponent implements Component {
658
647
  const leftPlain = ` ${marker} [${box}] ${label}`;
659
648
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
660
649
  const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `;
661
- return this.wrap("\u2551", content, "\u2551");
650
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
662
651
  }
663
652
  }
664
653
 
@@ -0,0 +1,66 @@
1
+ /** Provider-specific NVIDIA green border colorizer for Switchyard. */
2
+ import { rgbToHex, wrapDeg, type BorderColorizer } from "./rainbow.js";
3
+ import { detectColorMode, hexToFgAnsi, parseHex, type ColorMode } from "./theme.js";
4
+
5
+ export const SWITCHYARD_PROVIDER = "switchyard";
6
+ export const NVIDIA_GREEN_HEX = "#84c51a";
7
+ export const NVIDIA_GREEN_DARK_HEX = "#0b3d20";
8
+ const GREEN_SHADE_COUNT = 256;
9
+ const GREEN_BIAS = 0.65;
10
+
11
+ export function isSwitchyardProvider(provider: string | undefined): boolean {
12
+ return provider?.trim().toLowerCase() === SWITCHYARD_PROVIDER;
13
+ }
14
+
15
+ const ansiByShadeByMode = new Map<ColorMode, string[]>();
16
+
17
+ function buildAnsiByShade(mode: ColorMode): string[] {
18
+ const cached = ansiByShadeByMode.get(mode);
19
+ if (cached !== undefined) return cached;
20
+
21
+ const dark = parseHex(NVIDIA_GREEN_DARK_HEX);
22
+ const bright = parseHex(NVIDIA_GREEN_HEX);
23
+ if (dark === undefined || bright === undefined) {
24
+ throw new Error("Invalid NVIDIA green palette");
25
+ }
26
+
27
+ const ansiByShade: string[] = [];
28
+ for (let i = 0; i < GREEN_SHADE_COUNT; i++) {
29
+ const progress = i / (GREEN_SHADE_COUNT - 1);
30
+ const rgb: [number, number, number] = [
31
+ Math.round(dark[0] + (bright[0] - dark[0]) * progress),
32
+ Math.round(dark[1] + (bright[1] - dark[1]) * progress),
33
+ Math.round(dark[2] + (bright[2] - dark[2]) * progress),
34
+ ];
35
+ ansiByShade.push(hexToFgAnsi(rgbToHex(rgb), mode));
36
+ }
37
+
38
+ ansiByShadeByMode.set(mode, ansiByShade);
39
+ return ansiByShade;
40
+ }
41
+
42
+ export class NvidiaGreenBorder implements BorderColorizer {
43
+ #phaseDeg: number;
44
+ readonly #ansiByShade: string[];
45
+
46
+ constructor(phaseDeg = 0) {
47
+ this.#phaseDeg = wrapDeg(phaseDeg);
48
+ this.#ansiByShade = buildAnsiByShade(detectColorMode());
49
+ }
50
+
51
+ step(deltaDeg: number): void {
52
+ this.#phaseDeg = wrapDeg(this.#phaseDeg + deltaDeg);
53
+ }
54
+
55
+ colorChar(char: string, perimeterPos: number, perimeter: number): string {
56
+ return `${this.prefix(perimeterPos, perimeter)}${char}\x1b[39m`;
57
+ }
58
+
59
+ prefix(perimeterPos: number, perimeter: number): string {
60
+ const t = (perimeterPos / perimeter + this.#phaseDeg / 360) % 1;
61
+ const u = (1 - Math.cos(2 * Math.PI * t)) / 2;
62
+ const eased = u ** GREEN_BIAS;
63
+ const shadeIndex = Math.round((1 - eased) * (GREEN_SHADE_COUNT - 1));
64
+ return this.#ansiByShade[shadeIndex];
65
+ }
66
+ }
package/src/rainbow.ts CHANGED
@@ -24,6 +24,9 @@ export const RAINBOW_DEG_PER_FRAME = (360 * RAINBOW_FRAME_MS) / RAINBOW_CYCLE_MS
24
24
  const SATURATION = 1;
25
25
  const LIGHTNESS = 0.62;
26
26
 
27
+ /** Normalize a degree value into the [0, 360) range. */
28
+ export const wrapDeg = (deg: number): number => ((deg % 360) + 360) % 360;
29
+
27
30
  /**
28
31
  * Number of border cells in the box (rows 0..bottomIdx, cols 0..width-1):
29
32
  * two horizontal edges of `width` cells plus two vertical edges of
@@ -64,27 +67,38 @@ export function rgbToHex([r, g, b]: [number, number, number]): string {
64
67
  return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
65
68
  }
66
69
 
70
+ export interface BorderColorizer {
71
+ colorChar(ch: string, perimeterPos: number, perimeter: number): string;
72
+ /** ANSI foreground-color escape for a perimeter position, with no glyph or reset. */
73
+ prefix(perimeterPos: number, perimeter: number): string;
74
+ step(deltaDeg: number): void;
75
+ }
76
+
67
77
  /**
68
78
  * Colors box glyphs with a hue that travels around the border perimeter as
69
79
  * the phase advances — the spectrum appears to flow around the box.
70
80
  */
71
- export class RainbowBorder {
81
+ export class RainbowBorder implements BorderColorizer {
72
82
  #phaseDeg: number;
73
83
  readonly #mode: ColorMode;
74
84
  readonly #ansiByHue: string[] = [];
75
85
 
76
86
  constructor(phaseDeg = 0) {
77
- this.#phaseDeg = ((phaseDeg % 360) + 360) % 360;
87
+ this.#phaseDeg = wrapDeg(phaseDeg);
78
88
  this.#mode = detectColorMode();
79
89
  }
80
90
 
81
91
  /** Advance the whole spectrum by `deltaDeg` (mod 360). */
82
92
  step(deltaDeg: number): void {
83
- this.#phaseDeg = ((this.#phaseDeg + deltaDeg) % 360 + 360) % 360;
93
+ this.#phaseDeg = wrapDeg(this.#phaseDeg + deltaDeg);
84
94
  }
85
95
 
86
96
  /** Color one border glyph whose walk position is `perimeterPos` of `perimeter`. */
87
97
  colorChar(char: string, perimeterPos: number, perimeter: number): string {
98
+ return `${this.prefix(perimeterPos, perimeter)}${char}\x1b[39m`;
99
+ }
100
+
101
+ prefix(perimeterPos: number, perimeter: number): string {
88
102
  const hue = (this.#phaseDeg + (360 * perimeterPos) / perimeter) % 360;
89
103
  const bucket = Math.round(hue) % 360;
90
104
  let ansi = this.#ansiByHue[bucket];
@@ -93,6 +107,6 @@ export class RainbowBorder {
93
107
  ansi = hexToFgAnsi(hex, this.#mode);
94
108
  this.#ansiByHue[bucket] = ansi;
95
109
  }
96
- return `${ansi}${char}\x1b[39m`;
110
+ return ansi;
97
111
  }
98
112
  }
package/src/segments.ts CHANGED
@@ -14,6 +14,7 @@ import { MAJOR_COLOR_HEXES, hexToFgAnsi, theme } from "./theme.js";
14
14
  import {
15
15
  clampPathLength,
16
16
  feedKey,
17
+ formatNumber,
17
18
  getSessionAccentHex,
18
19
  sanitizeLabel,
19
20
  sanitizeStatusText,
@@ -21,6 +22,7 @@ import {
21
22
  withIcon,
22
23
  } from "./utils.js";
23
24
  import type {
25
+ CompactionProgressView,
24
26
  FeedFormat,
25
27
  RenderedSegment,
26
28
  SegmentContext,
@@ -75,6 +77,24 @@ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string |
75
77
  return { scratch: false, relative: null };
76
78
  }
77
79
 
80
+ interface PathClassification {
81
+ scratch: boolean;
82
+ relative: string | null;
83
+ strippedDisplay: string;
84
+ }
85
+
86
+ let cachedCwd: string | undefined;
87
+ let cachedClassification: PathClassification | undefined;
88
+
89
+ /** cwd is effectively constant for a session, so cache the root scans instead of redoing them on every render (including repeated shrink passes). */
90
+ function classifyCwd(cwd: string): PathClassification {
91
+ if (cachedCwd === cwd && cachedClassification) return cachedClassification;
92
+ const { scratch, relative } = classifyProjectDir(cwd);
93
+ cachedClassification = { scratch, relative, strippedDisplay: stripDisplayRoot(cwd) };
94
+ cachedCwd = cwd;
95
+ return cachedClassification;
96
+ }
97
+
78
98
  // ═══════════════════════════════════════════════════════════════════════════
79
99
  // Segment implementations
80
100
  // ═══════════════════════════════════════════════════════════════════════════
@@ -133,14 +153,14 @@ const pathSegment: StatusLineSegment = {
133
153
  return { content: theme.fg("statusLinePath", content), visible: true };
134
154
  }
135
155
 
136
- const { scratch, relative } = classifyProjectDir(ctx.cwd);
156
+ const { scratch, relative, strippedDisplay } = classifyCwd(ctx.cwd);
137
157
  let pwd = ctx.cwd;
138
158
 
139
159
  if (opts.stripWorkPrefix) {
140
160
  if (scratch) {
141
161
  if (relative) pwd = relative;
142
162
  } else {
143
- pwd = stripDisplayRoot(pwd);
163
+ pwd = strippedDisplay;
144
164
  }
145
165
  }
146
166
  if (opts.abbreviate) {
@@ -296,9 +316,9 @@ const scrollHintSegment: StatusLineSegment = {
296
316
  };
297
317
 
298
318
  /**
299
- * Pi's embedded working indicator (spinner + message), already colored by the
300
- * host, so it is passed through verbatim. The layout truncates it, without an
301
- * ellipsis, when it overflows, mirroring pi's own border rendering.
319
+ * Pi's embedded status spinner (spinner + message), already colored by the
320
+ * host, so it is passed through verbatim. The layout applies the caller's
321
+ * truncation style: a bare cut for working, ellipsis for message-style kinds.
302
322
  */
303
323
  const workingSegment: StatusLineSegment = {
304
324
  id: "working",
@@ -326,12 +346,62 @@ const contextGraphSegment: StatusLineSegment = {
326
346
  labelColor: theme.getFgAnsi("dim"),
327
347
  showBar,
328
348
  showStats,
349
+ estimated: ctx.contextEstimated === true,
329
350
  }),
330
351
  visible: true,
331
352
  };
332
353
  },
333
354
  };
334
355
 
356
+ const clampPercent = (percent: number): number => Math.round(Math.min(100, Math.max(0, percent)));
357
+
358
+ /** "57% summarized · 16.3s": how much of the context has been summarized so far, and the time taken. */
359
+ export function formatCompactionInfo(view: CompactionProgressView): string {
360
+ const elapsed = `${(Math.max(0, view.elapsedMs) / 1000).toFixed(1)}s`;
361
+ return `${clampPercent(view.percent)}% summarized${theme.sep.dot}${elapsed}`;
362
+ }
363
+
364
+ /**
365
+ * "86K/131K (66%)": the tokens being compacted over the window, and as a share
366
+ * of it, in the context label's number style. The share is omitted when the
367
+ * window is unknown.
368
+ */
369
+ export function formatCompactionTokens(view: CompactionProgressView): string {
370
+ const before = formatNumber(Math.max(0, view.tokensBefore));
371
+ if (view.contextWindow <= 0) return `${before}/?`;
372
+ const usage = clampPercent((view.tokensBefore / view.contextWindow) * 100);
373
+ return `${before}/${formatNumber(view.contextWindow)} (${usage}%)`;
374
+ }
375
+
376
+ /** pi-topping-compact's figures, standing in for pi's stats while its progress is hosted. */
377
+ const compactionInfoSegment: StatusLineSegment = {
378
+ id: "compaction_info",
379
+ render(ctx) {
380
+ const view = ctx.compaction;
381
+ if (!view) return INVISIBLE;
382
+ return { content: theme.fg("dim", formatCompactionInfo(view)), visible: true };
383
+ },
384
+ };
385
+
386
+ /**
387
+ * pi-topping-compact's bar, standing in for the context graph while its progress
388
+ * is hosted, under the graph's own bar and stats toggles. The bar arrives
389
+ * pre-rendered with fg-only resets, so it is passed through verbatim.
390
+ */
391
+ const compactionGraphSegment: StatusLineSegment = {
392
+ id: "compaction_graph",
393
+ render(ctx) {
394
+ const view = ctx.compaction;
395
+ if (!view) return INVISIBLE;
396
+ const { showBar, showStats } = ctx.options.context;
397
+ const parts: string[] = [];
398
+ if (showBar) parts.push(view.bar);
399
+ if (showStats) parts.push(theme.fg("dim", formatCompactionTokens(view)));
400
+ if (parts.length === 0) return INVISIBLE;
401
+ return { content: parts.join(" "), visible: true };
402
+ },
403
+ };
404
+
335
405
  const accentAnsi = new Map<string, string>();
336
406
 
337
407
  const sessionNameSegment: StatusLineSegment = {
@@ -366,6 +436,8 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
366
436
  context_graph: contextGraphSegment,
367
437
  scroll_hint: scrollHintSegment,
368
438
  working: workingSegment,
439
+ compaction_info: compactionInfoSegment,
440
+ compaction_graph: compactionGraphSegment,
369
441
  };
370
442
 
371
443
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {