@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 +14 -12
- package/index.ts +5 -4
- package/package.json +1 -1
- package/src/context.ts +12 -5
- package/src/layout.ts +4 -7
- package/src/menu.ts +9 -9
- package/src/rainbow.ts +2 -2
- package/src/settings-menu.ts +13 -9
- package/src/settings.ts +3 -0
- package/src/tests/feeds.test.ts +0 -2
- package/src/tests/rainbow.test.ts +3 -1
- package/src/theme.ts +0 -8
- package/src/types.ts +4 -1
- package/src/utils.ts +10 -23
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/
|
|
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
|
|
83
|
-
|
|
84
|
-
Intelligence's screen border.
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
134
|
-
seconds, because pi copies the whole entry list on each read
|
|
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
|
-
//
|
|
59
|
-
//
|
|
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 (
|
|
66
|
+
if (rainbowAnimationActive()) {
|
|
66
67
|
if (!rainbowTimer) {
|
|
67
68
|
rainbowTimer = setInterval(() => {
|
|
68
|
-
if (!
|
|
69
|
+
if (!rainbowAnimationActive()) {
|
|
69
70
|
syncRainbow();
|
|
70
71
|
return;
|
|
71
72
|
}
|
package/package.json
CHANGED
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> =
|
|
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 =
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
74
|
+
left.push(rendered.content);
|
|
75
75
|
leftSegIds.push(segId);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/src/settings-menu.ts
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
295
|
-
|
|
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(
|
|
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 },
|
package/src/tests/feeds.test.ts
CHANGED
|
@@ -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("
|
|
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
|
-
/**
|
|
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)
|
|
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)
|
|
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
|
|
150
|
+
return rgbToHex(hslToRgb(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS));
|
|
164
151
|
}
|