@underactive/pi-topping-statusline 0.1.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/LICENSE +23 -0
- package/README.md +208 -0
- package/index.ts +355 -0
- package/package.json +54 -0
- package/src/context-graph.ts +48 -0
- package/src/context-thresholds.ts +84 -0
- package/src/context.ts +387 -0
- package/src/layout.ts +164 -0
- package/src/menu.ts +687 -0
- package/src/rainbow.ts +98 -0
- package/src/segments.ts +349 -0
- package/src/separators.ts +22 -0
- package/src/settings-menu.ts +370 -0
- package/src/settings.ts +169 -0
- package/src/tests/context-thresholds.test.ts +50 -0
- package/src/tests/feeds.test.ts +368 -0
- package/src/tests/menu.test.ts +109 -0
- package/src/tests/rainbow.test.ts +133 -0
- package/src/tests/token-rate-monitor.test.ts +87 -0
- package/src/tests/token-rate.test.ts +75 -0
- package/src/tests/ts-resolve.ts +20 -0
- package/src/theme.ts +454 -0
- package/src/token-rate.ts +242 -0
- package/src/types.ts +166 -0
- package/src/utils.ts +161 -0
package/src/rainbow.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animated rainbow border for the max thinking level.
|
|
3
|
+
*
|
|
4
|
+
* Instead of painting the editor box border with the host's fixed
|
|
5
|
+
* thinking-max theme token, the border glyphs get a full hue cycle (0–360°)
|
|
6
|
+
* distributed around the box perimeter, with the whole spectrum rotating over
|
|
7
|
+
* time — the border looks like a spectrum flowing around the screen, similar
|
|
8
|
+
* to Apple Intelligence's border effect.
|
|
9
|
+
*
|
|
10
|
+
* The RainbowBorder class is a pure colorer: callers map glyphs to perimeter
|
|
11
|
+
* positions with perimeterPosition(), then either step the phase each frame
|
|
12
|
+
* (renderBoxed in index.ts) or construct a fresh instance with a phase
|
|
13
|
+
* derived from elapsed time (the settings preview).
|
|
14
|
+
*/
|
|
15
|
+
import { detectColorMode, hexToFgAnsi, type ColorMode } from "./theme.js";
|
|
16
|
+
|
|
17
|
+
/** Frame cadence of the sweep (also the settings-preview refresh delay). */
|
|
18
|
+
export const RAINBOW_FRAME_MS = 50;
|
|
19
|
+
/** One full hue rotation around the border perimeter takes this long. */
|
|
20
|
+
export const RAINBOW_CYCLE_MS = 14_000;
|
|
21
|
+
/** Hue advance per frame tick. */
|
|
22
|
+
export const RAINBOW_DEG_PER_FRAME = (360 * RAINBOW_FRAME_MS) / RAINBOW_CYCLE_MS;
|
|
23
|
+
|
|
24
|
+
const SATURATION = 1;
|
|
25
|
+
const LIGHTNESS = 0.62;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Number of border cells in the box (rows 0..bottomIdx, cols 0..width-1):
|
|
29
|
+
* two horizontal edges of `width` cells plus two vertical edges of
|
|
30
|
+
* `bottomIdx + 1` cells, minus the four corners counted twice.
|
|
31
|
+
*/
|
|
32
|
+
export function perimeterLength(width: number, bottomIdx: number): number {
|
|
33
|
+
return 2 * width + 2 * bottomIdx - 2;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Clockwise walk position of a box border cell, starting at the top-left
|
|
38
|
+
* corner: top edge left→right, right edge top→bottom, bottom edge
|
|
39
|
+
* right→left, left edge bottom→top. Consecutive cells along the walk get
|
|
40
|
+
* consecutive positions, so the hue gradient stays continuous around the box.
|
|
41
|
+
*/
|
|
42
|
+
export function perimeterPosition(row: number, col: number, width: number, bottomIdx: number): number {
|
|
43
|
+
if (row === 0) return col;
|
|
44
|
+
if (row === bottomIdx) return 2 * width + bottomIdx - 2 - col;
|
|
45
|
+
if (col === width - 1) return width - 1 + row;
|
|
46
|
+
return 2 * width + 2 * bottomIdx - 2 - row; // col === 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
50
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
51
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
52
|
+
const m = l - c / 2;
|
|
53
|
+
let rgb: [number, number, number];
|
|
54
|
+
if (h < 60) rgb = [c, x, 0];
|
|
55
|
+
else if (h < 120) rgb = [x, c, 0];
|
|
56
|
+
else if (h < 180) rgb = [0, c, x];
|
|
57
|
+
else if (h < 240) rgb = [0, x, c];
|
|
58
|
+
else if (h < 300) rgb = [x, 0, c];
|
|
59
|
+
else rgb = [c, 0, x];
|
|
60
|
+
return [Math.round((rgb[0] + m) * 255), Math.round((rgb[1] + m) * 255), Math.round((rgb[2] + m) * 255)];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rgbToHex([r, g, b]: [number, number, number]): string {
|
|
64
|
+
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Colors box glyphs with a hue that travels around the border perimeter as
|
|
69
|
+
* the phase advances — the spectrum appears to flow around the box.
|
|
70
|
+
*/
|
|
71
|
+
export class RainbowBorder {
|
|
72
|
+
#phaseDeg: number;
|
|
73
|
+
readonly #mode: ColorMode;
|
|
74
|
+
readonly #ansiByHue: string[] = [];
|
|
75
|
+
|
|
76
|
+
constructor(phaseDeg = 0) {
|
|
77
|
+
this.#phaseDeg = ((phaseDeg % 360) + 360) % 360;
|
|
78
|
+
this.#mode = detectColorMode();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Advance the whole spectrum by `deltaDeg` (mod 360). */
|
|
82
|
+
step(deltaDeg: number): void {
|
|
83
|
+
this.#phaseDeg = ((this.#phaseDeg + deltaDeg) % 360 + 360) % 360;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Color one border glyph whose walk position is `perimeterPos` of `perimeter`. */
|
|
87
|
+
colorChar(char: string, perimeterPos: number, perimeter: number): string {
|
|
88
|
+
const hue = (this.#phaseDeg + (360 * perimeterPos) / perimeter) % 360;
|
|
89
|
+
const bucket = Math.round(hue) % 360;
|
|
90
|
+
let ansi = this.#ansiByHue[bucket];
|
|
91
|
+
if (ansi === undefined) {
|
|
92
|
+
const hex = rgbToHex(hslToRgb(bucket, SATURATION, LIGHTNESS));
|
|
93
|
+
ansi = hexToFgAnsi(hex, this.#mode);
|
|
94
|
+
this.#ansiByHue[bucket] = ansi;
|
|
95
|
+
}
|
|
96
|
+
return `${ansi}${char}\x1b[39m`;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/segments.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segment renderers + registry, ported from oh-my-pi status-line/segments.ts.
|
|
3
|
+
*
|
|
4
|
+
* OMP-only decorations inside segments (advisor badge, premium requests,
|
|
5
|
+
* "(sub)", fast-mode icon, jj support) are omitted — pi has no data source
|
|
6
|
+
* for them.
|
|
7
|
+
*/
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { getCapabilities, hyperlink, truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { renderContextGraph } from "./context-graph.js";
|
|
12
|
+
import { formatContextUsage } from "./context-thresholds.js";
|
|
13
|
+
import { MAJOR_COLOR_HEXES, hexToFgAnsi, theme } from "./theme.js";
|
|
14
|
+
import {
|
|
15
|
+
clampPathLength,
|
|
16
|
+
getSessionAccentHex,
|
|
17
|
+
sanitizeLabel,
|
|
18
|
+
sanitizeStatusText,
|
|
19
|
+
shortenPath,
|
|
20
|
+
withIcon,
|
|
21
|
+
} from "./utils.js";
|
|
22
|
+
import type {
|
|
23
|
+
FeedFormat,
|
|
24
|
+
RenderedSegment,
|
|
25
|
+
SegmentContext,
|
|
26
|
+
StatusLineSegment,
|
|
27
|
+
StatusLineSegmentId,
|
|
28
|
+
} from "./types.js";
|
|
29
|
+
|
|
30
|
+
const INVISIBLE: RenderedSegment = { content: "", visible: false };
|
|
31
|
+
|
|
32
|
+
function relativePathWithinRoot(root: string, target: string): string | null {
|
|
33
|
+
const rel = path.relative(root, target);
|
|
34
|
+
if (rel === "" || rel === ".") return ".";
|
|
35
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
36
|
+
return rel;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DISPLAY_ROOTS: readonly string[] = [path.join(os.homedir(), "Projects"), "/work"];
|
|
40
|
+
|
|
41
|
+
function stripDisplayRoot(pwd: string): string {
|
|
42
|
+
for (const root of DISPLAY_ROOTS) {
|
|
43
|
+
const relative = relativePathWithinRoot(root, pwd);
|
|
44
|
+
if (relative) return relative;
|
|
45
|
+
}
|
|
46
|
+
return pwd;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SCRATCH_ROOTS: readonly string[] = (() => {
|
|
50
|
+
const roots = new Set<string>([os.tmpdir(), path.join(os.homedir(), "tmp")]);
|
|
51
|
+
if (process.platform === "win32") {
|
|
52
|
+
const { TEMP, TMP, SystemRoot } = process.env;
|
|
53
|
+
if (TEMP) roots.add(TEMP);
|
|
54
|
+
if (TMP) roots.add(TMP);
|
|
55
|
+
if (SystemRoot) roots.add(path.join(SystemRoot, "Temp"));
|
|
56
|
+
} else {
|
|
57
|
+
roots.add("/tmp");
|
|
58
|
+
roots.add("/var/tmp");
|
|
59
|
+
if (process.platform === "darwin") {
|
|
60
|
+
roots.add("/private/tmp");
|
|
61
|
+
roots.add("/private/var/tmp");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...roots];
|
|
65
|
+
})();
|
|
66
|
+
|
|
67
|
+
function classifyProjectDir(pwd: string): { scratch: boolean; relative: string | null } {
|
|
68
|
+
for (const root of SCRATCH_ROOTS) {
|
|
69
|
+
const relative = relativePathWithinRoot(root, pwd);
|
|
70
|
+
if (relative !== null) {
|
|
71
|
+
return { scratch: true, relative: relative === "." ? null : relative };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { scratch: false, relative: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
// Segment implementations
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
80
|
+
|
|
81
|
+
const piSegment: StatusLineSegment = {
|
|
82
|
+
id: "pi",
|
|
83
|
+
render() {
|
|
84
|
+
return { content: theme.fg("accent", theme.icon.pi), visible: true };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Model · provider · thinking level — related parts joined by the dot, not the group separator. */
|
|
89
|
+
const modelSegment: StatusLineSegment = {
|
|
90
|
+
id: "model",
|
|
91
|
+
render(ctx) {
|
|
92
|
+
const { showModel, showProvider, showThinking } = ctx.options.model;
|
|
93
|
+
const parts: string[] = [];
|
|
94
|
+
|
|
95
|
+
if (showModel) {
|
|
96
|
+
let modelName = ctx.model?.name || ctx.model?.id || "no-model";
|
|
97
|
+
if (modelName.startsWith("Claude ")) {
|
|
98
|
+
modelName = modelName.slice(7);
|
|
99
|
+
}
|
|
100
|
+
parts.push(withIcon(theme.icon.model, sanitizeStatusText(modelName)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (showProvider && ctx.model?.provider) {
|
|
104
|
+
parts.push(sanitizeStatusText(ctx.model.provider));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (showThinking && ctx.model?.reasoning) {
|
|
108
|
+
const level = ctx.thinkingLevel;
|
|
109
|
+
if (level && level !== "off") {
|
|
110
|
+
const display = theme.thinking[level as keyof typeof theme.thinking] ?? "";
|
|
111
|
+
if (display) parts.push(display);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (parts.length === 0) return INVISIBLE;
|
|
116
|
+
return { content: theme.fg("statusLineModel", parts.join(theme.sep.dot)), visible: true };
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const pathSegment: StatusLineSegment = {
|
|
121
|
+
id: "path",
|
|
122
|
+
render(ctx) {
|
|
123
|
+
const opts = ctx.options.path;
|
|
124
|
+
|
|
125
|
+
// Linked git worktree: collapse to the project name, appending the
|
|
126
|
+
// worktree dir only when it diverges from the branch (already shown by
|
|
127
|
+
// the git segment).
|
|
128
|
+
if (opts.stripWorkPrefix && ctx.worktree) {
|
|
129
|
+
const { projectName, worktreeName } = ctx.worktree;
|
|
130
|
+
const label = ctx.git.branch === worktreeName ? projectName : `${projectName}/${worktreeName}`;
|
|
131
|
+
const content = withIcon(theme.icon.worktree, clampPathLength(sanitizeStatusText(label), opts.maxLength));
|
|
132
|
+
return { content: theme.fg("statusLinePath", content), visible: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { scratch, relative } = classifyProjectDir(ctx.cwd);
|
|
136
|
+
let pwd = ctx.cwd;
|
|
137
|
+
|
|
138
|
+
if (opts.stripWorkPrefix) {
|
|
139
|
+
if (scratch) {
|
|
140
|
+
if (relative) pwd = relative;
|
|
141
|
+
} else {
|
|
142
|
+
pwd = stripDisplayRoot(pwd);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (opts.abbreviate) {
|
|
146
|
+
pwd = shortenPath(pwd);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
pwd = clampPathLength(pwd, opts.maxLength);
|
|
150
|
+
|
|
151
|
+
const showScratchIcon = scratch && opts.stripWorkPrefix;
|
|
152
|
+
const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder;
|
|
153
|
+
const content = withIcon(icon, sanitizeStatusText(pwd));
|
|
154
|
+
return { content: theme.fg("statusLinePath", content), visible: true };
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const gitSegment: StatusLineSegment = {
|
|
159
|
+
id: "git",
|
|
160
|
+
render(ctx) {
|
|
161
|
+
const { branch, status } = ctx.git;
|
|
162
|
+
if (!branch && !status) return INVISIBLE;
|
|
163
|
+
|
|
164
|
+
const isDirty = status && (status.staged > 0 || status.unstaged > 0 || status.untracked > 0);
|
|
165
|
+
|
|
166
|
+
let content = "";
|
|
167
|
+
if (branch) {
|
|
168
|
+
content = withIcon(theme.icon.branch, sanitizeStatusText(branch));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (status) {
|
|
172
|
+
const indicators: string[] = [];
|
|
173
|
+
if (status.unstaged > 0) indicators.push(theme.fg("statusLineDirty", `*${status.unstaged}`));
|
|
174
|
+
if (status.staged > 0) indicators.push(theme.fg("statusLineStaged", `+${status.staged}`));
|
|
175
|
+
if (status.untracked > 0) indicators.push(theme.fg("statusLineUntracked", `?${status.untracked}`));
|
|
176
|
+
if (indicators.length > 0) {
|
|
177
|
+
const indicatorText = indicators.join(" ");
|
|
178
|
+
content += content ? ` ${indicatorText}` : indicatorText;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!content) return INVISIBLE;
|
|
183
|
+
|
|
184
|
+
const colorName = isDirty ? "statusLineGitDirty" : "statusLineGitClean";
|
|
185
|
+
return { content: theme.fg(colorName, content), visible: true };
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const prSegment: StatusLineSegment = {
|
|
190
|
+
id: "pr",
|
|
191
|
+
render(ctx) {
|
|
192
|
+
const { pr } = ctx.git;
|
|
193
|
+
if (!pr) return INVISIBLE;
|
|
194
|
+
|
|
195
|
+
const label = withIcon(theme.icon.pr, `#${pr.number}`);
|
|
196
|
+
const content = getCapabilities().hyperlinks ? hyperlink(label, pr.url) : label;
|
|
197
|
+
return { content: theme.fg("accent", content), visible: true };
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const piStatsSegment: StatusLineSegment = {
|
|
202
|
+
id: "pi_stats",
|
|
203
|
+
render(ctx) {
|
|
204
|
+
const stats = ctx.piStats;
|
|
205
|
+
if (!stats) return INVISIBLE;
|
|
206
|
+
return { content: theme.fg("dim", sanitizeStatusText(stats)), visible: true };
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Live tok/s badge. Always visible when enabled: active rate in accent, held
|
|
212
|
+
* rate fading accent→dim over FADE_SHADE_COUNT shades, then the `---`
|
|
213
|
+
* placeholder in dim. The value is right-aligned to the placeholder's width so
|
|
214
|
+
* a one- or two-digit rate does not shift whatever sits beside it.
|
|
215
|
+
*/
|
|
216
|
+
const tokenRateSegment: StatusLineSegment = {
|
|
217
|
+
id: "token_rate",
|
|
218
|
+
render(ctx) {
|
|
219
|
+
const display = ctx.tokenRate;
|
|
220
|
+
if (!display) return INVISIBLE;
|
|
221
|
+
const value = display.rate === null ? "---" : String(display.rate).padStart(3, " ");
|
|
222
|
+
const content = `${value} tok/s`;
|
|
223
|
+
if (display.phase === "active") return { content: theme.fg("accent", content), visible: true };
|
|
224
|
+
if (display.phase === "fading") {
|
|
225
|
+
return { content: theme.fadeFg("accent", "dim", display.fadeShade, content), visible: true };
|
|
226
|
+
}
|
|
227
|
+
return { content: theme.fg("dim", content), visible: true };
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Half a cent is pi-prompt-cache's own display cutoff. Matching it keeps this
|
|
233
|
+
* segment and that extension's footer from ever contradicting each other, and
|
|
234
|
+
* suppresses the negative figures published early in a session while the
|
|
235
|
+
* cache-write premium has yet to be amortized by reads.
|
|
236
|
+
*/
|
|
237
|
+
const CURRENCY_MIN = 0.005;
|
|
238
|
+
|
|
239
|
+
/** Rendered text for one feed value, or undefined when it should not show. */
|
|
240
|
+
function formatFeedValue(value: unknown, format: FeedFormat): string | undefined {
|
|
241
|
+
if (value == null) return undefined;
|
|
242
|
+
if (format === "text") {
|
|
243
|
+
const text = truncateToWidth(sanitizeStatusText(String(value)), 32);
|
|
244
|
+
return text || undefined;
|
|
245
|
+
}
|
|
246
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
247
|
+
if (format === "currency") {
|
|
248
|
+
if (value < CURRENCY_MIN) return undefined;
|
|
249
|
+
// Savings scale with prefix size and model price: cents on cheap models,
|
|
250
|
+
// but three digits on a heavy session, so the width is not budgeted tight.
|
|
251
|
+
return `$${value < 100 ? value.toFixed(2) : Math.round(value)}`;
|
|
252
|
+
}
|
|
253
|
+
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Values published by other extensions as custom session entries, each shown
|
|
258
|
+
* as its configured prefix followed by the value. A feed contributes nothing
|
|
259
|
+
* when its publisher is absent, has published nothing this run, or reports a
|
|
260
|
+
* value the configured format suppresses.
|
|
261
|
+
*/
|
|
262
|
+
const feedsSegment: StatusLineSegment = {
|
|
263
|
+
id: "feeds",
|
|
264
|
+
render(ctx) {
|
|
265
|
+
const data = ctx.feedData;
|
|
266
|
+
if (!data) return INVISIBLE;
|
|
267
|
+
const parts: string[] = [];
|
|
268
|
+
for (const feed of ctx.options.feeds) {
|
|
269
|
+
const payload = data[feed.customType];
|
|
270
|
+
if (payload == null || typeof payload !== "object") continue;
|
|
271
|
+
if (!Object.hasOwn(payload, feed.field)) continue;
|
|
272
|
+
const text = formatFeedValue((payload as Record<string, unknown>)[feed.field], feed.format);
|
|
273
|
+
if (text !== undefined) parts.push(`${sanitizeLabel(feed.prefix)}${text}`);
|
|
274
|
+
}
|
|
275
|
+
if (parts.length === 0) return INVISIBLE;
|
|
276
|
+
return { content: theme.fg("success", parts.join(" ")), visible: true };
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const scrollHintSegment: StatusLineSegment = {
|
|
281
|
+
id: "scroll_hint",
|
|
282
|
+
render(ctx) {
|
|
283
|
+
const hint = ctx.scrollHint;
|
|
284
|
+
if (!hint) return INVISIBLE;
|
|
285
|
+
return { content: theme.fg("dim", hint), visible: true };
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Context bar + stats — related parts joined by a space, not the group separator. */
|
|
290
|
+
const contextGraphSegment: StatusLineSegment = {
|
|
291
|
+
id: "context_graph",
|
|
292
|
+
render(ctx) {
|
|
293
|
+
const { showBar, showStats } = ctx.options.context;
|
|
294
|
+
if (!showBar && !showStats) return INVISIBLE;
|
|
295
|
+
const percent = ctx.contextPercent;
|
|
296
|
+
if (percent == null) {
|
|
297
|
+
if (!showStats || ctx.contextTokens <= 0) return INVISIBLE;
|
|
298
|
+
return { content: theme.fg("dim", formatContextUsage(null, ctx.contextWindow, ctx.contextTokens)), visible: true };
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
content: renderContextGraph(percent, ctx.contextWindow, {
|
|
302
|
+
usedTokens: ctx.contextTokens,
|
|
303
|
+
labelColor: theme.getFgAnsi("dim"),
|
|
304
|
+
showBar,
|
|
305
|
+
showStats,
|
|
306
|
+
}),
|
|
307
|
+
visible: true,
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const accentAnsi = new Map<string, string>();
|
|
313
|
+
|
|
314
|
+
const sessionNameSegment: StatusLineSegment = {
|
|
315
|
+
id: "session_name",
|
|
316
|
+
render(ctx) {
|
|
317
|
+
const name = ctx.sessionName;
|
|
318
|
+
if (!name) return INVISIBLE;
|
|
319
|
+
|
|
320
|
+
let ansi = accentAnsi.get(name);
|
|
321
|
+
if (ansi === undefined) {
|
|
322
|
+
ansi = hexToFgAnsi(getSessionAccentHex(name, MAJOR_COLOR_HEXES));
|
|
323
|
+
accentAnsi.set(name, ansi);
|
|
324
|
+
}
|
|
325
|
+
return { content: `${ansi}${sanitizeStatusText(name)}\x1b[39m`, visible: true };
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
330
|
+
// Segment registry
|
|
331
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
332
|
+
|
|
333
|
+
export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
334
|
+
pi: piSegment,
|
|
335
|
+
model: modelSegment,
|
|
336
|
+
path: pathSegment,
|
|
337
|
+
git: gitSegment,
|
|
338
|
+
pr: prSegment,
|
|
339
|
+
session_name: sessionNameSegment,
|
|
340
|
+
pi_stats: piStatsSegment,
|
|
341
|
+
token_rate: tokenRateSegment,
|
|
342
|
+
feeds: feedsSegment,
|
|
343
|
+
context_graph: contextGraphSegment,
|
|
344
|
+
scroll_hint: scrollHintSegment,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
|
348
|
+
return SEGMENTS[id].render(ctx);
|
|
349
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Ported from oh-my-pi status-line/separators.ts; end caps are transparency-driven in layout.ts. */
|
|
2
|
+
import { theme } from "./theme.js";
|
|
3
|
+
import type { SeparatorDef, StatusLineSeparatorStyle } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function getSeparator(style: StatusLineSeparatorStyle): SeparatorDef {
|
|
6
|
+
switch (style) {
|
|
7
|
+
case "powerline":
|
|
8
|
+
return { left: theme.sep.powerlineLeft, right: theme.sep.powerlineRight };
|
|
9
|
+
case "slash": {
|
|
10
|
+
const slash = theme.sep.slash.trim();
|
|
11
|
+
return { left: slash, right: slash };
|
|
12
|
+
}
|
|
13
|
+
case "pipe": {
|
|
14
|
+
const pipe = theme.sep.pipe.trim();
|
|
15
|
+
return { left: pipe, right: pipe };
|
|
16
|
+
}
|
|
17
|
+
case "ascii":
|
|
18
|
+
return { left: theme.sep.asciiLeft, right: theme.sep.asciiRight };
|
|
19
|
+
case "powerline-thin":
|
|
20
|
+
return { left: theme.sep.powerlineThinLeft, right: theme.sep.powerlineThinRight };
|
|
21
|
+
}
|
|
22
|
+
}
|