@underactive/pi-topping-statusline 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/index.ts +111 -6
- package/package.json +5 -4
- package/src/context.ts +1 -1
- package/src/layout.ts +39 -8
- package/src/segments.ts +15 -0
- package/src/settings-menu.ts +13 -3
- package/src/settings.ts +12 -0
- package/src/tests/working.test.ts +140 -0
- package/src/theme.ts +42 -1
- package/src/types.ts +10 -1
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ and apply live.
|
|
|
42
42
|
║ Border style ‹ rounded › ║
|
|
43
43
|
║ [■] Rainbow border on max thinking ON ║
|
|
44
44
|
║ [■] Animate rainbow border ON ║
|
|
45
|
+
║ [ ] Embed 'Working' indicator OFF ║
|
|
45
46
|
║ ║
|
|
46
47
|
╟─ Top Left Segment Group ───────────────────────────────────────╢
|
|
47
48
|
║ [■] Pi symbol ON ║
|
|
@@ -66,18 +67,18 @@ and apply live.
|
|
|
66
67
|
║ + add feed ║
|
|
67
68
|
║ ║
|
|
68
69
|
║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
|
|
69
|
-
╚════════════════════════════════════════════════════════[ 1/
|
|
70
|
+
╚════════════════════════════════════════════════════════[ 1/30 ]╝
|
|
70
71
|
```
|
|
71
72
|
|
|
72
73
|
| Section | Settings |
|
|
73
74
|
| --- | --- |
|
|
74
|
-
| Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border |
|
|
75
|
+
| Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border · Embed 'Working' indicator |
|
|
75
76
|
| Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
|
|
76
77
|
| Top Right Segment Group | Token rate · Session name |
|
|
77
78
|
| Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
|
|
78
79
|
| Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
|
|
79
80
|
| Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
|
|
80
|
-
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on |
|
|
81
|
+
| Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · Embed 'Working' indicator off |
|
|
81
82
|
|
|
82
83
|
With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
|
|
83
84
|
replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
|
|
@@ -87,6 +88,17 @@ the rainbow at a fixed color phase without its repaint timer; the settings previ
|
|
|
87
88
|
phase too. Any other thinking level keeps the normal theme border color. Disable animation over
|
|
88
89
|
slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
|
|
89
90
|
|
|
91
|
+
**Embed 'Working' indicator** (off by default) moves pi's streaming status — spinner, message,
|
|
92
|
+
and any loader text a topping such as pi-topping supplies — out of its own row and into the
|
|
93
|
+
top-left group, right after the Pi symbol and its chevron. The remaining left segments (model,
|
|
94
|
+
path, git, PR) step aside while a response streams and return when it ends; the Pi symbol never
|
|
95
|
+
moves. The status appears instantly when a stream starts; when it ends, the swap back
|
|
96
|
+
cross-fades over 750ms (the status sinks into the bar background, then the user's segments
|
|
97
|
+
rise out of it) rather than cutting. A status too long for the bar is truncated without an ellipsis, as pi's own border does.
|
|
98
|
+
Requires pi 0.85 or later. Toggling the setting reinstalls the editor through pi, so it applies
|
|
99
|
+
immediately, even mid-response. Only the statusline's own editor opts in: when another
|
|
100
|
+
extension owns the editor slot and is wrapped, pi keeps its standalone working row.
|
|
101
|
+
|
|
90
102
|
## Segments
|
|
91
103
|
|
|
92
104
|
Ported with pi data: `pi`, `model` (model · provider · thinking level), `path`
|
package/index.ts
CHANGED
|
@@ -37,11 +37,16 @@ import {
|
|
|
37
37
|
import { buildStatusLine } from "./src/layout.js";
|
|
38
38
|
import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder } from "./src/rainbow.js";
|
|
39
39
|
import { registerSettingsCommand } from "./src/settings-menu.js";
|
|
40
|
-
import { createSettingsState } from "./src/settings.js";
|
|
40
|
+
import { createSettingsState, topLeftSegments } from "./src/settings.js";
|
|
41
|
+
import { easeFade } from "./src/theme.js";
|
|
41
42
|
import { theme } from "./src/theme.js";
|
|
42
43
|
import { TokenRateMonitor } from "./src/token-rate.js";
|
|
43
44
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
44
45
|
|
|
46
|
+
/** Cross-fade budget when the embedded working status disappears: half out, half in. */
|
|
47
|
+
const WORKING_FADE_MS = 750;
|
|
48
|
+
const WORKING_FADE_FRAME_MS = 15;
|
|
49
|
+
|
|
45
50
|
export default function (pi: ExtensionAPI) {
|
|
46
51
|
const state = createSettingsState();
|
|
47
52
|
const builder = new SegmentContextBuilder(pi);
|
|
@@ -54,9 +59,77 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
59
|
let patchedFooter: Component | undefined;
|
|
55
60
|
let footerRenderOriginal: ((width: number) => string[]) | undefined;
|
|
56
61
|
let restoreFooterLayout: (() => void) | undefined;
|
|
62
|
+
// The CustomEditor this extension constructed itself (undefined when it wraps
|
|
63
|
+
// another extension's editor), and the host's working indicator captured
|
|
64
|
+
// from that editor while a response streams.
|
|
65
|
+
let activeEditor: CustomEditor | undefined;
|
|
66
|
+
let embeddedWorkingStatus: ((width: number) => string) | undefined;
|
|
67
|
+
// Fade from the working status back to the user's left segments when a
|
|
68
|
+
// stream ends. The last status text is kept so the outgoing half still has
|
|
69
|
+
// something to fade after pi has cleared its indicator.
|
|
70
|
+
let workingShown = false;
|
|
71
|
+
let lastWorkingText: string | undefined;
|
|
72
|
+
let workingFade: { start: number } | undefined;
|
|
73
|
+
let workingFadeTimer: ReturnType<typeof setInterval> | undefined;
|
|
74
|
+
const stopWorkingFadeTimer = (): void => {
|
|
75
|
+
if (workingFadeTimer) clearInterval(workingFadeTimer);
|
|
76
|
+
workingFadeTimer = undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const syncEmbed = (): void => {
|
|
80
|
+
const ctx = activeCtx;
|
|
81
|
+
const factory = ourFactory;
|
|
82
|
+
if (!activeEditor || !ctx || !factory) return;
|
|
83
|
+
const next = state.effective.embedWorkingStatus;
|
|
84
|
+
if (activeEditor.embedWorkingStatus === next) return;
|
|
85
|
+
ctx.ui.setEditorComponent(factory);
|
|
86
|
+
};
|
|
57
87
|
|
|
58
88
|
const requestRender = () => activeTui?.requestRender();
|
|
59
89
|
builder.setRequestRender(requestRender);
|
|
90
|
+
const startWorkingFadeTimer = (): void => {
|
|
91
|
+
if (workingFadeTimer) return;
|
|
92
|
+
workingFadeTimer = setInterval(() => {
|
|
93
|
+
if (!workingFade || Date.now() - workingFade.start >= WORKING_FADE_MS) {
|
|
94
|
+
workingFade = undefined;
|
|
95
|
+
stopWorkingFadeTimer();
|
|
96
|
+
}
|
|
97
|
+
requestRender();
|
|
98
|
+
}, WORKING_FADE_FRAME_MS);
|
|
99
|
+
workingFadeTimer.unref?.();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which left group this frame shows and how far it has faded. A stream
|
|
104
|
+
* starting shows the working status at once. A stream ending starts a
|
|
105
|
+
* 750ms clock: the status sinks into the bar background for the first
|
|
106
|
+
* half, then the user's segments rise out of it.
|
|
107
|
+
*/
|
|
108
|
+
const resolveWorkingFrame = (live: string | undefined): { working: string | undefined; leftFade?: number } => {
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const liveShown = live !== undefined;
|
|
111
|
+
if (liveShown) lastWorkingText = live;
|
|
112
|
+
if (liveShown !== workingShown) {
|
|
113
|
+
workingShown = liveShown;
|
|
114
|
+
if (liveShown) {
|
|
115
|
+
// Instant cut in, and it cancels any fade-out still running.
|
|
116
|
+
workingFade = undefined;
|
|
117
|
+
} else {
|
|
118
|
+
workingFade = { start: now };
|
|
119
|
+
startWorkingFadeTimer();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!workingFade) return { working: live };
|
|
123
|
+
const half = WORKING_FADE_MS / 2;
|
|
124
|
+
const elapsed = now - workingFade.start;
|
|
125
|
+
if (elapsed >= WORKING_FADE_MS) {
|
|
126
|
+
workingFade = undefined;
|
|
127
|
+
return { working: live };
|
|
128
|
+
}
|
|
129
|
+
const outgoing = elapsed < half;
|
|
130
|
+
const leftFade = easeFade(outgoing ? 1 - elapsed / half : (elapsed - half) / half);
|
|
131
|
+
return { working: outgoing ? lastWorkingText : undefined, leftFade };
|
|
132
|
+
};
|
|
60
133
|
|
|
61
134
|
const rateMonitor = new TokenRateMonitor(requestRender);
|
|
62
135
|
rateMonitor.attach(pi);
|
|
@@ -90,6 +163,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
90
163
|
|
|
91
164
|
registerSettingsCommand(pi, state, builder, () => {
|
|
92
165
|
syncRainbow();
|
|
166
|
+
syncEmbed();
|
|
93
167
|
requestRender();
|
|
94
168
|
});
|
|
95
169
|
|
|
@@ -127,16 +201,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
127
201
|
const rainbowOn = rainbowActive();
|
|
128
202
|
const box = theme.getBox(effective.borderStyle);
|
|
129
203
|
const painters = makeBoxPainters({ rainbowOn, rainbow, box, width, bottomIdx, flat: border });
|
|
204
|
+
// The layout truncates the status to fit, so it is rendered at full width here.
|
|
205
|
+
const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
|
|
206
|
+
const { working, leftFade } = resolveWorkingFrame(live);
|
|
130
207
|
const bar = buildStatusLine(
|
|
131
208
|
innerWidth,
|
|
132
|
-
segCtx,
|
|
209
|
+
{ ...segCtx, workingStatus: working },
|
|
133
210
|
effective,
|
|
134
211
|
painters.gapColor,
|
|
135
212
|
{
|
|
136
|
-
left: effective
|
|
213
|
+
left: topLeftSegments(effective, working !== undefined),
|
|
137
214
|
right: effective.rightSegments,
|
|
138
215
|
},
|
|
139
216
|
{ col: 3, row: 0 },
|
|
217
|
+
{ leftFade },
|
|
140
218
|
);
|
|
141
219
|
// Leading spacer row: keeps the transcript from sitting flush on the box.
|
|
142
220
|
const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
|
|
@@ -166,9 +244,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
166
244
|
const wrapFactory = (inner: EditorFactory | undefined): EditorFactory => {
|
|
167
245
|
return (tui, editorTheme, keybindings) => {
|
|
168
246
|
activeTui = tui;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
:
|
|
247
|
+
let editor: ReturnType<EditorFactory>;
|
|
248
|
+
if (inner) {
|
|
249
|
+
// A wrapped third-party editor is not opted in: pi keeps its
|
|
250
|
+
// standalone working row for it.
|
|
251
|
+
editor = inner(tui, editorTheme, keybindings);
|
|
252
|
+
activeEditor = undefined;
|
|
253
|
+
embeddedWorkingStatus = undefined;
|
|
254
|
+
} else {
|
|
255
|
+
const own = new CustomEditor(tui, editorTheme, keybindings, {
|
|
256
|
+
embedWorkingStatus: state.effective.embedWorkingStatus,
|
|
257
|
+
});
|
|
258
|
+
// Capture the host's indicator on its way in; renderBoxed draws it
|
|
259
|
+
// into the top bar since the inner top border (lines[0]) is discarded.
|
|
260
|
+
if (typeof own.setWorkingStatusIndicator === "function") {
|
|
261
|
+
const setIndicator = own.setWorkingStatusIndicator.bind(own);
|
|
262
|
+
own.setWorkingStatusIndicator = indicator => {
|
|
263
|
+
embeddedWorkingStatus = indicator ? width => indicator.renderInBorder(width) : undefined;
|
|
264
|
+
setIndicator(indicator);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
activeEditor = own;
|
|
268
|
+
editor = own;
|
|
269
|
+
}
|
|
172
270
|
const innerRender = editor.render.bind(editor);
|
|
173
271
|
let boxRenderFailed = false;
|
|
174
272
|
editor.render = width => {
|
|
@@ -266,6 +364,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
266
364
|
rateMonitor.enable();
|
|
267
365
|
syncRainbow();
|
|
268
366
|
ensureInstalled();
|
|
367
|
+
syncEmbed();
|
|
269
368
|
ensureFooterPatched();
|
|
270
369
|
ensureTimer ??= setInterval(() => {
|
|
271
370
|
ensureInstalled();
|
|
@@ -300,6 +399,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
300
399
|
restoreFooterLayout = undefined;
|
|
301
400
|
ourFactory = undefined;
|
|
302
401
|
wrappedFactory = undefined;
|
|
402
|
+
activeEditor = undefined;
|
|
403
|
+
embeddedWorkingStatus = undefined;
|
|
404
|
+
stopWorkingFadeTimer();
|
|
405
|
+
workingFade = undefined;
|
|
406
|
+
workingShown = false;
|
|
407
|
+
lastWorkingText = undefined;
|
|
303
408
|
activeTui = undefined;
|
|
304
409
|
activeCtx = undefined;
|
|
305
410
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-statusline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -45,9 +45,10 @@
|
|
|
45
45
|
"@earendil-works/pi-tui": "*"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@earendil-works/pi-ai": "0.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
50
|
-
"@earendil-works/pi-
|
|
48
|
+
"@earendil-works/pi-ai": "0.85.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
50
|
+
"@earendil-works/pi-server": "0.85.0",
|
|
51
|
+
"@earendil-works/pi-tui": "0.85.0",
|
|
51
52
|
"@types/node": "^22.0.0",
|
|
52
53
|
"typescript": "^5.7.0"
|
|
53
54
|
}
|
package/src/context.ts
CHANGED
|
@@ -63,7 +63,7 @@ function deriveContextUsage(ctx: ExtensionContext | undefined): {
|
|
|
63
63
|
const usage = ctx?.getContextUsage();
|
|
64
64
|
const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
|
|
65
65
|
const tokens = usage?.tokens ?? undefined;
|
|
66
|
-
const percent = usage ? usage.percent :
|
|
66
|
+
const percent = usage ? usage.percent : null;
|
|
67
67
|
return { contextWindow, tokens, percent };
|
|
68
68
|
}
|
|
69
69
|
|
package/src/layout.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The bar layout engine, ported from oh-my-pi component.ts #buildStatusLine.
|
|
3
3
|
*
|
|
4
|
-
* Overflow strategy (in order): drop right segments right-to-left,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Overflow strategy (in order): drop right segments right-to-left, truncate
|
|
5
|
+
* the embedded `working` status (no ellipsis, as pi's own border does), shrink
|
|
6
|
+
* the elastic `path` segment down to ~8 cells, drop left segments end-first
|
|
7
|
+
* while protecting `path`. The gap between the two groups is filled with the
|
|
7
8
|
* box-horizontal glyph colored like the editor border, so it tracks the
|
|
8
9
|
* thinking-level border color; the callback receives the gap's absolute
|
|
9
10
|
* column and row in the box, so a caller can paint it as part of a
|
|
10
11
|
* continuous border gradient (the rainbow effect).
|
|
12
|
+
*
|
|
13
|
+
* `options.leftFade` (0..1) blends every left segment after a leading `pi`
|
|
14
|
+
* symbol toward the bar background, which the working-indicator transition
|
|
15
|
+
* uses to cross-fade the group without moving the symbol or its chevron.
|
|
11
16
|
*/
|
|
12
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
18
|
import { renderSegment } from "./segments.js";
|
|
14
19
|
import { getSeparator } from "./separators.js";
|
|
15
20
|
import { theme } from "./theme.js";
|
|
@@ -58,6 +63,7 @@ export function buildStatusLine(
|
|
|
58
63
|
gapBorderColor: (str: string, startCol: number, row: number) => string,
|
|
59
64
|
segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
|
|
60
65
|
barOrigin: { col: number; row: number } = { col: 0, row: 0 },
|
|
66
|
+
options: { leftFade?: number } = {},
|
|
61
67
|
): string {
|
|
62
68
|
const separatorDef = getSeparator(settings.separator);
|
|
63
69
|
|
|
@@ -112,6 +118,19 @@ export function buildStatusLine(
|
|
|
112
118
|
right.pop();
|
|
113
119
|
rightWidth = rightGroupWidth(right);
|
|
114
120
|
}
|
|
121
|
+
// The working status absorbs overflow first so the Pi symbol beside it
|
|
122
|
+
// survives on narrow terminals.
|
|
123
|
+
const workingIdx = leftSegIds.indexOf("working");
|
|
124
|
+
if (workingIdx >= 0 && totalWidth() > width) {
|
|
125
|
+
const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
|
|
126
|
+
if (available >= 1) {
|
|
127
|
+
left[workingIdx] = truncateToWidth(left[workingIdx], available, "");
|
|
128
|
+
} else {
|
|
129
|
+
left.splice(workingIdx, 1);
|
|
130
|
+
leftSegIds.splice(workingIdx, 1);
|
|
131
|
+
}
|
|
132
|
+
leftWidth = leftGroupWidth(left);
|
|
133
|
+
}
|
|
115
134
|
// Shrink path before dropping left segments — path is the only elastic segment
|
|
116
135
|
const pathIdx = leftSegIds.indexOf("path");
|
|
117
136
|
if (pathIdx >= 0 && totalWidth() > width) {
|
|
@@ -137,13 +156,25 @@ export function buildStatusLine(
|
|
|
137
156
|
}
|
|
138
157
|
}
|
|
139
158
|
|
|
140
|
-
const renderGroup = (parts: string[], direction: "left" | "right"): string => {
|
|
159
|
+
const renderGroup = (parts: string[], direction: "left" | "right", fadeFrom = parts.length): string => {
|
|
141
160
|
if (parts.length === 0) return "";
|
|
142
161
|
const sep = direction === "left" ? separatorDef.left : separatorDef.right;
|
|
143
162
|
const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
|
|
144
|
-
|
|
163
|
+
const sepText = ` ${sepAnsi}${sep}${fgAnsi} `;
|
|
164
|
+
|
|
165
|
+
let body: string;
|
|
166
|
+
const fade = options.leftFade;
|
|
167
|
+
if (fade !== undefined && fade < 1 && fadeFrom < parts.length) {
|
|
168
|
+
// The faded tail opens with an explicit default-fg reset so plain text
|
|
169
|
+
// inside it is recolored too, not just segments that set their own color.
|
|
170
|
+
const head = parts.slice(0, fadeFrom).join(sepText);
|
|
171
|
+
const tail = `\x1b[39m${parts.slice(fadeFrom).join(sepText)}`;
|
|
172
|
+
body = head + theme.fadeAnsi((head ? sepText : "") + tail, fade);
|
|
173
|
+
} else {
|
|
174
|
+
body = parts.join(sepText);
|
|
175
|
+
}
|
|
145
176
|
let content = bgAnsi + fgAnsi;
|
|
146
|
-
content += ` ${
|
|
177
|
+
content += ` ${body} `;
|
|
147
178
|
content += "\x1b[0m";
|
|
148
179
|
|
|
149
180
|
if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
|
|
@@ -151,7 +182,7 @@ export function buildStatusLine(
|
|
|
151
182
|
return content;
|
|
152
183
|
};
|
|
153
184
|
|
|
154
|
-
const leftGroup = renderGroup(left, "left");
|
|
185
|
+
const leftGroup = renderGroup(left, "left", leftSegIds[0] === "pi" ? 1 : 0);
|
|
155
186
|
const rightGroup = renderGroup(right, "right");
|
|
156
187
|
if (!leftGroup && !rightGroup) return "";
|
|
157
188
|
|
package/src/segments.ts
CHANGED
|
@@ -295,6 +295,20 @@ const scrollHintSegment: StatusLineSegment = {
|
|
|
295
295
|
},
|
|
296
296
|
};
|
|
297
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Pi's embedded working indicator (spinner + message), already colored by the
|
|
300
|
+
* host, so it is passed through verbatim. The layout truncates it, without an
|
|
301
|
+
* ellipsis, when it overflows, mirroring pi's own border rendering.
|
|
302
|
+
*/
|
|
303
|
+
const workingSegment: StatusLineSegment = {
|
|
304
|
+
id: "working",
|
|
305
|
+
render(ctx) {
|
|
306
|
+
const status = ctx.workingStatus;
|
|
307
|
+
if (!status) return INVISIBLE;
|
|
308
|
+
return { content: status, visible: true };
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
298
312
|
/** Context bar + stats — related parts joined by a space, not the group separator. */
|
|
299
313
|
const contextGraphSegment: StatusLineSegment = {
|
|
300
314
|
id: "context_graph",
|
|
@@ -351,6 +365,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
351
365
|
feeds: feedsSegment,
|
|
352
366
|
context_graph: contextGraphSegment,
|
|
353
367
|
scroll_hint: scrollHintSegment,
|
|
368
|
+
working: workingSegment,
|
|
354
369
|
};
|
|
355
370
|
|
|
356
371
|
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
package/src/settings-menu.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
saveSettings,
|
|
20
20
|
sanitizeFeeds,
|
|
21
21
|
SEPARATORS,
|
|
22
|
+
topLeftSegments,
|
|
22
23
|
type SettingsState,
|
|
23
24
|
} from "./settings.js";
|
|
24
25
|
import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
|
|
@@ -140,6 +141,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
|
|
|
140
141
|
label: "Animate rainbow border",
|
|
141
142
|
value: settings.rainbowAnimation ?? true,
|
|
142
143
|
},
|
|
144
|
+
{
|
|
145
|
+
id: "embedWorkingStatus",
|
|
146
|
+
label: "Embed 'Working' indicator",
|
|
147
|
+
value: settings.embedWorkingStatus ?? false,
|
|
148
|
+
},
|
|
143
149
|
],
|
|
144
150
|
};
|
|
145
151
|
return [
|
|
@@ -170,6 +176,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
|
|
|
170
176
|
feeds: sanitizeFeeds(feedsFromValues(values)),
|
|
171
177
|
rainbowBorder: values.rainbowBorder === true,
|
|
172
178
|
rainbowAnimation: values.rainbowAnimation === true,
|
|
179
|
+
embedWorkingStatus: values.embedWorkingStatus === true,
|
|
173
180
|
};
|
|
174
181
|
}
|
|
175
182
|
|
|
@@ -184,6 +191,8 @@ const CANNED_STATS = "↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42";
|
|
|
184
191
|
const CANNED_GIT = { branch: "main", status: { staged: 1, unstaged: 2, untracked: 3 } };
|
|
185
192
|
const CANNED_PERCENT = 42;
|
|
186
193
|
const CANNED_WINDOW = 200_000;
|
|
194
|
+
/** What pi's border indicator looks like mid-stream with pi-topping's loader installed. */
|
|
195
|
+
const CANNED_WORKING = "⠙ Mulling ⢾⣿⣿⣿⣿⣿⢾⢾ 28 tps · 11s · ↓ 316 tokens";
|
|
187
196
|
|
|
188
197
|
/** Fill any feed the live session has no entry for, so the preview stays legible. */
|
|
189
198
|
function cannedFeedData(
|
|
@@ -266,13 +275,14 @@ class StatusLinePreview {
|
|
|
266
275
|
bottomIdx,
|
|
267
276
|
flat: s => this.#uiTheme.fg("border", s),
|
|
268
277
|
});
|
|
278
|
+
const embedOn = effective.embedWorkingStatus;
|
|
269
279
|
const top = buildStatusLine(
|
|
270
280
|
barWidth,
|
|
271
|
-
ctx,
|
|
281
|
+
embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", CANNED_WORKING) } : ctx,
|
|
272
282
|
effective,
|
|
273
283
|
painters.gapColor,
|
|
274
284
|
{
|
|
275
|
-
left: effective
|
|
285
|
+
left: topLeftSegments(effective, embedOn),
|
|
276
286
|
right: effective.rightSegments,
|
|
277
287
|
},
|
|
278
288
|
{ col: 3, row: 0 },
|
|
@@ -312,7 +322,7 @@ export function registerSettingsCommand(
|
|
|
312
322
|
onChange: () => void,
|
|
313
323
|
): void {
|
|
314
324
|
pi.registerCommand("topping-statusline-settings", {
|
|
315
|
-
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds,
|
|
325
|
+
description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, and the embedded working indicator",
|
|
316
326
|
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
317
327
|
if (ctx.mode !== "tui") {
|
|
318
328
|
ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
|
package/src/settings.ts
CHANGED
|
@@ -93,6 +93,7 @@ function loadSettings(): StatusLineSettings {
|
|
|
93
93
|
if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
|
|
94
94
|
if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
|
|
95
95
|
if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
|
|
96
|
+
if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
|
|
96
97
|
return settings;
|
|
97
98
|
} catch {
|
|
98
99
|
// Missing or corrupt settings.json falls back to defaults.
|
|
@@ -155,6 +156,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
155
156
|
bottomRightSegments,
|
|
156
157
|
rainbowBorder: settings.rainbowBorder ?? true,
|
|
157
158
|
rainbowAnimation: settings.rainbowAnimation ?? true,
|
|
159
|
+
embedWorkingStatus: settings.embedWorkingStatus ?? false,
|
|
158
160
|
includes,
|
|
159
161
|
segmentOptions: {
|
|
160
162
|
model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
|
|
@@ -165,6 +167,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
|
|
|
165
167
|
};
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The top-left group for one frame. While pi's working status is embedded,
|
|
172
|
+
* it stands in for every left segment except the leading Pi symbol, so the
|
|
173
|
+
* symbol and its chevron never move when a stream starts or ends.
|
|
174
|
+
*/
|
|
175
|
+
export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
|
|
176
|
+
if (!working) return effective.leftSegments;
|
|
177
|
+
return effective.leftSegments[0] === "pi" ? ["pi", "working"] : ["working"];
|
|
178
|
+
}
|
|
179
|
+
|
|
168
180
|
export interface SettingsState {
|
|
169
181
|
settings: StatusLineSettings;
|
|
170
182
|
effective: EffectiveStatusLineSettings;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts for the opt-in embedded working indicator: the `working` segment
|
|
3
|
+
* passes pi's pre-rendered status through verbatim, the top-left group swaps
|
|
4
|
+
* everything but the Pi symbol for it while a stream is live, and the layout
|
|
5
|
+
* truncates it (no ellipsis) before any other left segment is dropped.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { buildStatusLine } from "../layout.ts";
|
|
11
|
+
import { SEGMENTS } from "../segments.ts";
|
|
12
|
+
import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
|
|
13
|
+
import { easeFade, theme } from "../theme.ts";
|
|
14
|
+
import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
|
|
15
|
+
|
|
16
|
+
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
17
|
+
const noGap = (s: string) => s;
|
|
18
|
+
|
|
19
|
+
const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
|
|
20
|
+
|
|
21
|
+
function ctxWith(workingStatus: string | undefined): SegmentContext {
|
|
22
|
+
return {
|
|
23
|
+
options: EFFECTIVE.segmentOptions,
|
|
24
|
+
model: undefined,
|
|
25
|
+
thinkingLevel: "off",
|
|
26
|
+
cwd: "/tmp/pi-topping-statusline-tests",
|
|
27
|
+
sessionName: undefined,
|
|
28
|
+
contextPercent: null,
|
|
29
|
+
contextTokens: 0,
|
|
30
|
+
contextWindow: 0,
|
|
31
|
+
git: { branch: null, status: null, pr: null },
|
|
32
|
+
worktree: null,
|
|
33
|
+
scrollHint: undefined,
|
|
34
|
+
piStats: undefined,
|
|
35
|
+
tokenRate: undefined,
|
|
36
|
+
feedData: undefined,
|
|
37
|
+
workingStatus,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("working segment renders the host text verbatim", () => {
|
|
42
|
+
const status = "\x1b[36m⠙ Working\x1b[39m";
|
|
43
|
+
assert.deepEqual(SEGMENTS.working.render(ctxWith(status)), { content: status, visible: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("working segment is invisible when idle", () => {
|
|
47
|
+
assert.equal(SEGMENTS.working.render(ctxWith(undefined)).visible, false);
|
|
48
|
+
assert.equal(SEGMENTS.working.render(ctxWith("")).visible, false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("topLeftSegments keeps pi first and swaps the rest while streaming", () => {
|
|
52
|
+
assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working"]);
|
|
53
|
+
assert.deepEqual(topLeftSegments(EFFECTIVE, false), EFFECTIVE.leftSegments);
|
|
54
|
+
assert.ok(EFFECTIVE.leftSegments.length > 2, "default left group has more than pi + one segment");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("topLeftSegments yields only working when the pi symbol is off", () => {
|
|
58
|
+
const noPi = resolveEffectiveSettings({ segments: { pi: false } });
|
|
59
|
+
assert.deepEqual(topLeftSegments(noPi, true), ["working"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("embedWorkingStatus resolves off by default and on when set", () => {
|
|
63
|
+
assert.equal(resolveEffectiveSettings({}).embedWorkingStatus, false);
|
|
64
|
+
assert.equal(resolveEffectiveSettings({ embedWorkingStatus: true }).embedWorkingStatus, true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("top bar renders pi symbol, separator, then the status", () => {
|
|
68
|
+
const bar = buildStatusLine(80, ctxWith("⠙ Mulling 28 tps"), EFFECTIVE, noGap, {
|
|
69
|
+
left: ["pi", "working"],
|
|
70
|
+
right: [],
|
|
71
|
+
});
|
|
72
|
+
const plain = stripAnsi(bar);
|
|
73
|
+
const piAt = plain.indexOf(theme.icon.pi);
|
|
74
|
+
const statusAt = plain.indexOf("⠙ Mulling 28 tps");
|
|
75
|
+
assert.ok(piAt >= 0, "pi symbol present");
|
|
76
|
+
assert.ok(statusAt > piAt, "status follows the pi symbol");
|
|
77
|
+
assert.equal(visibleWidth(bar), 80);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("narrow widths truncate the status without an ellipsis and keep the pi symbol", () => {
|
|
81
|
+
const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
|
|
82
|
+
const bar = buildStatusLine(20, ctxWith(status), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
|
|
83
|
+
const plain = stripAnsi(bar);
|
|
84
|
+
assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
|
|
85
|
+
assert.ok(plain.includes("⠙ Mull"), "status head is kept");
|
|
86
|
+
assert.ok(!plain.includes("…"), "no ellipsis");
|
|
87
|
+
assert.ok(!plain.includes("tokens"), "status tail is cut");
|
|
88
|
+
assert.equal(visibleWidth(bar), 20);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a long ANSI-styled status never exceeds the bar width", () => {
|
|
92
|
+
const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
|
|
93
|
+
for (const width of [12, 16, 24, 40, 60]) {
|
|
94
|
+
const bar = buildStatusLine(width, ctxWith(status), EFFECTIVE, noGap, {
|
|
95
|
+
left: ["pi", "working"],
|
|
96
|
+
right: [],
|
|
97
|
+
});
|
|
98
|
+
assert.equal(visibleWidth(bar), width, `width ${width}`);
|
|
99
|
+
assert.ok(stripAnsi(bar).includes(theme.icon.pi), `pi symbol at width ${width}`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the status drops entirely rather than the pi symbol when there is no room", () => {
|
|
104
|
+
const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
|
|
105
|
+
const plain = stripAnsi(bar);
|
|
106
|
+
assert.ok(plain.includes(theme.icon.pi));
|
|
107
|
+
assert.ok(!plain.includes("⠙"));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("fadeAnsi leaves text untouched at full opacity and sinks colors into the bar at zero", () => {
|
|
111
|
+
const styled = "\x1b[38;2;254;188;56mA\x1b[39m \x1b[48;5;70mB\x1b[49m";
|
|
112
|
+
assert.equal(theme.fadeAnsi(styled, 1), styled);
|
|
113
|
+
const sunk = theme.fadeAnsi(styled, 0);
|
|
114
|
+
assert.equal(
|
|
115
|
+
sunk,
|
|
116
|
+
`${theme.getFgAnsi("statusLineBg")}A${theme.getFgAnsi("statusLineBg")} ${theme.getBgAnsi()}B\x1b[49m`,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("easeFade is monotonic and pinned at both ends", () => {
|
|
121
|
+
assert.equal(easeFade(0), 0);
|
|
122
|
+
assert.equal(easeFade(1), 1);
|
|
123
|
+
assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("leftFade recolors the tail but not the pi symbol or the bar width", () => {
|
|
127
|
+
const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
|
|
128
|
+
const groups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] } = { left: ["pi", "working"], right: [] };
|
|
129
|
+
const origin = { col: 0, row: 0 };
|
|
130
|
+
const build = (fade?: number) =>
|
|
131
|
+
buildStatusLine(60, ctxWith(status), EFFECTIVE, noGap, groups, origin, { leftFade: fade });
|
|
132
|
+
const solid = build();
|
|
133
|
+
const faded = build(0.2);
|
|
134
|
+
assert.equal(visibleWidth(faded), 60);
|
|
135
|
+
assert.equal(stripAnsi(faded), stripAnsi(solid));
|
|
136
|
+
assert.ok(!faded.includes("10;200;30"), "status color blended");
|
|
137
|
+
const piPrefix = (bar: string) => bar.slice(0, bar.indexOf(theme.icon.pi));
|
|
138
|
+
assert.equal(piPrefix(faded), piPrefix(solid), "pi symbol styling untouched");
|
|
139
|
+
assert.equal(build(1), solid);
|
|
140
|
+
});
|
package/src/theme.ts
CHANGED
|
@@ -249,6 +249,18 @@ export function parseHex(hex: string): [number, number, number] | undefined {
|
|
|
249
249
|
/** Number of discrete, eased shades used for the token-rate and feed fades (pi-topping). */
|
|
250
250
|
export const FADE_SHADE_COUNT = 5;
|
|
251
251
|
|
|
252
|
+
/** Stand-in for the terminal's default foreground when a fade must recolor it. */
|
|
253
|
+
const DEFAULT_FG_RGB: [number, number, number] = [204, 204, 204];
|
|
254
|
+
|
|
255
|
+
/** Every SGR color the layout or a segment can emit: truecolor, 256-color, or the default-fg reset. */
|
|
256
|
+
const SGR_COLOR_RE = /\x1b\[(38|48);2;(\d+);(\d+);(\d+)m|\x1b\[(38|48);5;(\d+)m|\x1b\[39m/g;
|
|
257
|
+
|
|
258
|
+
/** Raised-cosine ease shared by the discrete and continuous fades. */
|
|
259
|
+
export function easeFade(progress: number): number {
|
|
260
|
+
const clamped = Math.max(0, Math.min(1, progress));
|
|
261
|
+
return 0.5 * (1 - Math.cos(Math.PI * clamped));
|
|
262
|
+
}
|
|
263
|
+
|
|
252
264
|
/** Resolve an xterm-256 palette index to RGB (16–231 cube, 232–255 grayscale). */
|
|
253
265
|
function xterm256ToRgb(n: number): [number, number, number] {
|
|
254
266
|
if (n >= 232) {
|
|
@@ -358,7 +370,7 @@ class StatusTheme {
|
|
|
358
370
|
fadeFg(from: StatusColor, to: StatusColor, shade: number, text: string): string {
|
|
359
371
|
const clamped = Math.max(0, Math.min(FADE_SHADE_COUNT - 1, Math.floor(shade)));
|
|
360
372
|
const progress = (clamped + 1) / FADE_SHADE_COUNT;
|
|
361
|
-
const eased =
|
|
373
|
+
const eased = easeFade(progress);
|
|
362
374
|
const a = colorValueToRgb(FG_COLORS[from]);
|
|
363
375
|
const b = colorValueToRgb(FG_COLORS[to]);
|
|
364
376
|
const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
|
|
@@ -367,6 +379,35 @@ class StatusTheme {
|
|
|
367
379
|
const ansi = rgbToFgAnsi(r, g, bl, this.#mode);
|
|
368
380
|
return `${ansi}${text}\x1b[39m`;
|
|
369
381
|
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Blend every color already present in `text` toward the bar background
|
|
385
|
+
* by `opacity` (1 = untouched, 0 = fully sunk into the background), so a
|
|
386
|
+
* fully styled group can fade as a whole without re-rendering its segments.
|
|
387
|
+
* Default-foreground resets are recolored from a neutral stand-in so plain
|
|
388
|
+
* labels fade alongside their colored neighbors; background resets (49)
|
|
389
|
+
* stay put so transparent bars keep the terminal's own background.
|
|
390
|
+
*/
|
|
391
|
+
fadeAnsi(text: string, opacity: number): string {
|
|
392
|
+
const alpha = Math.max(0, Math.min(1, opacity));
|
|
393
|
+
if (alpha >= 1) return text;
|
|
394
|
+
const target = colorValueToRgb(STATUS_LINE_BG);
|
|
395
|
+
const mode = this.#mode;
|
|
396
|
+
return text.replace(SGR_COLOR_RE, (_m, tcPlane, r, g, b, ixPlane, index) => {
|
|
397
|
+
let plane = "38";
|
|
398
|
+
let rgb: [number, number, number] = DEFAULT_FG_RGB;
|
|
399
|
+
if (tcPlane) {
|
|
400
|
+
plane = tcPlane;
|
|
401
|
+
rgb = [Number(r), Number(g), Number(b)];
|
|
402
|
+
} else if (ixPlane) {
|
|
403
|
+
plane = ixPlane;
|
|
404
|
+
rgb = xterm256ToRgb(Number(index));
|
|
405
|
+
}
|
|
406
|
+
const mix = (i: number) => Math.round(rgb[i] * alpha + target[i] * (1 - alpha));
|
|
407
|
+
const fg = rgbToFgAnsi(mix(0), mix(1), mix(2), mode);
|
|
408
|
+
return plane === "38" ? fg : fg.replace("\x1b[38;", "\x1b[48;");
|
|
409
|
+
});
|
|
410
|
+
}
|
|
370
411
|
}
|
|
371
412
|
|
|
372
413
|
export const theme = new StatusTheme();
|
package/src/types.ts
CHANGED
|
@@ -18,7 +18,8 @@ export type StatusLineSegmentId =
|
|
|
18
18
|
| "token_rate"
|
|
19
19
|
| "feeds"
|
|
20
20
|
| "context_graph"
|
|
21
|
-
| "scroll_hint"
|
|
21
|
+
| "scroll_hint"
|
|
22
|
+
| "working";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* A feed the statusline subscribes to: custom session entries published by
|
|
@@ -73,6 +74,8 @@ export interface StatusLineSettings {
|
|
|
73
74
|
rainbowBorder?: boolean;
|
|
74
75
|
/** Animate the rainbow border when it is visible. */
|
|
75
76
|
rainbowAnimation?: boolean;
|
|
77
|
+
/** Render pi's streaming working status in the top bar instead of its own row. */
|
|
78
|
+
embedWorkingStatus?: boolean;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
/** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
|
|
@@ -105,6 +108,7 @@ export interface EffectiveStatusLineSettings {
|
|
|
105
108
|
segmentOptions: StatusLineSegmentOptions;
|
|
106
109
|
rainbowBorder: boolean;
|
|
107
110
|
rainbowAnimation: boolean;
|
|
111
|
+
embedWorkingStatus: boolean;
|
|
108
112
|
includes: SegmentIncludes;
|
|
109
113
|
}
|
|
110
114
|
|
|
@@ -133,6 +137,11 @@ export interface SegmentContext {
|
|
|
133
137
|
scrollHint: string | undefined;
|
|
134
138
|
/** Pi's own footer stats, when this frame needs them. */
|
|
135
139
|
piStats: string | undefined;
|
|
140
|
+
/**
|
|
141
|
+
* Pi's embedded working indicator, pre-rendered and pre-colored by the host
|
|
142
|
+
* for this frame. Absent while no response is streaming.
|
|
143
|
+
*/
|
|
144
|
+
workingStatus?: string;
|
|
136
145
|
/** Live tok/s display state, when this frame needs it. */
|
|
137
146
|
tokenRate: TokenRateDisplay | undefined;
|
|
138
147
|
/**
|