@pi-vault/pi-status 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +54 -20
- package/docs/assets/statusline-configuration.png +0 -0
- package/docs/assets/statusline-ui.png +0 -0
- package/package.json +8 -7
- package/src/{config.ts → core/config.ts} +23 -63
- package/src/core/snapshot.ts +75 -0
- package/src/core/usage-runtime.ts +62 -0
- package/src/index.ts +89 -150
- package/src/shared/types.ts +63 -0
- package/src/{ui/statusline-editor.ts → tui/editor.ts} +91 -114
- package/src/{render.ts → tui/render.ts} +25 -82
- package/src/tui/theme.ts +35 -0
- package/src/ui/statusline-theme.ts +0 -67
|
@@ -5,13 +5,11 @@ import {
|
|
|
5
5
|
visibleWidth,
|
|
6
6
|
type Component,
|
|
7
7
|
} from "@earendil-works/pi-tui";
|
|
8
|
-
import type {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from "../render.ts";
|
|
14
|
-
import type { StatuslineMenuTheme } from "./statusline-theme.ts";
|
|
8
|
+
import type { FooterRenderInput } from "./render.ts";
|
|
9
|
+
import { buildFooterLine } from "./render.ts";
|
|
10
|
+
import type { StatusLineTheme } from "./theme.ts";
|
|
11
|
+
import type { PiStatusConfig, StatusLineSegmentId } from "../shared/types.ts";
|
|
12
|
+
import { isUsageSegment } from "../shared/types.ts";
|
|
15
13
|
|
|
16
14
|
type SegmentMetadata = {
|
|
17
15
|
id: StatusLineSegmentId;
|
|
@@ -20,11 +18,7 @@ type SegmentMetadata = {
|
|
|
20
18
|
};
|
|
21
19
|
|
|
22
20
|
const SEGMENT_ORDER: readonly SegmentMetadata[] = [
|
|
23
|
-
{
|
|
24
|
-
id: "model",
|
|
25
|
-
label: "Model",
|
|
26
|
-
description: "Current model name",
|
|
27
|
-
},
|
|
21
|
+
{ id: "model", label: "Model", description: "Current model name" },
|
|
28
22
|
{
|
|
29
23
|
id: "model-with-reasoning",
|
|
30
24
|
label: "Model + Reasoning",
|
|
@@ -111,8 +105,8 @@ const SEGMENT_METADATA = new Map(
|
|
|
111
105
|
);
|
|
112
106
|
|
|
113
107
|
const STATUS_ROW_DESCRIPTION = "Visible when extension-statuses is enabled";
|
|
114
|
-
const POLICY_ROW_LABEL = "
|
|
115
|
-
const POLICY_ROW_DESCRIPTION = "
|
|
108
|
+
const POLICY_ROW_LABEL = "Extension Statuses";
|
|
109
|
+
const POLICY_ROW_DESCRIPTION = "Show extension statuses";
|
|
116
110
|
const EMPTY_EXTENSION_STATUSES_HINT = "No extension statuses yet.";
|
|
117
111
|
const SEGMENT_SECTION_TITLE = "Status line items";
|
|
118
112
|
const STATUS_SECTION_TITLE = "Extension statuses";
|
|
@@ -152,11 +146,9 @@ export function mapStatusDraftToFilter(input: {
|
|
|
152
146
|
a.localeCompare(b),
|
|
153
147
|
);
|
|
154
148
|
const shown = new Set(input.shownKeys);
|
|
155
|
-
|
|
156
149
|
if (input.newStatusesShown) {
|
|
157
150
|
return { mode: "all", hidden: discovered.filter((k) => !shown.has(k)) };
|
|
158
151
|
}
|
|
159
|
-
|
|
160
152
|
return { mode: "only", shown: discovered.filter((k) => shown.has(k)) };
|
|
161
153
|
}
|
|
162
154
|
|
|
@@ -169,34 +161,39 @@ function includesFuzzy(haystack: string, needle: string): boolean {
|
|
|
169
161
|
return j === n.length;
|
|
170
162
|
}
|
|
171
163
|
|
|
164
|
+
function styleSelected(
|
|
165
|
+
text: string,
|
|
166
|
+
theme: StatusLineTheme,
|
|
167
|
+
selected: boolean,
|
|
168
|
+
): string {
|
|
169
|
+
return selected ? theme.fg("accent", theme.bold(text)) : text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
172
|
function renderRowLine(
|
|
173
173
|
row: {
|
|
174
|
-
|
|
174
|
+
selected: boolean;
|
|
175
175
|
checkbox: string;
|
|
176
176
|
labelWithOrder: string;
|
|
177
177
|
description: string;
|
|
178
178
|
},
|
|
179
179
|
width: number,
|
|
180
|
-
theme:
|
|
180
|
+
theme: StatusLineTheme,
|
|
181
181
|
): string {
|
|
182
182
|
if (width < 1) return "";
|
|
183
183
|
|
|
184
|
-
const
|
|
185
|
-
const
|
|
184
|
+
const markerRaw = row.selected ? "▸" : " ";
|
|
185
|
+
const marker = row.selected ? theme.fg("accent", markerRaw) : markerRaw;
|
|
186
|
+
const prefixRaw = `${markerRaw} ${row.checkbox} `;
|
|
187
|
+
const prefixWidth = visibleWidth(prefixRaw);
|
|
186
188
|
const alignedMinWidth =
|
|
187
189
|
prefixWidth +
|
|
188
190
|
LABEL_COLUMN_WIDTH +
|
|
189
191
|
LAYOUT_GAP.length +
|
|
190
192
|
MIN_DESCRIPTION_WIDTH;
|
|
191
193
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// widths, so handle these cases explicitly to keep the cursor marker
|
|
196
|
-
// identifiable for selected rows.
|
|
197
|
-
if (row.cursor === ">") return ">".padEnd(width, " ");
|
|
198
|
-
return " ".repeat(width);
|
|
199
|
-
}
|
|
194
|
+
const checkbox = styleSelected(row.checkbox, theme, row.selected);
|
|
195
|
+
|
|
196
|
+
if (width < prefixWidth) return truncateToWidth(marker, width);
|
|
200
197
|
|
|
201
198
|
if (width >= alignedMinWidth) {
|
|
202
199
|
const labelFitted = truncateToWidth(row.labelWithOrder, LABEL_COLUMN_WIDTH);
|
|
@@ -206,32 +203,38 @@ function renderRowLine(
|
|
|
206
203
|
width - prefixWidth - LABEL_COLUMN_WIDTH - LAYOUT_GAP.length,
|
|
207
204
|
);
|
|
208
205
|
const desc = truncateToWidth(row.description, descWidth);
|
|
209
|
-
|
|
206
|
+
const label = styleSelected(labelPadded, theme, row.selected);
|
|
207
|
+
return `${marker} ${checkbox} ${label}${LAYOUT_GAP}${theme.dim(desc)}`;
|
|
210
208
|
}
|
|
211
209
|
|
|
212
210
|
const separator = " - ";
|
|
213
211
|
const remainingWidth = width - prefixWidth;
|
|
214
212
|
if (remainingWidth <= separator.length + 1) {
|
|
215
|
-
|
|
213
|
+
const label = truncateToWidth(
|
|
214
|
+
row.labelWithOrder,
|
|
215
|
+
Math.max(0, width - prefixWidth),
|
|
216
|
+
);
|
|
217
|
+
return truncateToWidth(`${markerRaw} ${row.checkbox} ${label}`, width);
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
const labelWidth = Math.max(1, remainingWidth - separator.length - 1);
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
const fallbackDescWidth = Math.max(0, width - visibleWidth(
|
|
221
|
+
const labelRaw = truncateToWidth(row.labelWithOrder, labelWidth);
|
|
222
|
+
const fallbackBaseRaw = `${prefixRaw}${labelRaw}${separator}`;
|
|
223
|
+
const fallbackDescWidth = Math.max(0, width - visibleWidth(fallbackBaseRaw));
|
|
222
224
|
const desc = truncateToWidth(row.description, fallbackDescWidth);
|
|
223
|
-
|
|
225
|
+
const label = styleSelected(labelRaw, theme, row.selected);
|
|
226
|
+
return `${marker} ${checkbox} ${label}${separator}${theme.dim(desc)}`;
|
|
224
227
|
}
|
|
225
228
|
|
|
226
229
|
function renderSectionHeader(
|
|
227
230
|
text: string,
|
|
228
231
|
width: number,
|
|
229
|
-
theme:
|
|
232
|
+
theme: StatusLineTheme,
|
|
230
233
|
): string {
|
|
231
234
|
return truncateToWidth(theme.dim(text), width);
|
|
232
235
|
}
|
|
233
236
|
|
|
234
|
-
function renderDivider(width: number, theme:
|
|
237
|
+
function renderDivider(width: number, theme: StatusLineTheme): string {
|
|
235
238
|
return truncateToWidth(
|
|
236
239
|
theme.fg("borderMuted", "─".repeat(Math.max(1, width))),
|
|
237
240
|
width,
|
|
@@ -241,22 +244,27 @@ function renderDivider(width: number, theme: StatuslineMenuTheme): string {
|
|
|
241
244
|
function renderHint(
|
|
242
245
|
text: string,
|
|
243
246
|
width: number,
|
|
244
|
-
theme:
|
|
247
|
+
theme: StatusLineTheme,
|
|
245
248
|
): string {
|
|
246
249
|
return truncateToWidth(theme.dim(text), width);
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
export function
|
|
252
|
+
export function createStatusLineEditor(options: {
|
|
250
253
|
config: PiStatusConfig;
|
|
251
254
|
discoveredStatuses: string[];
|
|
252
255
|
previewInput: Omit<FooterRenderInput, "segments" | "filter">;
|
|
253
|
-
theme:
|
|
256
|
+
theme: StatusLineTheme;
|
|
254
257
|
done: (result: PiStatusConfig | null) => void;
|
|
255
258
|
requestRender: () => void;
|
|
259
|
+
usageAvailable?: boolean;
|
|
256
260
|
}): Component {
|
|
257
261
|
const orderedStatuses = [...options.discoveredStatuses].sort((a, b) =>
|
|
258
262
|
a.localeCompare(b),
|
|
259
263
|
);
|
|
264
|
+
const visibleSegments = SEGMENT_ORDER.filter(
|
|
265
|
+
(segment) =>
|
|
266
|
+
options.usageAvailable !== false || !isUsageSegment(segment.id),
|
|
267
|
+
);
|
|
260
268
|
let enabledSegments = [...options.config.segments];
|
|
261
269
|
|
|
262
270
|
const shownNew = options.config.filter.mode === "all";
|
|
@@ -278,17 +286,18 @@ export function createStatuslineEditor(options: {
|
|
|
278
286
|
}
|
|
279
287
|
|
|
280
288
|
function getInteractiveRows(): InteractiveRow[] {
|
|
281
|
-
const enabled = enabledSegments
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
const enabled = enabledSegments
|
|
290
|
+
.filter((id): id is StatusLineSegmentId =>
|
|
291
|
+
visibleSegments.some((segment) => segment.id === id),
|
|
292
|
+
)
|
|
293
|
+
.map((id) => ({ type: "segment", id })) as SegmentInteractiveRow[];
|
|
294
|
+
|
|
295
|
+
const disabled = visibleSegments
|
|
296
|
+
.filter((segment) => !isEnabledSegment(segment.id))
|
|
297
|
+
.map((segment) => ({
|
|
298
|
+
type: "segment",
|
|
299
|
+
id: segment.id,
|
|
300
|
+
})) as SegmentInteractiveRow[];
|
|
292
301
|
|
|
293
302
|
const policy: PolicyInteractiveRow = { type: "policy" };
|
|
294
303
|
const statuses = orderedStatuses.map((key) => ({
|
|
@@ -301,20 +310,17 @@ export function createStatuslineEditor(options: {
|
|
|
301
310
|
|
|
302
311
|
function rowMatchesQuery(row: InteractiveRow): boolean {
|
|
303
312
|
if (!query) return true;
|
|
304
|
-
|
|
305
313
|
if (row.type === "segment") {
|
|
306
314
|
const meta = SEGMENT_METADATA.get(row.id);
|
|
307
315
|
if (!meta) return false;
|
|
308
316
|
return includesFuzzy(`${meta.label} ${meta.description}`, query);
|
|
309
317
|
}
|
|
310
|
-
|
|
311
318
|
if (row.type === "policy") {
|
|
312
319
|
return includesFuzzy(
|
|
313
320
|
`${POLICY_ROW_LABEL} ${POLICY_ROW_DESCRIPTION}`,
|
|
314
321
|
query,
|
|
315
322
|
);
|
|
316
323
|
}
|
|
317
|
-
|
|
318
324
|
return includesFuzzy(`${row.key} ${STATUS_ROW_DESCRIPTION}`, query);
|
|
319
325
|
}
|
|
320
326
|
|
|
@@ -324,7 +330,6 @@ export function createStatuslineEditor(options: {
|
|
|
324
330
|
|
|
325
331
|
function getRenderRows(): RenderRow[] {
|
|
326
332
|
const filtered = getFilteredInteractiveRows();
|
|
327
|
-
|
|
328
333
|
if (query) {
|
|
329
334
|
return filtered.map((row, interactiveIndex) => ({
|
|
330
335
|
type: "interactive",
|
|
@@ -343,25 +348,23 @@ export function createStatuslineEditor(options: {
|
|
|
343
348
|
|
|
344
349
|
const renderRows: RenderRow[] = [];
|
|
345
350
|
let interactiveIndex = 0;
|
|
346
|
-
|
|
347
351
|
renderRows.push({ type: "header", text: SEGMENT_SECTION_TITLE });
|
|
348
|
-
for (const row of segmentRows)
|
|
349
|
-
renderRows.push({
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
352
|
+
for (const row of segmentRows)
|
|
353
|
+
renderRows.push({
|
|
354
|
+
type: "interactive",
|
|
355
|
+
row,
|
|
356
|
+
interactiveIndex: interactiveIndex++,
|
|
357
|
+
});
|
|
353
358
|
renderRows.push({ type: "divider" });
|
|
354
359
|
renderRows.push({ type: "header", text: STATUS_SECTION_TITLE });
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (orderedStatuses.length === 0)
|
|
360
|
+
for (const row of extensionRows)
|
|
361
|
+
renderRows.push({
|
|
362
|
+
type: "interactive",
|
|
363
|
+
row,
|
|
364
|
+
interactiveIndex: interactiveIndex++,
|
|
365
|
+
});
|
|
366
|
+
if (orderedStatuses.length === 0)
|
|
362
367
|
renderRows.push({ type: "hint", text: EMPTY_EXTENSION_STATUSES_HINT });
|
|
363
|
-
}
|
|
364
|
-
|
|
365
368
|
return renderRows;
|
|
366
369
|
}
|
|
367
370
|
|
|
@@ -377,26 +380,21 @@ export function createStatuslineEditor(options: {
|
|
|
377
380
|
|
|
378
381
|
function toggleRow(row: InteractiveRow): void {
|
|
379
382
|
if (row.type === "segment") {
|
|
380
|
-
if (isEnabledSegment(row.id))
|
|
383
|
+
if (isEnabledSegment(row.id))
|
|
381
384
|
enabledSegments = enabledSegments.filter((x) => x !== row.id);
|
|
382
|
-
|
|
383
|
-
enabledSegments = [...enabledSegments, row.id];
|
|
384
|
-
}
|
|
385
|
+
else enabledSegments = [...enabledSegments, row.id];
|
|
385
386
|
return;
|
|
386
387
|
}
|
|
387
|
-
|
|
388
388
|
if (row.type === "status") {
|
|
389
389
|
if (shown.has(row.key)) shown.delete(row.key);
|
|
390
390
|
else shown.add(row.key);
|
|
391
391
|
return;
|
|
392
392
|
}
|
|
393
|
-
|
|
394
393
|
newPolicyShown = !newPolicyShown;
|
|
395
394
|
}
|
|
396
395
|
|
|
397
396
|
function moveSegment(delta: -1 | 1, row: InteractiveRow): void {
|
|
398
|
-
if (query) return;
|
|
399
|
-
if (row.type !== "segment") return;
|
|
397
|
+
if (query || row.type !== "segment") return;
|
|
400
398
|
const idx = enabledSegments.indexOf(row.id);
|
|
401
399
|
if (idx < 0) return;
|
|
402
400
|
const next = idx + delta;
|
|
@@ -420,49 +418,37 @@ export function createStatuslineEditor(options: {
|
|
|
420
418
|
|
|
421
419
|
return {
|
|
422
420
|
invalidate(): void {},
|
|
423
|
-
|
|
424
421
|
handleInput(data: string): void {
|
|
425
422
|
clampSelection();
|
|
426
423
|
const list = getFilteredInteractiveRows();
|
|
427
424
|
const current = list[selected];
|
|
428
425
|
|
|
429
|
-
if (matchesKey(data, Key.escape))
|
|
430
|
-
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
if (matchesKey(data, Key.enter)) {
|
|
434
|
-
options.done(toConfig());
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
426
|
+
if (matchesKey(data, Key.escape)) return void options.done(null);
|
|
427
|
+
if (matchesKey(data, Key.enter)) return void options.done(toConfig());
|
|
437
428
|
if (matchesKey(data, Key.up)) {
|
|
438
429
|
selected--;
|
|
439
430
|
clampSelection();
|
|
440
|
-
options.requestRender();
|
|
441
|
-
return;
|
|
431
|
+
return void options.requestRender();
|
|
442
432
|
}
|
|
443
433
|
if (matchesKey(data, Key.down)) {
|
|
444
434
|
selected++;
|
|
445
435
|
clampSelection();
|
|
446
|
-
options.requestRender();
|
|
447
|
-
return;
|
|
436
|
+
return void options.requestRender();
|
|
448
437
|
}
|
|
449
438
|
if (matchesKey(data, Key.space) && current) {
|
|
450
439
|
toggleRow(current);
|
|
451
440
|
clampSelection();
|
|
452
|
-
options.requestRender();
|
|
453
|
-
return;
|
|
441
|
+
return void options.requestRender();
|
|
454
442
|
}
|
|
455
443
|
if (matchesKey(data, Key.left) && current) {
|
|
456
444
|
moveSegment(-1, current);
|
|
457
445
|
clampSelection();
|
|
458
|
-
options.requestRender();
|
|
459
|
-
return;
|
|
446
|
+
return void options.requestRender();
|
|
460
447
|
}
|
|
461
448
|
if (matchesKey(data, Key.right) && current) {
|
|
462
449
|
moveSegment(1, current);
|
|
463
450
|
clampSelection();
|
|
464
|
-
options.requestRender();
|
|
465
|
-
return;
|
|
451
|
+
return void options.requestRender();
|
|
466
452
|
}
|
|
467
453
|
if (matchesKey(data, Key.backspace)) {
|
|
468
454
|
if (query.length > 0) {
|
|
@@ -472,24 +458,18 @@ export function createStatuslineEditor(options: {
|
|
|
472
458
|
}
|
|
473
459
|
return;
|
|
474
460
|
}
|
|
475
|
-
|
|
476
461
|
if (/^[\x21-\x7E]$/.test(data)) {
|
|
477
462
|
query += data;
|
|
478
463
|
clampSelection();
|
|
479
464
|
options.requestRender();
|
|
480
465
|
}
|
|
481
466
|
},
|
|
482
|
-
|
|
483
467
|
render(width: number): string[] {
|
|
484
468
|
clampSelection();
|
|
485
469
|
const renderRows = getRenderRows();
|
|
486
470
|
const cfg = toConfig();
|
|
487
471
|
const preview = buildFooterLine(
|
|
488
|
-
{
|
|
489
|
-
...options.previewInput,
|
|
490
|
-
segments: cfg.segments,
|
|
491
|
-
filter: cfg.filter,
|
|
492
|
-
},
|
|
472
|
+
{ ...options.previewInput, segments: cfg.segments, filter: cfg.filter },
|
|
493
473
|
options.theme,
|
|
494
474
|
width,
|
|
495
475
|
);
|
|
@@ -504,7 +484,7 @@ export function createStatuslineEditor(options: {
|
|
|
504
484
|
lines.push(truncateToWidth(options.theme.dim(SHELL_SUBTITLE), width));
|
|
505
485
|
lines.push(truncateToWidth("", width));
|
|
506
486
|
lines.push(truncateToWidth(options.theme.dim(SHELL_PLACEHOLDER), width));
|
|
507
|
-
lines.push(truncateToWidth(
|
|
487
|
+
lines.push(truncateToWidth(`▸ ${query}`, width));
|
|
508
488
|
|
|
509
489
|
for (const renderRow of renderRows) {
|
|
510
490
|
if (renderRow.type === "header") {
|
|
@@ -521,7 +501,7 @@ export function createStatuslineEditor(options: {
|
|
|
521
501
|
}
|
|
522
502
|
|
|
523
503
|
const row = renderRow.row;
|
|
524
|
-
const
|
|
504
|
+
const selectedRow = renderRow.interactiveIndex === selected;
|
|
525
505
|
if (row.type === "segment") {
|
|
526
506
|
const enabled = isEnabledSegment(row.id) ? "[x]" : "[ ]";
|
|
527
507
|
const order = isEnabledSegment(row.id)
|
|
@@ -529,13 +509,12 @@ export function createStatuslineEditor(options: {
|
|
|
529
509
|
: "";
|
|
530
510
|
const meta = SEGMENT_METADATA.get(row.id);
|
|
531
511
|
if (!meta) continue;
|
|
532
|
-
const labelWithOrder = `${meta.label}${order}`;
|
|
533
512
|
lines.push(
|
|
534
513
|
renderRowLine(
|
|
535
514
|
{
|
|
536
|
-
|
|
515
|
+
selected: selectedRow,
|
|
537
516
|
checkbox: enabled,
|
|
538
|
-
labelWithOrder
|
|
517
|
+
labelWithOrder: `${meta.label}${order}`,
|
|
539
518
|
description: meta.description,
|
|
540
519
|
},
|
|
541
520
|
width,
|
|
@@ -545,12 +524,11 @@ export function createStatuslineEditor(options: {
|
|
|
545
524
|
continue;
|
|
546
525
|
}
|
|
547
526
|
if (row.type === "status") {
|
|
548
|
-
const enabled = shown.has(row.key) ? "[x]" : "[ ]";
|
|
549
527
|
lines.push(
|
|
550
528
|
renderRowLine(
|
|
551
529
|
{
|
|
552
|
-
|
|
553
|
-
checkbox:
|
|
530
|
+
selected: selectedRow,
|
|
531
|
+
checkbox: shown.has(row.key) ? "[x]" : "[ ]",
|
|
554
532
|
labelWithOrder: row.key,
|
|
555
533
|
description: STATUS_ROW_DESCRIPTION,
|
|
556
534
|
},
|
|
@@ -560,12 +538,11 @@ export function createStatuslineEditor(options: {
|
|
|
560
538
|
);
|
|
561
539
|
continue;
|
|
562
540
|
}
|
|
563
|
-
const checkbox = `[${newPolicyShown ? "shown" : "hidden"}]`;
|
|
564
541
|
lines.push(
|
|
565
542
|
renderRowLine(
|
|
566
543
|
{
|
|
567
|
-
|
|
568
|
-
checkbox,
|
|
544
|
+
selected: selectedRow,
|
|
545
|
+
checkbox: newPolicyShown ? "[x]" : "[ ]",
|
|
569
546
|
labelWithOrder: POLICY_ROW_LABEL,
|
|
570
547
|
description: POLICY_ROW_DESCRIPTION,
|
|
571
548
|
},
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { basename, dirname, join } from "node:path";
|
|
3
2
|
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_SEGMENTS,
|
|
7
|
+
type StatusFilter,
|
|
8
|
+
type StatusLineSegmentId,
|
|
9
|
+
} from "../shared/types.ts";
|
|
5
10
|
|
|
6
|
-
// Color roles the live `buildFooterLine` rendering actually depends on. Kept
|
|
7
|
-
// narrow on purpose: it only covers the segment colors and the "dim"
|
|
8
|
-
// separator, not the full Pi `ThemeColor` union. `StatuslineMenuTheme` is
|
|
9
|
-
// built as a superset of this so the same adapted theme can also feed the
|
|
10
|
-
// bottom preview line.
|
|
11
11
|
export type FooterRenderColor =
|
|
12
12
|
| "accent"
|
|
13
13
|
| "dim"
|
|
@@ -25,30 +25,8 @@ export type ModelLike = {
|
|
|
25
25
|
reasoning?: boolean;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
export type StatusLineSegmentId =
|
|
29
|
-
| "model"
|
|
30
|
-
| "model-with-reasoning"
|
|
31
|
-
| "project-name"
|
|
32
|
-
| "current-dir"
|
|
33
|
-
| "git-branch"
|
|
34
|
-
| "run-state"
|
|
35
|
-
| "context-remaining"
|
|
36
|
-
| "context-used"
|
|
37
|
-
| "context-window-size"
|
|
38
|
-
| "used-tokens"
|
|
39
|
-
| "total-input-tokens"
|
|
40
|
-
| "total-output-tokens"
|
|
41
|
-
| "session-id"
|
|
42
|
-
| "five-hour-limit"
|
|
43
|
-
| "weekly-limit"
|
|
44
|
-
| "extension-statuses";
|
|
45
|
-
|
|
46
28
|
export type RunState = "busy" | "queued" | "idle";
|
|
47
29
|
|
|
48
|
-
export type StatusFilter =
|
|
49
|
-
| { mode: "all"; hidden: string[] }
|
|
50
|
-
| { mode: "only"; shown: string[] };
|
|
51
|
-
|
|
52
30
|
export type FooterRenderInput = {
|
|
53
31
|
model?: ModelLike;
|
|
54
32
|
cwd: string;
|
|
@@ -80,10 +58,7 @@ export type FooterRenderInput = {
|
|
|
80
58
|
segments: StatusLineSegmentId[];
|
|
81
59
|
};
|
|
82
60
|
|
|
83
|
-
export
|
|
84
|
-
"model-with-reasoning",
|
|
85
|
-
"current-dir",
|
|
86
|
-
];
|
|
61
|
+
export { DEFAULT_SEGMENTS };
|
|
87
62
|
|
|
88
63
|
export function normalizeThinkingLevel(level: string): string {
|
|
89
64
|
switch (level) {
|
|
@@ -124,10 +99,7 @@ export function abbreviateHomeDir(cwd: string, home = homedir()): string {
|
|
|
124
99
|
export function findProjectRootLabel(cwd: string): string | null {
|
|
125
100
|
let current = cwd;
|
|
126
101
|
while (true) {
|
|
127
|
-
if (
|
|
128
|
-
existsSync(join(current, ".git")) ||
|
|
129
|
-
existsSync(join(current, ".pi/settings.json"))
|
|
130
|
-
) {
|
|
102
|
+
if (existsSync(join(current, ".git")) || existsSync(join(current, ".pi/settings.json"))) {
|
|
131
103
|
const base = basename(current);
|
|
132
104
|
return base || current;
|
|
133
105
|
}
|
|
@@ -151,14 +123,10 @@ function getRateWindow(
|
|
|
151
123
|
key: "fiveHour" | "weekly",
|
|
152
124
|
): { usedPercent: number } | null {
|
|
153
125
|
const snapshot = input.usageState?.compatibility?.currentLiveProviderSnapshot;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
!window ||
|
|
158
|
-
typeof window.usedPercent !== "number" ||
|
|
159
|
-
window.unavailableReason
|
|
160
|
-
)
|
|
126
|
+
const window = snapshot?.windows.find((item) => item.key === key);
|
|
127
|
+
if (!window || typeof window.usedPercent !== "number" || window.unavailableReason) {
|
|
161
128
|
return null;
|
|
129
|
+
}
|
|
162
130
|
return { usedPercent: window.usedPercent };
|
|
163
131
|
}
|
|
164
132
|
|
|
@@ -189,20 +157,14 @@ function formatExtensionStatuses(
|
|
|
189
157
|
input: FooterRenderInput,
|
|
190
158
|
theme: ThemeLike,
|
|
191
159
|
): string | null {
|
|
192
|
-
const entries = [...(input.extensionStatuses?.entries() ?? [])].sort(
|
|
193
|
-
|
|
160
|
+
const entries = [...(input.extensionStatuses?.entries() ?? [])].sort(([a], [b]) =>
|
|
161
|
+
a.localeCompare(b),
|
|
194
162
|
);
|
|
195
163
|
if (entries.length === 0) return null;
|
|
196
164
|
|
|
197
165
|
const filter = input.filter;
|
|
198
|
-
const blocked =
|
|
199
|
-
|
|
200
|
-
? new Set(normalizeFilterList(filter.hidden))
|
|
201
|
-
: undefined;
|
|
202
|
-
const allowed =
|
|
203
|
-
filter.mode === "only"
|
|
204
|
-
? new Set(normalizeFilterList(filter.shown))
|
|
205
|
-
: undefined;
|
|
166
|
+
const blocked = filter.mode === "all" ? new Set(normalizeFilterList(filter.hidden)) : undefined;
|
|
167
|
+
const allowed = filter.mode === "only" ? new Set(normalizeFilterList(filter.shown)) : undefined;
|
|
206
168
|
const visible =
|
|
207
169
|
filter.mode === "all"
|
|
208
170
|
? entries.filter(([key]) => !blocked?.has(key))
|
|
@@ -225,7 +187,7 @@ function formatExtensionStatuses(
|
|
|
225
187
|
return parts.join(theme.fg("dim", " | "));
|
|
226
188
|
}
|
|
227
189
|
|
|
228
|
-
function formatSegment(
|
|
190
|
+
export function formatSegment(
|
|
229
191
|
id: StatusLineSegmentId,
|
|
230
192
|
input: FooterRenderInput,
|
|
231
193
|
theme: ThemeLike,
|
|
@@ -262,11 +224,8 @@ function formatSegment(
|
|
|
262
224
|
const window = input.contextUsage?.contextWindow;
|
|
263
225
|
const percent = input.contextUsage?.percent;
|
|
264
226
|
if (
|
|
265
|
-
total === undefined ||
|
|
266
|
-
|
|
267
|
-
window === undefined ||
|
|
268
|
-
percent === undefined ||
|
|
269
|
-
percent === null
|
|
227
|
+
total === undefined || total === null || window === undefined ||
|
|
228
|
+
percent === undefined || percent === null
|
|
270
229
|
) {
|
|
271
230
|
return null;
|
|
272
231
|
}
|
|
@@ -275,48 +234,32 @@ function formatSegment(
|
|
|
275
234
|
}
|
|
276
235
|
case "context-window-size": {
|
|
277
236
|
const value = input.contextUsage?.contextWindow;
|
|
278
|
-
return value === undefined
|
|
279
|
-
? null
|
|
280
|
-
: [`${formatCompactNumber(value)} ctx`, "dim"];
|
|
237
|
+
return value === undefined ? null : [`${formatCompactNumber(value)} ctx`, "dim"];
|
|
281
238
|
}
|
|
282
239
|
case "used-tokens": {
|
|
283
240
|
const value = input.branchTotals?.totalTokens;
|
|
284
|
-
return value === undefined
|
|
285
|
-
? null
|
|
286
|
-
: [`${formatCompactNumber(value)} tok`, "dim"];
|
|
241
|
+
return value === undefined ? null : [`${formatCompactNumber(value)} tok`, "dim"];
|
|
287
242
|
}
|
|
288
243
|
case "total-input-tokens": {
|
|
289
244
|
const value = input.branchTotals?.input;
|
|
290
|
-
return value === undefined
|
|
291
|
-
? null
|
|
292
|
-
: [`↑${formatCompactNumber(value)}`, "dim"];
|
|
245
|
+
return value === undefined ? null : [`↑${formatCompactNumber(value)}`, "dim"];
|
|
293
246
|
}
|
|
294
247
|
case "total-output-tokens": {
|
|
295
248
|
const value = input.branchTotals?.output;
|
|
296
|
-
return value === undefined
|
|
297
|
-
? null
|
|
298
|
-
: [`↓${formatCompactNumber(value)}`, "dim"];
|
|
249
|
+
return value === undefined ? null : [`↓${formatCompactNumber(value)}`, "dim"];
|
|
299
250
|
}
|
|
300
251
|
case "session-id":
|
|
301
|
-
return input.sessionId
|
|
302
|
-
? [`sid ${input.sessionId.slice(0, 8)}`, "dim"]
|
|
303
|
-
: null;
|
|
252
|
+
return input.sessionId ? [`sid ${input.sessionId.slice(0, 8)}`, "dim"] : null;
|
|
304
253
|
case "five-hour-limit": {
|
|
305
254
|
const window = getRateWindow(input, "fiveHour");
|
|
306
255
|
if (!window) return null;
|
|
307
|
-
const remaining = Math.min(
|
|
308
|
-
100,
|
|
309
|
-
Math.max(0, 100 - Math.round(window.usedPercent)),
|
|
310
|
-
);
|
|
256
|
+
const remaining = Math.min(100, Math.max(0, 100 - Math.round(window.usedPercent)));
|
|
311
257
|
return [`5h ${remaining}% left`, rateColor(window.usedPercent)];
|
|
312
258
|
}
|
|
313
259
|
case "weekly-limit": {
|
|
314
260
|
const window = getRateWindow(input, "weekly");
|
|
315
261
|
if (!window) return null;
|
|
316
|
-
const remaining = Math.min(
|
|
317
|
-
100,
|
|
318
|
-
Math.max(0, 100 - Math.round(window.usedPercent)),
|
|
319
|
-
);
|
|
262
|
+
const remaining = Math.min(100, Math.max(0, 100 - Math.round(window.usedPercent)));
|
|
320
263
|
return [`wk ${remaining}% left`, rateColor(window.usedPercent)];
|
|
321
264
|
}
|
|
322
265
|
case "extension-statuses": {
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FooterRenderColor } from "./render.ts";
|
|
2
|
+
|
|
3
|
+
export type StatusLineMenuColor = FooterRenderColor | "borderMuted";
|
|
4
|
+
|
|
5
|
+
export type StatusLineTheme = {
|
|
6
|
+
fg: (color: StatusLineMenuColor, text: string) => string;
|
|
7
|
+
bold: (text: string) => string;
|
|
8
|
+
dim: (text: string) => string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type PiThemeLike = {
|
|
12
|
+
fg: (color: string, text: string) => string;
|
|
13
|
+
bold: (text: string) => string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function isPiThemeLike(value: unknown): value is PiThemeLike {
|
|
17
|
+
if (!value || typeof value !== "object") return false;
|
|
18
|
+
const candidate = value as { fg?: unknown; bold?: unknown };
|
|
19
|
+
return typeof candidate.fg === "function" && typeof candidate.bold === "function";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const noTheme: StatusLineTheme = {
|
|
23
|
+
fg: (_color, text) => text,
|
|
24
|
+
bold: (text) => text,
|
|
25
|
+
dim: (text) => text,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function fromPiTheme(theme: unknown): StatusLineTheme {
|
|
29
|
+
if (!isPiThemeLike(theme)) return noTheme;
|
|
30
|
+
return {
|
|
31
|
+
fg: (color, text) => theme.fg(color, text),
|
|
32
|
+
bold: (text) => theme.bold(text),
|
|
33
|
+
dim: (text) => theme.fg("dim", text),
|
|
34
|
+
};
|
|
35
|
+
}
|