@underactive/pi-topping-statusline 0.1.0 → 0.1.1
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/footer.ts +34 -0
- package/src/layout.ts +38 -28
- package/src/settings-menu.ts +18 -39
- package/src/settings.ts +13 -1
- package/src/tests/box.test.ts +84 -0
- package/src/tests/footer.test.ts +65 -0
- package/src/tests/utils.test.ts +57 -0
- package/src/theme.ts +4 -80
- package/src/types.ts +1 -0
- package/src/utils.ts +11 -8
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/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,
|
|
@@ -84,34 +118,10 @@ export function buildStatusLine(
|
|
|
84
118
|
// Shrink path before dropping left segments — path is the only elastic segment
|
|
85
119
|
const pathIdx = leftSegIds.indexOf("path");
|
|
86
120
|
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
|
-
}
|
|
121
|
+
const replacement = shrinkPathSegment(ctx, left[pathIdx], totalWidth() - width);
|
|
122
|
+
if (replacement !== undefined) {
|
|
123
|
+
left[pathIdx] = replacement;
|
|
124
|
+
leftWidth = leftGroupWidth(left);
|
|
115
125
|
}
|
|
116
126
|
}
|
|
117
127
|
const leftOverflowDropIndex = (): number => {
|
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,
|
|
@@ -131,6 +132,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
131
132
|
if (seg.feedsBottomLeft) bottomLeftSegments.push("feeds");
|
|
132
133
|
if (seg.tokenRateBottomLeft) bottomLeftSegments.push("token_rate");
|
|
133
134
|
|
|
135
|
+
const feeds = settings.feeds ? sanitizeFeeds(settings.feeds) : [...DEFAULT_FEEDS];
|
|
136
|
+
const allSegments = [...leftSegments, ...rightSegments, ...bottomLeftSegments, ...bottomRightSegments];
|
|
137
|
+
const includes: SegmentIncludes = {
|
|
138
|
+
git: allSegments.includes("git"),
|
|
139
|
+
pr: allSegments.includes("pr"),
|
|
140
|
+
piStats: allSegments.includes("pi_stats"),
|
|
141
|
+
tokenRate: allSegments.includes("token_rate"),
|
|
142
|
+
feeds: allSegments.includes("feeds") ? feeds.map(feed => feed.customType) : [],
|
|
143
|
+
};
|
|
144
|
+
|
|
134
145
|
return {
|
|
135
146
|
transparent: settings.transparent ?? true,
|
|
136
147
|
separator: settings.separator ?? "powerline-thin",
|
|
@@ -141,11 +152,12 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
141
152
|
bottomLeftSegments,
|
|
142
153
|
bottomRightSegments,
|
|
143
154
|
rainbowBorder: settings.rainbowBorder ?? true,
|
|
155
|
+
includes,
|
|
144
156
|
segmentOptions: {
|
|
145
157
|
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
|
146
158
|
path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
|
|
147
159
|
context: { showBar: seg.contextBar, showStats: seg.contextStats },
|
|
148
|
-
feeds
|
|
160
|
+
feeds,
|
|
149
161
|
},
|
|
150
162
|
};
|
|
151
163
|
}
|
|
@@ -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
|
+
});
|
|
@@ -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
|
@@ -44,24 +44,8 @@ export interface StatusSymbols {
|
|
|
44
44
|
folder: string;
|
|
45
45
|
scratchFolder: string;
|
|
46
46
|
worktree: string;
|
|
47
|
-
git: string;
|
|
48
47
|
branch: string;
|
|
49
48
|
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
49
|
};
|
|
66
50
|
thinking: {
|
|
67
51
|
minimal: string;
|
|
@@ -70,7 +54,6 @@ export interface StatusSymbols {
|
|
|
70
54
|
high: string;
|
|
71
55
|
xhigh: string;
|
|
72
56
|
max: string;
|
|
73
|
-
autoPending: string;
|
|
74
57
|
};
|
|
75
58
|
}
|
|
76
59
|
|
|
@@ -96,24 +79,8 @@ const UNICODE_SYMBOLS: StatusSymbols = {
|
|
|
96
79
|
folder: "📁",
|
|
97
80
|
scratchFolder: "🗑",
|
|
98
81
|
worktree: "🌳",
|
|
99
|
-
git: "⎇",
|
|
100
82
|
branch: "⑂",
|
|
101
83
|
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
84
|
},
|
|
118
85
|
thinking: {
|
|
119
86
|
minimal: "○ min",
|
|
@@ -122,7 +89,6 @@ const UNICODE_SYMBOLS: StatusSymbols = {
|
|
|
122
89
|
high: "◒ high",
|
|
123
90
|
xhigh: "◕ xhigh",
|
|
124
91
|
max: "◉ max",
|
|
125
|
-
autoPending: "⟳",
|
|
126
92
|
},
|
|
127
93
|
};
|
|
128
94
|
|
|
@@ -148,24 +114,8 @@ const NERD_SYMBOLS: StatusSymbols = {
|
|
|
148
114
|
folder: "\uf115",
|
|
149
115
|
scratchFolder: "\uf014",
|
|
150
116
|
worktree: "\uf0e8",
|
|
151
|
-
git: "\uf1d3",
|
|
152
117
|
branch: "\uf126",
|
|
153
118
|
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
119
|
},
|
|
170
120
|
thinking: {
|
|
171
121
|
minimal: "\u{f0a9e} min",
|
|
@@ -174,7 +124,6 @@ const NERD_SYMBOLS: StatusSymbols = {
|
|
|
174
124
|
high: "\u{f0aa3} high",
|
|
175
125
|
xhigh: "\u{f0aa5} xhigh",
|
|
176
126
|
max: "\u{f06d} max",
|
|
177
|
-
autoPending: "\u{f074}",
|
|
178
127
|
},
|
|
179
128
|
};
|
|
180
129
|
|
|
@@ -200,24 +149,8 @@ const ASCII_SYMBOLS: StatusSymbols = {
|
|
|
200
149
|
folder: "[D]",
|
|
201
150
|
scratchFolder: "[T]",
|
|
202
151
|
worktree: "[wt]",
|
|
203
|
-
git: "git:",
|
|
204
152
|
branch: "@",
|
|
205
153
|
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
154
|
},
|
|
222
155
|
thinking: {
|
|
223
156
|
minimal: "[min]",
|
|
@@ -226,7 +159,6 @@ const ASCII_SYMBOLS: StatusSymbols = {
|
|
|
226
159
|
high: "[high]",
|
|
227
160
|
xhigh: "[xhigh]",
|
|
228
161
|
max: "[max]",
|
|
229
|
-
autoPending: "[~]",
|
|
230
162
|
},
|
|
231
163
|
};
|
|
232
164
|
|
|
@@ -260,7 +192,6 @@ export type StatusColor =
|
|
|
260
192
|
| "muted"
|
|
261
193
|
| "dim"
|
|
262
194
|
| "text"
|
|
263
|
-
| "thinkingHigh"
|
|
264
195
|
| "statusLineSep"
|
|
265
196
|
| "statusLineModel"
|
|
266
197
|
| "statusLinePath"
|
|
@@ -268,13 +199,9 @@ export type StatusColor =
|
|
|
268
199
|
| "statusLineGitDirty"
|
|
269
200
|
| "statusLineContext"
|
|
270
201
|
| "statusLineContextElevated"
|
|
271
|
-
| "statusLineSpend"
|
|
272
202
|
| "statusLineStaged"
|
|
273
203
|
| "statusLineDirty"
|
|
274
|
-
| "statusLineUntracked"
|
|
275
|
-
| "statusLineOutput"
|
|
276
|
-
| "statusLineCost"
|
|
277
|
-
| "statusLineSubagents";
|
|
204
|
+
| "statusLineUntracked";
|
|
278
205
|
|
|
279
206
|
const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
280
207
|
accent: "#febc38",
|
|
@@ -284,7 +211,6 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
|
284
211
|
muted: "#777d88",
|
|
285
212
|
dim: "#5f6673",
|
|
286
213
|
text: "",
|
|
287
|
-
thinkingHigh: "#b281d6",
|
|
288
214
|
statusLineSep: 244,
|
|
289
215
|
statusLineModel: "#d787af",
|
|
290
216
|
statusLinePath: "#00afaf",
|
|
@@ -292,13 +218,9 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
|
292
218
|
statusLineGitDirty: "#d7af5f",
|
|
293
219
|
statusLineContext: "#8787af",
|
|
294
220
|
statusLineContextElevated: "#e07a1f",
|
|
295
|
-
statusLineSpend: "#5fafaf",
|
|
296
221
|
statusLineStaged: 70,
|
|
297
222
|
statusLineDirty: 178,
|
|
298
223
|
statusLineUntracked: 39,
|
|
299
|
-
statusLineOutput: 205,
|
|
300
|
-
statusLineCost: 205,
|
|
301
|
-
statusLineSubagents: "#febc38",
|
|
302
224
|
};
|
|
303
225
|
|
|
304
226
|
const STATUS_LINE_BG: ColorValue = "#121212";
|
|
@@ -391,9 +313,11 @@ class StatusTheme {
|
|
|
391
313
|
#fgCache = new Map<StatusColor, string>();
|
|
392
314
|
#bgAnsi: string | undefined;
|
|
393
315
|
|
|
394
|
-
setSymbolPreset(preset: SymbolPreset):
|
|
316
|
+
setSymbolPreset(preset: SymbolPreset): SymbolPreset {
|
|
317
|
+
const previous = this.#preset;
|
|
395
318
|
this.#preset = preset;
|
|
396
319
|
this.#symbols = SYMBOL_PRESETS[preset];
|
|
320
|
+
return previous;
|
|
397
321
|
}
|
|
398
322
|
|
|
399
323
|
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,6 +5,7 @@
|
|
|
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";
|
|
9
10
|
|
|
10
11
|
export function formatNumber(n: number): string {
|
|
@@ -27,13 +28,14 @@ export function shortenPath(filePath: string, homeDir?: string): string {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/**
|
|
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.
|
|
31
|
+
* Clamp a path/label to `maxLen` display cells by eliding middle components
|
|
32
|
+
* with `…`. The first folder (after `~` or the root) and the basename survive
|
|
33
|
+
* longest; middle components drop farthest-from-cwd first. Falls back to plain
|
|
34
|
+
* left-truncation once even `…/basename` overflows. Widths are terminal cells
|
|
35
|
+
* (matching layout.ts's visibleWidth budgets), not UTF-16 units.
|
|
34
36
|
*/
|
|
35
37
|
export function clampPathLength(pwd: string, maxLen: number): string {
|
|
36
|
-
if (pwd
|
|
38
|
+
if (visibleWidth(pwd) <= maxLen) return pwd;
|
|
37
39
|
|
|
38
40
|
const parts = pwd.split("/");
|
|
39
41
|
const last = parts.length - 1;
|
|
@@ -41,16 +43,17 @@ export function clampPathLength(pwd: string, maxLen: number): string {
|
|
|
41
43
|
|
|
42
44
|
for (let dropped = 1; dropped <= last - firstIdx - 1; dropped++) {
|
|
43
45
|
const candidate = [...parts.slice(0, firstIdx + 1), "…", ...parts.slice(firstIdx + 1 + dropped)].join("/");
|
|
44
|
-
if (candidate
|
|
46
|
+
if (visibleWidth(candidate) <= maxLen) return candidate;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// The first folder no longer fits: keep whatever remains closest to the cwd.
|
|
48
50
|
for (let start = firstIdx + 1; start <= last; start++) {
|
|
49
51
|
const candidate = `…/${parts.slice(start).join("/")}`;
|
|
50
|
-
if (candidate
|
|
52
|
+
if (visibleWidth(candidate) <= maxLen) return candidate;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
const tailWidth = Math.max(0, maxLen - 1);
|
|
56
|
+
return `…${sliceByColumn(pwd, visibleWidth(pwd) - tailWidth, tailWidth)}`;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */
|