@underactive/pi-topping-statusline 0.2.1 → 0.2.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/README.md +39 -9
- package/index.ts +146 -39
- package/package.json +1 -1
- 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
package/src/layout.ts
CHANGED
|
@@ -12,13 +12,18 @@
|
|
|
12
12
|
* column and row in the box, so a caller can paint it as part of a
|
|
13
13
|
* continuous border gradient (the rainbow effect).
|
|
14
14
|
*
|
|
15
|
-
* `options.leftFade` (0..1) blends
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* `options.leftFade` (0..1) blends transient left segments toward the bar
|
|
16
|
+
* background while keeping the Pi symbol and model details solid, which the
|
|
17
|
+
* working-indicator transition uses without hiding stable model information.
|
|
18
|
+
* `options.workingReveal` (0..1) slides the `working` status out from behind
|
|
19
|
+
* the segment before it, trailing separator first, pushing the segments after
|
|
20
|
+
* it right. Overflow is resolved for the fully revealed status, so no segment
|
|
21
|
+
* appears or drops mid-slide and the last frame matches the settled bar.
|
|
18
22
|
*/
|
|
19
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
23
|
+
import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
20
24
|
import { renderSegment } from "./segments.js";
|
|
21
25
|
import { getSeparator } from "./separators.js";
|
|
26
|
+
import { STATUS_STABLE_SEGMENTS } from "./settings.js";
|
|
22
27
|
import { theme } from "./theme.js";
|
|
23
28
|
import type { EffectiveStatusLineSettings, SegmentContext, StatusLineSegmentId } from "./types.js";
|
|
24
29
|
|
|
@@ -65,7 +70,13 @@ export function buildStatusLine(
|
|
|
65
70
|
gapBorderColor: (str: string, startCol: number, row: number) => string,
|
|
66
71
|
segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
|
|
67
72
|
barOrigin: { col: number; row: number } = { col: 0, row: 0 },
|
|
68
|
-
options: {
|
|
73
|
+
options: {
|
|
74
|
+
leftFade?: number;
|
|
75
|
+
workingReveal?: number;
|
|
76
|
+
workingEllipsis?: string;
|
|
77
|
+
/** Called once overflow trimming settles, with the segment ids that actually made it into each rendered group. */
|
|
78
|
+
onFit?: (fit: { left: readonly StatusLineSegmentId[]; right: readonly StatusLineSegmentId[] }) => void;
|
|
79
|
+
} = {},
|
|
69
80
|
): string {
|
|
70
81
|
const separatorDef = getSeparator(settings.separator);
|
|
71
82
|
|
|
@@ -73,6 +84,8 @@ export function buildStatusLine(
|
|
|
73
84
|
const transparentBg = bgAnsi === TRANSPARENT_BG_ANSI;
|
|
74
85
|
const fgAnsi = theme.getFgAnsi("text");
|
|
75
86
|
const sepAnsi = theme.getFgAnsi("statusLineSep");
|
|
87
|
+
const leftSepText = ` ${sepAnsi}${separatorDef.left}${fgAnsi} `;
|
|
88
|
+
const rightSepText = ` ${sepAnsi}${separatorDef.right}${fgAnsi} `;
|
|
76
89
|
|
|
77
90
|
const left: string[] = [];
|
|
78
91
|
const leftSegIds: StatusLineSegmentId[] = [];
|
|
@@ -85,10 +98,12 @@ export function buildStatusLine(
|
|
|
85
98
|
}
|
|
86
99
|
|
|
87
100
|
const right: string[] = [];
|
|
101
|
+
const rightSegIds: StatusLineSegmentId[] = [];
|
|
88
102
|
for (const segId of segmentGroups.right) {
|
|
89
103
|
const rendered = renderSegment(segId, ctx);
|
|
90
104
|
if (rendered.visible && rendered.content) {
|
|
91
105
|
right.push(rendered.content);
|
|
106
|
+
rightSegIds.push(segId);
|
|
92
107
|
}
|
|
93
108
|
}
|
|
94
109
|
|
|
@@ -105,7 +120,7 @@ export function buildStatusLine(
|
|
|
105
120
|
const groupWidth = (parts: string[], sepWidth: number): number => {
|
|
106
121
|
if (parts.length === 0) return 0;
|
|
107
122
|
const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
|
|
108
|
-
const sepTotal =
|
|
123
|
+
const sepTotal = (parts.length - 1) * (sepWidth + 2);
|
|
109
124
|
return partsWidth + sepTotal + 2 + capWidth;
|
|
110
125
|
};
|
|
111
126
|
|
|
@@ -118,15 +133,27 @@ export function buildStatusLine(
|
|
|
118
133
|
if (width > 0) {
|
|
119
134
|
while (totalWidth() > width && right.length > 0) {
|
|
120
135
|
right.pop();
|
|
136
|
+
rightSegIds.pop();
|
|
121
137
|
rightWidth = rightGroupWidth(right);
|
|
122
138
|
}
|
|
123
139
|
// The working status absorbs overflow first so the Pi symbol beside it
|
|
124
|
-
// survives on narrow terminals.
|
|
140
|
+
// survives on narrow terminals. Segments trailing it are dropped first so
|
|
141
|
+
// a static segment (e.g. model) never outlives the live status indicator.
|
|
125
142
|
const workingIdx = leftSegIds.indexOf("working");
|
|
143
|
+
while (
|
|
144
|
+
workingIdx >= 0 &&
|
|
145
|
+
workingIdx < left.length - 1 &&
|
|
146
|
+
visibleWidth(left[workingIdx]) - (totalWidth() - width) < 1
|
|
147
|
+
) {
|
|
148
|
+
left.pop();
|
|
149
|
+
leftSegIds.pop();
|
|
150
|
+
leftWidth = leftGroupWidth(left);
|
|
151
|
+
}
|
|
126
152
|
if (workingIdx >= 0 && totalWidth() > width) {
|
|
127
153
|
const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
|
|
128
154
|
if (available >= 1) {
|
|
129
|
-
left[workingIdx] =
|
|
155
|
+
left[workingIdx] =
|
|
156
|
+
truncateToWidth(left[workingIdx], available, options.workingEllipsis ?? "") + bgAnsi + fgAnsi;
|
|
130
157
|
} else {
|
|
131
158
|
left.splice(workingIdx, 1);
|
|
132
159
|
leftSegIds.splice(workingIdx, 1);
|
|
@@ -158,25 +185,49 @@ export function buildStatusLine(
|
|
|
158
185
|
}
|
|
159
186
|
}
|
|
160
187
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
188
|
+
// Trimming above only drops or truncates segments, never adds them back, so
|
|
189
|
+
// the surviving ids are settled here regardless of width.
|
|
190
|
+
options.onFit?.({ left: leftSegIds, right: rightSegIds });
|
|
191
|
+
|
|
192
|
+
const fade = options.leftFade;
|
|
193
|
+
if (fade !== undefined && fade < 1) {
|
|
194
|
+
for (const [i, id] of leftSegIds.entries()) {
|
|
195
|
+
if (STATUS_STABLE_SEGMENTS.has(id)) continue;
|
|
196
|
+
// Reset first so plain text is recolored too, not just segments that
|
|
197
|
+
// set their own foreground color.
|
|
198
|
+
left[i] = theme.fadeAnsi(`\x1b[39m${left[i]}`, fade);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const reveal = options.workingReveal;
|
|
203
|
+
const slideIdx = leftSegIds.indexOf("working");
|
|
204
|
+
if (reveal !== undefined && reveal < 1 && slideIdx >= 0) {
|
|
205
|
+
// Only the status's tail has emerged. Ahead of another segment, the tail
|
|
206
|
+
// includes the separator between them and is joined onto that segment,
|
|
207
|
+
// which then moves right one cell per revealed cell.
|
|
208
|
+
const pushes = slideIdx < left.length - 1;
|
|
209
|
+
const unit = pushes ? left[slideIdx] + leftSepText : left[slideIdx];
|
|
210
|
+
const unitWidth = visibleWidth(unit);
|
|
211
|
+
const shown = Math.round(unitWidth * reveal);
|
|
212
|
+
if (shown <= 0) {
|
|
213
|
+
left.splice(slideIdx, 1);
|
|
214
|
+
leftSegIds.splice(slideIdx, 1);
|
|
215
|
+
} else if (pushes) {
|
|
216
|
+
left.splice(slideIdx, 2, sliceByColumn(unit, unitWidth - shown, shown) + left[slideIdx + 1]);
|
|
217
|
+
leftSegIds.splice(slideIdx, 1);
|
|
175
218
|
} else {
|
|
176
|
-
|
|
219
|
+
left[slideIdx] = sliceByColumn(unit, unitWidth - shown, shown);
|
|
177
220
|
}
|
|
221
|
+
leftWidth = leftGroupWidth(left);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const renderGroup = (parts: string[], direction: "left" | "right"): string => {
|
|
225
|
+
if (parts.length === 0) return "";
|
|
226
|
+
const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
|
|
227
|
+
const sepText = direction === "left" ? leftSepText : rightSepText;
|
|
228
|
+
|
|
178
229
|
let content = bgAnsi + fgAnsi;
|
|
179
|
-
content += ` ${
|
|
230
|
+
content += ` ${parts.join(sepText)} `;
|
|
180
231
|
content += "\x1b[0m";
|
|
181
232
|
|
|
182
233
|
if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
|
|
@@ -184,7 +235,7 @@ export function buildStatusLine(
|
|
|
184
235
|
return content;
|
|
185
236
|
};
|
|
186
237
|
|
|
187
|
-
const leftGroup = renderGroup(left, "left"
|
|
238
|
+
const leftGroup = renderGroup(left, "left");
|
|
188
239
|
const rightGroup = renderGroup(right, "right");
|
|
189
240
|
if (!leftGroup && !rightGroup) return "";
|
|
190
241
|
|
|
@@ -198,7 +249,5 @@ export function buildStatusLine(
|
|
|
198
249
|
barOrigin.col + leftWidth,
|
|
199
250
|
barOrigin.row,
|
|
200
251
|
);
|
|
201
|
-
if (!leftGroup) return gapFill + rightGroup;
|
|
202
|
-
if (!rightGroup) return leftGroup + gapFill;
|
|
203
252
|
return leftGroup + gapFill + rightGroup;
|
|
204
253
|
}
|
package/src/menu.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* ║ ╭── π ⬢ model ───────────────────── session ──╮ ║
|
|
17
17
|
* ║ ║
|
|
18
18
|
* ╟─ Global ─────────────────────────────────────────╢
|
|
19
|
-
* ║
|
|
19
|
+
* ║ > [■] Transparent Segments ON ║
|
|
20
20
|
* ║ Separator ‹ powerline-thin › ║
|
|
21
21
|
* ║ ║
|
|
22
22
|
* ╟──────────────────────────────────────────────────╢
|
|
@@ -118,11 +118,6 @@ const PLAIN_ROW_PREFIX_LEN = 5;
|
|
|
118
118
|
const CHECKBOX_ROW_PREFIX_LEN = 8;
|
|
119
119
|
|
|
120
120
|
interface FlatItem {
|
|
121
|
-
id: string;
|
|
122
|
-
label: string;
|
|
123
|
-
cycleValues?: readonly string[];
|
|
124
|
-
cycleEnabledBy?: string;
|
|
125
|
-
cycleDisabledValue?: string;
|
|
126
121
|
item: MenuItem;
|
|
127
122
|
sectionIndex: number;
|
|
128
123
|
}
|
|
@@ -131,15 +126,7 @@ function flattenSections(sections: MenuSection[]): FlatItem[] {
|
|
|
131
126
|
const flat: FlatItem[] = [];
|
|
132
127
|
for (const [sectionIndex, section] of sections.entries()) {
|
|
133
128
|
for (const item of section.items) {
|
|
134
|
-
flat.push({
|
|
135
|
-
id: item.id,
|
|
136
|
-
label: item.label,
|
|
137
|
-
cycleValues: item.cycleValues,
|
|
138
|
-
cycleEnabledBy: item.cycleEnabledBy,
|
|
139
|
-
cycleDisabledValue: item.cycleDisabledValue,
|
|
140
|
-
item,
|
|
141
|
-
sectionIndex,
|
|
142
|
-
});
|
|
129
|
+
flat.push({ item, sectionIndex });
|
|
143
130
|
}
|
|
144
131
|
}
|
|
145
132
|
return flat;
|
|
@@ -260,26 +247,26 @@ export class MenuComponent implements Component {
|
|
|
260
247
|
[Key.space]: () => {
|
|
261
248
|
const item = this.flat[this.cursor]!;
|
|
262
249
|
if (item.item.action) {
|
|
263
|
-
this.runAction(item.id);
|
|
250
|
+
this.runAction(item.item.id);
|
|
264
251
|
return;
|
|
265
252
|
}
|
|
266
253
|
if (item.item.text) {
|
|
267
254
|
this.beginEditing(item);
|
|
268
255
|
return;
|
|
269
256
|
}
|
|
270
|
-
if (item.cycleValues && item.cycleEnabledBy) {
|
|
271
|
-
this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
|
|
272
|
-
if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) {
|
|
273
|
-
this.values[item.id] = item.cycleDisabledValue;
|
|
257
|
+
if (item.item.cycleValues && item.item.cycleEnabledBy) {
|
|
258
|
+
this.values[item.item.cycleEnabledBy] = !this.values[item.item.cycleEnabledBy] as boolean;
|
|
259
|
+
if (!this.values[item.item.cycleEnabledBy] && item.item.cycleDisabledValue !== undefined) {
|
|
260
|
+
this.values[item.item.id] = item.item.cycleDisabledValue;
|
|
274
261
|
}
|
|
275
|
-
} else if (!item.cycleValues) this.values[item.id] = !this.values[item.id] as boolean;
|
|
262
|
+
} else if (!item.item.cycleValues) this.values[item.item.id] = !this.values[item.item.id] as boolean;
|
|
276
263
|
this.invalidate();
|
|
277
264
|
},
|
|
278
265
|
// Enter opens an editor or fires an action on those rows, so applying
|
|
279
266
|
// the whole menu stays available from any other row.
|
|
280
267
|
[Key.enter]: () => {
|
|
281
268
|
const item = this.flat[this.cursor]!;
|
|
282
|
-
if (item.item.action) this.runAction(item.id);
|
|
269
|
+
if (item.item.action) this.runAction(item.item.id);
|
|
283
270
|
else if (item.item.text) this.beginEditing(item);
|
|
284
271
|
else this.done({ applied: true, values: { ...this.values } });
|
|
285
272
|
},
|
|
@@ -297,7 +284,7 @@ export class MenuComponent implements Component {
|
|
|
297
284
|
}
|
|
298
285
|
|
|
299
286
|
private beginEditing(item: FlatItem): void {
|
|
300
|
-
this.editing = { id: item.id, buffer: String(this.values[item.id] ?? "") };
|
|
287
|
+
this.editing = { id: item.item.id, buffer: String(this.values[item.item.id] ?? "") };
|
|
301
288
|
this.invalidate();
|
|
302
289
|
}
|
|
303
290
|
|
|
@@ -373,10 +360,10 @@ export class MenuComponent implements Component {
|
|
|
373
360
|
|
|
374
361
|
private cycleCurrentValue(delta: number): void {
|
|
375
362
|
const item = this.flat[this.cursor]!;
|
|
376
|
-
if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
|
|
377
|
-
const current = item.cycleValues.indexOf(this.values[item.id] as string);
|
|
378
|
-
const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
|
|
379
|
-
this.values[item.id] = item.cycleValues[index]!;
|
|
363
|
+
if (!item.item.cycleValues?.length || (item.item.cycleEnabledBy && !this.values[item.item.cycleEnabledBy])) return;
|
|
364
|
+
const current = item.item.cycleValues.indexOf(this.values[item.item.id] as string);
|
|
365
|
+
const index = (current + delta + item.item.cycleValues.length) % item.item.cycleValues.length;
|
|
366
|
+
this.values[item.item.id] = item.item.cycleValues[index]!;
|
|
380
367
|
this.invalidate();
|
|
381
368
|
}
|
|
382
369
|
|
|
@@ -386,7 +373,7 @@ export class MenuComponent implements Component {
|
|
|
386
373
|
const result = this.previewFn(
|
|
387
374
|
this.values,
|
|
388
375
|
this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
|
|
389
|
-
this.flat[this.cursor]?.id,
|
|
376
|
+
this.flat[this.cursor]?.item.id,
|
|
390
377
|
innerWidth,
|
|
391
378
|
);
|
|
392
379
|
if (Array.isArray(result)) {
|
|
@@ -500,8 +487,8 @@ export class MenuComponent implements Component {
|
|
|
500
487
|
}
|
|
501
488
|
|
|
502
489
|
private availableRows(): number | undefined {
|
|
503
|
-
const rows =
|
|
504
|
-
return
|
|
490
|
+
const rows = this.tui?.terminal.rows;
|
|
491
|
+
return rows !== undefined && rows > 0 ? rows : undefined;
|
|
505
492
|
}
|
|
506
493
|
|
|
507
494
|
private buildLines(maxWidth: number, maxRows?: number): string[] {
|
|
@@ -597,15 +584,17 @@ export class MenuComponent implements Component {
|
|
|
597
584
|
private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
|
|
598
585
|
const th = this.theme;
|
|
599
586
|
const value = this.values[item.id]!;
|
|
600
|
-
const marker = selected ? "
|
|
587
|
+
const marker = selected ? ">" : " ";
|
|
601
588
|
const markerColored = selected ? th.fg("accent", marker) : marker;
|
|
589
|
+
const rowBackground = (content: string) =>
|
|
590
|
+
selected ? th.bg("selectedBg", content.replaceAll("\x1b[0m", `\x1b[0m${th.getBgAnsi("selectedBg")}`)) : content;
|
|
602
591
|
|
|
603
592
|
if (item.action) {
|
|
604
593
|
const label = truncateToWidth(item.label, Math.max(0, innerWidth - 5));
|
|
605
594
|
const leftPlain = ` ${marker} ${label}`;
|
|
606
595
|
const pad = Math.max(0, innerWidth - visibleWidth(leftPlain));
|
|
607
596
|
const content = ` ${markerColored} ${th.fg(selected ? "accent" : "muted", label)}${" ".repeat(pad)}`;
|
|
608
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
597
|
+
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
609
598
|
}
|
|
610
599
|
|
|
611
600
|
if (item.text) {
|
|
@@ -626,7 +615,7 @@ export class MenuComponent implements Component {
|
|
|
626
615
|
const leftPlain = ` ${marker} ${label} ${fieldText}${caret}`;
|
|
627
616
|
const pad = Math.max(0, innerWidth - visibleWidth(leftPlain) - 1);
|
|
628
617
|
const content = ` ${markerColored} ${th.fg("dim", label)} ${fieldStyled}${editing ? th.fg("accent", caret) : ""}${" ".repeat(pad)} `;
|
|
629
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
618
|
+
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
630
619
|
}
|
|
631
620
|
|
|
632
621
|
if (item.cycleValues) {
|
|
@@ -638,7 +627,7 @@ export class MenuComponent implements Component {
|
|
|
638
627
|
const leftPlain = ` ${marker} ${label}`;
|
|
639
628
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
|
|
640
629
|
const content = ` ${markerColored} ${th.fg("text", label)}${" ".repeat(gap)}${th.fg("accent", stateWord)} `;
|
|
641
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
630
|
+
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
642
631
|
}
|
|
643
632
|
const enabled = this.values[item.cycleEnabledBy] as boolean;
|
|
644
633
|
const maxLabelLen = Math.max(0, innerWidth - CHECKBOX_ROW_PREFIX_LEN - visibleWidth(stateWord) - 1);
|
|
@@ -646,7 +635,7 @@ export class MenuComponent implements Component {
|
|
|
646
635
|
const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
|
|
647
636
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
|
|
648
637
|
const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
|
|
649
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
638
|
+
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
650
639
|
}
|
|
651
640
|
|
|
652
641
|
const enabled = value as boolean;
|
|
@@ -658,7 +647,7 @@ export class MenuComponent implements Component {
|
|
|
658
647
|
const leftPlain = ` ${marker} [${box}] ${label}`;
|
|
659
648
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
660
649
|
const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `;
|
|
661
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
650
|
+
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
662
651
|
}
|
|
663
652
|
}
|
|
664
653
|
|
package/src/segments.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { MAJOR_COLOR_HEXES, hexToFgAnsi, theme } from "./theme.js";
|
|
|
14
14
|
import {
|
|
15
15
|
clampPathLength,
|
|
16
16
|
feedKey,
|
|
17
|
+
formatNumber,
|
|
17
18
|
getSessionAccentHex,
|
|
18
19
|
sanitizeLabel,
|
|
19
20
|
sanitizeStatusText,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
withIcon,
|
|
22
23
|
} from "./utils.js";
|
|
23
24
|
import type {
|
|
25
|
+
CompactionProgressView,
|
|
24
26
|
FeedFormat,
|
|
25
27
|
RenderedSegment,
|
|
26
28
|
SegmentContext,
|
|
@@ -344,12 +346,62 @@ const contextGraphSegment: StatusLineSegment = {
|
|
|
344
346
|
labelColor: theme.getFgAnsi("dim"),
|
|
345
347
|
showBar,
|
|
346
348
|
showStats,
|
|
349
|
+
estimated: ctx.contextEstimated === true,
|
|
347
350
|
}),
|
|
348
351
|
visible: true,
|
|
349
352
|
};
|
|
350
353
|
},
|
|
351
354
|
};
|
|
352
355
|
|
|
356
|
+
const clampPercent = (percent: number): number => Math.round(Math.min(100, Math.max(0, percent)));
|
|
357
|
+
|
|
358
|
+
/** "57% summarized · 16.3s": how much of the context has been summarized so far, and the time taken. */
|
|
359
|
+
export function formatCompactionInfo(view: CompactionProgressView): string {
|
|
360
|
+
const elapsed = `${(Math.max(0, view.elapsedMs) / 1000).toFixed(1)}s`;
|
|
361
|
+
return `${clampPercent(view.percent)}% summarized${theme.sep.dot}${elapsed}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* "86K/131K (66%)": the tokens being compacted over the window, and as a share
|
|
366
|
+
* of it, in the context label's number style. The share is omitted when the
|
|
367
|
+
* window is unknown.
|
|
368
|
+
*/
|
|
369
|
+
export function formatCompactionTokens(view: CompactionProgressView): string {
|
|
370
|
+
const before = formatNumber(Math.max(0, view.tokensBefore));
|
|
371
|
+
if (view.contextWindow <= 0) return `${before}/?`;
|
|
372
|
+
const usage = clampPercent((view.tokensBefore / view.contextWindow) * 100);
|
|
373
|
+
return `${before}/${formatNumber(view.contextWindow)} (${usage}%)`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** pi-topping-compact's figures, standing in for pi's stats while its progress is hosted. */
|
|
377
|
+
const compactionInfoSegment: StatusLineSegment = {
|
|
378
|
+
id: "compaction_info",
|
|
379
|
+
render(ctx) {
|
|
380
|
+
const view = ctx.compaction;
|
|
381
|
+
if (!view) return INVISIBLE;
|
|
382
|
+
return { content: theme.fg("dim", formatCompactionInfo(view)), visible: true };
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* pi-topping-compact's bar, standing in for the context graph while its progress
|
|
388
|
+
* is hosted, under the graph's own bar and stats toggles. The bar arrives
|
|
389
|
+
* pre-rendered with fg-only resets, so it is passed through verbatim.
|
|
390
|
+
*/
|
|
391
|
+
const compactionGraphSegment: StatusLineSegment = {
|
|
392
|
+
id: "compaction_graph",
|
|
393
|
+
render(ctx) {
|
|
394
|
+
const view = ctx.compaction;
|
|
395
|
+
if (!view) return INVISIBLE;
|
|
396
|
+
const { showBar, showStats } = ctx.options.context;
|
|
397
|
+
const parts: string[] = [];
|
|
398
|
+
if (showBar) parts.push(view.bar);
|
|
399
|
+
if (showStats) parts.push(theme.fg("dim", formatCompactionTokens(view)));
|
|
400
|
+
if (parts.length === 0) return INVISIBLE;
|
|
401
|
+
return { content: parts.join(" "), visible: true };
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
353
405
|
const accentAnsi = new Map<string, string>();
|
|
354
406
|
|
|
355
407
|
const sessionNameSegment: StatusLineSegment = {
|
|
@@ -384,6 +436,8 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
384
436
|
context_graph: contextGraphSegment,
|
|
385
437
|
scroll_hint: scrollHintSegment,
|
|
386
438
|
working: workingSegment,
|
|
439
|
+
compaction_info: compactionInfoSegment,
|
|
440
|
+
compaction_graph: compactionGraphSegment,
|
|
387
441
|
};
|
|
388
442
|
|
|
389
443
|
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
package/src/settings-menu.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { makeBoxPainters, renderBoxRow } from "./box.js";
|
|
8
|
+
import { COMPACTION_FIT_PROBE } from "./compaction-embed.js";
|
|
8
9
|
import type { SegmentContextBuilder } from "./context.js";
|
|
9
10
|
import { buildStatusLine } from "./layout.js";
|
|
10
11
|
import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
|
|
@@ -13,6 +14,7 @@ import { RAINBOW_CYCLE_MS, RAINBOW_FRAME_MS, RainbowBorder } from "./rainbow.js"
|
|
|
13
14
|
import {
|
|
14
15
|
applySettings,
|
|
15
16
|
BORDER_STYLES,
|
|
17
|
+
bottomRightSegments,
|
|
16
18
|
DEFAULT_FEEDS,
|
|
17
19
|
DEFAULT_SEGMENTS,
|
|
18
20
|
FEED_FORMATS,
|
|
@@ -71,48 +73,64 @@ const SEGMENT_ROWS: readonly {
|
|
|
71
73
|
{ id: "tokenRateBottomLeft", label: "Token rate", section: "Bottom Left Segment Group" },
|
|
72
74
|
];
|
|
73
75
|
|
|
74
|
-
// Feed rows
|
|
76
|
+
// Feed rows carry a stable key (independent of on-screen position) so
|
|
77
|
+
// MenuComponent.rebuild's id-keyed value carry-over survives a removal.
|
|
75
78
|
const FEED_PREFIX = "feed";
|
|
76
|
-
const feedKey = (
|
|
79
|
+
const feedKey = (key: number, part: keyof StatusLineFeed) => `${FEED_PREFIX}.${key}.${part}`;
|
|
77
80
|
const ADD_FEED_ID = `${FEED_PREFIX}.add`;
|
|
78
|
-
const removeFeedId = (
|
|
81
|
+
const removeFeedId = (key: number) => `${FEED_PREFIX}.${key}.remove`;
|
|
79
82
|
const FEED_ROW_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.`);
|
|
83
|
+
const REMOVE_FEED_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.remove$`);
|
|
80
84
|
|
|
81
|
-
|
|
85
|
+
interface FeedPair {
|
|
86
|
+
key: number;
|
|
87
|
+
feed: StatusLineFeed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pairsFromFeeds = (feeds: readonly StatusLineFeed[]): FeedPair[] => feeds.map((feed, key) => ({ key, feed }));
|
|
91
|
+
|
|
92
|
+
function feedSection(pairs: readonly FeedPair[]): MenuSection {
|
|
82
93
|
const items: MenuSection["items"] = [];
|
|
83
|
-
|
|
94
|
+
pairs.forEach(({ key, feed }, index) => {
|
|
84
95
|
const n = index + 1;
|
|
85
96
|
items.push(
|
|
86
|
-
{ id: feedKey(
|
|
87
|
-
{ id: feedKey(
|
|
88
|
-
{ id: feedKey(
|
|
89
|
-
{ id: feedKey(
|
|
90
|
-
{ id: removeFeedId(
|
|
97
|
+
{ id: feedKey(key, "customType"), label: `${n}. type`, value: feed.customType, text: true, placeholder: "ext/custom-type" },
|
|
98
|
+
{ id: feedKey(key, "field"), label: `${n}. field`, value: feed.field, text: true, placeholder: "fieldName" },
|
|
99
|
+
{ id: feedKey(key, "prefix"), label: `${n}. prefix`, value: feed.prefix, text: true, placeholder: "(none)" },
|
|
100
|
+
{ id: feedKey(key, "format"), label: `${n}. format`, value: feed.format, cycleValues: FEED_FORMATS },
|
|
101
|
+
{ id: removeFeedId(key), label: `${n}. remove this feed`, value: false, action: true },
|
|
91
102
|
);
|
|
92
|
-
}
|
|
103
|
+
});
|
|
93
104
|
items.push({ id: ADD_FEED_ID, label: "+ add feed", value: false, action: true });
|
|
94
105
|
return { title: "Feeds", items };
|
|
95
106
|
}
|
|
96
107
|
|
|
97
|
-
/** Read the feed rows back out of the menu's flat value map. */
|
|
98
|
-
function
|
|
99
|
-
const
|
|
100
|
-
for (const
|
|
101
|
-
const match = FEED_ROW_RE.exec(
|
|
102
|
-
if (match)
|
|
108
|
+
/** Read the feed rows back out of the menu's flat value map, keyed by their stable id. */
|
|
109
|
+
function feedPairsFromValues(values: Record<string, MenuValue>): FeedPair[] {
|
|
110
|
+
const keys = new Set<number>();
|
|
111
|
+
for (const id of Object.keys(values)) {
|
|
112
|
+
const match = FEED_ROW_RE.exec(id);
|
|
113
|
+
if (match) keys.add(Number(match[1]));
|
|
103
114
|
}
|
|
104
|
-
return [...
|
|
115
|
+
return [...keys]
|
|
105
116
|
.sort((a, b) => a - b)
|
|
106
|
-
.map((
|
|
107
|
-
const rawFormat = String(values[feedKey(
|
|
117
|
+
.map((key): FeedPair => {
|
|
118
|
+
const rawFormat = String(values[feedKey(key, "format")] ?? "text");
|
|
108
119
|
return {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
key,
|
|
121
|
+
feed: {
|
|
122
|
+
customType: String(values[feedKey(key, "customType")] ?? ""),
|
|
123
|
+
field: String(values[feedKey(key, "field")] ?? ""),
|
|
124
|
+
prefix: String(values[feedKey(key, "prefix")] ?? ""),
|
|
125
|
+
format: FEED_FORMATS.includes(rawFormat as FeedFormat) ? (rawFormat as FeedFormat) : "text",
|
|
126
|
+
},
|
|
113
127
|
};
|
|
114
128
|
})
|
|
115
|
-
.filter(feed => feed.customType.trim() || feed.field.trim() || feed.prefix.trim());
|
|
129
|
+
.filter(({ feed }) => feed.customType.trim() || feed.field.trim() || feed.prefix.trim());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function feedsFromValues(values: Record<string, MenuValue>): StatusLineFeed[] {
|
|
133
|
+
return feedPairsFromValues(values).map(({ feed }) => feed);
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
@@ -159,6 +177,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
159
177
|
label: "Embed status spinners",
|
|
160
178
|
value: settings.embedWorkingStatus ?? false,
|
|
161
179
|
},
|
|
180
|
+
{
|
|
181
|
+
id: "embedCompactionProgress",
|
|
182
|
+
label: "Embed compaction progress",
|
|
183
|
+
value: settings.embedCompactionProgress ?? true,
|
|
184
|
+
},
|
|
162
185
|
],
|
|
163
186
|
};
|
|
164
187
|
return [
|
|
@@ -171,7 +194,7 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
171
194
|
value: seg[row.id],
|
|
172
195
|
})),
|
|
173
196
|
})),
|
|
174
|
-
feedSection(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS),
|
|
197
|
+
feedSection(pairsFromFeeds(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS)),
|
|
175
198
|
];
|
|
176
199
|
}
|
|
177
200
|
|
|
@@ -192,6 +215,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
|
|
|
192
215
|
nvidiaGreenBorder: values.nvidiaGreenBorder === true,
|
|
193
216
|
nvidiaGreenAnimation: values.nvidiaGreenAnimation === true,
|
|
194
217
|
embedWorkingStatus: values.embedWorkingStatus === true,
|
|
218
|
+
embedCompactionProgress: values.embedCompactionProgress === true,
|
|
195
219
|
};
|
|
196
220
|
}
|
|
197
221
|
|
|
@@ -312,14 +336,17 @@ class StatusLinePreview {
|
|
|
312
336
|
{ col: 3, row: 0 },
|
|
313
337
|
{ workingEllipsis: compactionDemo ? "…" : "" },
|
|
314
338
|
);
|
|
339
|
+
// Focusing the compaction row demos the bottom bar hosting
|
|
340
|
+
// pi-topping-compact's progress, while the setting is on.
|
|
341
|
+
const progressDemo = activeItemId === "embedCompactionProgress" && effective.embedCompactionProgress;
|
|
315
342
|
const bottom = buildStatusLine(
|
|
316
343
|
barWidth,
|
|
317
|
-
ctx,
|
|
344
|
+
progressDemo ? { ...ctx, compaction: COMPACTION_FIT_PROBE } : ctx,
|
|
318
345
|
effective,
|
|
319
346
|
painters.gapColor,
|
|
320
347
|
{
|
|
321
348
|
left: effective.bottomLeftSegments,
|
|
322
|
-
right: effective
|
|
349
|
+
right: bottomRightSegments(effective, progressDemo),
|
|
323
350
|
},
|
|
324
351
|
{ col: 3, row: bottomIdx },
|
|
325
352
|
);
|
|
@@ -347,7 +374,7 @@ export function registerSettingsCommand(
|
|
|
347
374
|
onChange: () => void,
|
|
348
375
|
): void {
|
|
349
376
|
pi.registerCommand("topping-statusline-settings", {
|
|
350
|
-
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, the Switchyard NVIDIA-green border, and the embedded
|
|
377
|
+
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, the Switchyard NVIDIA-green border, the embedded status spinners, and the embedded compaction progress",
|
|
351
378
|
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
352
379
|
if (ctx.mode !== "tui") {
|
|
353
380
|
ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
|
|
@@ -360,17 +387,20 @@ export function registerSettingsCommand(
|
|
|
360
387
|
hints: ["↑↓ move", "←→ cycle", "␣ toggle", "⏎ apply/edit", "esc cancel"],
|
|
361
388
|
preview: preview.render.bind(preview),
|
|
362
389
|
onAction: (id, values) => {
|
|
363
|
-
const
|
|
390
|
+
const pairs = feedPairsFromValues(values);
|
|
391
|
+
let nextPairs: FeedPair[];
|
|
364
392
|
if (id === ADD_FEED_ID) {
|
|
365
|
-
|
|
393
|
+
const key = Math.max(-1, ...pairs.map(p => p.key)) + 1;
|
|
394
|
+
nextPairs = [...pairs, { key, feed: { customType: "", field: "", prefix: "", format: "currency" } }];
|
|
366
395
|
} else {
|
|
367
|
-
const match =
|
|
396
|
+
const match = REMOVE_FEED_RE.exec(id);
|
|
368
397
|
if (!match) return undefined;
|
|
369
|
-
|
|
398
|
+
const removedKey = Number(match[1]);
|
|
399
|
+
nextPairs = pairs.filter(p => p.key !== removedKey);
|
|
370
400
|
}
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
return [...buildSections(valuesToSettings(values)).slice(0, -1), feedSection(
|
|
401
|
+
// The whole menu is regenerated so positional labels stay correct
|
|
402
|
+
// after an insert or removal; row ids are untouched by this.
|
|
403
|
+
return [...buildSections(valuesToSettings(values)).slice(0, -1), feedSection(nextPairs)];
|
|
374
404
|
},
|
|
375
405
|
});
|
|
376
406
|
if (!result.applied) return;
|