@underactive/pi-topping-statusline 0.1.0 → 0.1.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/index.ts CHANGED
@@ -25,38 +25,15 @@ import {
25
25
  type ExtensionContext,
26
26
  } from "@earendil-works/pi-coding-agent";
27
27
  import type { Component, TUI } from "@earendil-works/pi-tui";
28
- import { visibleWidth } from "@earendil-works/pi-tui";
28
+ import { makeBoxPainters, renderBoxRow } from "./src/box.js";
29
29
  import { SegmentContextBuilder } from "./src/context.js";
30
+ import { isBorderRow, stripAnsi, stripRedundantStats } from "./src/footer.js";
30
31
  import { buildStatusLine } from "./src/layout.js";
31
- import {
32
- perimeterLength,
33
- perimeterPosition,
34
- RAINBOW_DEG_PER_FRAME,
35
- RAINBOW_FRAME_MS,
36
- RainbowBorder,
37
- } from "./src/rainbow.js";
32
+ import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder } from "./src/rainbow.js";
38
33
  import { registerSettingsCommand } from "./src/settings-menu.js";
39
34
  import { createSettingsState } from "./src/settings.js";
40
35
  import { theme } from "./src/theme.js";
41
36
  import { TokenRateMonitor } from "./src/token-rate.js";
42
- import type { EffectiveStatusLineSettings, SegmentIncludes } from "./src/types.js";
43
-
44
- function segmentIncludes(effective: EffectiveStatusLineSettings): SegmentIncludes {
45
- const allSegments = [
46
- ...effective.leftSegments,
47
- ...effective.rightSegments,
48
- ...effective.bottomLeftSegments,
49
- ...effective.bottomRightSegments,
50
- ];
51
- return {
52
- git: allSegments.includes("git"),
53
- pr: allSegments.includes("pr"),
54
- piStats: allSegments.includes("pi_stats"),
55
- tokenRate: allSegments.includes("token_rate"),
56
- feeds: allSegments.includes("feeds") ? effective.segmentOptions.feeds.map(feed => feed.customType) : [],
57
- };
58
- }
59
-
60
37
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
61
38
 
62
39
  export default function (pi: ExtensionAPI) {
@@ -109,11 +86,6 @@ export default function (pi: ExtensionAPI) {
109
86
  });
110
87
 
111
88
  const MIN_BOXED_WIDTH = 12;
112
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
113
- const isBorderRow = (row: string): boolean => {
114
- const text = row.replace(ANSI_RE, "");
115
- return /^─+$/.test(text) || /^─+ [↑↓] \d+ more\s*─*$/.test(text);
116
- };
117
89
 
118
90
  const renderBoxed = (
119
91
  innerRender: (width: number) => string[],
@@ -138,77 +110,45 @@ export default function (pi: ExtensionAPI) {
138
110
  }
139
111
  if (lines.length < 3 || bottomIdx < 2) return innerRender(width);
140
112
 
141
- const hint = (lines[bottomIdx] ?? "").replace(ANSI_RE, "").match(/[↑↓] \d+ more/)?.[0] ?? "";
113
+ const hint = stripAnsi(lines[bottomIdx] ?? "").match(/[↑↓] \d+ more/)?.[0] ?? "";
142
114
  const effective = state.effective;
143
- const include = segmentIncludes(effective);
115
+ const include = effective.includes;
144
116
  const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
145
117
  // borderColor is assigned by the host after the factory returns — read late.
146
118
  const border = editor.borderColor ?? ((s: string) => s);
147
119
  const rainbowOn = rainbowActive();
148
120
  const box = theme.getBox(effective.borderStyle);
149
- const perimeter = perimeterLength(width, bottomIdx);
150
- // Per-glyph paint: rainbow mode colors each cell by its perimeter walk
151
- // position; otherwise the host border color is applied per glyph, which
152
- // renders identically to the old per-fragment wraps.
153
- const paint = (row: number, col: number, ch: string): string =>
154
- rainbowOn ? rainbow.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : border(ch);
155
- const horizRun = (row: number, startCol: number, count: number): string => {
156
- if (!rainbowOn) return border(box.horizontal.repeat(count));
157
- let out = "";
158
- for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
159
- return out;
160
- };
161
- const gapColor = (str: string, startCol: number, row: number): string => {
162
- if (!rainbowOn) return border(str);
163
- let out = "";
164
- for (let k = 0; k < str.length; k++) out += paint(row, startCol + k, str[k]!);
165
- return out;
166
- };
121
+ const painters = makeBoxPainters({ rainbowOn, rainbow, box, width, bottomIdx, flat: border });
167
122
  const bar = buildStatusLine(
168
123
  innerWidth,
169
124
  segCtx,
170
125
  effective,
171
- gapColor,
126
+ painters.gapColor,
172
127
  {
173
128
  left: effective.leftSegments,
174
129
  right: effective.rightSegments,
175
130
  },
176
131
  { col: 3, row: 0 },
177
132
  );
178
- const barWidth = visibleWidth(bar);
179
- const pad = Math.max(0, innerWidth - barWidth);
180
133
  // Leading spacer row: keeps the transcript from sitting flush on the box.
181
- const out: string[] = [
182
- "",
183
- paint(0, 0, box.topLeft) +
184
- horizRun(0, 1, 2) +
185
- bar +
186
- horizRun(0, 3 + barWidth, pad + 2) +
187
- paint(0, width - 1, box.topRight),
188
- ];
134
+ const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
189
135
  for (let i = 1; i < bottomIdx; i++) {
190
- out.push(paint(i, 0, box.vertical) + " " + (lines[i] ?? "") + " " + paint(i, width - 1, box.vertical));
136
+ out.push(
137
+ painters.paint(i, 0, box.vertical) + " " + (lines[i] ?? "") + " " + painters.paint(i, width - 1, box.vertical),
138
+ );
191
139
  }
192
140
  const bottomBar = buildStatusLine(
193
141
  innerWidth,
194
142
  segCtx,
195
143
  effective,
196
- gapColor,
144
+ painters.gapColor,
197
145
  {
198
146
  left: effective.bottomLeftSegments,
199
147
  right: effective.bottomRightSegments,
200
148
  },
201
149
  { col: 3, row: bottomIdx },
202
150
  );
203
- const bottomBarWidth = visibleWidth(bottomBar);
204
- const bottomPad = Math.max(0, innerWidth - bottomBarWidth);
205
- out.push(
206
- paint(bottomIdx, 0, box.bottomLeft) +
207
- horizRun(bottomIdx, 1, 2) +
208
- bottomBar +
209
- horizRun(bottomIdx, 3 + bottomBarWidth, bottomPad + 2) +
210
- paint(bottomIdx, width - 1, box.bottomRight),
211
- );
151
+ out.push(renderBoxRow(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
212
152
  for (let i = bottomIdx + 1; i < lines.length; i++) {
213
153
  out.push(` ${lines[i]}`);
214
154
  }
@@ -237,25 +177,6 @@ export default function (pi: ExtensionAPI) {
237
177
  ctx.ui.setEditorComponent(ourFactory);
238
178
  };
239
179
 
240
- // Redundant with the bar or the graph: the footer's first line (cwd + git
241
- // branch + session name) entirely, and — within the stats line — the context
242
- // chunk ("22.8%/1.0M (auto)", which the graph's own label repeats) and the
243
- // right-aligned model side ("(provider) model • thinking"). The unique stats
244
- // (↑↓ tokens, cache R/W, hit rate, cost) survive; when nothing unique remains
245
- // the line collapses entirely.
246
- const CONTEXT_CHUNK_RE = /(?:\?|\d+(?:\.\d+)?%)\/\S+(?: \(auto\))?/;
247
- const stripRedundantStats = (statsLine: string | undefined): string | undefined => {
248
- if (!statsLine) return undefined;
249
- const plain = statsLine.replace(ANSI_RE, "");
250
- // The model side is right-aligned with >=2 spaces of padding; stats parts
251
- // are single-space separated, so the first 2+ space run is the boundary.
252
- const left = (plain.split(/ {2,}/)[0] ?? "")
253
- .replace(CONTEXT_CHUNK_RE, "")
254
- .replace(/\s+/g, " ")
255
- .trim();
256
- return left || undefined;
257
- };
258
-
259
180
  const PI_STATS_CACHE_MS = 250;
260
181
  let piStatsCache: { at: number; value: string | undefined } | undefined;
261
182
  builder.setPiStatsProvider(width => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/box.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared box-chrome painting for the live editor box (index.ts) and the
3
+ * settings-menu preview, so the 3/2/+2 row geometry has one definition.
4
+ *
5
+ * Rainbow mode colors each glyph by its perimeter-walk position; otherwise
6
+ * the caller's flat painter wraps whole runs in one escape sequence, which
7
+ * renders identically to per-glyph wraps with fewer bytes. The flat painter
8
+ * and the RainbowBorder instance are injected because they differ per caller
9
+ * (host borderColor vs menu theme; shared stepped animator vs phase-from-
10
+ * elapsed instance).
11
+ */
12
+ import { visibleWidth } from "@earendil-works/pi-tui";
13
+ import { perimeterLength, perimeterPosition, type RainbowBorder } from "./rainbow.js";
14
+ import type { BoxGlyphs } from "./theme.js";
15
+
16
+ export interface BoxPainters {
17
+ paint(row: number, col: number, ch: string): string;
18
+ horizRun(row: number, startCol: number, count: number): string;
19
+ /** Signature matches buildStatusLine's gapBorderColor callback. */
20
+ gapColor(str: string, startCol: number, row: number): string;
21
+ }
22
+
23
+ export function makeBoxPainters(opts: {
24
+ rainbowOn: boolean;
25
+ rainbow: RainbowBorder;
26
+ box: BoxGlyphs;
27
+ width: number;
28
+ bottomIdx: number;
29
+ flat: (str: string) => string;
30
+ }): BoxPainters {
31
+ const { rainbowOn, rainbow, box, width, bottomIdx, flat } = opts;
32
+ const perimeter = perimeterLength(width, bottomIdx);
33
+ const paint = (row: number, col: number, ch: string): string =>
34
+ rainbowOn ? rainbow.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : flat(ch);
35
+ const horizRun = (row: number, startCol: number, count: number): string => {
36
+ if (!rainbowOn) return flat(box.horizontal.repeat(count));
37
+ let out = "";
38
+ for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
39
+ return out;
40
+ };
41
+ const gapColor = (str: string, startCol: number, row: number): string => {
42
+ if (!rainbowOn) return flat(str);
43
+ let out = "";
44
+ let k = 0;
45
+ for (const ch of str) out += paint(row, startCol + k++, ch);
46
+ return out;
47
+ };
48
+ return { paint, horizRun, gapColor };
49
+ }
50
+
51
+ /** A bar row: corner, 2-cell run, the bar, a pad+2 run, corner — 6 cells of chrome. */
52
+ export function renderBoxRow(
53
+ painters: BoxPainters,
54
+ bar: string,
55
+ rowIdx: number,
56
+ boxWidth: number,
57
+ leftGlyph: string,
58
+ rightGlyph: string,
59
+ ): string {
60
+ const barWidth = visibleWidth(bar);
61
+ const pad = Math.max(0, boxWidth - 6 - barWidth);
62
+ return (
63
+ painters.paint(rowIdx, 0, leftGlyph) +
64
+ painters.horizRun(rowIdx, 1, 2) +
65
+ bar +
66
+ painters.horizRun(rowIdx, 3 + barWidth, pad + 2) +
67
+ painters.paint(rowIdx, boxWidth - 1, rightGlyph)
68
+ );
69
+ }
package/src/context.ts CHANGED
@@ -17,6 +17,9 @@ const EXEC_TIMEOUT_MS = 2_000;
17
17
  /** getEntries() filters and copies the whole session, so rescans are rate-limited. */
18
18
  const FEED_TTL_MS = 2_000;
19
19
 
20
+ /** Prototype-less map — feed keys are publisher-supplied customTypes. */
21
+ const emptyFeedData = (): Record<string, unknown> => Object.create(null);
22
+
20
23
  interface GitStatusCounts {
21
24
  staged: number;
22
25
  unstaged: number;
@@ -85,7 +88,7 @@ export class SegmentContextBuilder {
85
88
 
86
89
  #worktree: { projectName: string; worktreeName: string } | null = null;
87
90
 
88
- #feedData: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
91
+ #feedData: Record<string, unknown> = emptyFeedData();
89
92
  #feedsScannedAt = 0;
90
93
  #feedsScanned = "";
91
94
  #runStartedAt = 0;
@@ -113,7 +116,7 @@ export class SegmentContextBuilder {
113
116
  // session still carries the previous run's entries. Anything predating this
114
117
  // attach belongs to that stale run.
115
118
  this.#runStartedAt = Date.now();
116
- this.#feedData = Object.create(null) as Record<string, unknown>;
119
+ this.#feedData = emptyFeedData();
117
120
  this.#feedsScannedAt = 0;
118
121
  this.#feedsScanned = "";
119
122
  if (this.#repoCwd !== ctx.cwd) {
@@ -265,7 +268,7 @@ export class SegmentContextBuilder {
265
268
  */
266
269
  #refreshFeeds(customTypes: readonly string[], now: number): void {
267
270
  this.#feedsScannedAt = now;
268
- this.#feedData = Object.create(null) as Record<string, unknown>;
271
+ this.#feedData = emptyFeedData();
269
272
  const entries = this.#ctx?.sessionManager?.getEntries();
270
273
  if (!entries) return;
271
274
  const pending = new Set(customTypes);
package/src/footer.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pure helpers for parsing host-rendered rows: the editor's plain ─── border
3
+ * rows (with or without the scroll hint) and the default footer's stats line.
4
+ */
5
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
6
+
7
+ export function stripAnsi(str: string): string {
8
+ return str.replace(ANSI_RE, "");
9
+ }
10
+
11
+ export function isBorderRow(row: string): boolean {
12
+ const text = stripAnsi(row);
13
+ return /^─+$/.test(text) || /^─+ [↑↓] \d+ more\s*─*$/.test(text);
14
+ }
15
+
16
+ // Redundant with the bar or the graph: the footer's first line (cwd + git
17
+ // branch + session name) entirely, and — within the stats line — the context
18
+ // chunk ("22.8%/1.0M (auto)", which the graph's own label repeats) and the
19
+ // right-aligned model side ("(provider) model • thinking"). The unique stats
20
+ // (↑↓ tokens, cache R/W, hit rate, cost) survive; when nothing unique remains
21
+ // the line collapses entirely.
22
+ const CONTEXT_CHUNK_RE = /(?:\?|\d+(?:\.\d+)?%)\/\S+(?: \(auto\))?/;
23
+
24
+ export function stripRedundantStats(statsLine: string | undefined): string | undefined {
25
+ if (!statsLine) return undefined;
26
+ const plain = stripAnsi(statsLine);
27
+ // The model side is right-aligned with >=2 spaces of padding; stats parts
28
+ // are single-space separated, so the first 2+ space run is the boundary.
29
+ const left = (plain.split(/ {2,}/)[0] ?? "")
30
+ .replace(CONTEXT_CHUNK_RE, "")
31
+ .replace(/\s+/g, " ")
32
+ .trim();
33
+ return left || undefined;
34
+ }
package/src/layout.ts CHANGED
@@ -17,6 +17,40 @@ import type { EffectiveStatusLineSettings, SegmentContext, StatusLineSegmentId }
17
17
 
18
18
  const TRANSPARENT_BG_ANSI = "\x1b[49m";
19
19
 
20
+ const MIN_PATH_WIDTH = 8;
21
+ const MAX_SHRINK_PASSES = 8;
22
+ const MIN_PATH_MAX_LENGTH = 4;
23
+
24
+ /** Re-render the path segment narrower to absorb `overflow` cells; undefined when it cannot shrink. */
25
+ function shrinkPathSegment(ctx: SegmentContext, currentContent: string, overflow: number): string | undefined {
26
+ const currentPathVW = visibleWidth(currentContent);
27
+ const shrinkable = currentPathVW - MIN_PATH_WIDTH;
28
+ if (shrinkable <= 0) return undefined;
29
+
30
+ const shrinkBy = Math.min(shrinkable, overflow);
31
+ const currentMaxLen = ctx.options.path.maxLength;
32
+ let newMaxLen = Math.max(MIN_PATH_MAX_LENGTH, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
33
+ const pathCtx = (maxLen: number): SegmentContext => ({
34
+ ...ctx,
35
+ options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
36
+ });
37
+ let reRendered = renderSegment("path", pathCtx(newMaxLen));
38
+ if (!reRendered.visible || !reRendered.content) return undefined;
39
+
40
+ // maxLength governs path text, not icon prefix; iterate to compensate
41
+ for (let i = 0; i < MAX_SHRINK_PASSES; i++) {
42
+ const saved = currentPathVW - visibleWidth(reRendered.content);
43
+ if (saved >= shrinkBy) break;
44
+ const nextMaxLen = Math.max(MIN_PATH_MAX_LENGTH, newMaxLen - (shrinkBy - saved));
45
+ if (nextMaxLen >= newMaxLen) break;
46
+ newMaxLen = nextMaxLen;
47
+ const adjusted = renderSegment("path", pathCtx(newMaxLen));
48
+ if (!adjusted.visible || !adjusted.content) break;
49
+ reRendered = adjusted;
50
+ }
51
+ return reRendered.content;
52
+ }
53
+
20
54
  export function buildStatusLine(
21
55
  width: number,
22
56
  ctx: SegmentContext,
@@ -32,27 +66,24 @@ export function buildStatusLine(
32
66
  const fgAnsi = theme.getFgAnsi("text");
33
67
  const sepAnsi = theme.getFgAnsi("statusLineSep");
34
68
 
35
- const leftParts: string[] = [];
69
+ const left: string[] = [];
36
70
  const leftSegIds: StatusLineSegmentId[] = [];
37
71
  for (const segId of segmentGroups.left) {
38
72
  const rendered = renderSegment(segId, ctx);
39
73
  if (rendered.visible && rendered.content) {
40
- leftParts.push(rendered.content);
74
+ left.push(rendered.content);
41
75
  leftSegIds.push(segId);
42
76
  }
43
77
  }
44
78
 
45
- const rightParts: string[] = [];
79
+ const right: string[] = [];
46
80
  for (const segId of segmentGroups.right) {
47
81
  const rendered = renderSegment(segId, ctx);
48
82
  if (rendered.visible && rendered.content) {
49
- rightParts.push(rendered.content);
83
+ right.push(rendered.content);
50
84
  }
51
85
  }
52
86
 
53
- const left = [...leftParts];
54
- const right = [...rightParts];
55
-
56
87
  const leftSepWidth = visibleWidth(separatorDef.left);
57
88
  const rightSepWidth = visibleWidth(separatorDef.right);
58
89
  // Transparent mode drops the round caps (they need a bg fill to bridge);
@@ -84,34 +115,10 @@ export function buildStatusLine(
84
115
  // Shrink path before dropping left segments — path is the only elastic segment
85
116
  const pathIdx = leftSegIds.indexOf("path");
86
117
  if (pathIdx >= 0 && totalWidth() > width) {
87
- const overflow = totalWidth() - width;
88
- const currentPathVW = visibleWidth(left[pathIdx]);
89
- const minPathVW = 8;
90
- const shrinkable = currentPathVW - minPathVW;
91
- if (shrinkable > 0) {
92
- const shrinkBy = Math.min(shrinkable, overflow);
93
- const currentMaxLen = ctx.options.path.maxLength;
94
- let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
95
- const pathCtx = (maxLen: number): SegmentContext => ({
96
- ...ctx,
97
- options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
98
- });
99
- let reRendered = renderSegment("path", pathCtx(newMaxLen));
100
- if (reRendered.visible && reRendered.content) {
101
- // maxLength governs path text, not icon prefix; iterate to compensate
102
- for (let i = 0; i < 8; i++) {
103
- const saved = currentPathVW - visibleWidth(reRendered.content);
104
- if (saved >= shrinkBy) break;
105
- const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
106
- if (nextMaxLen >= newMaxLen) break;
107
- newMaxLen = nextMaxLen;
108
- const adjusted = renderSegment("path", pathCtx(newMaxLen));
109
- if (!adjusted.visible || !adjusted.content) break;
110
- reRendered = adjusted;
111
- }
112
- left[pathIdx] = reRendered.content;
113
- leftWidth = leftGroupWidth(left);
114
- }
118
+ const replacement = shrinkPathSegment(ctx, left[pathIdx], totalWidth() - width);
119
+ if (replacement !== undefined) {
120
+ left[pathIdx] = replacement;
121
+ leftWidth = leftGroupWidth(left);
115
122
  }
116
123
  }
117
124
  const leftOverflowDropIndex = (): number => {
package/src/menu.ts CHANGED
@@ -112,6 +112,10 @@ export interface MenuResult<T> {
112
112
  const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
113
113
  const EDIT_HINTS = ["type to edit", "\u232b delete", "\u23ce commit", "esc discard"];
114
114
  const OVERLAY_WIDTH = "90%";
115
+ /** Width of the " <marker> " prefix shared by rows without a checkbox. */
116
+ const PLAIN_ROW_PREFIX_LEN = 5;
117
+ /** Width of the " <marker> [■] " prefix shared by checkbox rows. */
118
+ const CHECKBOX_ROW_PREFIX_LEN = 8;
115
119
 
116
120
  interface FlatItem {
117
121
  id: string;
@@ -607,10 +611,9 @@ export class MenuComponent implements Component {
607
611
  if (item.text) {
608
612
  const editing = this.editing?.id === item.id ? this.editing : undefined;
609
613
  const raw = editing ? editing.buffer : String(value ?? "");
610
- const fixedLeftLen = 5;
611
- const labelWidth = Math.min(16, Math.max(0, innerWidth - fixedLeftLen - 4));
614
+ const labelWidth = Math.min(16, Math.max(0, innerWidth - PLAIN_ROW_PREFIX_LEN - 4));
612
615
  const label = truncateToWidth(item.label, labelWidth).padEnd(labelWidth);
613
- const fieldWidth = Math.max(0, innerWidth - fixedLeftLen - labelWidth - 3);
616
+ const fieldWidth = Math.max(0, innerWidth - PLAIN_ROW_PREFIX_LEN - labelWidth - 3);
614
617
  // Keep the caret in view by showing the buffer's tail once it overflows.
615
618
  const shownRaw =
616
619
  editing && raw.length > fieldWidth - 1 ? raw.slice(raw.length - (fieldWidth - 1)) : raw;
@@ -630,8 +633,7 @@ export class MenuComponent implements Component {
630
633
  const stateWord = `‹ ${value} ›`;
631
634
  // Ungated cycle rows carry no checkbox; gated ones keep it for the space toggle.
632
635
  if (!item.cycleEnabledBy) {
633
- const fixedLeftLen = 5; // " " + marker(1) + " " + margin
634
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - visibleWidth(stateWord) - 1);
636
+ const maxLabelLen = Math.max(0, innerWidth - PLAIN_ROW_PREFIX_LEN - visibleWidth(stateWord) - 1);
635
637
  const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
636
638
  const leftPlain = ` ${marker} ${label}`;
637
639
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
@@ -639,8 +641,7 @@ export class MenuComponent implements Component {
639
641
  return this.wrap("\u2551", content, "\u2551");
640
642
  }
641
643
  const enabled = this.values[item.cycleEnabledBy] as boolean;
642
- const fixedLeftLen = 8;
643
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - visibleWidth(stateWord) - 1);
644
+ const maxLabelLen = Math.max(0, innerWidth - CHECKBOX_ROW_PREFIX_LEN - visibleWidth(stateWord) - 1);
644
645
  const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
645
646
  const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
646
647
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
@@ -652,8 +653,7 @@ export class MenuComponent implements Component {
652
653
  const box = enabled ? "\u25a0" : " ";
653
654
  const stateWord = enabled ? "ON" : "OFF";
654
655
  const rightPlain = `${stateWord} `;
655
- const fixedLeftLen = 8; // " " + marker(1) + " " + "[" + box(1) + "]" + " "
656
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - 1);
656
+ const maxLabelLen = Math.max(0, innerWidth - CHECKBOX_ROW_PREFIX_LEN - rightPlain.length - 1);
657
657
  const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
658
658
  const leftPlain = ` ${marker} [${box}] ${label}`;
659
659
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
package/src/rainbow.ts CHANGED
@@ -46,7 +46,7 @@ export function perimeterPosition(row: number, col: number, width: number, botto
46
46
  return 2 * width + 2 * bottomIdx - 2 - row; // col === 0
47
47
  }
48
48
 
49
- function hslToRgb(h: number, s: number, l: number): [number, number, number] {
49
+ export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
50
50
  const c = (1 - Math.abs(2 * l - 1)) * s;
51
51
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
52
52
  const m = l - c / 2;
@@ -60,7 +60,7 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
60
60
  return [Math.round((rgb[0] + m) * 255), Math.round((rgb[1] + m) * 255), Math.round((rgb[2] + m) * 255)];
61
61
  }
62
62
 
63
- function rgbToHex([r, g, b]: [number, number, number]): string {
63
+ export function rgbToHex([r, g, b]: [number, number, number]): string {
64
64
  return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
65
65
  }
66
66
 
@@ -4,17 +4,11 @@
4
4
  * menu values, and the command registration.
5
5
  */
6
6
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
7
- import { visibleWidth } from "@earendil-works/pi-tui";
7
+ import { makeBoxPainters, renderBoxRow } from "./box.js";
8
8
  import type { SegmentContextBuilder } from "./context.js";
9
9
  import { buildStatusLine } from "./layout.js";
10
10
  import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
11
- import {
12
- perimeterLength,
13
- perimeterPosition,
14
- RAINBOW_CYCLE_MS,
15
- RAINBOW_FRAME_MS,
16
- RainbowBorder,
17
- } from "./rainbow.js";
11
+ import { RAINBOW_CYCLE_MS, RAINBOW_FRAME_MS, RainbowBorder } from "./rainbow.js";
18
12
  import {
19
13
  applySettings,
20
14
  BORDER_STYLES,
@@ -227,8 +221,7 @@ class StatusLinePreview {
227
221
  // The theme singleton drives glyph lookups everywhere, so swap the
228
222
  // symbol preset for this synchronous render only; the live bar behind
229
223
  // the overlay keeps the applied preset.
230
- const applied = this.#state.effective.symbols;
231
- theme.setSymbolPreset(effective.symbols);
224
+ const applied = theme.setSymbolPreset(effective.symbols);
232
225
  try {
233
226
  const include: SegmentIncludes = {
234
227
  git: true,
@@ -258,37 +251,21 @@ class StatusLinePreview {
258
251
  };
259
252
 
260
253
  const box = theme.getBox(effective.borderStyle);
261
- const staticBorder = (s: string, _startCol: number, _row: number) => this.#uiTheme.fg("border", s);
262
254
  // The preview box is three rows tall: top bar, side verticals, bottom bar.
263
255
  const bottomIdx = 2;
264
- const rainbow = new RainbowBorder((elapsedMs * 360) / RAINBOW_CYCLE_MS);
265
- const perimeter = perimeterLength(boxWidth, bottomIdx);
266
- const paint = (row: number, col: number, ch: string): string =>
267
- rainbowOn
268
- ? rainbow.colorChar(ch, perimeterPosition(row, col, boxWidth, bottomIdx), perimeter)
269
- : staticBorder(ch, col, row);
270
- const horizRun = (row: number, startCol: number, count: number): string => {
271
- let out = "";
272
- for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
273
- return out;
274
- };
275
- const gapColor = (str: string, startCol: number, row: number): string =>
276
- rainbowOn ? [...str].map((ch, k) => paint(row, startCol + k, ch)).join("") : staticBorder(str, startCol, row);
277
- const row = (bar: string, rowIdx: number, left: string, right: string): string => {
278
- const pad = Math.max(0, barWidth - visibleWidth(bar));
279
- return (
280
- paint(rowIdx, 0, left) +
281
- horizRun(rowIdx, 1, 2) +
282
- bar +
283
- horizRun(rowIdx, 3 + visibleWidth(bar), pad + 2) +
284
- paint(rowIdx, boxWidth - 1, right)
285
- );
286
- };
256
+ const painters = makeBoxPainters({
257
+ rainbowOn,
258
+ rainbow: new RainbowBorder((elapsedMs * 360) / RAINBOW_CYCLE_MS),
259
+ box,
260
+ width: boxWidth,
261
+ bottomIdx,
262
+ flat: s => this.#uiTheme.fg("border", s),
263
+ });
287
264
  const top = buildStatusLine(
288
265
  barWidth,
289
266
  ctx,
290
267
  effective,
291
- gapColor,
268
+ painters.gapColor,
292
269
  {
293
270
  left: effective.leftSegments,
294
271
  right: effective.rightSegments,
@@ -299,7 +276,7 @@ class StatusLinePreview {
299
276
  barWidth,
300
277
  ctx,
301
278
  effective,
302
- gapColor,
279
+ painters.gapColor,
303
280
  {
304
281
  left: effective.bottomLeftSegments,
305
282
  right: effective.bottomRightSegments,
@@ -308,9 +285,11 @@ class StatusLinePreview {
308
285
  );
309
286
  return {
310
287
  lines: [
311
- row(top, 0, box.topLeft, box.topRight),
312
- paint(1, 0, box.vertical) + " ".repeat(Math.max(0, boxWidth - 2)) + paint(1, boxWidth - 1, box.vertical),
313
- row(bottom, bottomIdx, box.bottomLeft, box.bottomRight),
288
+ renderBoxRow(painters, top, 0, boxWidth, box.topLeft, box.topRight),
289
+ painters.paint(1, 0, box.vertical) +
290
+ " ".repeat(Math.max(0, boxWidth - 2)) +
291
+ painters.paint(1, boxWidth - 1, box.vertical),
292
+ renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
314
293
  ],
315
294
  // Keep the preview animated only while the rainbow is enabled;
316
295
  // a static preview refreshes once and stops.
package/src/settings.ts CHANGED
@@ -10,6 +10,7 @@ import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
10
10
  import type {
11
11
  EffectiveStatusLineSettings,
12
12
  FeedFormat,
13
+ SegmentIncludes,
13
14
  StatusLineFeed,
14
15
  StatusLineSegmentId,
15
16
  StatusLineSegmentToggles,
@@ -93,6 +94,7 @@ function loadSettings(): StatusLineSettings {
93
94
  if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
94
95
  return settings;
95
96
  } catch {
97
+ // Missing or corrupt settings.json falls back to defaults.
96
98
  return {};
97
99
  }
98
100
  }
@@ -131,6 +133,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
131
133
  if (seg.feedsBottomLeft) bottomLeftSegments.push("feeds");
132
134
  if (seg.tokenRateBottomLeft) bottomLeftSegments.push("token_rate");
133
135
 
136
+ const feeds = settings.feeds ? sanitizeFeeds(settings.feeds) : [...DEFAULT_FEEDS];
137
+ const allSegments = [...leftSegments, ...rightSegments, ...bottomLeftSegments, ...bottomRightSegments];
138
+ const includes: SegmentIncludes = {
139
+ git: allSegments.includes("git"),
140
+ pr: allSegments.includes("pr"),
141
+ piStats: allSegments.includes("pi_stats"),
142
+ tokenRate: allSegments.includes("token_rate"),
143
+ feeds: allSegments.includes("feeds") ? feeds.map(feed => feed.customType) : [],
144
+ };
145
+
134
146
  return {
135
147
  transparent: settings.transparent ?? true,
136
148
  separator: settings.separator ?? "powerline-thin",
@@ -141,11 +153,12 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
141
153
  bottomLeftSegments,
142
154
  bottomRightSegments,
143
155
  rainbowBorder: settings.rainbowBorder ?? true,
156
+ includes,
144
157
  segmentOptions: {
145
158
  model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
146
159
  path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
147
160
  context: { showBar: seg.contextBar, showStats: seg.contextStats },
148
- feeds: settings.feeds ? sanitizeFeeds(settings.feeds) : [...DEFAULT_FEEDS],
161
+ feeds,
149
162
  },
150
163
  };
151
164
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Geometry contracts for the shared box chrome: every bar row must total
3
+ * exactly boxWidth cells with the 3/2/+2 layout (corner, 2-cell run, bar,
4
+ * pad+2 run, corner), and the non-rainbow painters must wrap whole runs in a
5
+ * single flat call rather than per glyph — index.ts relies on that fast path
6
+ * to keep per-frame ANSI output small.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import test from "node:test";
10
+ import { stripVTControlCharacters } from "node:util";
11
+ import { visibleWidth } from "@earendil-works/pi-tui";
12
+ import { makeBoxPainters, renderBoxRow } from "../box.ts";
13
+ import { RainbowBorder } from "../rainbow.ts";
14
+ import type { BoxGlyphs } from "../theme.ts";
15
+
16
+ const ROUNDED: BoxGlyphs = {
17
+ topLeft: "╭",
18
+ topRight: "╮",
19
+ bottomLeft: "╰",
20
+ bottomRight: "╯",
21
+ horizontal: "─",
22
+ vertical: "│",
23
+ };
24
+
25
+ const identityPainters = (boxWidth: number) =>
26
+ makeBoxPainters({
27
+ rainbowOn: false,
28
+ rainbow: new RainbowBorder(0),
29
+ box: ROUNDED,
30
+ width: boxWidth,
31
+ bottomIdx: 2,
32
+ flat: s => s,
33
+ });
34
+
35
+ test("a bar row is corner + 2 run + bar + pad+2 run + corner", () => {
36
+ const out = renderBoxRow(identityPainters(20), "X", 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
37
+ assert.equal(out, "╭──X" + "─".repeat(15) + "╮");
38
+ assert.equal(visibleWidth(out), 20);
39
+ });
40
+
41
+ test("an empty bar renders chrome only, still totalling boxWidth", () => {
42
+ const out = renderBoxRow(identityPainters(20), "", 2, 20, ROUNDED.bottomLeft, ROUNDED.bottomRight);
43
+ assert.equal(out, "╰──" + "─".repeat(16) + "╯");
44
+ assert.equal(visibleWidth(out), 20);
45
+ });
46
+
47
+ test("wide characters in the bar consume pad cells, keeping the total fixed", () => {
48
+ const out = renderBoxRow(identityPainters(20), "日本", 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
49
+ assert.equal(out, "╭──日本" + "─".repeat(12) + "╮");
50
+ assert.equal(visibleWidth(out), 20);
51
+ });
52
+
53
+ test("a bar wider than the budget clamps pad to zero", () => {
54
+ const bar = "x".repeat(15);
55
+ const out = renderBoxRow(identityPainters(20), bar, 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
56
+ assert.equal(out, `╭──${bar}──╮`);
57
+ });
58
+
59
+ test("non-rainbow painters wrap whole runs in one flat call", () => {
60
+ const painters = makeBoxPainters({
61
+ rainbowOn: false,
62
+ rainbow: new RainbowBorder(0),
63
+ box: ROUNDED,
64
+ width: 20,
65
+ bottomIdx: 2,
66
+ flat: s => `[${s}]`,
67
+ });
68
+ assert.equal(painters.horizRun(0, 1, 3), "[───]");
69
+ assert.equal(painters.gapColor("──", 5, 0), "[──]");
70
+ assert.equal(painters.paint(0, 0, ROUNDED.topLeft), "[╭]");
71
+ });
72
+
73
+ test("rainbow coloring never changes the geometry", () => {
74
+ const painters = makeBoxPainters({
75
+ rainbowOn: true,
76
+ rainbow: new RainbowBorder(0),
77
+ box: ROUNDED,
78
+ width: 20,
79
+ bottomIdx: 2,
80
+ flat: s => s,
81
+ });
82
+ const out = renderBoxRow(painters, "X", 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
83
+ assert.equal(stripVTControlCharacters(out), "╭──X" + "─".repeat(15) + "╮");
84
+ });
@@ -15,7 +15,6 @@ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-wor
15
15
  import { SegmentContextBuilder } from "../context.ts";
16
16
  import { SEGMENTS } from "../segments.ts";
17
17
  import { DEFAULT_FEEDS, DEFAULT_SEGMENTS, resolveEffectiveSettings, sanitizeFeeds } from "../settings.ts";
18
- import { theme } from "../theme.ts";
19
18
  import type { SegmentContext, StatusLineFeed } from "../types.ts";
20
19
 
21
20
  const SAVINGS = "pi-prompt-cache/savings";
@@ -364,5 +363,4 @@ test("the default subscription matches what pi-prompt-cache publishes", () => {
364
363
  assert.equal(DEFAULT_FEEDS[0]?.customType, SAVINGS);
365
364
  assert.equal(DEFAULT_FEEDS[0]?.field, "savedUsd");
366
365
  assert.equal(DEFAULT_FEEDS[0]?.format, "currency");
367
- void theme;
368
366
  });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * These helpers regex-parse rows the HOST renders (the editor's border rows
3
+ * and the default footer's stats line), so the fixtures mirror real pi output:
4
+ * border rows are ─ (U+2500) runs, the scroll hint is "↑/↓ N more", stats
5
+ * parts are single-space separated with the model side right-aligned behind a
6
+ * 2+ space gap, and the context chunk looks like "22.8%/1.0M (auto)".
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import test from "node:test";
10
+ import { isBorderRow, stripAnsi, stripRedundantStats } from "../footer.ts";
11
+
12
+ test("isBorderRow accepts a plain border run", () => {
13
+ assert.equal(isBorderRow("─────"), true);
14
+ });
15
+
16
+ test("isBorderRow accepts a scroll-hint row with a trailing run", () => {
17
+ assert.equal(isBorderRow("─── ↑ 3 more ───"), true);
18
+ });
19
+
20
+ test("isBorderRow accepts a scroll-hint row without a trailing run", () => {
21
+ assert.equal(isBorderRow("── ↓ 12 more"), true);
22
+ });
23
+
24
+ test("isBorderRow sees through ANSI color codes", () => {
25
+ assert.equal(isBorderRow("\x1b[38;5;244m────\x1b[0m"), true);
26
+ });
27
+
28
+ test("isBorderRow rejects text rows, empty rows, and ASCII dashes", () => {
29
+ assert.equal(isBorderRow("│ hello │"), false);
30
+ assert.equal(isBorderRow(""), false);
31
+ assert.equal(isBorderRow("--- ascii ---"), false);
32
+ });
33
+
34
+ test("stripAnsi removes SGR sequences only", () => {
35
+ assert.equal(stripAnsi("\x1b[32mgreen\x1b[0m plain"), "green plain");
36
+ });
37
+
38
+ test("stripRedundantStats passes undefined through", () => {
39
+ assert.equal(stripRedundantStats(undefined), undefined);
40
+ });
41
+
42
+ test("stripRedundantStats drops the context chunk but keeps unique stats", () => {
43
+ // "92.3%" must not false-match the chunk regex (no slash follows it).
44
+ assert.equal(
45
+ stripRedundantStats("↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42 22.8%/1.0M (auto)"),
46
+ "↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42",
47
+ );
48
+ });
49
+
50
+ test("stripRedundantStats drops the right-aligned model side", () => {
51
+ assert.equal(stripRedundantStats("↑ 1K ↓ 2K (anthropic) claude-opus • max"), "↑ 1K ↓ 2K");
52
+ });
53
+
54
+ test("stripRedundantStats collapses to undefined when only redundant parts remain", () => {
55
+ assert.equal(stripRedundantStats("?/200K (openai) gpt"), undefined);
56
+ assert.equal(stripRedundantStats("22.8%/1.0M (auto) model"), undefined);
57
+ });
58
+
59
+ test("stripRedundantStats leaves a line of unique stats untouched", () => {
60
+ assert.equal(stripRedundantStats("↑ 1K ↓ 2K R 5K W 1K $0.10"), "↑ 1K ↓ 2K R 5K W 1K $0.10");
61
+ });
62
+
63
+ test("stripRedundantStats strips ANSI before parsing", () => {
64
+ assert.equal(stripRedundantStats("\x1b[32m↑ 1K\x1b[0m ↓ 2K"), "↑ 1K ↓ 2K");
65
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * clampPathLength budgets display cells (visibleWidth), matching the cell
3
+ * budgets layout.ts compares its output against. ASCII behavior is unchanged
4
+ * from the .length-based implementation; CJK/emoji paths are where the two
5
+ * measures diverge (a CJK char is 1 UTF-16 unit but 2 cells).
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import test from "node:test";
9
+ import { visibleWidth } from "@earendil-works/pi-tui";
10
+ import { clampPathLength } from "../utils.ts";
11
+
12
+ test("returns the path unchanged when it fits the budget", () => {
13
+ assert.equal(clampPathLength("~/a/b", 20), "~/a/b");
14
+ });
15
+
16
+ test("elides middle components, keeping the first folder and basename", () => {
17
+ assert.equal(clampPathLength("~/projects/pi/topping/src", 20), "~/projects/…/src");
18
+ });
19
+
20
+ test("drops middle components farthest from the cwd first", () => {
21
+ assert.equal(clampPathLength("~/a/bb/cc/dd", 11), "~/a/…/cc/dd");
22
+ });
23
+
24
+ test("drops the first folder once even one middle component overflows", () => {
25
+ assert.equal(clampPathLength("~/aaaa/bb/cc", 8), "…/bb/cc");
26
+ });
27
+
28
+ test("falls back to tail truncation on a long basename", () => {
29
+ const result = clampPathLength("~/x/averylongbasename", 10);
30
+ assert.equal(result, "…gbasename");
31
+ assert.ok(visibleWidth(result) <= 10);
32
+ });
33
+
34
+ test("CJK components are measured in cells, not UTF-16 units", () => {
35
+ const pwd = "~/文档/项目/源代码";
36
+ // The .length-based implementation would have returned this un-clamped:
37
+ // 11 UTF-16 units fit a budget of 12, but the path spans 18 cells.
38
+ assert.ok(pwd.length <= 12 && visibleWidth(pwd) > 12);
39
+ const result = clampPathLength(pwd, 12);
40
+ assert.equal(result, "…/源代码");
41
+ assert.ok(visibleWidth(result) <= 12);
42
+ });
43
+
44
+ test("emoji tail truncation never splits a surrogate pair or exceeds the budget", () => {
45
+ const result = clampPathLength("~/x/📁📁📁name", 6);
46
+ assert.ok(result.startsWith("…"));
47
+ assert.ok(visibleWidth(result) <= 6);
48
+ assert.doesNotThrow(() => encodeURIComponent(result));
49
+ });
50
+
51
+ test("all-CJK fallback stays within the cell budget", () => {
52
+ const pwd = "/一二三四五六七";
53
+ assert.ok(pwd.length <= 8 && visibleWidth(pwd) > 8);
54
+ const result = clampPathLength(pwd, 8);
55
+ assert.equal(result, "…五六七");
56
+ assert.ok(visibleWidth(result) <= 8);
57
+ });
package/src/theme.ts CHANGED
@@ -30,8 +30,6 @@ export interface StatusSymbols {
30
30
  powerlineThinRight: string;
31
31
  halfCircleLeft: string;
32
32
  halfCircleRight: string;
33
- block: string;
34
- space: string;
35
33
  asciiLeft: string;
36
34
  asciiRight: string;
37
35
  dot: string;
@@ -44,24 +42,8 @@ export interface StatusSymbols {
44
42
  folder: string;
45
43
  scratchFolder: string;
46
44
  worktree: string;
47
- git: string;
48
45
  branch: string;
49
46
  pr: string;
50
- tokens: string;
51
- context: string;
52
- cost: string;
53
- time: string;
54
- ghost: string;
55
- agents: string;
56
- job: string;
57
- cache: string;
58
- input: string;
59
- output: string;
60
- throughput: string;
61
- host: string;
62
- session: string;
63
- auto: string;
64
- fast: string;
65
47
  };
66
48
  thinking: {
67
49
  minimal: string;
@@ -70,7 +52,6 @@ export interface StatusSymbols {
70
52
  high: string;
71
53
  xhigh: string;
72
54
  max: string;
73
- autoPending: string;
74
55
  };
75
56
  }
76
57
 
@@ -82,8 +63,6 @@ const UNICODE_SYMBOLS: StatusSymbols = {
82
63
  powerlineThinRight: "<",
83
64
  halfCircleLeft: "",
84
65
  halfCircleRight: "",
85
- block: "▌",
86
- space: " ",
87
66
  asciiLeft: ">",
88
67
  asciiRight: "<",
89
68
  dot: " · ",
@@ -96,24 +75,8 @@ const UNICODE_SYMBOLS: StatusSymbols = {
96
75
  folder: "📁",
97
76
  scratchFolder: "🗑",
98
77
  worktree: "🌳",
99
- git: "⎇",
100
78
  branch: "⑂",
101
79
  pr: "⤴",
102
- tokens: "🪙",
103
- context: "◫",
104
- cost: "💲",
105
- time: "⏱",
106
- ghost: "👻",
107
- agents: "👥",
108
- job: "⚙",
109
- cache: "💾",
110
- input: "⤵",
111
- output: "⤴",
112
- throughput: "⚡",
113
- host: "🖥",
114
- session: "🆔",
115
- auto: "⟲",
116
- fast: "⚡",
117
80
  },
118
81
  thinking: {
119
82
  minimal: "○ min",
@@ -122,7 +85,6 @@ const UNICODE_SYMBOLS: StatusSymbols = {
122
85
  high: "◒ high",
123
86
  xhigh: "◕ xhigh",
124
87
  max: "◉ max",
125
- autoPending: "⟳",
126
88
  },
127
89
  };
128
90
 
@@ -134,8 +96,6 @@ const NERD_SYMBOLS: StatusSymbols = {
134
96
  powerlineThinRight: "\ue0b3",
135
97
  halfCircleLeft: "\ue0b6",
136
98
  halfCircleRight: "\ue0b4",
137
- block: "█",
138
- space: " ",
139
99
  asciiLeft: ">",
140
100
  asciiRight: "<",
141
101
  dot: " · ",
@@ -148,24 +108,8 @@ const NERD_SYMBOLS: StatusSymbols = {
148
108
  folder: "\uf115",
149
109
  scratchFolder: "\uf014",
150
110
  worktree: "\uf0e8",
151
- git: "\uf1d3",
152
111
  branch: "\uf126",
153
112
  pr: "\uea64",
154
- tokens: "\ue26b",
155
- context: "\ue70f",
156
- cost: "\uf155",
157
- time: "\uf017",
158
- ghost: "\u{f02a0}",
159
- agents: "\uf0c0",
160
- job: "\uf013",
161
- cache: "\uf1c0",
162
- input: "\uf090",
163
- output: "\uf08b",
164
- throughput: "⚡",
165
- host: "\uf109",
166
- session: "\u{f0051}",
167
- auto: "\u{f0068}",
168
- fast: "\uf0e7",
169
113
  },
170
114
  thinking: {
171
115
  minimal: "\u{f0a9e} min",
@@ -174,7 +118,6 @@ const NERD_SYMBOLS: StatusSymbols = {
174
118
  high: "\u{f0aa3} high",
175
119
  xhigh: "\u{f0aa5} xhigh",
176
120
  max: "\u{f06d} max",
177
- autoPending: "\u{f074}",
178
121
  },
179
122
  };
180
123
 
@@ -186,8 +129,6 @@ const ASCII_SYMBOLS: StatusSymbols = {
186
129
  powerlineThinRight: "<",
187
130
  halfCircleLeft: "",
188
131
  halfCircleRight: "",
189
- block: "#",
190
- space: " ",
191
132
  asciiLeft: ">",
192
133
  asciiRight: "<",
193
134
  dot: " - ",
@@ -200,24 +141,8 @@ const ASCII_SYMBOLS: StatusSymbols = {
200
141
  folder: "[D]",
201
142
  scratchFolder: "[T]",
202
143
  worktree: "[wt]",
203
- git: "git:",
204
144
  branch: "@",
205
145
  pr: "PR",
206
- tokens: "tok:",
207
- context: "ctx:",
208
- cost: "$",
209
- time: "t:",
210
- ghost: "@",
211
- agents: "AG",
212
- job: "bg",
213
- cache: "cache",
214
- input: "in:",
215
- output: "out:",
216
- throughput: "tok/s:",
217
- host: "host",
218
- session: "id",
219
- auto: "[A]",
220
- fast: ">>",
221
146
  },
222
147
  thinking: {
223
148
  minimal: "[min]",
@@ -226,7 +151,6 @@ const ASCII_SYMBOLS: StatusSymbols = {
226
151
  high: "[high]",
227
152
  xhigh: "[xhigh]",
228
153
  max: "[max]",
229
- autoPending: "[~]",
230
154
  },
231
155
  };
232
156
 
@@ -260,7 +184,6 @@ export type StatusColor =
260
184
  | "muted"
261
185
  | "dim"
262
186
  | "text"
263
- | "thinkingHigh"
264
187
  | "statusLineSep"
265
188
  | "statusLineModel"
266
189
  | "statusLinePath"
@@ -268,13 +191,9 @@ export type StatusColor =
268
191
  | "statusLineGitDirty"
269
192
  | "statusLineContext"
270
193
  | "statusLineContextElevated"
271
- | "statusLineSpend"
272
194
  | "statusLineStaged"
273
195
  | "statusLineDirty"
274
- | "statusLineUntracked"
275
- | "statusLineOutput"
276
- | "statusLineCost"
277
- | "statusLineSubagents";
196
+ | "statusLineUntracked";
278
197
 
279
198
  const FG_COLORS: Record<StatusColor, ColorValue> = {
280
199
  accent: "#febc38",
@@ -284,7 +203,6 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
284
203
  muted: "#777d88",
285
204
  dim: "#5f6673",
286
205
  text: "",
287
- thinkingHigh: "#b281d6",
288
206
  statusLineSep: 244,
289
207
  statusLineModel: "#d787af",
290
208
  statusLinePath: "#00afaf",
@@ -292,13 +210,9 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
292
210
  statusLineGitDirty: "#d7af5f",
293
211
  statusLineContext: "#8787af",
294
212
  statusLineContextElevated: "#e07a1f",
295
- statusLineSpend: "#5fafaf",
296
213
  statusLineStaged: 70,
297
214
  statusLineDirty: 178,
298
215
  statusLineUntracked: 39,
299
- statusLineOutput: 205,
300
- statusLineCost: 205,
301
- statusLineSubagents: "#febc38",
302
216
  };
303
217
 
304
218
  const STATUS_LINE_BG: ColorValue = "#121212";
@@ -391,9 +305,11 @@ class StatusTheme {
391
305
  #fgCache = new Map<StatusColor, string>();
392
306
  #bgAnsi: string | undefined;
393
307
 
394
- setSymbolPreset(preset: SymbolPreset): void {
308
+ setSymbolPreset(preset: SymbolPreset): SymbolPreset {
309
+ const previous = this.#preset;
395
310
  this.#preset = preset;
396
311
  this.#symbols = SYMBOL_PRESETS[preset];
312
+ return previous;
397
313
  }
398
314
 
399
315
  get sep(): StatusSymbols["sep"] {
package/src/types.ts CHANGED
@@ -102,6 +102,7 @@ export interface EffectiveStatusLineSettings {
102
102
  bottomRightSegments: StatusLineSegmentId[];
103
103
  segmentOptions: StatusLineSegmentOptions;
104
104
  rainbowBorder: boolean;
105
+ includes: SegmentIncludes;
105
106
  }
106
107
 
107
108
  // ═══════════════════════════════════════════════════════════════════════════
package/src/utils.ts CHANGED
@@ -5,14 +5,22 @@
5
5
  * replaced with local hex math so this runs under any pi runtime.
6
6
  */
7
7
  import { stripVTControlCharacters } from "node:util";
8
+ import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
8
9
  import { parseHex } from "./theme.js";
10
+ import { hslToRgb, rgbToHex } from "./rainbow.js";
9
11
 
10
12
  export function formatNumber(n: number): string {
11
13
  if (n < 1_000) return n.toString();
12
14
  if (n < 10_000) return `${(n / 1_000).toFixed(1)}K`;
13
- if (n < 1_000_000) return `${Math.round(n / 1_000)}K`;
15
+ if (n < 1_000_000) {
16
+ const k = Math.round(n / 1_000);
17
+ return k < 1000 ? `${k}K` : `${(n / 1_000_000).toFixed(1)}M`;
18
+ }
14
19
  if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
15
- if (n < 1_000_000_000) return `${Math.round(n / 1_000_000)}M`;
20
+ if (n < 1_000_000_000) {
21
+ const m = Math.round(n / 1_000_000);
22
+ return m < 1000 ? `${m}M` : `${(n / 1_000_000_000).toFixed(1)}B`;
23
+ }
16
24
  if (n < 10_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
17
25
  return `${Math.round(n / 1_000_000_000)}B`;
18
26
  }
@@ -27,13 +35,14 @@ export function shortenPath(filePath: string, homeDir?: string): string {
27
35
  }
28
36
 
29
37
  /**
30
- * Clamp a path/label to `maxLen` by eliding middle components with `…`.
31
- * The first folder (after `~` or the root) and the basename survive longest;
32
- * middle components drop farthest-from-cwd first. Falls back to plain
33
- * left-truncation once even `…/basename` overflows.
38
+ * Clamp a path/label to `maxLen` display cells by eliding middle components
39
+ * with `…`. The first folder (after `~` or the root) and the basename survive
40
+ * longest; middle components drop farthest-from-cwd first. Falls back to plain
41
+ * left-truncation once even `…/basename` overflows. Widths are terminal cells
42
+ * (matching layout.ts's visibleWidth budgets), not UTF-16 units.
34
43
  */
35
44
  export function clampPathLength(pwd: string, maxLen: number): string {
36
- if (pwd.length <= maxLen) return pwd;
45
+ if (visibleWidth(pwd) <= maxLen) return pwd;
37
46
 
38
47
  const parts = pwd.split("/");
39
48
  const last = parts.length - 1;
@@ -41,16 +50,17 @@ export function clampPathLength(pwd: string, maxLen: number): string {
41
50
 
42
51
  for (let dropped = 1; dropped <= last - firstIdx - 1; dropped++) {
43
52
  const candidate = [...parts.slice(0, firstIdx + 1), "…", ...parts.slice(firstIdx + 1 + dropped)].join("/");
44
- if (candidate.length <= maxLen) return candidate;
53
+ if (visibleWidth(candidate) <= maxLen) return candidate;
45
54
  }
46
55
 
47
56
  // The first folder no longer fits: keep whatever remains closest to the cwd.
48
57
  for (let start = firstIdx + 1; start <= last; start++) {
49
58
  const candidate = `…/${parts.slice(start).join("/")}`;
50
- if (candidate.length <= maxLen) return candidate;
59
+ if (visibleWidth(candidate) <= maxLen) return candidate;
51
60
  }
52
61
 
53
- return `…${pwd.slice(-Math.max(0, maxLen - 1))}`;
62
+ const tailWidth = Math.max(0, maxLen - 1);
63
+ return `…${sliceByColumn(pwd, visibleWidth(pwd) - tailWidth, tailWidth)}`;
54
64
  }
55
65
 
56
66
  /** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */
@@ -119,26 +129,6 @@ function hexToHue(hex: string): number | undefined {
119
129
  return (h + 360) % 360;
120
130
  }
121
131
 
122
- function hslToHex(h: number, s: number, l: number): string {
123
- const c = (1 - Math.abs(2 * l - 1)) * s;
124
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
125
- const m = l - c / 2;
126
- let r = 0;
127
- let g = 0;
128
- let b = 0;
129
- if (h < 60) [r, g, b] = [c, x, 0];
130
- else if (h < 120) [r, g, b] = [x, c, 0];
131
- else if (h < 180) [r, g, b] = [0, c, x];
132
- else if (h < 240) [r, g, b] = [0, x, c];
133
- else if (h < 300) [r, g, b] = [x, 0, c];
134
- else [r, g, b] = [c, 0, x];
135
- const toHex = (v: number) =>
136
- Math.round((v + m) * 255)
137
- .toString(16)
138
- .padStart(2, "0");
139
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
140
- }
141
-
142
132
  function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number {
143
133
  if (occupied.length === 0) return target;
144
134
  if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) return target;
@@ -157,5 +147,5 @@ export function getSessionAccentHex(name: string, themeColorHexes: string[]): st
157
147
  let targetHue = DARK_HUE_START + (nameToHue(name) % range);
158
148
  const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
159
149
  targetHue = findSafeHue(targetHue, themeHues, DARK_HUE_START, DARK_HUE_END);
160
- return hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
150
+ return rgbToHex(hslToRgb(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS));
161
151
  }