@underactive/pi-topping-statusline 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -9
- package/index.ts +146 -39
- package/package.json +4 -4
- package/src/compaction-embed.ts +62 -0
- package/src/context-graph.ts +5 -5
- package/src/context-thresholds.ts +7 -3
- package/src/context.ts +72 -13
- package/src/layout.ts +76 -27
- package/src/menu.ts +25 -36
- package/src/segments.ts +54 -0
- package/src/settings-menu.ts +66 -36
- package/src/settings.ts +25 -3
- package/src/tests/compaction-embed.test.ts +184 -0
- package/src/tests/context-estimate.test.ts +207 -0
- package/src/tests/context-thresholds.test.ts +6 -0
- package/src/tests/feeds.test.ts +2 -8
- package/src/tests/menu.test.ts +31 -2
- package/src/tests/token-rate.test.ts +2 -1
- package/src/tests/working-status-embed.test.ts +12 -0
- package/src/tests/working-status.test.ts +60 -9
- package/src/tests/working.test.ts +220 -25
- package/src/token-rate.ts +1 -1
- package/src/types.ts +28 -2
- package/src/working-status-embed.ts +15 -0
- package/src/working-status.ts +42 -15
|
@@ -1,21 +1,56 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
STATUS_HOLD_MS,
|
|
5
|
+
STATUS_SLIDE_MS,
|
|
6
|
+
type StatusIndicatorKind,
|
|
7
|
+
StatusTransition,
|
|
8
|
+
WORKING_FADE_MS,
|
|
9
|
+
} from "../working-status.ts";
|
|
10
|
+
|
|
11
|
+
/** Show a status early enough that its slide-out has finished by `at`. */
|
|
12
|
+
function settle(transition: StatusTransition, status: string, kind: StatusIndicatorKind | undefined, at: number): void {
|
|
13
|
+
transition.resolve(status, kind, at - STATUS_SLIDE_MS);
|
|
14
|
+
transition.resolve(status, kind, at);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("a status appearing over the user's segments slides out, then settles verbatim", () => {
|
|
6
18
|
const transition = new StatusTransition();
|
|
7
19
|
|
|
8
20
|
assert.deepEqual(transition.resolve("⠙ Working", "working", 1_000), {
|
|
9
21
|
status: "⠙ Working",
|
|
10
22
|
kind: "working",
|
|
23
|
+
reveal: 0,
|
|
24
|
+
pending: true,
|
|
25
|
+
});
|
|
26
|
+
// Spinner frames change the text mid-slide without restarting it.
|
|
27
|
+
const early = transition.resolve("⠹ Working", "working", 1_000 + STATUS_SLIDE_MS / 4).reveal ?? -1;
|
|
28
|
+
const late = transition.resolve("⠸ Working", "working", 1_000 + (STATUS_SLIDE_MS * 3) / 4).reveal ?? -1;
|
|
29
|
+
assert.ok(0 < early && early < late && late < 1, "reveal grows monotonically");
|
|
30
|
+
assert.equal(transition.pending(1_000 + STATUS_SLIDE_MS - 1), true);
|
|
31
|
+
|
|
32
|
+
assert.deepEqual(transition.resolve("⠼ Working", "working", 1_000 + STATUS_SLIDE_MS), {
|
|
33
|
+
status: "⠼ Working",
|
|
34
|
+
kind: "working",
|
|
11
35
|
pending: false,
|
|
12
36
|
});
|
|
13
|
-
assert.equal(transition.pending(1_000), false);
|
|
37
|
+
assert.equal(transition.pending(1_000 + STATUS_SLIDE_MS), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a status cleared mid-slide keeps sliding through its hold", () => {
|
|
41
|
+
const transition = new StatusTransition();
|
|
42
|
+
transition.resolve("⠙ Working", "working", 0);
|
|
43
|
+
|
|
44
|
+
const clearedAt = STATUS_SLIDE_MS / 4;
|
|
45
|
+
const cleared = transition.resolve(undefined, undefined, clearedAt);
|
|
46
|
+
const held = transition.resolve(undefined, undefined, clearedAt + STATUS_HOLD_MS / 2);
|
|
47
|
+
assert.equal(held.status, "⠙ Working");
|
|
48
|
+
assert.ok(0 < (cleared.reveal ?? 1) && (cleared.reveal ?? 1) < (held.reveal ?? 1), "the slide continues");
|
|
14
49
|
});
|
|
15
50
|
|
|
16
51
|
test("clearing a working status holds it, cross-fades both halves, then settles", () => {
|
|
17
52
|
const transition = new StatusTransition();
|
|
18
|
-
transition
|
|
53
|
+
settle(transition, "⠙ Working", "working", 1_000);
|
|
19
54
|
|
|
20
55
|
const clearedAt = 2_000;
|
|
21
56
|
const holdEndsAt = clearedAt + STATUS_HOLD_MS;
|
|
@@ -66,7 +101,7 @@ test("clearing a working status holds it, cross-fades both halves, then settles"
|
|
|
66
101
|
test("clearing message-style statuses holds them and then cuts without a fade", () => {
|
|
67
102
|
for (const kind of ["compaction", "retry"] as const) {
|
|
68
103
|
const transition = new StatusTransition();
|
|
69
|
-
transition
|
|
104
|
+
settle(transition, `⠙ ${kind}`, kind, 100);
|
|
70
105
|
assert.deepEqual(transition.resolve(undefined, undefined, 200), {
|
|
71
106
|
status: `⠙ ${kind}`,
|
|
72
107
|
kind,
|
|
@@ -87,7 +122,7 @@ test("clearing message-style statuses holds them and then cuts without a fade",
|
|
|
87
122
|
|
|
88
123
|
test("a new status during the hold cuts in immediately and cancels the exit", () => {
|
|
89
124
|
const transition = new StatusTransition();
|
|
90
|
-
transition
|
|
125
|
+
settle(transition, "⠙ Working", "working", 1_000);
|
|
91
126
|
transition.resolve(undefined, undefined, 1_100);
|
|
92
127
|
|
|
93
128
|
assert.deepEqual(transition.resolve("⠹ Retrying", "retry", 1_200), {
|
|
@@ -100,7 +135,7 @@ test("a new status during the hold cuts in immediately and cancels the exit", ()
|
|
|
100
135
|
|
|
101
136
|
test("a new status during the fade cuts in immediately and cancels the exit", () => {
|
|
102
137
|
const transition = new StatusTransition();
|
|
103
|
-
transition
|
|
138
|
+
settle(transition, "⠙ Working", "working", 0);
|
|
104
139
|
transition.resolve(undefined, undefined, 100);
|
|
105
140
|
const fading = transition.resolve(undefined, undefined, 300);
|
|
106
141
|
assert.equal(fading.status, "⠙ Working");
|
|
@@ -114,6 +149,21 @@ test("a new status during the fade cuts in immediately and cancels the exit", ()
|
|
|
114
149
|
assert.equal(transition.pending(1_000), false);
|
|
115
150
|
});
|
|
116
151
|
|
|
152
|
+
test("a status arriving after the fade's outgoing half slides out again", () => {
|
|
153
|
+
const transition = new StatusTransition();
|
|
154
|
+
settle(transition, "⠙ Working", "working", 0);
|
|
155
|
+
transition.resolve(undefined, undefined, 100);
|
|
156
|
+
|
|
157
|
+
const incomingAt = 100 + STATUS_HOLD_MS + WORKING_FADE_MS / 2;
|
|
158
|
+
assert.equal(transition.resolve(undefined, undefined, incomingAt).status, undefined);
|
|
159
|
+
assert.deepEqual(transition.resolve("⠹ Retrying", "retry", incomingAt + 1), {
|
|
160
|
+
status: "⠹ Retrying",
|
|
161
|
+
kind: "retry",
|
|
162
|
+
reveal: 0,
|
|
163
|
+
pending: true,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
117
167
|
test("a working-to-compaction handoff across the hold never yields an empty status", () => {
|
|
118
168
|
const transition = new StatusTransition();
|
|
119
169
|
const frames = [
|
|
@@ -143,7 +193,7 @@ test("an undefined kind preserves the pre-0.86 working fade", () => {
|
|
|
143
193
|
});
|
|
144
194
|
});
|
|
145
195
|
|
|
146
|
-
test("reset clears live and
|
|
196
|
+
test("reset clears live, exiting, and sliding state", () => {
|
|
147
197
|
const transition = new StatusTransition();
|
|
148
198
|
transition.resolve("⠙ Working", "working", 10);
|
|
149
199
|
transition.resolve(undefined, undefined, 20);
|
|
@@ -155,4 +205,5 @@ test("reset clears live and exiting state", () => {
|
|
|
155
205
|
pending: false,
|
|
156
206
|
});
|
|
157
207
|
assert.equal(transition.pending(21), false);
|
|
208
|
+
assert.equal(transition.resolve("⠹ Working", "working", 22).reveal, 0, "the next status slides from the start");
|
|
158
209
|
});
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Contracts for the opt-in embedded status indicator: the `working` segment
|
|
3
|
-
* passes pi's pre-rendered status through verbatim, the top-left group
|
|
4
|
-
*
|
|
5
|
-
* truncates it before any other left segment is dropped. The caller
|
|
6
|
-
* whether truncated status text uses an ellipsis
|
|
3
|
+
* passes pi's pre-rendered status through verbatim, the top-left group keeps
|
|
4
|
+
* the Pi symbol and configured model details while a stream is live, and the
|
|
5
|
+
* layout truncates it before any other left segment is dropped. The caller
|
|
6
|
+
* chooses whether truncated status text uses an ellipsis, and how far an
|
|
7
|
+
* appearing status has slid out ahead of the model details.
|
|
7
8
|
*/
|
|
8
9
|
import assert from "node:assert/strict";
|
|
9
10
|
import test from "node:test";
|
|
10
11
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { stripAnsi } from "../footer.ts";
|
|
11
13
|
import { buildStatusLine } from "../layout.ts";
|
|
12
14
|
import { SEGMENTS } from "../segments.ts";
|
|
15
|
+
import { getSeparator } from "../separators.ts";
|
|
13
16
|
import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
|
|
14
17
|
import { easeFade, theme } from "../theme.ts";
|
|
15
18
|
import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
|
|
16
19
|
|
|
17
|
-
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
18
20
|
const noGap = (s: string) => s;
|
|
19
21
|
|
|
20
22
|
const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
|
|
@@ -49,15 +51,20 @@ test("working segment is invisible when idle", () => {
|
|
|
49
51
|
assert.equal(SEGMENTS.working.render(ctxWith("")).visible, false);
|
|
50
52
|
});
|
|
51
53
|
|
|
52
|
-
test("topLeftSegments keeps pi
|
|
53
|
-
assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working"]);
|
|
54
|
+
test("topLeftSegments keeps pi and model details around the working status", () => {
|
|
55
|
+
assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working", "model"]);
|
|
54
56
|
assert.deepEqual(topLeftSegments(EFFECTIVE, false), EFFECTIVE.leftSegments);
|
|
55
|
-
assert.ok(EFFECTIVE.leftSegments.
|
|
57
|
+
assert.ok(EFFECTIVE.leftSegments.includes("path"), "default path segment is suppressed while working");
|
|
56
58
|
});
|
|
57
59
|
|
|
58
|
-
test("topLeftSegments
|
|
60
|
+
test("topLeftSegments respects disabled pi and model details", () => {
|
|
59
61
|
const noPi = resolveEffectiveSettings({ segments: { pi: false } });
|
|
60
|
-
assert.deepEqual(topLeftSegments(noPi, true), ["working"]);
|
|
62
|
+
assert.deepEqual(topLeftSegments(noPi, true), ["working", "model"]);
|
|
63
|
+
|
|
64
|
+
const noModelDetails = resolveEffectiveSettings({
|
|
65
|
+
segments: { model: false, provider: false, thinking: false },
|
|
66
|
+
});
|
|
67
|
+
assert.deepEqual(topLeftSegments(noModelDetails, true), ["pi", "working"]);
|
|
61
68
|
});
|
|
62
69
|
|
|
63
70
|
test("embedWorkingStatus resolves off by default and on when set", () => {
|
|
@@ -65,39 +72,98 @@ test("embedWorkingStatus resolves off by default and on when set", () => {
|
|
|
65
72
|
assert.equal(resolveEffectiveSettings({ embedWorkingStatus: true }).embedWorkingStatus, true);
|
|
66
73
|
});
|
|
67
74
|
|
|
68
|
-
test("top bar renders pi symbol,
|
|
69
|
-
const
|
|
70
|
-
|
|
75
|
+
test("top bar renders pi symbol, status, then model details", () => {
|
|
76
|
+
const ctx = ctxWith("⠙ Mulling 28 tps");
|
|
77
|
+
ctx.model = { name: "GPT-5.6 Luna", id: "gpt-5.6", provider: "openai-codex", reasoning: true };
|
|
78
|
+
ctx.thinkingLevel = "max";
|
|
79
|
+
const effective = resolveEffectiveSettings({
|
|
80
|
+
transparent: true,
|
|
81
|
+
segments: { model: true, provider: true, thinking: true },
|
|
82
|
+
});
|
|
83
|
+
ctx.options = effective.segmentOptions;
|
|
84
|
+
const bar = buildStatusLine(120, ctx, effective, noGap, {
|
|
85
|
+
left: topLeftSegments(effective, true),
|
|
71
86
|
right: [],
|
|
72
87
|
});
|
|
73
88
|
const plain = stripAnsi(bar);
|
|
74
89
|
const piAt = plain.indexOf(theme.icon.pi);
|
|
75
90
|
const statusAt = plain.indexOf("⠙ Mulling 28 tps");
|
|
91
|
+
const modelAt = plain.indexOf("GPT-5.6 Luna");
|
|
76
92
|
assert.ok(piAt >= 0, "pi symbol present");
|
|
77
93
|
assert.ok(statusAt > piAt, "status follows the pi symbol");
|
|
78
|
-
assert.
|
|
94
|
+
assert.ok(modelAt > statusAt, "model details follow the status");
|
|
95
|
+
assert.ok(plain.includes("openai-codex"), "provider remains visible");
|
|
96
|
+
assert.ok(plain.includes("max"), "thinking level remains visible");
|
|
97
|
+
assert.equal(visibleWidth(bar), 120);
|
|
79
98
|
});
|
|
80
99
|
|
|
81
|
-
test("narrow widths truncate the status
|
|
100
|
+
test("narrow widths truncate the status before dropping model details", () => {
|
|
82
101
|
const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
|
|
83
|
-
const
|
|
102
|
+
const ctx = ctxWith(status);
|
|
103
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
104
|
+
const bar = buildStatusLine(24, ctx, EFFECTIVE, noGap, {
|
|
105
|
+
left: topLeftSegments(EFFECTIVE, true),
|
|
106
|
+
right: [],
|
|
107
|
+
});
|
|
84
108
|
const plain = stripAnsi(bar);
|
|
85
109
|
assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
|
|
86
110
|
assert.ok(plain.includes("⠙ Mull"), "status head is kept");
|
|
111
|
+
assert.ok(plain.includes("Luna"), "model details survive");
|
|
87
112
|
assert.ok(!plain.includes("…"), "no ellipsis");
|
|
88
113
|
assert.ok(!plain.includes("tokens"), "status tail is cut");
|
|
89
|
-
assert.equal(visibleWidth(bar),
|
|
114
|
+
assert.equal(visibleWidth(bar), 24);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("opaque bars restore the group background after truncating the status", () => {
|
|
118
|
+
const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
|
|
119
|
+
const ctx = ctxWith(status);
|
|
120
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
121
|
+
const opaque = resolveEffectiveSettings({ transparent: false });
|
|
122
|
+
ctx.options = opaque.segmentOptions;
|
|
123
|
+
const bar = buildStatusLine(24, ctx, opaque, noGap, {
|
|
124
|
+
left: topLeftSegments(opaque, true),
|
|
125
|
+
right: [],
|
|
126
|
+
});
|
|
127
|
+
const plain = stripAnsi(bar);
|
|
128
|
+
assert.ok(plain.includes("⠙ Mull"), "status head is kept");
|
|
129
|
+
assert.ok(!plain.includes("tokens"), "status tail is cut");
|
|
130
|
+
const reset = theme.getBgAnsi() + theme.getFgAnsi("text");
|
|
131
|
+
const resetAt = bar.indexOf(reset);
|
|
132
|
+
const modelAt = bar.indexOf("Luna");
|
|
133
|
+
assert.ok(resetAt >= 0, "group background and text color are re-asserted after the truncated status");
|
|
134
|
+
assert.ok(resetAt < modelAt, "the reset lands before the model text");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("overflow drops trailing model details before touching the live status", () => {
|
|
138
|
+
const status = "⠙ Working";
|
|
139
|
+
const ctx = ctxWith(status);
|
|
140
|
+
ctx.model = { name: "GPT-5.6 Luna", id: "gpt-5.6", provider: "openai-codex", reasoning: true };
|
|
141
|
+
ctx.thinkingLevel = "max";
|
|
142
|
+
const effective = resolveEffectiveSettings({
|
|
143
|
+
transparent: true,
|
|
144
|
+
segments: { model: true, provider: true, thinking: true },
|
|
145
|
+
});
|
|
146
|
+
ctx.options = effective.segmentOptions;
|
|
147
|
+
const bar = buildStatusLine(34, ctx, effective, noGap, {
|
|
148
|
+
left: topLeftSegments(effective, true),
|
|
149
|
+
right: [],
|
|
150
|
+
});
|
|
151
|
+
const plain = stripAnsi(bar);
|
|
152
|
+
assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
|
|
153
|
+
assert.ok(plain.includes("⠙ Working"), "the spinner status survives untruncated");
|
|
154
|
+
assert.ok(!plain.includes("GPT-5.6"), "model details are dropped to protect the live status");
|
|
155
|
+
assert.equal(visibleWidth(bar), 34);
|
|
90
156
|
});
|
|
91
157
|
|
|
92
158
|
test("workingEllipsis marks truncated prose without changing the bar width", () => {
|
|
93
159
|
const status = "⠙ Context overflow detected, Auto-compacting... (esc to cancel)";
|
|
94
|
-
for (const width of [
|
|
160
|
+
for (const width of [24, 40, 60]) {
|
|
95
161
|
const bar = buildStatusLine(
|
|
96
162
|
width,
|
|
97
163
|
ctxWith(status),
|
|
98
164
|
EFFECTIVE,
|
|
99
165
|
noGap,
|
|
100
|
-
{ left:
|
|
166
|
+
{ left: topLeftSegments(EFFECTIVE, true), right: [] },
|
|
101
167
|
{ col: 0, row: 0 },
|
|
102
168
|
{ workingEllipsis: "…" },
|
|
103
169
|
);
|
|
@@ -113,7 +179,7 @@ test("a long ANSI-styled status never exceeds the bar width", () => {
|
|
|
113
179
|
const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
|
|
114
180
|
for (const width of [12, 16, 24, 40, 60]) {
|
|
115
181
|
const bar = buildStatusLine(width, ctxWith(status), EFFECTIVE, noGap, {
|
|
116
|
-
left:
|
|
182
|
+
left: topLeftSegments(EFFECTIVE, true),
|
|
117
183
|
right: [],
|
|
118
184
|
});
|
|
119
185
|
assert.equal(visibleWidth(bar), width, `width ${width}`);
|
|
@@ -122,7 +188,10 @@ test("a long ANSI-styled status never exceeds the bar width", () => {
|
|
|
122
188
|
});
|
|
123
189
|
|
|
124
190
|
test("the status drops entirely rather than the pi symbol when there is no room", () => {
|
|
125
|
-
const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, {
|
|
191
|
+
const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, {
|
|
192
|
+
left: topLeftSegments(EFFECTIVE, true),
|
|
193
|
+
right: [],
|
|
194
|
+
});
|
|
126
195
|
const plain = stripAnsi(bar);
|
|
127
196
|
assert.ok(plain.includes(theme.icon.pi));
|
|
128
197
|
assert.ok(!plain.includes("⠙"));
|
|
@@ -144,18 +213,144 @@ test("easeFade is monotonic and pinned at both ends", () => {
|
|
|
144
213
|
assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
|
|
145
214
|
});
|
|
146
215
|
|
|
147
|
-
test("leftFade recolors the
|
|
216
|
+
test("leftFade recolors the working status but not pi or model details", () => {
|
|
148
217
|
const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
|
|
149
|
-
const
|
|
218
|
+
const ctx = ctxWith(status);
|
|
219
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
220
|
+
const groups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] } = {
|
|
221
|
+
left: topLeftSegments(EFFECTIVE, true),
|
|
222
|
+
right: [],
|
|
223
|
+
};
|
|
150
224
|
const origin = { col: 0, row: 0 };
|
|
151
|
-
const build = (fade?: number) =>
|
|
152
|
-
buildStatusLine(60, ctxWith(status), EFFECTIVE, noGap, groups, origin, { leftFade: fade });
|
|
225
|
+
const build = (fade?: number) => buildStatusLine(60, ctx, EFFECTIVE, noGap, groups, origin, { leftFade: fade });
|
|
153
226
|
const solid = build();
|
|
154
227
|
const faded = build(0.2);
|
|
228
|
+
const renderedModel = SEGMENTS.model.render(ctx);
|
|
155
229
|
assert.equal(visibleWidth(faded), 60);
|
|
156
230
|
assert.equal(stripAnsi(faded), stripAnsi(solid));
|
|
157
231
|
assert.ok(!faded.includes("10;200;30"), "status color blended");
|
|
232
|
+
assert.ok(renderedModel.visible && faded.includes(renderedModel.content), "model styling untouched");
|
|
158
233
|
const piPrefix = (bar: string) => bar.slice(0, bar.indexOf(theme.icon.pi));
|
|
159
234
|
assert.equal(piPrefix(faded), piPrefix(solid), "pi symbol styling untouched");
|
|
160
235
|
assert.equal(build(1), solid);
|
|
161
236
|
});
|
|
237
|
+
|
|
238
|
+
test("leftFade does not recolor the right group", () => {
|
|
239
|
+
const ctx = ctxWith("⠙ Working");
|
|
240
|
+
ctx.sessionName = "stable-session";
|
|
241
|
+
const renderedSession = SEGMENTS.session_name.render(ctx);
|
|
242
|
+
const bar = buildStatusLine(
|
|
243
|
+
80,
|
|
244
|
+
ctx,
|
|
245
|
+
EFFECTIVE,
|
|
246
|
+
noGap,
|
|
247
|
+
{ left: topLeftSegments(EFFECTIVE, true), right: ["session_name"] },
|
|
248
|
+
{ col: 0, row: 0 },
|
|
249
|
+
{ leftFade: 0.2 },
|
|
250
|
+
);
|
|
251
|
+
assert.ok(renderedSession.visible && bar.includes(renderedSession.content), "right group styling untouched");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/** A top bar mid-slide, with the status revealed by `reveal` and Luna as the model. */
|
|
255
|
+
function slidingBar(
|
|
256
|
+
status: string,
|
|
257
|
+
reveal: number | undefined,
|
|
258
|
+
{ width = 60, effective = EFFECTIVE, leftFade }: {
|
|
259
|
+
width?: number;
|
|
260
|
+
effective?: ReturnType<typeof resolveEffectiveSettings>;
|
|
261
|
+
leftFade?: number;
|
|
262
|
+
} = {},
|
|
263
|
+
): string {
|
|
264
|
+
const ctx = ctxWith(status);
|
|
265
|
+
ctx.options = effective.segmentOptions;
|
|
266
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
267
|
+
ctx.sessionName = "stable-session";
|
|
268
|
+
return buildStatusLine(
|
|
269
|
+
width,
|
|
270
|
+
ctx,
|
|
271
|
+
effective,
|
|
272
|
+
noGap,
|
|
273
|
+
{ left: topLeftSegments(effective, true), right: ["session_name"] },
|
|
274
|
+
{ col: 0, row: 0 },
|
|
275
|
+
{ leftFade, workingReveal: reveal },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
test("workingReveal slides the status out behind the pi symbol, pushing model details right", () => {
|
|
280
|
+
const settled = slidingBar("⠙ Working...", undefined);
|
|
281
|
+
const piAt = stripAnsi(settled).indexOf(theme.icon.pi);
|
|
282
|
+
assert.equal(slidingBar("⠙ Working...", 1), settled);
|
|
283
|
+
assert.ok(!stripAnsi(slidingBar("⠙ Working...", 0)).includes("⠙"), "nothing has emerged at the start");
|
|
284
|
+
|
|
285
|
+
let modelAt = -1;
|
|
286
|
+
for (let step = 0; step <= 30; step++) {
|
|
287
|
+
const bar = slidingBar("⠙ Working...", step / 30);
|
|
288
|
+
const plain = stripAnsi(bar);
|
|
289
|
+
assert.equal(visibleWidth(bar), 60, `width at step ${step}`);
|
|
290
|
+
assert.equal(plain.indexOf(theme.icon.pi), piAt, `pi symbol stays put at step ${step}`);
|
|
291
|
+
const at = plain.indexOf("Luna");
|
|
292
|
+
assert.ok(at >= modelAt, `model details never move left at step ${step}`);
|
|
293
|
+
modelAt = at;
|
|
294
|
+
}
|
|
295
|
+
assert.equal(modelAt, stripAnsi(settled).indexOf("Luna"), "model details end where they settle");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("the status's tail emerges first, led by its trailing separator", () => {
|
|
299
|
+
const status = "⠙ Working...";
|
|
300
|
+
const sep = getSeparator(EFFECTIVE.separator).left;
|
|
301
|
+
const unitWidth = visibleWidth(status) + visibleWidth(sep) + 2;
|
|
302
|
+
const frame = (shown: number) => stripAnsi(slidingBar(status, shown / unitWidth));
|
|
303
|
+
const model = `${theme.icon.model} Luna`;
|
|
304
|
+
assert.ok(frame(2).includes(`${theme.icon.pi} ${sep} ${sep} ${model}`), "separator first");
|
|
305
|
+
assert.ok(frame(7).includes(`${theme.icon.pi} ${sep} g... ${sep} ${model}`), "then the status's tail");
|
|
306
|
+
assert.ok(!frame(7).includes("Workin"), "the head is still hidden");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("without model details the status slides out alone after the pi separator", () => {
|
|
310
|
+
const effective = resolveEffectiveSettings({
|
|
311
|
+
transparent: true,
|
|
312
|
+
segments: { model: false, provider: false, thinking: false },
|
|
313
|
+
});
|
|
314
|
+
const sep = getSeparator(effective.separator).left;
|
|
315
|
+
assert.ok(!stripAnsi(slidingBar("⠙ Working...", 0, { effective })).includes(sep), "no separator yet");
|
|
316
|
+
const plain = stripAnsi(slidingBar("⠙ Working...", 4 / 12, { effective }));
|
|
317
|
+
assert.ok(plain.includes(`${theme.icon.pi} ${sep} g...`), "tail follows the pi separator");
|
|
318
|
+
assert.ok(!plain.includes("Workin"), "the head is still hidden");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("the slide keeps the overflow decided for the fully revealed status", () => {
|
|
322
|
+
// At 40 cells the settled status leaves no room for the session name, even
|
|
323
|
+
// though a partly revealed one would.
|
|
324
|
+
assert.ok(!stripAnsi(slidingBar("⠙ Working...", undefined, { width: 40 })).includes("stable-session"));
|
|
325
|
+
for (let step = 0; step <= 20; step++) {
|
|
326
|
+
const bar = slidingBar("⠙ Working...", step / 20, { width: 40 });
|
|
327
|
+
assert.ok(!stripAnsi(bar).includes("stable-session"), `right group stays dropped at step ${step}`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("sliding frames keep the bar width with opaque caps, wide glyphs, and truncation", () => {
|
|
332
|
+
const opaque = resolveEffectiveSettings({ transparent: false });
|
|
333
|
+
const statuses = ["⠙ Working...", "🌑 Working", `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(10)}\x1b[39m`];
|
|
334
|
+
for (const effective of [EFFECTIVE, opaque]) {
|
|
335
|
+
for (const status of statuses) {
|
|
336
|
+
for (const width of [24, 60]) {
|
|
337
|
+
for (let step = 0; step <= 20; step++) {
|
|
338
|
+
const bar = slidingBar(status, step / 20, { width, effective });
|
|
339
|
+
assert.equal(visibleWidth(bar), width, `${JSON.stringify(status)} at width ${width}, step ${step}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("a status fading mid-slide blends its emerged tail but not model details", () => {
|
|
347
|
+
const status = "\x1b[38;2;10;200;30m⠙ Working...\x1b[39m";
|
|
348
|
+
const bar = slidingBar(status, 0.5, { leftFade: 0.2 });
|
|
349
|
+
const ctx = ctxWith(status);
|
|
350
|
+
ctx.model = { name: "Luna", id: "luna", provider: "openai-codex", reasoning: false };
|
|
351
|
+
const renderedModel = SEGMENTS.model.render(ctx);
|
|
352
|
+
assert.ok(stripAnsi(bar).includes("..."), "part of the status has emerged");
|
|
353
|
+
assert.ok(!bar.includes("10;200;30"), "emerged status blended");
|
|
354
|
+
assert.ok(renderedModel.visible && bar.includes(renderedModel.content), "model styling untouched");
|
|
355
|
+
assert.equal(visibleWidth(bar), 60);
|
|
356
|
+
});
|
package/src/token-rate.ts
CHANGED
|
@@ -29,7 +29,7 @@ const EMA_ALPHA = 0.4;
|
|
|
29
29
|
class StreamingWordCounter {
|
|
30
30
|
#inWordByStream = new Map<string, boolean>();
|
|
31
31
|
|
|
32
|
-
count(text: string, stream
|
|
32
|
+
count(text: string, stream: string): number {
|
|
33
33
|
let inWord = this.#inWordByStream.get(stream) ?? false;
|
|
34
34
|
let count = 0;
|
|
35
35
|
for (let i = 0; i < text.length; i++) {
|
package/src/types.ts
CHANGED
|
@@ -19,7 +19,9 @@ export type StatusLineSegmentId =
|
|
|
19
19
|
| "feeds"
|
|
20
20
|
| "context_graph"
|
|
21
21
|
| "scroll_hint"
|
|
22
|
-
| "working"
|
|
22
|
+
| "working"
|
|
23
|
+
| "compaction_info"
|
|
24
|
+
| "compaction_graph";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* A feed the statusline subscribes to: custom session entries published by
|
|
@@ -80,6 +82,8 @@ export interface StatusLineSettings {
|
|
|
80
82
|
nvidiaGreenAnimation?: boolean;
|
|
81
83
|
/** Render pi's status spinners in the top bar instead of their own row. */
|
|
82
84
|
embedWorkingStatus?: boolean;
|
|
85
|
+
/** Host pi-topping-compact's compaction progress in the bottom bar while a compaction runs. */
|
|
86
|
+
embedCompactionProgress?: boolean;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
/** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
|
|
@@ -115,6 +119,7 @@ export interface EffectiveStatusLineSettings {
|
|
|
115
119
|
nvidiaGreenBorder: boolean;
|
|
116
120
|
nvidiaGreenAnimation: boolean;
|
|
117
121
|
embedWorkingStatus: boolean;
|
|
122
|
+
embedCompactionProgress: boolean;
|
|
118
123
|
includes: SegmentIncludes;
|
|
119
124
|
}
|
|
120
125
|
|
|
@@ -128,10 +133,15 @@ export interface SegmentContext {
|
|
|
128
133
|
thinkingLevel: string;
|
|
129
134
|
cwd: string;
|
|
130
135
|
sessionName: string | undefined;
|
|
131
|
-
/** Context usage percent, or null when unknown (
|
|
136
|
+
/** Context usage percent, or null when unknown (no model, or a session pi cannot size). */
|
|
132
137
|
contextPercent: number | null;
|
|
133
138
|
contextTokens: number;
|
|
134
139
|
contextWindow: number;
|
|
140
|
+
/**
|
|
141
|
+
* True while contextPercent and contextTokens are pi's estimate of a freshly
|
|
142
|
+
* compacted context, shown until pi measures the next response.
|
|
143
|
+
*/
|
|
144
|
+
contextEstimated?: boolean;
|
|
135
145
|
git: {
|
|
136
146
|
branch: string | null;
|
|
137
147
|
status: { staged: number; unstaged: number; untracked: number } | null;
|
|
@@ -148,6 +158,11 @@ export interface SegmentContext {
|
|
|
148
158
|
* this frame. Absent while no status indicator is active.
|
|
149
159
|
*/
|
|
150
160
|
workingStatus?: string;
|
|
161
|
+
/**
|
|
162
|
+
* pi-topping-compact's live compaction progress, while its bar and figures
|
|
163
|
+
* stand in for the context graph and pi's stats in the bottom-right group.
|
|
164
|
+
*/
|
|
165
|
+
compaction?: CompactionProgressView;
|
|
151
166
|
/** Live tok/s display state, when this frame needs it. */
|
|
152
167
|
tokenRate: TokenRateDisplay | undefined;
|
|
153
168
|
/**
|
|
@@ -164,6 +179,17 @@ export interface SegmentContext {
|
|
|
164
179
|
feedDisplayState?: Record<string, FeedDisplayState>;
|
|
165
180
|
}
|
|
166
181
|
|
|
182
|
+
/** One broadcast from pi-topping-compact: its bar, pre-rendered, plus the snapshot it was drawn from. */
|
|
183
|
+
export interface CompactionProgressView {
|
|
184
|
+
/** The 20-cell bar, pre-rendered and pre-colored by pi-topping-compact with fg-only resets. */
|
|
185
|
+
bar: string;
|
|
186
|
+
/** How much of the context has been summarized so far, 0..100. */
|
|
187
|
+
percent: number;
|
|
188
|
+
tokensBefore: number;
|
|
189
|
+
contextWindow: number;
|
|
190
|
+
elapsedMs: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
167
193
|
export type FeedPhase = "active" | "fading" | "hidden";
|
|
168
194
|
|
|
169
195
|
export interface FeedDisplayState {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Event-bus channel on which this extension announces, as `{ embedded: boolean }`,
|
|
5
|
+
* whether its bar hosts Pi's working status. pi-topping slides its loader's response
|
|
6
|
+
* model out only while it does. Shared with pi-topping.
|
|
7
|
+
*/
|
|
8
|
+
export const WORKING_STATUS_EMBED_CHANNEL = "pi-topping-statusline:working-status-embedded";
|
|
9
|
+
|
|
10
|
+
type EmbeddingEditor = Partial<Pick<CustomEditor, "embedWorkingStatus" | "setWorkingStatusIndicator">>;
|
|
11
|
+
|
|
12
|
+
/** Whether an editor this extension built draws the host's working status in its bar. */
|
|
13
|
+
export function hostsWorkingStatus(editor: EmbeddingEditor | undefined): boolean {
|
|
14
|
+
return editor?.embedWorkingStatus === true && typeof editor.setWorkingStatusIndicator === "function";
|
|
15
|
+
}
|