@underactive/pi-topping-statusline 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +13 -92
- package/package.json +1 -1
- package/src/box.ts +69 -0
- package/src/context.ts +6 -3
- package/src/footer.ts +34 -0
- package/src/layout.ts +42 -35
- package/src/menu.ts +9 -9
- package/src/rainbow.ts +2 -2
- package/src/settings-menu.ts +18 -39
- package/src/settings.ts +14 -1
- package/src/tests/box.test.ts +84 -0
- package/src/tests/feeds.test.ts +0 -2
- package/src/tests/footer.test.ts +65 -0
- package/src/tests/utils.test.ts +57 -0
- package/src/theme.ts +4 -88
- package/src/types.ts +1 -0
- package/src/utils.ts +21 -31
package/index.ts
CHANGED
|
@@ -25,38 +25,15 @@ import {
|
|
|
25
25
|
type ExtensionContext,
|
|
26
26
|
} from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
28
|
-
import {
|
|
28
|
+
import { makeBoxPainters, renderBoxRow } from "./src/box.js";
|
|
29
29
|
import { SegmentContextBuilder } from "./src/context.js";
|
|
30
|
+
import { isBorderRow, stripAnsi, stripRedundantStats } from "./src/footer.js";
|
|
30
31
|
import { buildStatusLine } from "./src/layout.js";
|
|
31
|
-
import {
|
|
32
|
-
perimeterLength,
|
|
33
|
-
perimeterPosition,
|
|
34
|
-
RAINBOW_DEG_PER_FRAME,
|
|
35
|
-
RAINBOW_FRAME_MS,
|
|
36
|
-
RainbowBorder,
|
|
37
|
-
} from "./src/rainbow.js";
|
|
32
|
+
import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder } from "./src/rainbow.js";
|
|
38
33
|
import { registerSettingsCommand } from "./src/settings-menu.js";
|
|
39
34
|
import { createSettingsState } from "./src/settings.js";
|
|
40
35
|
import { theme } from "./src/theme.js";
|
|
41
36
|
import { TokenRateMonitor } from "./src/token-rate.js";
|
|
42
|
-
import type { EffectiveStatusLineSettings, SegmentIncludes } from "./src/types.js";
|
|
43
|
-
|
|
44
|
-
function segmentIncludes(effective: EffectiveStatusLineSettings): SegmentIncludes {
|
|
45
|
-
const allSegments = [
|
|
46
|
-
...effective.leftSegments,
|
|
47
|
-
...effective.rightSegments,
|
|
48
|
-
...effective.bottomLeftSegments,
|
|
49
|
-
...effective.bottomRightSegments,
|
|
50
|
-
];
|
|
51
|
-
return {
|
|
52
|
-
git: allSegments.includes("git"),
|
|
53
|
-
pr: allSegments.includes("pr"),
|
|
54
|
-
piStats: allSegments.includes("pi_stats"),
|
|
55
|
-
tokenRate: allSegments.includes("token_rate"),
|
|
56
|
-
feeds: allSegments.includes("feeds") ? effective.segmentOptions.feeds.map(feed => feed.customType) : [],
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
37
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
61
38
|
|
|
62
39
|
export default function (pi: ExtensionAPI) {
|
|
@@ -109,11 +86,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
109
86
|
});
|
|
110
87
|
|
|
111
88
|
const MIN_BOXED_WIDTH = 12;
|
|
112
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
113
|
-
const isBorderRow = (row: string): boolean => {
|
|
114
|
-
const text = row.replace(ANSI_RE, "");
|
|
115
|
-
return /^─+$/.test(text) || /^─+ [↑↓] \d+ more\s*─*$/.test(text);
|
|
116
|
-
};
|
|
117
89
|
|
|
118
90
|
const renderBoxed = (
|
|
119
91
|
innerRender: (width: number) => string[],
|
|
@@ -138,77 +110,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
138
110
|
}
|
|
139
111
|
if (lines.length < 3 || bottomIdx < 2) return innerRender(width);
|
|
140
112
|
|
|
141
|
-
const hint = (lines[bottomIdx] ?? "").
|
|
113
|
+
const hint = stripAnsi(lines[bottomIdx] ?? "").match(/[↑↓] \d+ more/)?.[0] ?? "";
|
|
142
114
|
const effective = state.effective;
|
|
143
|
-
const include =
|
|
115
|
+
const include = effective.includes;
|
|
144
116
|
const segCtx = builder.build(innerWidth, effective.segmentOptions, include, hint || undefined);
|
|
145
117
|
// borderColor is assigned by the host after the factory returns — read late.
|
|
146
118
|
const border = editor.borderColor ?? ((s: string) => s);
|
|
147
119
|
const rainbowOn = rainbowActive();
|
|
148
120
|
const box = theme.getBox(effective.borderStyle);
|
|
149
|
-
const
|
|
150
|
-
// Per-glyph paint: rainbow mode colors each cell by its perimeter walk
|
|
151
|
-
// position; otherwise the host border color is applied per glyph, which
|
|
152
|
-
// renders identically to the old per-fragment wraps.
|
|
153
|
-
const paint = (row: number, col: number, ch: string): string =>
|
|
154
|
-
rainbowOn ? rainbow.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : border(ch);
|
|
155
|
-
const horizRun = (row: number, startCol: number, count: number): string => {
|
|
156
|
-
if (!rainbowOn) return border(box.horizontal.repeat(count));
|
|
157
|
-
let out = "";
|
|
158
|
-
for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
|
|
159
|
-
return out;
|
|
160
|
-
};
|
|
161
|
-
const gapColor = (str: string, startCol: number, row: number): string => {
|
|
162
|
-
if (!rainbowOn) return border(str);
|
|
163
|
-
let out = "";
|
|
164
|
-
for (let k = 0; k < str.length; k++) out += paint(row, startCol + k, str[k]!);
|
|
165
|
-
return out;
|
|
166
|
-
};
|
|
121
|
+
const painters = makeBoxPainters({ rainbowOn, rainbow, box, width, bottomIdx, flat: border });
|
|
167
122
|
const bar = buildStatusLine(
|
|
168
123
|
innerWidth,
|
|
169
124
|
segCtx,
|
|
170
125
|
effective,
|
|
171
|
-
gapColor,
|
|
126
|
+
painters.gapColor,
|
|
172
127
|
{
|
|
173
128
|
left: effective.leftSegments,
|
|
174
129
|
right: effective.rightSegments,
|
|
175
130
|
},
|
|
176
131
|
{ col: 3, row: 0 },
|
|
177
132
|
);
|
|
178
|
-
const barWidth = visibleWidth(bar);
|
|
179
|
-
const pad = Math.max(0, innerWidth - barWidth);
|
|
180
133
|
// Leading spacer row: keeps the transcript from sitting flush on the box.
|
|
181
|
-
const out: string[] = [
|
|
182
|
-
"",
|
|
183
|
-
paint(0, 0, box.topLeft) +
|
|
184
|
-
horizRun(0, 1, 2) +
|
|
185
|
-
bar +
|
|
186
|
-
horizRun(0, 3 + barWidth, pad + 2) +
|
|
187
|
-
paint(0, width - 1, box.topRight),
|
|
188
|
-
];
|
|
134
|
+
const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
|
|
189
135
|
for (let i = 1; i < bottomIdx; i++) {
|
|
190
|
-
out.push(
|
|
136
|
+
out.push(
|
|
137
|
+
painters.paint(i, 0, box.vertical) + " " + (lines[i] ?? "") + " " + painters.paint(i, width - 1, box.vertical),
|
|
138
|
+
);
|
|
191
139
|
}
|
|
192
140
|
const bottomBar = buildStatusLine(
|
|
193
141
|
innerWidth,
|
|
194
142
|
segCtx,
|
|
195
143
|
effective,
|
|
196
|
-
gapColor,
|
|
144
|
+
painters.gapColor,
|
|
197
145
|
{
|
|
198
146
|
left: effective.bottomLeftSegments,
|
|
199
147
|
right: effective.bottomRightSegments,
|
|
200
148
|
},
|
|
201
149
|
{ col: 3, row: bottomIdx },
|
|
202
150
|
);
|
|
203
|
-
|
|
204
|
-
const bottomPad = Math.max(0, innerWidth - bottomBarWidth);
|
|
205
|
-
out.push(
|
|
206
|
-
paint(bottomIdx, 0, box.bottomLeft) +
|
|
207
|
-
horizRun(bottomIdx, 1, 2) +
|
|
208
|
-
bottomBar +
|
|
209
|
-
horizRun(bottomIdx, 3 + bottomBarWidth, bottomPad + 2) +
|
|
210
|
-
paint(bottomIdx, width - 1, box.bottomRight),
|
|
211
|
-
);
|
|
151
|
+
out.push(renderBoxRow(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
|
|
212
152
|
for (let i = bottomIdx + 1; i < lines.length; i++) {
|
|
213
153
|
out.push(` ${lines[i]}`);
|
|
214
154
|
}
|
|
@@ -237,25 +177,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
177
|
ctx.ui.setEditorComponent(ourFactory);
|
|
238
178
|
};
|
|
239
179
|
|
|
240
|
-
// Redundant with the bar or the graph: the footer's first line (cwd + git
|
|
241
|
-
// branch + session name) entirely, and — within the stats line — the context
|
|
242
|
-
// chunk ("22.8%/1.0M (auto)", which the graph's own label repeats) and the
|
|
243
|
-
// right-aligned model side ("(provider) model • thinking"). The unique stats
|
|
244
|
-
// (↑↓ tokens, cache R/W, hit rate, cost) survive; when nothing unique remains
|
|
245
|
-
// the line collapses entirely.
|
|
246
|
-
const CONTEXT_CHUNK_RE = /(?:\?|\d+(?:\.\d+)?%)\/\S+(?: \(auto\))?/;
|
|
247
|
-
const stripRedundantStats = (statsLine: string | undefined): string | undefined => {
|
|
248
|
-
if (!statsLine) return undefined;
|
|
249
|
-
const plain = statsLine.replace(ANSI_RE, "");
|
|
250
|
-
// The model side is right-aligned with >=2 spaces of padding; stats parts
|
|
251
|
-
// are single-space separated, so the first 2+ space run is the boundary.
|
|
252
|
-
const left = (plain.split(/ {2,}/)[0] ?? "")
|
|
253
|
-
.replace(CONTEXT_CHUNK_RE, "")
|
|
254
|
-
.replace(/\s+/g, " ")
|
|
255
|
-
.trim();
|
|
256
|
-
return left || undefined;
|
|
257
|
-
};
|
|
258
|
-
|
|
259
180
|
const PI_STATS_CACHE_MS = 250;
|
|
260
181
|
let piStatsCache: { at: number; value: string | undefined } | undefined;
|
|
261
182
|
builder.setPiStatsProvider(width => {
|
package/package.json
CHANGED
package/src/box.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared box-chrome painting for the live editor box (index.ts) and the
|
|
3
|
+
* settings-menu preview, so the 3/2/+2 row geometry has one definition.
|
|
4
|
+
*
|
|
5
|
+
* Rainbow mode colors each glyph by its perimeter-walk position; otherwise
|
|
6
|
+
* the caller's flat painter wraps whole runs in one escape sequence, which
|
|
7
|
+
* renders identically to per-glyph wraps with fewer bytes. The flat painter
|
|
8
|
+
* and the RainbowBorder instance are injected because they differ per caller
|
|
9
|
+
* (host borderColor vs menu theme; shared stepped animator vs phase-from-
|
|
10
|
+
* elapsed instance).
|
|
11
|
+
*/
|
|
12
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import { perimeterLength, perimeterPosition, type RainbowBorder } from "./rainbow.js";
|
|
14
|
+
import type { BoxGlyphs } from "./theme.js";
|
|
15
|
+
|
|
16
|
+
export interface BoxPainters {
|
|
17
|
+
paint(row: number, col: number, ch: string): string;
|
|
18
|
+
horizRun(row: number, startCol: number, count: number): string;
|
|
19
|
+
/** Signature matches buildStatusLine's gapBorderColor callback. */
|
|
20
|
+
gapColor(str: string, startCol: number, row: number): string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function makeBoxPainters(opts: {
|
|
24
|
+
rainbowOn: boolean;
|
|
25
|
+
rainbow: RainbowBorder;
|
|
26
|
+
box: BoxGlyphs;
|
|
27
|
+
width: number;
|
|
28
|
+
bottomIdx: number;
|
|
29
|
+
flat: (str: string) => string;
|
|
30
|
+
}): BoxPainters {
|
|
31
|
+
const { rainbowOn, rainbow, box, width, bottomIdx, flat } = opts;
|
|
32
|
+
const perimeter = perimeterLength(width, bottomIdx);
|
|
33
|
+
const paint = (row: number, col: number, ch: string): string =>
|
|
34
|
+
rainbowOn ? rainbow.colorChar(ch, perimeterPosition(row, col, width, bottomIdx), perimeter) : flat(ch);
|
|
35
|
+
const horizRun = (row: number, startCol: number, count: number): string => {
|
|
36
|
+
if (!rainbowOn) return flat(box.horizontal.repeat(count));
|
|
37
|
+
let out = "";
|
|
38
|
+
for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
const gapColor = (str: string, startCol: number, row: number): string => {
|
|
42
|
+
if (!rainbowOn) return flat(str);
|
|
43
|
+
let out = "";
|
|
44
|
+
let k = 0;
|
|
45
|
+
for (const ch of str) out += paint(row, startCol + k++, ch);
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
48
|
+
return { paint, horizRun, gapColor };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A bar row: corner, 2-cell run, the bar, a pad+2 run, corner — 6 cells of chrome. */
|
|
52
|
+
export function renderBoxRow(
|
|
53
|
+
painters: BoxPainters,
|
|
54
|
+
bar: string,
|
|
55
|
+
rowIdx: number,
|
|
56
|
+
boxWidth: number,
|
|
57
|
+
leftGlyph: string,
|
|
58
|
+
rightGlyph: string,
|
|
59
|
+
): string {
|
|
60
|
+
const barWidth = visibleWidth(bar);
|
|
61
|
+
const pad = Math.max(0, boxWidth - 6 - barWidth);
|
|
62
|
+
return (
|
|
63
|
+
painters.paint(rowIdx, 0, leftGlyph) +
|
|
64
|
+
painters.horizRun(rowIdx, 1, 2) +
|
|
65
|
+
bar +
|
|
66
|
+
painters.horizRun(rowIdx, 3 + barWidth, pad + 2) +
|
|
67
|
+
painters.paint(rowIdx, boxWidth - 1, rightGlyph)
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/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) {
|
|
@@ -265,7 +268,7 @@ export class SegmentContextBuilder {
|
|
|
265
268
|
*/
|
|
266
269
|
#refreshFeeds(customTypes: readonly string[], now: number): void {
|
|
267
270
|
this.#feedsScannedAt = now;
|
|
268
|
-
this.#feedData =
|
|
271
|
+
this.#feedData = emptyFeedData();
|
|
269
272
|
const entries = this.#ctx?.sessionManager?.getEntries();
|
|
270
273
|
if (!entries) return;
|
|
271
274
|
const pending = new Set(customTypes);
|
package/src/footer.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for parsing host-rendered rows: the editor's plain ─── border
|
|
3
|
+
* rows (with or without the scroll hint) and the default footer's stats line.
|
|
4
|
+
*/
|
|
5
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
6
|
+
|
|
7
|
+
export function stripAnsi(str: string): string {
|
|
8
|
+
return str.replace(ANSI_RE, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isBorderRow(row: string): boolean {
|
|
12
|
+
const text = stripAnsi(row);
|
|
13
|
+
return /^─+$/.test(text) || /^─+ [↑↓] \d+ more\s*─*$/.test(text);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Redundant with the bar or the graph: the footer's first line (cwd + git
|
|
17
|
+
// branch + session name) entirely, and — within the stats line — the context
|
|
18
|
+
// chunk ("22.8%/1.0M (auto)", which the graph's own label repeats) and the
|
|
19
|
+
// right-aligned model side ("(provider) model • thinking"). The unique stats
|
|
20
|
+
// (↑↓ tokens, cache R/W, hit rate, cost) survive; when nothing unique remains
|
|
21
|
+
// the line collapses entirely.
|
|
22
|
+
const CONTEXT_CHUNK_RE = /(?:\?|\d+(?:\.\d+)?%)\/\S+(?: \(auto\))?/;
|
|
23
|
+
|
|
24
|
+
export function stripRedundantStats(statsLine: string | undefined): string | undefined {
|
|
25
|
+
if (!statsLine) return undefined;
|
|
26
|
+
const plain = stripAnsi(statsLine);
|
|
27
|
+
// The model side is right-aligned with >=2 spaces of padding; stats parts
|
|
28
|
+
// are single-space separated, so the first 2+ space run is the boundary.
|
|
29
|
+
const left = (plain.split(/ {2,}/)[0] ?? "")
|
|
30
|
+
.replace(CONTEXT_CHUNK_RE, "")
|
|
31
|
+
.replace(/\s+/g, " ")
|
|
32
|
+
.trim();
|
|
33
|
+
return left || undefined;
|
|
34
|
+
}
|
package/src/layout.ts
CHANGED
|
@@ -17,6 +17,40 @@ import type { EffectiveStatusLineSettings, SegmentContext, StatusLineSegmentId }
|
|
|
17
17
|
|
|
18
18
|
const TRANSPARENT_BG_ANSI = "\x1b[49m";
|
|
19
19
|
|
|
20
|
+
const MIN_PATH_WIDTH = 8;
|
|
21
|
+
const MAX_SHRINK_PASSES = 8;
|
|
22
|
+
const MIN_PATH_MAX_LENGTH = 4;
|
|
23
|
+
|
|
24
|
+
/** Re-render the path segment narrower to absorb `overflow` cells; undefined when it cannot shrink. */
|
|
25
|
+
function shrinkPathSegment(ctx: SegmentContext, currentContent: string, overflow: number): string | undefined {
|
|
26
|
+
const currentPathVW = visibleWidth(currentContent);
|
|
27
|
+
const shrinkable = currentPathVW - MIN_PATH_WIDTH;
|
|
28
|
+
if (shrinkable <= 0) return undefined;
|
|
29
|
+
|
|
30
|
+
const shrinkBy = Math.min(shrinkable, overflow);
|
|
31
|
+
const currentMaxLen = ctx.options.path.maxLength;
|
|
32
|
+
let newMaxLen = Math.max(MIN_PATH_MAX_LENGTH, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
|
|
33
|
+
const pathCtx = (maxLen: number): SegmentContext => ({
|
|
34
|
+
...ctx,
|
|
35
|
+
options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
|
|
36
|
+
});
|
|
37
|
+
let reRendered = renderSegment("path", pathCtx(newMaxLen));
|
|
38
|
+
if (!reRendered.visible || !reRendered.content) return undefined;
|
|
39
|
+
|
|
40
|
+
// maxLength governs path text, not icon prefix; iterate to compensate
|
|
41
|
+
for (let i = 0; i < MAX_SHRINK_PASSES; i++) {
|
|
42
|
+
const saved = currentPathVW - visibleWidth(reRendered.content);
|
|
43
|
+
if (saved >= shrinkBy) break;
|
|
44
|
+
const nextMaxLen = Math.max(MIN_PATH_MAX_LENGTH, newMaxLen - (shrinkBy - saved));
|
|
45
|
+
if (nextMaxLen >= newMaxLen) break;
|
|
46
|
+
newMaxLen = nextMaxLen;
|
|
47
|
+
const adjusted = renderSegment("path", pathCtx(newMaxLen));
|
|
48
|
+
if (!adjusted.visible || !adjusted.content) break;
|
|
49
|
+
reRendered = adjusted;
|
|
50
|
+
}
|
|
51
|
+
return reRendered.content;
|
|
52
|
+
}
|
|
53
|
+
|
|
20
54
|
export function buildStatusLine(
|
|
21
55
|
width: number,
|
|
22
56
|
ctx: SegmentContext,
|
|
@@ -32,27 +66,24 @@ export function buildStatusLine(
|
|
|
32
66
|
const fgAnsi = theme.getFgAnsi("text");
|
|
33
67
|
const sepAnsi = theme.getFgAnsi("statusLineSep");
|
|
34
68
|
|
|
35
|
-
const
|
|
69
|
+
const left: string[] = [];
|
|
36
70
|
const leftSegIds: StatusLineSegmentId[] = [];
|
|
37
71
|
for (const segId of segmentGroups.left) {
|
|
38
72
|
const rendered = renderSegment(segId, ctx);
|
|
39
73
|
if (rendered.visible && rendered.content) {
|
|
40
|
-
|
|
74
|
+
left.push(rendered.content);
|
|
41
75
|
leftSegIds.push(segId);
|
|
42
76
|
}
|
|
43
77
|
}
|
|
44
78
|
|
|
45
|
-
const
|
|
79
|
+
const right: string[] = [];
|
|
46
80
|
for (const segId of segmentGroups.right) {
|
|
47
81
|
const rendered = renderSegment(segId, ctx);
|
|
48
82
|
if (rendered.visible && rendered.content) {
|
|
49
|
-
|
|
83
|
+
right.push(rendered.content);
|
|
50
84
|
}
|
|
51
85
|
}
|
|
52
86
|
|
|
53
|
-
const left = [...leftParts];
|
|
54
|
-
const right = [...rightParts];
|
|
55
|
-
|
|
56
87
|
const leftSepWidth = visibleWidth(separatorDef.left);
|
|
57
88
|
const rightSepWidth = visibleWidth(separatorDef.right);
|
|
58
89
|
// Transparent mode drops the round caps (they need a bg fill to bridge);
|
|
@@ -84,34 +115,10 @@ export function buildStatusLine(
|
|
|
84
115
|
// Shrink path before dropping left segments — path is the only elastic segment
|
|
85
116
|
const pathIdx = leftSegIds.indexOf("path");
|
|
86
117
|
if (pathIdx >= 0 && totalWidth() > width) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (shrinkable > 0) {
|
|
92
|
-
const shrinkBy = Math.min(shrinkable, overflow);
|
|
93
|
-
const currentMaxLen = ctx.options.path.maxLength;
|
|
94
|
-
let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
|
|
95
|
-
const pathCtx = (maxLen: number): SegmentContext => ({
|
|
96
|
-
...ctx,
|
|
97
|
-
options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
|
|
98
|
-
});
|
|
99
|
-
let reRendered = renderSegment("path", pathCtx(newMaxLen));
|
|
100
|
-
if (reRendered.visible && reRendered.content) {
|
|
101
|
-
// maxLength governs path text, not icon prefix; iterate to compensate
|
|
102
|
-
for (let i = 0; i < 8; i++) {
|
|
103
|
-
const saved = currentPathVW - visibleWidth(reRendered.content);
|
|
104
|
-
if (saved >= shrinkBy) break;
|
|
105
|
-
const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
|
|
106
|
-
if (nextMaxLen >= newMaxLen) break;
|
|
107
|
-
newMaxLen = nextMaxLen;
|
|
108
|
-
const adjusted = renderSegment("path", pathCtx(newMaxLen));
|
|
109
|
-
if (!adjusted.visible || !adjusted.content) break;
|
|
110
|
-
reRendered = adjusted;
|
|
111
|
-
}
|
|
112
|
-
left[pathIdx] = reRendered.content;
|
|
113
|
-
leftWidth = leftGroupWidth(left);
|
|
114
|
-
}
|
|
118
|
+
const replacement = shrinkPathSegment(ctx, left[pathIdx], totalWidth() - width);
|
|
119
|
+
if (replacement !== undefined) {
|
|
120
|
+
left[pathIdx] = replacement;
|
|
121
|
+
leftWidth = leftGroupWidth(left);
|
|
115
122
|
}
|
|
116
123
|
}
|
|
117
124
|
const leftOverflowDropIndex = (): number => {
|
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
|
@@ -4,17 +4,11 @@
|
|
|
4
4
|
* menu values, and the command registration.
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import {
|
|
7
|
+
import { makeBoxPainters, renderBoxRow } from "./box.js";
|
|
8
8
|
import type { SegmentContextBuilder } from "./context.js";
|
|
9
9
|
import { buildStatusLine } from "./layout.js";
|
|
10
10
|
import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
|
|
11
|
-
import {
|
|
12
|
-
perimeterLength,
|
|
13
|
-
perimeterPosition,
|
|
14
|
-
RAINBOW_CYCLE_MS,
|
|
15
|
-
RAINBOW_FRAME_MS,
|
|
16
|
-
RainbowBorder,
|
|
17
|
-
} from "./rainbow.js";
|
|
11
|
+
import { RAINBOW_CYCLE_MS, RAINBOW_FRAME_MS, RainbowBorder } from "./rainbow.js";
|
|
18
12
|
import {
|
|
19
13
|
applySettings,
|
|
20
14
|
BORDER_STYLES,
|
|
@@ -227,8 +221,7 @@ class StatusLinePreview {
|
|
|
227
221
|
// The theme singleton drives glyph lookups everywhere, so swap the
|
|
228
222
|
// symbol preset for this synchronous render only; the live bar behind
|
|
229
223
|
// the overlay keeps the applied preset.
|
|
230
|
-
const applied =
|
|
231
|
-
theme.setSymbolPreset(effective.symbols);
|
|
224
|
+
const applied = theme.setSymbolPreset(effective.symbols);
|
|
232
225
|
try {
|
|
233
226
|
const include: SegmentIncludes = {
|
|
234
227
|
git: true,
|
|
@@ -258,37 +251,21 @@ class StatusLinePreview {
|
|
|
258
251
|
};
|
|
259
252
|
|
|
260
253
|
const box = theme.getBox(effective.borderStyle);
|
|
261
|
-
const staticBorder = (s: string, _startCol: number, _row: number) => this.#uiTheme.fg("border", s);
|
|
262
254
|
// The preview box is three rows tall: top bar, side verticals, bottom bar.
|
|
263
255
|
const bottomIdx = 2;
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
for (let k = 0; k < count; k++) out += paint(row, startCol + k, box.horizontal);
|
|
273
|
-
return out;
|
|
274
|
-
};
|
|
275
|
-
const gapColor = (str: string, startCol: number, row: number): string =>
|
|
276
|
-
rainbowOn ? [...str].map((ch, k) => paint(row, startCol + k, ch)).join("") : staticBorder(str, startCol, row);
|
|
277
|
-
const row = (bar: string, rowIdx: number, left: string, right: string): string => {
|
|
278
|
-
const pad = Math.max(0, barWidth - visibleWidth(bar));
|
|
279
|
-
return (
|
|
280
|
-
paint(rowIdx, 0, left) +
|
|
281
|
-
horizRun(rowIdx, 1, 2) +
|
|
282
|
-
bar +
|
|
283
|
-
horizRun(rowIdx, 3 + visibleWidth(bar), pad + 2) +
|
|
284
|
-
paint(rowIdx, boxWidth - 1, right)
|
|
285
|
-
);
|
|
286
|
-
};
|
|
256
|
+
const painters = makeBoxPainters({
|
|
257
|
+
rainbowOn,
|
|
258
|
+
rainbow: new RainbowBorder((elapsedMs * 360) / RAINBOW_CYCLE_MS),
|
|
259
|
+
box,
|
|
260
|
+
width: boxWidth,
|
|
261
|
+
bottomIdx,
|
|
262
|
+
flat: s => this.#uiTheme.fg("border", s),
|
|
263
|
+
});
|
|
287
264
|
const top = buildStatusLine(
|
|
288
265
|
barWidth,
|
|
289
266
|
ctx,
|
|
290
267
|
effective,
|
|
291
|
-
gapColor,
|
|
268
|
+
painters.gapColor,
|
|
292
269
|
{
|
|
293
270
|
left: effective.leftSegments,
|
|
294
271
|
right: effective.rightSegments,
|
|
@@ -299,7 +276,7 @@ class StatusLinePreview {
|
|
|
299
276
|
barWidth,
|
|
300
277
|
ctx,
|
|
301
278
|
effective,
|
|
302
|
-
gapColor,
|
|
279
|
+
painters.gapColor,
|
|
303
280
|
{
|
|
304
281
|
left: effective.bottomLeftSegments,
|
|
305
282
|
right: effective.bottomRightSegments,
|
|
@@ -308,9 +285,11 @@ class StatusLinePreview {
|
|
|
308
285
|
);
|
|
309
286
|
return {
|
|
310
287
|
lines: [
|
|
311
|
-
|
|
312
|
-
paint(1, 0, box.vertical) +
|
|
313
|
-
|
|
288
|
+
renderBoxRow(painters, top, 0, boxWidth, box.topLeft, box.topRight),
|
|
289
|
+
painters.paint(1, 0, box.vertical) +
|
|
290
|
+
" ".repeat(Math.max(0, boxWidth - 2)) +
|
|
291
|
+
painters.paint(1, boxWidth - 1, box.vertical),
|
|
292
|
+
renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
|
|
314
293
|
],
|
|
315
294
|
// Keep the preview animated only while the rainbow is enabled;
|
|
316
295
|
// a static preview refreshes once and stops.
|
package/src/settings.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
|
|
|
10
10
|
import type {
|
|
11
11
|
EffectiveStatusLineSettings,
|
|
12
12
|
FeedFormat,
|
|
13
|
+
SegmentIncludes,
|
|
13
14
|
StatusLineFeed,
|
|
14
15
|
StatusLineSegmentId,
|
|
15
16
|
StatusLineSegmentToggles,
|
|
@@ -93,6 +94,7 @@ function loadSettings(): StatusLineSettings {
|
|
|
93
94
|
if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
|
|
94
95
|
return settings;
|
|
95
96
|
} catch {
|
|
97
|
+
// Missing or corrupt settings.json falls back to defaults.
|
|
96
98
|
return {};
|
|
97
99
|
}
|
|
98
100
|
}
|
|
@@ -131,6 +133,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
131
133
|
if (seg.feedsBottomLeft) bottomLeftSegments.push("feeds");
|
|
132
134
|
if (seg.tokenRateBottomLeft) bottomLeftSegments.push("token_rate");
|
|
133
135
|
|
|
136
|
+
const feeds = settings.feeds ? sanitizeFeeds(settings.feeds) : [...DEFAULT_FEEDS];
|
|
137
|
+
const allSegments = [...leftSegments, ...rightSegments, ...bottomLeftSegments, ...bottomRightSegments];
|
|
138
|
+
const includes: SegmentIncludes = {
|
|
139
|
+
git: allSegments.includes("git"),
|
|
140
|
+
pr: allSegments.includes("pr"),
|
|
141
|
+
piStats: allSegments.includes("pi_stats"),
|
|
142
|
+
tokenRate: allSegments.includes("token_rate"),
|
|
143
|
+
feeds: allSegments.includes("feeds") ? feeds.map(feed => feed.customType) : [],
|
|
144
|
+
};
|
|
145
|
+
|
|
134
146
|
return {
|
|
135
147
|
transparent: settings.transparent ?? true,
|
|
136
148
|
separator: settings.separator ?? "powerline-thin",
|
|
@@ -141,11 +153,12 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
141
153
|
bottomLeftSegments,
|
|
142
154
|
bottomRightSegments,
|
|
143
155
|
rainbowBorder: settings.rainbowBorder ?? true,
|
|
156
|
+
includes,
|
|
144
157
|
segmentOptions: {
|
|
145
158
|
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
|
146
159
|
path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
|
|
147
160
|
context: { showBar: seg.contextBar, showStats: seg.contextStats },
|
|
148
|
-
feeds
|
|
161
|
+
feeds,
|
|
149
162
|
},
|
|
150
163
|
};
|
|
151
164
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry contracts for the shared box chrome: every bar row must total
|
|
3
|
+
* exactly boxWidth cells with the 3/2/+2 layout (corner, 2-cell run, bar,
|
|
4
|
+
* pad+2 run, corner), and the non-rainbow painters must wrap whole runs in a
|
|
5
|
+
* single flat call rather than per glyph — index.ts relies on that fast path
|
|
6
|
+
* to keep per-frame ANSI output small.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { stripVTControlCharacters } from "node:util";
|
|
11
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { makeBoxPainters, renderBoxRow } from "../box.ts";
|
|
13
|
+
import { RainbowBorder } from "../rainbow.ts";
|
|
14
|
+
import type { BoxGlyphs } from "../theme.ts";
|
|
15
|
+
|
|
16
|
+
const ROUNDED: BoxGlyphs = {
|
|
17
|
+
topLeft: "╭",
|
|
18
|
+
topRight: "╮",
|
|
19
|
+
bottomLeft: "╰",
|
|
20
|
+
bottomRight: "╯",
|
|
21
|
+
horizontal: "─",
|
|
22
|
+
vertical: "│",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const identityPainters = (boxWidth: number) =>
|
|
26
|
+
makeBoxPainters({
|
|
27
|
+
rainbowOn: false,
|
|
28
|
+
rainbow: new RainbowBorder(0),
|
|
29
|
+
box: ROUNDED,
|
|
30
|
+
width: boxWidth,
|
|
31
|
+
bottomIdx: 2,
|
|
32
|
+
flat: s => s,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a bar row is corner + 2 run + bar + pad+2 run + corner", () => {
|
|
36
|
+
const out = renderBoxRow(identityPainters(20), "X", 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
|
|
37
|
+
assert.equal(out, "╭──X" + "─".repeat(15) + "╮");
|
|
38
|
+
assert.equal(visibleWidth(out), 20);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("an empty bar renders chrome only, still totalling boxWidth", () => {
|
|
42
|
+
const out = renderBoxRow(identityPainters(20), "", 2, 20, ROUNDED.bottomLeft, ROUNDED.bottomRight);
|
|
43
|
+
assert.equal(out, "╰──" + "─".repeat(16) + "╯");
|
|
44
|
+
assert.equal(visibleWidth(out), 20);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("wide characters in the bar consume pad cells, keeping the total fixed", () => {
|
|
48
|
+
const out = renderBoxRow(identityPainters(20), "日本", 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
|
|
49
|
+
assert.equal(out, "╭──日本" + "─".repeat(12) + "╮");
|
|
50
|
+
assert.equal(visibleWidth(out), 20);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a bar wider than the budget clamps pad to zero", () => {
|
|
54
|
+
const bar = "x".repeat(15);
|
|
55
|
+
const out = renderBoxRow(identityPainters(20), bar, 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
|
|
56
|
+
assert.equal(out, `╭──${bar}──╮`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("non-rainbow painters wrap whole runs in one flat call", () => {
|
|
60
|
+
const painters = makeBoxPainters({
|
|
61
|
+
rainbowOn: false,
|
|
62
|
+
rainbow: new RainbowBorder(0),
|
|
63
|
+
box: ROUNDED,
|
|
64
|
+
width: 20,
|
|
65
|
+
bottomIdx: 2,
|
|
66
|
+
flat: s => `[${s}]`,
|
|
67
|
+
});
|
|
68
|
+
assert.equal(painters.horizRun(0, 1, 3), "[───]");
|
|
69
|
+
assert.equal(painters.gapColor("──", 5, 0), "[──]");
|
|
70
|
+
assert.equal(painters.paint(0, 0, ROUNDED.topLeft), "[╭]");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("rainbow coloring never changes the geometry", () => {
|
|
74
|
+
const painters = makeBoxPainters({
|
|
75
|
+
rainbowOn: true,
|
|
76
|
+
rainbow: new RainbowBorder(0),
|
|
77
|
+
box: ROUNDED,
|
|
78
|
+
width: 20,
|
|
79
|
+
bottomIdx: 2,
|
|
80
|
+
flat: s => s,
|
|
81
|
+
});
|
|
82
|
+
const out = renderBoxRow(painters, "X", 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
|
|
83
|
+
assert.equal(stripVTControlCharacters(out), "╭──X" + "─".repeat(15) + "╮");
|
|
84
|
+
});
|
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
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* These helpers regex-parse rows the HOST renders (the editor's border rows
|
|
3
|
+
* and the default footer's stats line), so the fixtures mirror real pi output:
|
|
4
|
+
* border rows are ─ (U+2500) runs, the scroll hint is "↑/↓ N more", stats
|
|
5
|
+
* parts are single-space separated with the model side right-aligned behind a
|
|
6
|
+
* 2+ space gap, and the context chunk looks like "22.8%/1.0M (auto)".
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { isBorderRow, stripAnsi, stripRedundantStats } from "../footer.ts";
|
|
11
|
+
|
|
12
|
+
test("isBorderRow accepts a plain border run", () => {
|
|
13
|
+
assert.equal(isBorderRow("─────"), true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("isBorderRow accepts a scroll-hint row with a trailing run", () => {
|
|
17
|
+
assert.equal(isBorderRow("─── ↑ 3 more ───"), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("isBorderRow accepts a scroll-hint row without a trailing run", () => {
|
|
21
|
+
assert.equal(isBorderRow("── ↓ 12 more"), true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("isBorderRow sees through ANSI color codes", () => {
|
|
25
|
+
assert.equal(isBorderRow("\x1b[38;5;244m────\x1b[0m"), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("isBorderRow rejects text rows, empty rows, and ASCII dashes", () => {
|
|
29
|
+
assert.equal(isBorderRow("│ hello │"), false);
|
|
30
|
+
assert.equal(isBorderRow(""), false);
|
|
31
|
+
assert.equal(isBorderRow("--- ascii ---"), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("stripAnsi removes SGR sequences only", () => {
|
|
35
|
+
assert.equal(stripAnsi("\x1b[32mgreen\x1b[0m plain"), "green plain");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("stripRedundantStats passes undefined through", () => {
|
|
39
|
+
assert.equal(stripRedundantStats(undefined), undefined);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("stripRedundantStats drops the context chunk but keeps unique stats", () => {
|
|
43
|
+
// "92.3%" must not false-match the chunk regex (no slash follows it).
|
|
44
|
+
assert.equal(
|
|
45
|
+
stripRedundantStats("↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42 22.8%/1.0M (auto)"),
|
|
46
|
+
"↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("stripRedundantStats drops the right-aligned model side", () => {
|
|
51
|
+
assert.equal(stripRedundantStats("↑ 1K ↓ 2K (anthropic) claude-opus • max"), "↑ 1K ↓ 2K");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("stripRedundantStats collapses to undefined when only redundant parts remain", () => {
|
|
55
|
+
assert.equal(stripRedundantStats("?/200K (openai) gpt"), undefined);
|
|
56
|
+
assert.equal(stripRedundantStats("22.8%/1.0M (auto) model"), undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("stripRedundantStats leaves a line of unique stats untouched", () => {
|
|
60
|
+
assert.equal(stripRedundantStats("↑ 1K ↓ 2K R 5K W 1K $0.10"), "↑ 1K ↓ 2K R 5K W 1K $0.10");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("stripRedundantStats strips ANSI before parsing", () => {
|
|
64
|
+
assert.equal(stripRedundantStats("\x1b[32m↑ 1K\x1b[0m ↓ 2K"), "↑ 1K ↓ 2K");
|
|
65
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clampPathLength budgets display cells (visibleWidth), matching the cell
|
|
3
|
+
* budgets layout.ts compares its output against. ASCII behavior is unchanged
|
|
4
|
+
* from the .length-based implementation; CJK/emoji paths are where the two
|
|
5
|
+
* measures diverge (a CJK char is 1 UTF-16 unit but 2 cells).
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { clampPathLength } from "../utils.ts";
|
|
11
|
+
|
|
12
|
+
test("returns the path unchanged when it fits the budget", () => {
|
|
13
|
+
assert.equal(clampPathLength("~/a/b", 20), "~/a/b");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("elides middle components, keeping the first folder and basename", () => {
|
|
17
|
+
assert.equal(clampPathLength("~/projects/pi/topping/src", 20), "~/projects/…/src");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("drops middle components farthest from the cwd first", () => {
|
|
21
|
+
assert.equal(clampPathLength("~/a/bb/cc/dd", 11), "~/a/…/cc/dd");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("drops the first folder once even one middle component overflows", () => {
|
|
25
|
+
assert.equal(clampPathLength("~/aaaa/bb/cc", 8), "…/bb/cc");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("falls back to tail truncation on a long basename", () => {
|
|
29
|
+
const result = clampPathLength("~/x/averylongbasename", 10);
|
|
30
|
+
assert.equal(result, "…gbasename");
|
|
31
|
+
assert.ok(visibleWidth(result) <= 10);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("CJK components are measured in cells, not UTF-16 units", () => {
|
|
35
|
+
const pwd = "~/文档/项目/源代码";
|
|
36
|
+
// The .length-based implementation would have returned this un-clamped:
|
|
37
|
+
// 11 UTF-16 units fit a budget of 12, but the path spans 18 cells.
|
|
38
|
+
assert.ok(pwd.length <= 12 && visibleWidth(pwd) > 12);
|
|
39
|
+
const result = clampPathLength(pwd, 12);
|
|
40
|
+
assert.equal(result, "…/源代码");
|
|
41
|
+
assert.ok(visibleWidth(result) <= 12);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("emoji tail truncation never splits a surrogate pair or exceeds the budget", () => {
|
|
45
|
+
const result = clampPathLength("~/x/📁📁📁name", 6);
|
|
46
|
+
assert.ok(result.startsWith("…"));
|
|
47
|
+
assert.ok(visibleWidth(result) <= 6);
|
|
48
|
+
assert.doesNotThrow(() => encodeURIComponent(result));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("all-CJK fallback stays within the cell budget", () => {
|
|
52
|
+
const pwd = "/一二三四五六七";
|
|
53
|
+
assert.ok(pwd.length <= 8 && visibleWidth(pwd) > 8);
|
|
54
|
+
const result = clampPathLength(pwd, 8);
|
|
55
|
+
assert.equal(result, "…五六七");
|
|
56
|
+
assert.ok(visibleWidth(result) <= 8);
|
|
57
|
+
});
|
package/src/theme.ts
CHANGED
|
@@ -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;
|
|
@@ -44,24 +42,8 @@ export interface StatusSymbols {
|
|
|
44
42
|
folder: string;
|
|
45
43
|
scratchFolder: string;
|
|
46
44
|
worktree: string;
|
|
47
|
-
git: string;
|
|
48
45
|
branch: string;
|
|
49
46
|
pr: string;
|
|
50
|
-
tokens: string;
|
|
51
|
-
context: string;
|
|
52
|
-
cost: string;
|
|
53
|
-
time: string;
|
|
54
|
-
ghost: string;
|
|
55
|
-
agents: string;
|
|
56
|
-
job: string;
|
|
57
|
-
cache: string;
|
|
58
|
-
input: string;
|
|
59
|
-
output: string;
|
|
60
|
-
throughput: string;
|
|
61
|
-
host: string;
|
|
62
|
-
session: string;
|
|
63
|
-
auto: string;
|
|
64
|
-
fast: string;
|
|
65
47
|
};
|
|
66
48
|
thinking: {
|
|
67
49
|
minimal: string;
|
|
@@ -70,7 +52,6 @@ export interface StatusSymbols {
|
|
|
70
52
|
high: string;
|
|
71
53
|
xhigh: string;
|
|
72
54
|
max: string;
|
|
73
|
-
autoPending: string;
|
|
74
55
|
};
|
|
75
56
|
}
|
|
76
57
|
|
|
@@ -82,8 +63,6 @@ const UNICODE_SYMBOLS: StatusSymbols = {
|
|
|
82
63
|
powerlineThinRight: "<",
|
|
83
64
|
halfCircleLeft: "",
|
|
84
65
|
halfCircleRight: "",
|
|
85
|
-
block: "▌",
|
|
86
|
-
space: " ",
|
|
87
66
|
asciiLeft: ">",
|
|
88
67
|
asciiRight: "<",
|
|
89
68
|
dot: " · ",
|
|
@@ -96,24 +75,8 @@ const UNICODE_SYMBOLS: StatusSymbols = {
|
|
|
96
75
|
folder: "📁",
|
|
97
76
|
scratchFolder: "🗑",
|
|
98
77
|
worktree: "🌳",
|
|
99
|
-
git: "⎇",
|
|
100
78
|
branch: "⑂",
|
|
101
79
|
pr: "⤴",
|
|
102
|
-
tokens: "🪙",
|
|
103
|
-
context: "◫",
|
|
104
|
-
cost: "💲",
|
|
105
|
-
time: "⏱",
|
|
106
|
-
ghost: "👻",
|
|
107
|
-
agents: "👥",
|
|
108
|
-
job: "⚙",
|
|
109
|
-
cache: "💾",
|
|
110
|
-
input: "⤵",
|
|
111
|
-
output: "⤴",
|
|
112
|
-
throughput: "⚡",
|
|
113
|
-
host: "🖥",
|
|
114
|
-
session: "🆔",
|
|
115
|
-
auto: "⟲",
|
|
116
|
-
fast: "⚡",
|
|
117
80
|
},
|
|
118
81
|
thinking: {
|
|
119
82
|
minimal: "○ min",
|
|
@@ -122,7 +85,6 @@ const UNICODE_SYMBOLS: StatusSymbols = {
|
|
|
122
85
|
high: "◒ high",
|
|
123
86
|
xhigh: "◕ xhigh",
|
|
124
87
|
max: "◉ max",
|
|
125
|
-
autoPending: "⟳",
|
|
126
88
|
},
|
|
127
89
|
};
|
|
128
90
|
|
|
@@ -134,8 +96,6 @@ const NERD_SYMBOLS: StatusSymbols = {
|
|
|
134
96
|
powerlineThinRight: "\ue0b3",
|
|
135
97
|
halfCircleLeft: "\ue0b6",
|
|
136
98
|
halfCircleRight: "\ue0b4",
|
|
137
|
-
block: "█",
|
|
138
|
-
space: " ",
|
|
139
99
|
asciiLeft: ">",
|
|
140
100
|
asciiRight: "<",
|
|
141
101
|
dot: " · ",
|
|
@@ -148,24 +108,8 @@ const NERD_SYMBOLS: StatusSymbols = {
|
|
|
148
108
|
folder: "\uf115",
|
|
149
109
|
scratchFolder: "\uf014",
|
|
150
110
|
worktree: "\uf0e8",
|
|
151
|
-
git: "\uf1d3",
|
|
152
111
|
branch: "\uf126",
|
|
153
112
|
pr: "\uea64",
|
|
154
|
-
tokens: "\ue26b",
|
|
155
|
-
context: "\ue70f",
|
|
156
|
-
cost: "\uf155",
|
|
157
|
-
time: "\uf017",
|
|
158
|
-
ghost: "\u{f02a0}",
|
|
159
|
-
agents: "\uf0c0",
|
|
160
|
-
job: "\uf013",
|
|
161
|
-
cache: "\uf1c0",
|
|
162
|
-
input: "\uf090",
|
|
163
|
-
output: "\uf08b",
|
|
164
|
-
throughput: "⚡",
|
|
165
|
-
host: "\uf109",
|
|
166
|
-
session: "\u{f0051}",
|
|
167
|
-
auto: "\u{f0068}",
|
|
168
|
-
fast: "\uf0e7",
|
|
169
113
|
},
|
|
170
114
|
thinking: {
|
|
171
115
|
minimal: "\u{f0a9e} min",
|
|
@@ -174,7 +118,6 @@ const NERD_SYMBOLS: StatusSymbols = {
|
|
|
174
118
|
high: "\u{f0aa3} high",
|
|
175
119
|
xhigh: "\u{f0aa5} xhigh",
|
|
176
120
|
max: "\u{f06d} max",
|
|
177
|
-
autoPending: "\u{f074}",
|
|
178
121
|
},
|
|
179
122
|
};
|
|
180
123
|
|
|
@@ -186,8 +129,6 @@ const ASCII_SYMBOLS: StatusSymbols = {
|
|
|
186
129
|
powerlineThinRight: "<",
|
|
187
130
|
halfCircleLeft: "",
|
|
188
131
|
halfCircleRight: "",
|
|
189
|
-
block: "#",
|
|
190
|
-
space: " ",
|
|
191
132
|
asciiLeft: ">",
|
|
192
133
|
asciiRight: "<",
|
|
193
134
|
dot: " - ",
|
|
@@ -200,24 +141,8 @@ const ASCII_SYMBOLS: StatusSymbols = {
|
|
|
200
141
|
folder: "[D]",
|
|
201
142
|
scratchFolder: "[T]",
|
|
202
143
|
worktree: "[wt]",
|
|
203
|
-
git: "git:",
|
|
204
144
|
branch: "@",
|
|
205
145
|
pr: "PR",
|
|
206
|
-
tokens: "tok:",
|
|
207
|
-
context: "ctx:",
|
|
208
|
-
cost: "$",
|
|
209
|
-
time: "t:",
|
|
210
|
-
ghost: "@",
|
|
211
|
-
agents: "AG",
|
|
212
|
-
job: "bg",
|
|
213
|
-
cache: "cache",
|
|
214
|
-
input: "in:",
|
|
215
|
-
output: "out:",
|
|
216
|
-
throughput: "tok/s:",
|
|
217
|
-
host: "host",
|
|
218
|
-
session: "id",
|
|
219
|
-
auto: "[A]",
|
|
220
|
-
fast: ">>",
|
|
221
146
|
},
|
|
222
147
|
thinking: {
|
|
223
148
|
minimal: "[min]",
|
|
@@ -226,7 +151,6 @@ const ASCII_SYMBOLS: StatusSymbols = {
|
|
|
226
151
|
high: "[high]",
|
|
227
152
|
xhigh: "[xhigh]",
|
|
228
153
|
max: "[max]",
|
|
229
|
-
autoPending: "[~]",
|
|
230
154
|
},
|
|
231
155
|
};
|
|
232
156
|
|
|
@@ -260,7 +184,6 @@ export type StatusColor =
|
|
|
260
184
|
| "muted"
|
|
261
185
|
| "dim"
|
|
262
186
|
| "text"
|
|
263
|
-
| "thinkingHigh"
|
|
264
187
|
| "statusLineSep"
|
|
265
188
|
| "statusLineModel"
|
|
266
189
|
| "statusLinePath"
|
|
@@ -268,13 +191,9 @@ export type StatusColor =
|
|
|
268
191
|
| "statusLineGitDirty"
|
|
269
192
|
| "statusLineContext"
|
|
270
193
|
| "statusLineContextElevated"
|
|
271
|
-
| "statusLineSpend"
|
|
272
194
|
| "statusLineStaged"
|
|
273
195
|
| "statusLineDirty"
|
|
274
|
-
| "statusLineUntracked"
|
|
275
|
-
| "statusLineOutput"
|
|
276
|
-
| "statusLineCost"
|
|
277
|
-
| "statusLineSubagents";
|
|
196
|
+
| "statusLineUntracked";
|
|
278
197
|
|
|
279
198
|
const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
280
199
|
accent: "#febc38",
|
|
@@ -284,7 +203,6 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
|
284
203
|
muted: "#777d88",
|
|
285
204
|
dim: "#5f6673",
|
|
286
205
|
text: "",
|
|
287
|
-
thinkingHigh: "#b281d6",
|
|
288
206
|
statusLineSep: 244,
|
|
289
207
|
statusLineModel: "#d787af",
|
|
290
208
|
statusLinePath: "#00afaf",
|
|
@@ -292,13 +210,9 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
|
292
210
|
statusLineGitDirty: "#d7af5f",
|
|
293
211
|
statusLineContext: "#8787af",
|
|
294
212
|
statusLineContextElevated: "#e07a1f",
|
|
295
|
-
statusLineSpend: "#5fafaf",
|
|
296
213
|
statusLineStaged: 70,
|
|
297
214
|
statusLineDirty: 178,
|
|
298
215
|
statusLineUntracked: 39,
|
|
299
|
-
statusLineOutput: 205,
|
|
300
|
-
statusLineCost: 205,
|
|
301
|
-
statusLineSubagents: "#febc38",
|
|
302
216
|
};
|
|
303
217
|
|
|
304
218
|
const STATUS_LINE_BG: ColorValue = "#121212";
|
|
@@ -391,9 +305,11 @@ class StatusTheme {
|
|
|
391
305
|
#fgCache = new Map<StatusColor, string>();
|
|
392
306
|
#bgAnsi: string | undefined;
|
|
393
307
|
|
|
394
|
-
setSymbolPreset(preset: SymbolPreset):
|
|
308
|
+
setSymbolPreset(preset: SymbolPreset): SymbolPreset {
|
|
309
|
+
const previous = this.#preset;
|
|
395
310
|
this.#preset = preset;
|
|
396
311
|
this.#symbols = SYMBOL_PRESETS[preset];
|
|
312
|
+
return previous;
|
|
397
313
|
}
|
|
398
314
|
|
|
399
315
|
get sep(): StatusSymbols["sep"] {
|
package/src/types.ts
CHANGED
|
@@ -102,6 +102,7 @@ export interface EffectiveStatusLineSettings {
|
|
|
102
102
|
bottomRightSegments: StatusLineSegmentId[];
|
|
103
103
|
segmentOptions: StatusLineSegmentOptions;
|
|
104
104
|
rainbowBorder: boolean;
|
|
105
|
+
includes: SegmentIncludes;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// ═══════════════════════════════════════════════════════════════════════════
|
package/src/utils.ts
CHANGED
|
@@ -5,14 +5,22 @@
|
|
|
5
5
|
* replaced with local hex math so this runs under any pi runtime.
|
|
6
6
|
*/
|
|
7
7
|
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
9
|
import { parseHex } from "./theme.js";
|
|
10
|
+
import { hslToRgb, rgbToHex } from "./rainbow.js";
|
|
9
11
|
|
|
10
12
|
export function formatNumber(n: number): string {
|
|
11
13
|
if (n < 1_000) return n.toString();
|
|
12
14
|
if (n < 10_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
13
|
-
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
|
+
}
|
|
14
19
|
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
15
|
-
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
|
+
}
|
|
16
24
|
if (n < 10_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
17
25
|
return `${Math.round(n / 1_000_000_000)}B`;
|
|
18
26
|
}
|
|
@@ -27,13 +35,14 @@ export function shortenPath(filePath: string, homeDir?: string): string {
|
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
|
-
* Clamp a path/label to `maxLen` by eliding middle components
|
|
31
|
-
* The first folder (after `~` or the root) and the basename survive
|
|
32
|
-
* middle components drop farthest-from-cwd first. Falls back to plain
|
|
33
|
-
* left-truncation once even `…/basename` overflows.
|
|
38
|
+
* Clamp a path/label to `maxLen` display cells by eliding middle components
|
|
39
|
+
* with `…`. The first folder (after `~` or the root) and the basename survive
|
|
40
|
+
* longest; middle components drop farthest-from-cwd first. Falls back to plain
|
|
41
|
+
* left-truncation once even `…/basename` overflows. Widths are terminal cells
|
|
42
|
+
* (matching layout.ts's visibleWidth budgets), not UTF-16 units.
|
|
34
43
|
*/
|
|
35
44
|
export function clampPathLength(pwd: string, maxLen: number): string {
|
|
36
|
-
if (pwd
|
|
45
|
+
if (visibleWidth(pwd) <= maxLen) return pwd;
|
|
37
46
|
|
|
38
47
|
const parts = pwd.split("/");
|
|
39
48
|
const last = parts.length - 1;
|
|
@@ -41,16 +50,17 @@ export function clampPathLength(pwd: string, maxLen: number): string {
|
|
|
41
50
|
|
|
42
51
|
for (let dropped = 1; dropped <= last - firstIdx - 1; dropped++) {
|
|
43
52
|
const candidate = [...parts.slice(0, firstIdx + 1), "…", ...parts.slice(firstIdx + 1 + dropped)].join("/");
|
|
44
|
-
if (candidate
|
|
53
|
+
if (visibleWidth(candidate) <= maxLen) return candidate;
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
// The first folder no longer fits: keep whatever remains closest to the cwd.
|
|
48
57
|
for (let start = firstIdx + 1; start <= last; start++) {
|
|
49
58
|
const candidate = `…/${parts.slice(start).join("/")}`;
|
|
50
|
-
if (candidate
|
|
59
|
+
if (visibleWidth(candidate) <= maxLen) return candidate;
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
const tailWidth = Math.max(0, maxLen - 1);
|
|
63
|
+
return `…${sliceByColumn(pwd, visibleWidth(pwd) - tailWidth, tailWidth)}`;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */
|
|
@@ -119,26 +129,6 @@ function hexToHue(hex: string): number | undefined {
|
|
|
119
129
|
return (h + 360) % 360;
|
|
120
130
|
}
|
|
121
131
|
|
|
122
|
-
function hslToHex(h: number, s: number, l: number): string {
|
|
123
|
-
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
124
|
-
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
125
|
-
const m = l - c / 2;
|
|
126
|
-
let r = 0;
|
|
127
|
-
let g = 0;
|
|
128
|
-
let b = 0;
|
|
129
|
-
if (h < 60) [r, g, b] = [c, x, 0];
|
|
130
|
-
else if (h < 120) [r, g, b] = [x, c, 0];
|
|
131
|
-
else if (h < 180) [r, g, b] = [0, c, x];
|
|
132
|
-
else if (h < 240) [r, g, b] = [0, x, c];
|
|
133
|
-
else if (h < 300) [r, g, b] = [x, 0, c];
|
|
134
|
-
else [r, g, b] = [c, 0, x];
|
|
135
|
-
const toHex = (v: number) =>
|
|
136
|
-
Math.round((v + m) * 255)
|
|
137
|
-
.toString(16)
|
|
138
|
-
.padStart(2, "0");
|
|
139
|
-
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
132
|
function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number {
|
|
143
133
|
if (occupied.length === 0) return target;
|
|
144
134
|
if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) return target;
|
|
@@ -157,5 +147,5 @@ export function getSessionAccentHex(name: string, themeColorHexes: string[]): st
|
|
|
157
147
|
let targetHue = DARK_HUE_START + (nameToHue(name) % range);
|
|
158
148
|
const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
|
|
159
149
|
targetHue = findSafeHue(targetHue, themeHues, DARK_HUE_START, DARK_HUE_END);
|
|
160
|
-
return
|
|
150
|
+
return rgbToHex(hslToRgb(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS));
|
|
161
151
|
}
|