@underactive/pi-topping-statusline 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -41,6 +41,7 @@ and apply live.
41
41
  ║ Symbols ‹ nerdfont › ║
42
42
  ║ Border style ‹ rounded › ║
43
43
  ║ [■] Rainbow border on max thinking ON ║
44
+ ║ [■] Animate rainbow border ON ║
44
45
  ║ ║
45
46
  ╟─ Top Left Segment Group ───────────────────────────────────────╢
46
47
  ║ [■] Pi symbol ON ║
@@ -65,26 +66,26 @@ and apply live.
65
66
  ║ + add feed ║
66
67
  ║ ║
67
68
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
68
- ╚════════════════════════════════════════════════════════[ 1/28 ]╝
69
+ ╚════════════════════════════════════════════════════════[ 1/29 ]╝
69
70
  ```
70
71
 
71
72
  | Section | Settings |
72
73
  | --- | --- |
73
- | Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking |
74
+ | Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border |
74
75
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
75
76
  | Top Right Segment Group | Token rate · Session name |
76
77
  | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
77
78
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
78
79
  | Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
79
- | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on |
80
+ | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on |
80
81
 
81
82
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
82
- replaces the border's fixed theme color with an animated rainbow: a full hue cycle distributed
83
- around the box perimeter that slowly flows around the border (~14s per rotation), like Apple
84
- Intelligence's screen border. Any other thinking level keeps the normal theme border color.
85
- The settings preview animates while the toggle is on; the effect repaints the border about
86
- 20 times a second, so switch it off over slow SSH links or in terminals with expensive
87
- redraws.
83
+ replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
84
+ box perimeter. **Animate rainbow border** is also on by default, so the hues flow around the
85
+ border (~14s per rotation), like Apple Intelligence's screen border. Turn animation off to keep
86
+ the rainbow at a fixed color phase without its repaint timer; the settings preview uses a stable
87
+ phase too. Any other thinking level keeps the normal theme border color. Disable animation over
88
+ slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
88
89
 
89
90
  ## Segments
90
91
 
@@ -130,9 +131,10 @@ A feed contributes nothing when its publisher is absent, has published nothing t
130
131
  reports a value its format suppresses; the segment disappears entirely once every feed is
131
132
  quiet, so it costs nothing to leave switched on. Publishers typically reset per process run,
132
133
  so entries predating the current session start (`--resume`, `/reload`, `/new`, fork) are
133
- ignored rather than shown as a stale total. The session is re-read at most once every two
134
- seconds, because pi copies the whole entry list on each read while the bar re-renders on
135
- every keystroke.
134
+ ignored rather than shown as a stale total. During steady-state rendering, the session is
135
+ re-read at most once every two seconds, because pi copies the whole entry list on each read
136
+ while the bar re-renders on every keystroke; changing subscriptions or starting a new
137
+ session forces an immediate scan.
136
138
 
137
139
  ### Publishing a feed
138
140
 
package/index.ts CHANGED
@@ -55,17 +55,18 @@ export default function (pi: ExtensionAPI) {
55
55
  rateMonitor.attach(pi);
56
56
  builder.setTokenRateProvider(() => rateMonitor.getDisplay());
57
57
 
58
- // Animated rainbow border while the thinking level is max: a timer steps
59
- // the hue phase and re-renders; renderBoxed paints the box glyphs from it.
58
+ // The rainbow is visible at max thinking independently of whether its hue
59
+ // phase is animated. Stopping the timer preserves the current live phase.
60
60
  const rainbow = new RainbowBorder();
61
61
  let rainbowTimer: ReturnType<typeof setInterval> | undefined;
62
62
  const rainbowActive = (): boolean =>
63
63
  state.effective.rainbowBorder && activeCtx !== undefined && pi.getThinkingLevel() === "max";
64
+ const rainbowAnimationActive = (): boolean => rainbowActive() && state.effective.rainbowAnimation;
64
65
  const syncRainbow = (): void => {
65
- if (rainbowActive()) {
66
+ if (rainbowAnimationActive()) {
66
67
  if (!rainbowTimer) {
67
68
  rainbowTimer = setInterval(() => {
68
- if (!rainbowActive()) {
69
+ if (!rainbowAnimationActive()) {
69
70
  syncRainbow();
70
71
  return;
71
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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/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) {
@@ -188,6 +191,9 @@ export class SegmentContextBuilder {
188
191
  this.#requestRender();
189
192
  } finally {
190
193
  this.#repoResolving = false;
194
+ if (this.#repoCwd && this.#repoCwd !== cwd) {
195
+ void this.#resolveRepo(this.#repoCwd);
196
+ }
191
197
  }
192
198
  }
193
199
 
@@ -265,7 +271,7 @@ export class SegmentContextBuilder {
265
271
  */
266
272
  #refreshFeeds(customTypes: readonly string[], now: number): void {
267
273
  this.#feedsScannedAt = now;
268
- this.#feedData = Object.create(null) as Record<string, unknown>;
274
+ this.#feedData = emptyFeedData();
269
275
  const entries = this.#ctx?.sessionManager?.getEntries();
270
276
  if (!entries) return;
271
277
  const pending = new Set(customTypes);
@@ -275,7 +281,8 @@ export class SegmentContextBuilder {
275
281
  // first: matching on customType alone lets one shadow a real entry.
276
282
  if (entry?.type !== "custom" || !pending.has(entry.customType)) continue;
277
283
  const ts = Date.parse(entry.timestamp);
278
- if (!Number.isFinite(ts) || ts < this.#runStartedAt) break;
284
+ if (!Number.isFinite(ts)) continue;
285
+ if (ts < this.#runStartedAt) break;
279
286
  pending.delete(entry.customType);
280
287
  if (entry.data !== undefined) this.#feedData[entry.customType] = entry.data;
281
288
  }
@@ -304,7 +311,7 @@ export class SegmentContextBuilder {
304
311
  const u = URL.parse(parsed.url);
305
312
  if (
306
313
  u &&
307
- (u.protocol === "https:" || u.protocol === "http:") &&
314
+ u.protocol === "https:" &&
308
315
  u.hostname === "github.com" &&
309
316
  !/[\u0000-\u001f\u007f]/.test(parsed.url)
310
317
  ) {
package/src/layout.ts CHANGED
@@ -66,27 +66,24 @@ export function buildStatusLine(
66
66
  const fgAnsi = theme.getFgAnsi("text");
67
67
  const sepAnsi = theme.getFgAnsi("statusLineSep");
68
68
 
69
- const leftParts: string[] = [];
69
+ const left: string[] = [];
70
70
  const leftSegIds: StatusLineSegmentId[] = [];
71
71
  for (const segId of segmentGroups.left) {
72
72
  const rendered = renderSegment(segId, ctx);
73
73
  if (rendered.visible && rendered.content) {
74
- leftParts.push(rendered.content);
74
+ left.push(rendered.content);
75
75
  leftSegIds.push(segId);
76
76
  }
77
77
  }
78
78
 
79
- const rightParts: string[] = [];
79
+ const right: string[] = [];
80
80
  for (const segId of segmentGroups.right) {
81
81
  const rendered = renderSegment(segId, ctx);
82
82
  if (rendered.visible && rendered.content) {
83
- rightParts.push(rendered.content);
83
+ right.push(rendered.content);
84
84
  }
85
85
  }
86
86
 
87
- const left = [...leftParts];
88
- const right = [...rightParts];
89
-
90
87
  const leftSepWidth = visibleWidth(separatorDef.left);
91
88
  const rightSepWidth = visibleWidth(separatorDef.right);
92
89
  // Transparent mode drops the round caps (they need a bg fill to bridge);
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
 
@@ -135,6 +135,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
135
135
  label: "Rainbow border on max thinking",
136
136
  value: settings.rainbowBorder ?? true,
137
137
  },
138
+ {
139
+ id: "rainbowAnimation",
140
+ label: "Animate rainbow border",
141
+ value: settings.rainbowAnimation ?? true,
142
+ },
138
143
  ],
139
144
  };
140
145
  return [
@@ -164,6 +169,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
164
169
  segments,
165
170
  feeds: sanitizeFeeds(feedsFromValues(values)),
166
171
  rainbowBorder: values.rainbowBorder === true,
172
+ rainbowAnimation: values.rainbowAnimation === true,
167
173
  };
168
174
  }
169
175
 
@@ -195,12 +201,10 @@ function cannedFeedData(
195
201
  }
196
202
 
197
203
  class StatusLinePreview {
198
- readonly #state: SettingsState;
199
204
  readonly #builder: SegmentContextBuilder;
200
205
  readonly #uiTheme: Theme;
201
206
 
202
- constructor(state: SettingsState, builder: SegmentContextBuilder, uiTheme: Theme) {
203
- this.#state = state;
207
+ constructor(builder: SegmentContextBuilder, uiTheme: Theme) {
204
208
  this.#builder = builder;
205
209
  this.#uiTheme = uiTheme;
206
210
  }
@@ -217,6 +221,7 @@ class StatusLinePreview {
217
221
  const boxWidth = Math.max(24, innerWidth - 1);
218
222
  const barWidth = boxWidth - 6;
219
223
  const rainbowOn = effective.rainbowBorder;
224
+ const rainbowAnimationOn = rainbowOn && effective.rainbowAnimation;
220
225
 
221
226
  // The theme singleton drives glyph lookups everywhere, so swap the
222
227
  // symbol preset for this synchronous render only; the live bar behind
@@ -255,7 +260,7 @@ class StatusLinePreview {
255
260
  const bottomIdx = 2;
256
261
  const painters = makeBoxPainters({
257
262
  rainbowOn,
258
- rainbow: new RainbowBorder((elapsedMs * 360) / RAINBOW_CYCLE_MS),
263
+ rainbow: new RainbowBorder(rainbowAnimationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0),
259
264
  box,
260
265
  width: boxWidth,
261
266
  bottomIdx,
@@ -291,9 +296,8 @@ class StatusLinePreview {
291
296
  painters.paint(1, boxWidth - 1, box.vertical),
292
297
  renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
293
298
  ],
294
- // Keep the preview animated only while the rainbow is enabled;
295
- // a static preview refreshes once and stops.
296
- nextRefreshInMs: rainbowOn ? RAINBOW_FRAME_MS : undefined,
299
+ // A static rainbow stays at phase zero and needs no repaint timer.
300
+ nextRefreshInMs: rainbowAnimationOn ? RAINBOW_FRAME_MS : undefined,
297
301
  };
298
302
  } finally {
299
303
  theme.setSymbolPreset(applied);
@@ -308,13 +312,13 @@ export function registerSettingsCommand(
308
312
  onChange: () => void,
309
313
  ): void {
310
314
  pi.registerCommand("topping-statusline-settings", {
311
- description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border",
315
+ description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border animation",
312
316
  handler: async (_args, ctx: ExtensionCommandContext) => {
313
317
  if (ctx.mode !== "tui") {
314
318
  ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
315
319
  return;
316
320
  }
317
- const preview = new StatusLinePreview(state, builder, ctx.ui.theme);
321
+ const preview = new StatusLinePreview(builder, ctx.ui.theme);
318
322
  const result = await showMenu<Record<string, MenuValue>>(ctx, {
319
323
  title: "Pi Topping Statusline: Settings",
320
324
  sections: buildSections(state.settings),
package/src/settings.ts CHANGED
@@ -92,8 +92,10 @@ function loadSettings(): StatusLineSettings {
92
92
  }
93
93
  if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
94
94
  if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
95
+ if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
95
96
  return settings;
96
97
  } catch {
98
+ // Missing or corrupt settings.json falls back to defaults.
97
99
  return {};
98
100
  }
99
101
  }
@@ -152,6 +154,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
152
154
  bottomLeftSegments,
153
155
  bottomRightSegments,
154
156
  rainbowBorder: settings.rainbowBorder ?? true,
157
+ rainbowAnimation: settings.rainbowAnimation ?? true,
155
158
  includes,
156
159
  segmentOptions: {
157
160
  model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
@@ -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
  });
@@ -113,9 +113,11 @@ test("a left group shifts the gap start by its width", () => {
113
113
  assert.ok(calls[0]!.startCol > 3, "gap starts after the pi segment, not at the origin");
114
114
  });
115
115
 
116
- test("rainbowBorder defaults to true and is loadable", () => {
116
+ test("rainbow settings default to true and resolve explicit false", () => {
117
117
  assert.equal(resolveEffectiveSettings({}).rainbowBorder, true);
118
+ assert.equal(resolveEffectiveSettings({}).rainbowAnimation, true);
118
119
  assert.equal(resolveEffectiveSettings({ rainbowBorder: false }).rainbowBorder, false);
120
+ assert.equal(resolveEffectiveSettings({ rainbowAnimation: false }).rainbowAnimation, false);
119
121
  });
120
122
 
121
123
  test("borderStyle defaults to rounded and is loadable", () => {
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;
@@ -65,8 +63,6 @@ const UNICODE_SYMBOLS: StatusSymbols = {
65
63
  powerlineThinRight: "<",
66
64
  halfCircleLeft: "",
67
65
  halfCircleRight: "",
68
- block: "▌",
69
- space: " ",
70
66
  asciiLeft: ">",
71
67
  asciiRight: "<",
72
68
  dot: " · ",
@@ -100,8 +96,6 @@ const NERD_SYMBOLS: StatusSymbols = {
100
96
  powerlineThinRight: "\ue0b3",
101
97
  halfCircleLeft: "\ue0b6",
102
98
  halfCircleRight: "\ue0b4",
103
- block: "█",
104
- space: " ",
105
99
  asciiLeft: ">",
106
100
  asciiRight: "<",
107
101
  dot: " · ",
@@ -135,8 +129,6 @@ const ASCII_SYMBOLS: StatusSymbols = {
135
129
  powerlineThinRight: "<",
136
130
  halfCircleLeft: "",
137
131
  halfCircleRight: "",
138
- block: "#",
139
- space: " ",
140
132
  asciiLeft: ">",
141
133
  asciiRight: "<",
142
134
  dot: " - ",
package/src/types.ts CHANGED
@@ -69,8 +69,10 @@ export interface StatusLineSettings {
69
69
  borderStyle?: BorderStyle;
70
70
  segments?: StatusLineSegmentToggles;
71
71
  feeds?: StatusLineFeed[];
72
- /** Animated rainbow border while the thinking level is max. */
72
+ /** Rainbow border while the thinking level is max. */
73
73
  rainbowBorder?: boolean;
74
+ /** Animate the rainbow border when it is visible. */
75
+ rainbowAnimation?: boolean;
74
76
  }
75
77
 
76
78
  /** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
@@ -102,6 +104,7 @@ export interface EffectiveStatusLineSettings {
102
104
  bottomRightSegments: StatusLineSegmentId[];
103
105
  segmentOptions: StatusLineSegmentOptions;
104
106
  rainbowBorder: boolean;
107
+ rainbowAnimation: boolean;
105
108
  includes: SegmentIncludes;
106
109
  }
107
110
 
package/src/utils.ts CHANGED
@@ -7,13 +7,20 @@
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
  import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import { parseHex } from "./theme.js";
10
+ import { hslToRgb, rgbToHex } from "./rainbow.js";
10
11
 
11
12
  export function formatNumber(n: number): string {
12
13
  if (n < 1_000) return n.toString();
13
14
  if (n < 10_000) return `${(n / 1_000).toFixed(1)}K`;
14
- 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
+ }
15
19
  if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
16
- 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
+ }
17
24
  if (n < 10_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
18
25
  return `${Math.round(n / 1_000_000_000)}B`;
19
26
  }
@@ -122,26 +129,6 @@ function hexToHue(hex: string): number | undefined {
122
129
  return (h + 360) % 360;
123
130
  }
124
131
 
125
- function hslToHex(h: number, s: number, l: number): string {
126
- const c = (1 - Math.abs(2 * l - 1)) * s;
127
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
128
- const m = l - c / 2;
129
- let r = 0;
130
- let g = 0;
131
- let b = 0;
132
- if (h < 60) [r, g, b] = [c, x, 0];
133
- else if (h < 120) [r, g, b] = [x, c, 0];
134
- else if (h < 180) [r, g, b] = [0, c, x];
135
- else if (h < 240) [r, g, b] = [0, x, c];
136
- else if (h < 300) [r, g, b] = [x, 0, c];
137
- else [r, g, b] = [c, 0, x];
138
- const toHex = (v: number) =>
139
- Math.round((v + m) * 255)
140
- .toString(16)
141
- .padStart(2, "0");
142
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
143
- }
144
-
145
132
  function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number {
146
133
  if (occupied.length === 0) return target;
147
134
  if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) return target;
@@ -160,5 +147,5 @@ export function getSessionAccentHex(name: string, themeColorHexes: string[]): st
160
147
  let targetHue = DARK_HUE_START + (nameToHue(name) % range);
161
148
  const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
162
149
  targetHue = findSafeHue(targetHue, themeHues, DARK_HUE_START, DARK_HUE_END);
163
- return hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
150
+ return rgbToHex(hslToRgb(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS));
164
151
  }