@underactive/pi-topping-statusline 0.2.7 → 0.3.0
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 +11 -10
- package/index.ts +3 -2
- package/package.json +8 -5
- package/src/cache-hit.ts +107 -0
- package/src/compaction-bar.ts +9 -49
- package/src/context-graph.ts +6 -3
- package/src/context.ts +29 -3
- package/src/footer.ts +2 -4
- package/src/nvidia-green.ts +14 -10
- package/src/rainbow.ts +3 -23
- package/src/segments.ts +13 -1
- package/src/settings-menu.ts +73 -41
- package/src/settings.ts +49 -40
- package/src/tests/cache-hit.test.ts +318 -0
- package/src/tests/color-mode.test.ts +20 -0
- package/src/tests/compaction-bar.test.ts +9 -48
- package/src/tests/compaction-embed.test.ts +6 -2
- package/src/tests/context-estimate.test.ts +1 -1
- package/src/tests/feeds.test.ts +29 -11
- package/src/tests/git-status.test.ts +3 -3
- package/src/tests/nvidia-green.test.ts +0 -9
- package/src/tests/rainbow.test.ts +1 -1
- package/src/tests/settings-menu.test.ts +179 -0
- package/src/tests/settings.test.ts +56 -0
- package/src/tests/token-rate.test.ts +1 -1
- package/src/tests/working-status.test.ts +8 -8
- package/src/tests/working.test.ts +1 -7
- package/src/theme.ts +24 -101
- package/src/token-rate.ts +16 -117
- package/src/types.ts +14 -5
- package/src/utils.ts +8 -17
- package/src/working-status-embed.ts +0 -7
- package/src/working-status.ts +4 -5
- package/src/menu.ts +0 -676
- package/src/tests/menu.test.ts +0 -138
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-level smoke for /topping-statusline-settings: the command mounts the
|
|
3
|
+
* shared menu from @underactive/pi-topping-primitives through ctx.ui.custom,
|
|
4
|
+
* applying persists the draft and cancelling leaves everything alone. The menu's
|
|
5
|
+
* own behaviour is covered by the package's menu-*.test.ts suites; only the
|
|
6
|
+
* component's public surface (handleInput, render, snapshot) is used here.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
15
|
+
import { MenuComponent } from "@underactive/pi-topping-primitives/menu";
|
|
16
|
+
import { SegmentContextBuilder } from "../context.ts";
|
|
17
|
+
import { stripAnsi } from "../footer.ts";
|
|
18
|
+
import { registerSettingsCommand } from "../settings-menu.ts";
|
|
19
|
+
import { createSettingsState, DEFAULT_FEEDS, settingsPath } from "../settings.ts";
|
|
20
|
+
|
|
21
|
+
const KEY = { up: "\x1b[A", down: "\x1b[B", right: "\x1b[C", space: " ", enter: "\r", escape: "\x1b" } as const;
|
|
22
|
+
|
|
23
|
+
const theme = {
|
|
24
|
+
fg: (_color: string, text: string) => text,
|
|
25
|
+
bg: (_color: string, text: string) => text,
|
|
26
|
+
bold: (text: string) => text,
|
|
27
|
+
getBgAnsi: () => "",
|
|
28
|
+
} as unknown as Theme;
|
|
29
|
+
|
|
30
|
+
type CommandHandler = (args: string, ctx: ExtensionCommandContext) => Promise<void> | void;
|
|
31
|
+
type OverlayFactory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: unknown) => void) => Component;
|
|
32
|
+
|
|
33
|
+
interface Host {
|
|
34
|
+
handler: CommandHandler;
|
|
35
|
+
ctx: ExtensionCommandContext;
|
|
36
|
+
/** The component the command mounted, once it has. */
|
|
37
|
+
mounted: () => MenuComponent;
|
|
38
|
+
overlayOptions: () => unknown;
|
|
39
|
+
notices: string[];
|
|
40
|
+
changes: () => number;
|
|
41
|
+
settings: () => ReturnType<typeof createSettingsState>["settings"];
|
|
42
|
+
dispose: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Register the real command against fakes and expose what the command mounts. */
|
|
46
|
+
function host(): Host {
|
|
47
|
+
const commands = new Map<string, CommandHandler>();
|
|
48
|
+
const fakePi = {
|
|
49
|
+
registerCommand: (name: string, options: { handler: CommandHandler }) => commands.set(name, options.handler),
|
|
50
|
+
exec: async () => undefined,
|
|
51
|
+
getThinkingLevel: () => "off",
|
|
52
|
+
} as unknown as ExtensionAPI;
|
|
53
|
+
const state = createSettingsState();
|
|
54
|
+
const builder = new SegmentContextBuilder(fakePi);
|
|
55
|
+
let changes = 0;
|
|
56
|
+
registerSettingsCommand(fakePi, state, builder, () => changes++);
|
|
57
|
+
const handler = commands.get("topping-statusline-settings");
|
|
58
|
+
assert.ok(handler, "the command must be registered");
|
|
59
|
+
|
|
60
|
+
let mounted: Component | undefined;
|
|
61
|
+
let overlayOptions: unknown;
|
|
62
|
+
const notices: string[] = [];
|
|
63
|
+
const tui = { terminal: { rows: 40, columns: 100 }, requestRender: () => {} } as unknown as TUI;
|
|
64
|
+
const ctx = {
|
|
65
|
+
mode: "tui",
|
|
66
|
+
ui: {
|
|
67
|
+
theme,
|
|
68
|
+
notify: (message: string) => notices.push(message),
|
|
69
|
+
custom: (factory: OverlayFactory, options: unknown) =>
|
|
70
|
+
new Promise(resolve => {
|
|
71
|
+
overlayOptions = options;
|
|
72
|
+
mounted = factory(tui, theme, {} as KeybindingsManager, resolve);
|
|
73
|
+
}),
|
|
74
|
+
},
|
|
75
|
+
} as unknown as ExtensionCommandContext;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
handler,
|
|
79
|
+
ctx,
|
|
80
|
+
mounted: () => {
|
|
81
|
+
assert.ok(mounted instanceof MenuComponent, "the command must mount the shared MenuComponent");
|
|
82
|
+
return mounted;
|
|
83
|
+
},
|
|
84
|
+
overlayOptions: () => overlayOptions,
|
|
85
|
+
notices,
|
|
86
|
+
changes: () => changes,
|
|
87
|
+
settings: () => state.settings,
|
|
88
|
+
dispose: () => builder.dispose(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function withTempAgentDir<T>(run: (dir: string) => Promise<T>): Promise<T> {
|
|
93
|
+
const dir = mkdtempSync(join(tmpdir(), "statusline-menu-"));
|
|
94
|
+
const previous = process.env.PI_CODING_AGENT_DIR;
|
|
95
|
+
process.env.PI_CODING_AGENT_DIR = dir;
|
|
96
|
+
return run(dir).finally(() => {
|
|
97
|
+
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
98
|
+
else process.env.PI_CODING_AGENT_DIR = previous;
|
|
99
|
+
rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const plain = (menu: MenuComponent, width = 80) => menu.render(width).map(stripAnsi);
|
|
104
|
+
const typeText = (menu: MenuComponent, text: string) => {
|
|
105
|
+
for (const ch of text) menu.handleInput(ch);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
test("the settings command mounts the shared menu; adding a feed and applying saves it", () =>
|
|
109
|
+
withTempAgentDir(async () => {
|
|
110
|
+
const h = host();
|
|
111
|
+
try {
|
|
112
|
+
const run = h.handler("", h.ctx);
|
|
113
|
+
const menu = h.mounted();
|
|
114
|
+
assert.deepEqual(h.overlayOptions(), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%" } });
|
|
115
|
+
|
|
116
|
+
const frame = plain(menu);
|
|
117
|
+
assert.match(frame[0] ?? "", /Pi Topping Statusline: Settings/);
|
|
118
|
+
assert.ok(frame.some(line => line.includes("─ Preview ")), "the live preview section is present");
|
|
119
|
+
assert.ok(frame.some(line => /❯ \[■\] Transparent Segments\s+ON/.test(line)), "the selected row carries the ❯ marker");
|
|
120
|
+
assert.ok(!frame.some(line => /^║\s+> /.test(line)), "the old > marker is gone");
|
|
121
|
+
assert.ok(frame.some(line => line.includes("⏎ apply/edit")), "the command's own hints are shown");
|
|
122
|
+
|
|
123
|
+
// Up/down stop at the ends, so overshooting lands on the last row: "+ add feed".
|
|
124
|
+
const rows = menu.snapshot().rowCount;
|
|
125
|
+
for (let i = 0; i < rows; i++) menu.handleInput(KEY.down);
|
|
126
|
+
assert.equal(menu.snapshot().cursorId, "feed.add");
|
|
127
|
+
menu.handleInput(KEY.enter);
|
|
128
|
+
assert.equal(menu.snapshot().rowCount, rows + 5, "one feed is five rows");
|
|
129
|
+
assert.equal(menu.snapshot().cursorId, "feed.add", "the cursor keeps its row across the rebuild");
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < 5; i++) menu.handleInput(KEY.up);
|
|
132
|
+
assert.equal(menu.snapshot().cursorId, "feed.0.customType");
|
|
133
|
+
menu.handleInput(KEY.enter);
|
|
134
|
+
typeText(menu, "my-ext/tokens");
|
|
135
|
+
menu.handleInput(KEY.enter);
|
|
136
|
+
menu.handleInput(KEY.down);
|
|
137
|
+
assert.equal(menu.snapshot().cursorId, "feed.0.field");
|
|
138
|
+
menu.handleInput(KEY.enter);
|
|
139
|
+
typeText(menu, "count");
|
|
140
|
+
menu.handleInput(KEY.enter);
|
|
141
|
+
menu.handleInput(KEY.down);
|
|
142
|
+
menu.handleInput(KEY.down);
|
|
143
|
+
assert.equal(menu.snapshot().cursorId, "feed.0.format");
|
|
144
|
+
menu.handleInput(KEY.right); // currency -> number
|
|
145
|
+
assert.ok(plain(menu).some(line => line.includes("my-ext/tokens")), "the committed text shows in the row");
|
|
146
|
+
|
|
147
|
+
// Enter on a cycle row applies the whole menu (the menu has no buttons).
|
|
148
|
+
menu.handleInput(KEY.enter);
|
|
149
|
+
await run;
|
|
150
|
+
|
|
151
|
+
const added = { customType: "my-ext/tokens", field: "count", prefix: "", format: "number" };
|
|
152
|
+
assert.equal(h.changes(), 1);
|
|
153
|
+
assert.deepEqual(h.settings().feeds, [...DEFAULT_FEEDS, added]);
|
|
154
|
+
assert.deepEqual(JSON.parse(readFileSync(settingsPath(), "utf8")).feeds, [...DEFAULT_FEEDS, added]);
|
|
155
|
+
assert.deepEqual(h.notices, []);
|
|
156
|
+
} finally {
|
|
157
|
+
h.dispose();
|
|
158
|
+
}
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
test("escape resolves the command without applying the edited draft", () =>
|
|
162
|
+
withTempAgentDir(async () => {
|
|
163
|
+
const h = host();
|
|
164
|
+
try {
|
|
165
|
+
const run = h.handler("", h.ctx);
|
|
166
|
+
const menu = h.mounted();
|
|
167
|
+
menu.handleInput(KEY.space);
|
|
168
|
+
assert.equal(menu.snapshot().values.transparent, false, "the draft differs from the saved settings");
|
|
169
|
+
menu.handleInput(KEY.escape);
|
|
170
|
+
await run;
|
|
171
|
+
|
|
172
|
+
assert.equal(h.changes(), 0);
|
|
173
|
+
assert.deepEqual(h.settings(), {}, "cancelling leaves the loaded settings untouched");
|
|
174
|
+
assert.equal(existsSync(settingsPath()), false, "nothing is written on cancel");
|
|
175
|
+
assert.deepEqual(h.notices, []);
|
|
176
|
+
} finally {
|
|
177
|
+
h.dispose();
|
|
178
|
+
}
|
|
179
|
+
}));
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { loadSettings, saveSettings, settingsPath } from "../settings.ts";
|
|
7
|
+
|
|
8
|
+
function withAgentDir(run: (dir: string) => void): void {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "statusline-settings-"));
|
|
10
|
+
const previous = process.env.PI_CODING_AGENT_DIR;
|
|
11
|
+
process.env.PI_CODING_AGENT_DIR = dir;
|
|
12
|
+
try {
|
|
13
|
+
run(dir);
|
|
14
|
+
} finally {
|
|
15
|
+
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
16
|
+
else process.env.PI_CODING_AGENT_DIR = previous;
|
|
17
|
+
rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("the settings path follows PI_CODING_AGENT_DIR changes made after import", () => {
|
|
22
|
+
withAgentDir((first) => {
|
|
23
|
+
assert.equal(settingsPath(), join(first, "pi-topping-statusline", "settings.json"));
|
|
24
|
+
withAgentDir((second) => {
|
|
25
|
+
assert.equal(settingsPath(), join(second, "pi-topping-statusline", "settings.json"));
|
|
26
|
+
assert.equal(saveSettings({ transparent: true }).ok, true);
|
|
27
|
+
assert.equal(loadSettings().transparent, true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("saveSettings writes tab-indented JSON with a trailing newline, mode 0600", () => {
|
|
33
|
+
withAgentDir(() => {
|
|
34
|
+
assert.equal(saveSettings({ transparent: true, separator: "pipe" }).ok, true);
|
|
35
|
+
assert.equal(readFileSync(settingsPath(), "utf8"), '{\n\t"transparent": true,\n\t"separator": "pipe"\n}\n');
|
|
36
|
+
assert.equal(statSync(settingsPath()).mode & 0o777, 0o600);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("saveSettings reports a failure instead of throwing", () => {
|
|
41
|
+
withAgentDir((dir) => {
|
|
42
|
+
writeFileSync(join(dir, "pi-topping-statusline"), "a file where the directory belongs");
|
|
43
|
+
const result = saveSettings({ transparent: true });
|
|
44
|
+
assert.equal(result.ok, false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("loadSettings ignores a corrupt or non-object file", () => {
|
|
49
|
+
withAgentDir(() => {
|
|
50
|
+
mkdirSync(join(settingsPath(), ".."), { recursive: true });
|
|
51
|
+
writeFileSync(settingsPath(), "[1,2]");
|
|
52
|
+
assert.deepEqual(loadSettings(), {});
|
|
53
|
+
writeFileSync(settingsPath(), "{nope");
|
|
54
|
+
assert.deepEqual(loadSettings(), {});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -27,7 +27,7 @@ builder.attach(fakeCtx);
|
|
|
27
27
|
const BASE = builder.build(
|
|
28
28
|
80,
|
|
29
29
|
resolveEffectiveSettings({}).segmentOptions,
|
|
30
|
-
{ git: false, pr: false, piStats: false, tokenRate: true, feeds: [] },
|
|
30
|
+
{ git: false, pr: false, piStats: false, tokenRate: true, cacheHit: false, feeds: [] },
|
|
31
31
|
undefined,
|
|
32
32
|
);
|
|
33
33
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { WORKING_STATUS_SLIDE_MS } from "@underactive/pi-topping-primitives/contracts";
|
|
3
4
|
import {
|
|
4
5
|
STATUS_HOLD_MS,
|
|
5
|
-
STATUS_SLIDE_MS,
|
|
6
6
|
type StatusIndicatorKind,
|
|
7
7
|
StatusTransition,
|
|
8
8
|
WORKING_FADE_MS,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
|
|
11
11
|
/** Show a status early enough that its slide-out has finished by `at`. */
|
|
12
12
|
function settle(transition: StatusTransition, status: string, kind: StatusIndicatorKind | undefined, at: number): void {
|
|
13
|
-
transition.resolve(status, kind, at -
|
|
13
|
+
transition.resolve(status, kind, at - WORKING_STATUS_SLIDE_MS);
|
|
14
14
|
transition.resolve(status, kind, at);
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -24,24 +24,24 @@ test("a status appearing over the user's segments slides out, then settles verba
|
|
|
24
24
|
pending: true,
|
|
25
25
|
});
|
|
26
26
|
// Spinner frames change the text mid-slide without restarting it.
|
|
27
|
-
const early = transition.resolve("⠹ Working", "working", 1_000 +
|
|
28
|
-
const late = transition.resolve("⠸ Working", "working", 1_000 + (
|
|
27
|
+
const early = transition.resolve("⠹ Working", "working", 1_000 + WORKING_STATUS_SLIDE_MS / 4).reveal ?? -1;
|
|
28
|
+
const late = transition.resolve("⠸ Working", "working", 1_000 + (WORKING_STATUS_SLIDE_MS * 3) / 4).reveal ?? -1;
|
|
29
29
|
assert.ok(0 < early && early < late && late < 1, "reveal grows monotonically");
|
|
30
|
-
assert.equal(transition.pending(1_000 +
|
|
30
|
+
assert.equal(transition.pending(1_000 + WORKING_STATUS_SLIDE_MS - 1), true);
|
|
31
31
|
|
|
32
|
-
assert.deepEqual(transition.resolve("⠼ Working", "working", 1_000 +
|
|
32
|
+
assert.deepEqual(transition.resolve("⠼ Working", "working", 1_000 + WORKING_STATUS_SLIDE_MS), {
|
|
33
33
|
status: "⠼ Working",
|
|
34
34
|
kind: "working",
|
|
35
35
|
pending: false,
|
|
36
36
|
});
|
|
37
|
-
assert.equal(transition.pending(1_000 +
|
|
37
|
+
assert.equal(transition.pending(1_000 + WORKING_STATUS_SLIDE_MS), false);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
test("a status cleared mid-slide keeps sliding through its hold", () => {
|
|
41
41
|
const transition = new StatusTransition();
|
|
42
42
|
transition.resolve("⠙ Working", "working", 0);
|
|
43
43
|
|
|
44
|
-
const clearedAt =
|
|
44
|
+
const clearedAt = WORKING_STATUS_SLIDE_MS / 4;
|
|
45
45
|
const cleared = transition.resolve(undefined, undefined, clearedAt);
|
|
46
46
|
const held = transition.resolve(undefined, undefined, clearedAt + STATUS_HOLD_MS / 2);
|
|
47
47
|
assert.equal(held.status, "⠙ Working");
|
|
@@ -14,7 +14,7 @@ import { buildStatusLine } from "../layout.ts";
|
|
|
14
14
|
import { SEGMENTS } from "../segments.ts";
|
|
15
15
|
import { getSeparator } from "../separators.ts";
|
|
16
16
|
import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { theme } from "../theme.ts";
|
|
18
18
|
import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
|
|
19
19
|
|
|
20
20
|
const noGap = (s: string) => s;
|
|
@@ -207,12 +207,6 @@ test("fadeAnsi leaves text untouched at full opacity and sinks colors into the b
|
|
|
207
207
|
);
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
-
test("easeFade is monotonic and pinned at both ends", () => {
|
|
211
|
-
assert.equal(easeFade(0), 0);
|
|
212
|
-
assert.equal(easeFade(1), 1);
|
|
213
|
-
assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
|
|
214
|
-
});
|
|
215
|
-
|
|
216
210
|
test("leftFade recolors the working status but not pi or model details", () => {
|
|
217
211
|
const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
|
|
218
212
|
const ctx = ctxWith(status);
|
package/src/theme.ts
CHANGED
|
@@ -8,20 +8,22 @@
|
|
|
8
8
|
* embedded here. Values are hex strings (truecolor), 256-palette indices
|
|
9
9
|
* (numbers), or "" for the terminal default color.
|
|
10
10
|
*/
|
|
11
|
-
import { getTerminalColorMode } from "@earendil-works/pi-tui";
|
|
11
|
+
import { getTerminalColorMode, type TerminalColorMode } from "@earendil-works/pi-tui";
|
|
12
|
+
import { easeFade, FADE_SHADE_COUNT } from "@underactive/pi-topping-primitives/activity";
|
|
13
|
+
import {
|
|
14
|
+
channelsToFgAnsi,
|
|
15
|
+
fadeRgbToward,
|
|
16
|
+
hexToFgAnsi as sharedHexToFgAnsi,
|
|
17
|
+
parseHex,
|
|
18
|
+
xterm256ToRgb,
|
|
19
|
+
} from "@underactive/pi-topping-primitives/color";
|
|
20
|
+
import { ASCII_BOX, BOX_STYLES, type BoxGlyphs, type BoxStyle } from "@underactive/pi-topping-primitives/glyphs";
|
|
21
|
+
|
|
22
|
+
export type { BoxGlyphs };
|
|
12
23
|
|
|
13
24
|
export type SymbolPreset = "unicode" | "nerd" | "ascii";
|
|
14
25
|
|
|
15
|
-
export type BorderStyle =
|
|
16
|
-
|
|
17
|
-
export interface BoxGlyphs {
|
|
18
|
-
topLeft: string;
|
|
19
|
-
topRight: string;
|
|
20
|
-
bottomLeft: string;
|
|
21
|
-
bottomRight: string;
|
|
22
|
-
horizontal: string;
|
|
23
|
-
vertical: string;
|
|
24
|
-
}
|
|
26
|
+
export type BorderStyle = BoxStyle;
|
|
25
27
|
|
|
26
28
|
export interface StatusSymbols {
|
|
27
29
|
sep: {
|
|
@@ -161,16 +163,6 @@ const SYMBOL_PRESETS: Record<SymbolPreset, StatusSymbols> = {
|
|
|
161
163
|
ascii: ASCII_SYMBOLS,
|
|
162
164
|
};
|
|
163
165
|
|
|
164
|
-
const ASCII_BOX: BoxGlyphs = { topLeft: "+", topRight: "+", bottomLeft: "+", bottomRight: "+", horizontal: "-", vertical: "|" };
|
|
165
|
-
|
|
166
|
-
/** Same glyphs as pi-topping's User Prompt border style. */
|
|
167
|
-
const BOX_STYLES: Record<BorderStyle, BoxGlyphs> = {
|
|
168
|
-
rounded: { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│" },
|
|
169
|
-
heavy: { topLeft: "┏", topRight: "┓", bottomLeft: "┗", bottomRight: "┛", horizontal: "━", vertical: "┃" },
|
|
170
|
-
double: { topLeft: "╔", topRight: "╗", bottomLeft: "╚", bottomRight: "╝", horizontal: "═", vertical: "║" },
|
|
171
|
-
single: { topLeft: "┌", topRight: "┐", bottomLeft: "└", bottomRight: "┘", horizontal: "─", vertical: "│" },
|
|
172
|
-
};
|
|
173
|
-
|
|
174
166
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
175
167
|
// Palette (OMP dark.json)
|
|
176
168
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -191,6 +183,7 @@ export type StatusColor =
|
|
|
191
183
|
| "statusLineGitDirty"
|
|
192
184
|
| "statusLineContext"
|
|
193
185
|
| "statusLineContextElevated"
|
|
186
|
+
| "statusLineCacheHit"
|
|
194
187
|
| "statusLineStaged"
|
|
195
188
|
| "statusLineDirty"
|
|
196
189
|
| "statusLineUntracked"
|
|
@@ -212,6 +205,7 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
|
212
205
|
statusLineGitDirty: "#d7af5f",
|
|
213
206
|
statusLineContext: "#8787af",
|
|
214
207
|
statusLineContextElevated: "#e07a1f",
|
|
208
|
+
statusLineCacheHit: "#7fb069",
|
|
215
209
|
statusLineStaged: 70,
|
|
216
210
|
statusLineDirty: 178,
|
|
217
211
|
statusLineUntracked: 39,
|
|
@@ -229,88 +223,32 @@ export const MAJOR_COLOR_HEXES = [
|
|
|
229
223
|
"#00afaf",
|
|
230
224
|
];
|
|
231
225
|
|
|
232
|
-
export type ColorMode =
|
|
226
|
+
export type ColorMode = TerminalColorMode;
|
|
233
227
|
|
|
234
228
|
/** Defer to pi's cached terminal detection, which honors PI_TRUE_COLOR, terminal.trueColor, and capability overrides. */
|
|
235
229
|
export function detectColorMode(): ColorMode {
|
|
236
230
|
return getTerminalColorMode();
|
|
237
231
|
}
|
|
238
232
|
|
|
239
|
-
export
|
|
240
|
-
const match = /^#?([0-9a-f]{6})$/i.exec(hex);
|
|
241
|
-
if (!match) return undefined;
|
|
242
|
-
const n = Number.parseInt(match[1], 16);
|
|
243
|
-
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** Number of discrete, eased shades used for the token-rate and feed fades (pi-topping). */
|
|
247
|
-
export const FADE_SHADE_COUNT = 5;
|
|
248
|
-
|
|
249
|
-
/** Stand-in for the terminal's default foreground when a fade must recolor it. */
|
|
250
|
-
const DEFAULT_FG_RGB: [number, number, number] = [204, 204, 204];
|
|
251
|
-
|
|
252
|
-
/** Every SGR color the layout or a segment can emit: truecolor, 256-color, or the default-fg reset. */
|
|
253
|
-
const SGR_COLOR_RE = /\x1b\[(38|48);2;(\d+);(\d+);(\d+)m|\x1b\[(38|48);5;(\d+)m|\x1b\[39m/g;
|
|
254
|
-
|
|
255
|
-
/** Raised-cosine ease shared by the discrete and continuous fades. */
|
|
256
|
-
export function easeFade(progress: number): number {
|
|
257
|
-
const clamped = Math.max(0, Math.min(1, progress));
|
|
258
|
-
return 0.5 * (1 - Math.cos(Math.PI * clamped));
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** Resolve an xterm-256 palette index to RGB (16–231 cube, 232–255 grayscale). */
|
|
262
|
-
function xterm256ToRgb(n: number): [number, number, number] {
|
|
263
|
-
if (n >= 232) {
|
|
264
|
-
const v = 8 + (n - 232) * 10;
|
|
265
|
-
return [v, v, v];
|
|
266
|
-
}
|
|
267
|
-
const i = Math.max(0, n - 16);
|
|
268
|
-
const c = (v: number) => (v === 0 ? 0 : 55 + v * 51);
|
|
269
|
-
return [c(Math.floor(i / 36) % 6), c(Math.floor(i / 6) % 6), c(i % 6)];
|
|
270
|
-
}
|
|
233
|
+
export { easeFade, FADE_SHADE_COUNT } from "@underactive/pi-topping-primitives/activity";
|
|
271
234
|
|
|
272
235
|
function colorValueToRgb(color: ColorValue): [number, number, number] {
|
|
273
236
|
if (typeof color === "number") return xterm256ToRgb(color);
|
|
274
237
|
return parseHex(color) ?? [0, 0, 0];
|
|
275
238
|
}
|
|
276
239
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (r < 8) return 16;
|
|
280
|
-
if (r > 248) return 231;
|
|
281
|
-
return 232 + Math.round(((r - 8) / 247) * 24);
|
|
282
|
-
}
|
|
283
|
-
const scale = (v: number) => Math.round((v / 255) * 5);
|
|
284
|
-
return 16 + 36 * scale(r) + 6 * scale(g) + scale(b);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
export function mixRgb(
|
|
288
|
-
from: readonly [number, number, number],
|
|
289
|
-
to: readonly [number, number, number],
|
|
290
|
-
amount: number,
|
|
291
|
-
): [number, number, number] {
|
|
292
|
-
const clamped = Math.min(1, Math.max(0, amount));
|
|
293
|
-
return [
|
|
294
|
-
Math.round(from[0] + (to[0] - from[0]) * clamped),
|
|
295
|
-
Math.round(from[1] + (to[1] - from[1]) * clamped),
|
|
296
|
-
Math.round(from[2] + (to[2] - from[2]) * clamped),
|
|
297
|
-
];
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export function rgbToFgAnsi(r: number, g: number, b: number, mode: ColorMode): string {
|
|
301
|
-
return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
302
|
-
}
|
|
240
|
+
/** The bar background as a tuple, resolved once at load: `fadeAnsi` sinks colours toward it on every tick. */
|
|
241
|
+
const STATUS_LINE_BG_RGB = colorValueToRgb(STATUS_LINE_BG);
|
|
303
242
|
|
|
243
|
+
/** `#rrggbb` as a foreground escape, in the terminal's colour depth unless the caller passes one. */
|
|
304
244
|
export function hexToFgAnsi(hex: string, mode: ColorMode = detectColorMode()): string {
|
|
305
|
-
|
|
306
|
-
if (!rgb) return "";
|
|
307
|
-
return rgbToFgAnsi(...rgb, mode);
|
|
245
|
+
return sharedHexToFgAnsi(hex, mode);
|
|
308
246
|
}
|
|
309
247
|
|
|
310
248
|
function fgAnsi(color: ColorValue, mode: ColorMode): string {
|
|
311
249
|
if (color === "") return "\x1b[39m";
|
|
312
250
|
if (typeof color === "number") return `\x1b[38;5;${color}m`;
|
|
313
|
-
return
|
|
251
|
+
return sharedHexToFgAnsi(color, mode);
|
|
314
252
|
}
|
|
315
253
|
|
|
316
254
|
function bgAnsi(color: ColorValue, mode: ColorMode): string {
|
|
@@ -402,7 +340,7 @@ class StatusTheme {
|
|
|
402
340
|
const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
|
|
403
341
|
const g = Math.round(a[1] * (1 - eased) + b[1] * eased);
|
|
404
342
|
const bl = Math.round(a[2] * (1 - eased) + b[2] * eased);
|
|
405
|
-
const ansi =
|
|
343
|
+
const ansi = channelsToFgAnsi(r, g, bl, this.#currentMode());
|
|
406
344
|
return `${ansi}${text}\x1b[39m`;
|
|
407
345
|
}
|
|
408
346
|
|
|
@@ -417,22 +355,7 @@ class StatusTheme {
|
|
|
417
355
|
fadeAnsi(text: string, opacity: number): string {
|
|
418
356
|
const alpha = Math.max(0, Math.min(1, opacity));
|
|
419
357
|
if (alpha >= 1) return text;
|
|
420
|
-
|
|
421
|
-
const mode = this.#currentMode();
|
|
422
|
-
return text.replace(SGR_COLOR_RE, (_m, tcPlane, r, g, b, ixPlane, index) => {
|
|
423
|
-
let plane = "38";
|
|
424
|
-
let rgb: [number, number, number] = DEFAULT_FG_RGB;
|
|
425
|
-
if (tcPlane) {
|
|
426
|
-
plane = tcPlane;
|
|
427
|
-
rgb = [Number(r), Number(g), Number(b)];
|
|
428
|
-
} else if (ixPlane) {
|
|
429
|
-
plane = ixPlane;
|
|
430
|
-
rgb = xterm256ToRgb(Number(index));
|
|
431
|
-
}
|
|
432
|
-
const mix = (i: number) => Math.round(rgb[i] * alpha + target[i] * (1 - alpha));
|
|
433
|
-
const fg = rgbToFgAnsi(mix(0), mix(1), mix(2), mode);
|
|
434
|
-
return plane === "38" ? fg : fg.replace("\x1b[38;", "\x1b[48;");
|
|
435
|
-
});
|
|
358
|
+
return fadeRgbToward(text, STATUS_LINE_BG_RGB, alpha, this.#currentMode());
|
|
436
359
|
}
|
|
437
360
|
}
|
|
438
361
|
|