@underactive/pi-topping-statusline 0.1.0 → 0.1.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/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.1",
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/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,
@@ -84,34 +118,10 @@ export function buildStatusLine(
84
118
  // Shrink path before dropping left segments — path is the only elastic segment
85
119
  const pathIdx = leftSegIds.indexOf("path");
86
120
  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
- }
121
+ const replacement = shrinkPathSegment(ctx, left[pathIdx], totalWidth() - width);
122
+ if (replacement !== undefined) {
123
+ left[pathIdx] = replacement;
124
+ leftWidth = leftGroupWidth(left);
115
125
  }
116
126
  }
117
127
  const leftOverflowDropIndex = (): number => {
@@ -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,
@@ -131,6 +132,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
131
132
  if (seg.feedsBottomLeft) bottomLeftSegments.push("feeds");
132
133
  if (seg.tokenRateBottomLeft) bottomLeftSegments.push("token_rate");
133
134
 
135
+ const feeds = settings.feeds ? sanitizeFeeds(settings.feeds) : [...DEFAULT_FEEDS];
136
+ const allSegments = [...leftSegments, ...rightSegments, ...bottomLeftSegments, ...bottomRightSegments];
137
+ const includes: SegmentIncludes = {
138
+ git: allSegments.includes("git"),
139
+ pr: allSegments.includes("pr"),
140
+ piStats: allSegments.includes("pi_stats"),
141
+ tokenRate: allSegments.includes("token_rate"),
142
+ feeds: allSegments.includes("feeds") ? feeds.map(feed => feed.customType) : [],
143
+ };
144
+
134
145
  return {
135
146
  transparent: settings.transparent ?? true,
136
147
  separator: settings.separator ?? "powerline-thin",
@@ -141,11 +152,12 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
141
152
  bottomLeftSegments,
142
153
  bottomRightSegments,
143
154
  rainbowBorder: settings.rainbowBorder ?? true,
155
+ includes,
144
156
  segmentOptions: {
145
157
  model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
146
158
  path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
147
159
  context: { showBar: seg.contextBar, showStats: seg.contextStats },
148
- feeds: settings.feeds ? sanitizeFeeds(settings.feeds) : [...DEFAULT_FEEDS],
160
+ feeds,
149
161
  },
150
162
  };
151
163
  }
@@ -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
+ });
@@ -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
@@ -44,24 +44,8 @@ export interface StatusSymbols {
44
44
  folder: string;
45
45
  scratchFolder: string;
46
46
  worktree: string;
47
- git: string;
48
47
  branch: string;
49
48
  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
49
  };
66
50
  thinking: {
67
51
  minimal: string;
@@ -70,7 +54,6 @@ export interface StatusSymbols {
70
54
  high: string;
71
55
  xhigh: string;
72
56
  max: string;
73
- autoPending: string;
74
57
  };
75
58
  }
76
59
 
@@ -96,24 +79,8 @@ const UNICODE_SYMBOLS: StatusSymbols = {
96
79
  folder: "📁",
97
80
  scratchFolder: "🗑",
98
81
  worktree: "🌳",
99
- git: "⎇",
100
82
  branch: "⑂",
101
83
  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
84
  },
118
85
  thinking: {
119
86
  minimal: "○ min",
@@ -122,7 +89,6 @@ const UNICODE_SYMBOLS: StatusSymbols = {
122
89
  high: "◒ high",
123
90
  xhigh: "◕ xhigh",
124
91
  max: "◉ max",
125
- autoPending: "⟳",
126
92
  },
127
93
  };
128
94
 
@@ -148,24 +114,8 @@ const NERD_SYMBOLS: StatusSymbols = {
148
114
  folder: "\uf115",
149
115
  scratchFolder: "\uf014",
150
116
  worktree: "\uf0e8",
151
- git: "\uf1d3",
152
117
  branch: "\uf126",
153
118
  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
119
  },
170
120
  thinking: {
171
121
  minimal: "\u{f0a9e} min",
@@ -174,7 +124,6 @@ const NERD_SYMBOLS: StatusSymbols = {
174
124
  high: "\u{f0aa3} high",
175
125
  xhigh: "\u{f0aa5} xhigh",
176
126
  max: "\u{f06d} max",
177
- autoPending: "\u{f074}",
178
127
  },
179
128
  };
180
129
 
@@ -200,24 +149,8 @@ const ASCII_SYMBOLS: StatusSymbols = {
200
149
  folder: "[D]",
201
150
  scratchFolder: "[T]",
202
151
  worktree: "[wt]",
203
- git: "git:",
204
152
  branch: "@",
205
153
  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
154
  },
222
155
  thinking: {
223
156
  minimal: "[min]",
@@ -226,7 +159,6 @@ const ASCII_SYMBOLS: StatusSymbols = {
226
159
  high: "[high]",
227
160
  xhigh: "[xhigh]",
228
161
  max: "[max]",
229
- autoPending: "[~]",
230
162
  },
231
163
  };
232
164
 
@@ -260,7 +192,6 @@ export type StatusColor =
260
192
  | "muted"
261
193
  | "dim"
262
194
  | "text"
263
- | "thinkingHigh"
264
195
  | "statusLineSep"
265
196
  | "statusLineModel"
266
197
  | "statusLinePath"
@@ -268,13 +199,9 @@ export type StatusColor =
268
199
  | "statusLineGitDirty"
269
200
  | "statusLineContext"
270
201
  | "statusLineContextElevated"
271
- | "statusLineSpend"
272
202
  | "statusLineStaged"
273
203
  | "statusLineDirty"
274
- | "statusLineUntracked"
275
- | "statusLineOutput"
276
- | "statusLineCost"
277
- | "statusLineSubagents";
204
+ | "statusLineUntracked";
278
205
 
279
206
  const FG_COLORS: Record<StatusColor, ColorValue> = {
280
207
  accent: "#febc38",
@@ -284,7 +211,6 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
284
211
  muted: "#777d88",
285
212
  dim: "#5f6673",
286
213
  text: "",
287
- thinkingHigh: "#b281d6",
288
214
  statusLineSep: 244,
289
215
  statusLineModel: "#d787af",
290
216
  statusLinePath: "#00afaf",
@@ -292,13 +218,9 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
292
218
  statusLineGitDirty: "#d7af5f",
293
219
  statusLineContext: "#8787af",
294
220
  statusLineContextElevated: "#e07a1f",
295
- statusLineSpend: "#5fafaf",
296
221
  statusLineStaged: 70,
297
222
  statusLineDirty: 178,
298
223
  statusLineUntracked: 39,
299
- statusLineOutput: 205,
300
- statusLineCost: 205,
301
- statusLineSubagents: "#febc38",
302
224
  };
303
225
 
304
226
  const STATUS_LINE_BG: ColorValue = "#121212";
@@ -391,9 +313,11 @@ class StatusTheme {
391
313
  #fgCache = new Map<StatusColor, string>();
392
314
  #bgAnsi: string | undefined;
393
315
 
394
- setSymbolPreset(preset: SymbolPreset): void {
316
+ setSymbolPreset(preset: SymbolPreset): SymbolPreset {
317
+ const previous = this.#preset;
395
318
  this.#preset = preset;
396
319
  this.#symbols = SYMBOL_PRESETS[preset];
320
+ return previous;
397
321
  }
398
322
 
399
323
  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,6 +5,7 @@
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";
9
10
 
10
11
  export function formatNumber(n: number): string {
@@ -27,13 +28,14 @@ export function shortenPath(filePath: string, homeDir?: string): string {
27
28
  }
28
29
 
29
30
  /**
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.
31
+ * Clamp a path/label to `maxLen` display cells by eliding middle components
32
+ * with `…`. The first folder (after `~` or the root) and the basename survive
33
+ * longest; middle components drop farthest-from-cwd first. Falls back to plain
34
+ * left-truncation once even `…/basename` overflows. Widths are terminal cells
35
+ * (matching layout.ts's visibleWidth budgets), not UTF-16 units.
34
36
  */
35
37
  export function clampPathLength(pwd: string, maxLen: number): string {
36
- if (pwd.length <= maxLen) return pwd;
38
+ if (visibleWidth(pwd) <= maxLen) return pwd;
37
39
 
38
40
  const parts = pwd.split("/");
39
41
  const last = parts.length - 1;
@@ -41,16 +43,17 @@ export function clampPathLength(pwd: string, maxLen: number): string {
41
43
 
42
44
  for (let dropped = 1; dropped <= last - firstIdx - 1; dropped++) {
43
45
  const candidate = [...parts.slice(0, firstIdx + 1), "…", ...parts.slice(firstIdx + 1 + dropped)].join("/");
44
- if (candidate.length <= maxLen) return candidate;
46
+ if (visibleWidth(candidate) <= maxLen) return candidate;
45
47
  }
46
48
 
47
49
  // The first folder no longer fits: keep whatever remains closest to the cwd.
48
50
  for (let start = firstIdx + 1; start <= last; start++) {
49
51
  const candidate = `…/${parts.slice(start).join("/")}`;
50
- if (candidate.length <= maxLen) return candidate;
52
+ if (visibleWidth(candidate) <= maxLen) return candidate;
51
53
  }
52
54
 
53
- return `…${pwd.slice(-Math.max(0, maxLen - 1))}`;
55
+ const tailWidth = Math.max(0, maxLen - 1);
56
+ return `…${sliceByColumn(pwd, visibleWidth(pwd) - tailWidth, tailWidth)}`;
54
57
  }
55
58
 
56
59
  /** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */