@pi-vault/pi-status 0.1.0 → 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.
@@ -5,13 +5,11 @@ import {
5
5
  visibleWidth,
6
6
  type Component,
7
7
  } from "@earendil-works/pi-tui";
8
- import type { PiStatusConfig } from "../config.ts";
9
- import {
10
- buildFooterLine,
11
- type FooterRenderInput,
12
- type StatusLineSegmentId,
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 = "New extension statuses";
115
- const POLICY_ROW_DESCRIPTION = "Default visibility for new extension statuses";
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
- cursor: string;
174
+ selected: boolean;
175
175
  checkbox: string;
176
176
  labelWithOrder: string;
177
177
  description: string;
178
178
  },
179
179
  width: number,
180
- theme: StatuslineMenuTheme,
180
+ theme: StatusLineTheme,
181
181
  ): string {
182
182
  if (width < 1) return "";
183
183
 
184
- const prefix = `${row.cursor} ${row.checkbox} `;
185
- const prefixWidth = visibleWidth(prefix);
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
- if (width < prefixWidth) {
193
- // Not enough room for the full prefix. truncateToWidth uses an ellipsis
194
- // strategy that does not preserve the first character at very small
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
- return `${prefix}${labelPadded}${LAYOUT_GAP}${theme.dim(desc)}`;
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
- return truncateToWidth(`${prefix}${row.labelWithOrder}`, width);
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 label = truncateToWidth(row.labelWithOrder, labelWidth);
220
- const fallbackBase = `${prefix}${label}${separator}`;
221
- const fallbackDescWidth = Math.max(0, width - visibleWidth(fallbackBase));
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
- return `${fallbackBase}${theme.dim(desc)}`;
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: StatuslineMenuTheme,
232
+ theme: StatusLineTheme,
230
233
  ): string {
231
234
  return truncateToWidth(theme.dim(text), width);
232
235
  }
233
236
 
234
- function renderDivider(width: number, theme: StatuslineMenuTheme): string {
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: StatuslineMenuTheme,
247
+ theme: StatusLineTheme,
245
248
  ): string {
246
249
  return truncateToWidth(theme.dim(text), width);
247
250
  }
248
251
 
249
- export function createStatuslineEditor(options: {
252
+ export function createStatusLineEditor(options: {
250
253
  config: PiStatusConfig;
251
254
  discoveredStatuses: string[];
252
255
  previewInput: Omit<FooterRenderInput, "segments" | "filter">;
253
- theme: StatuslineMenuTheme;
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.map((id) => ({
282
- type: "segment",
283
- id,
284
- })) as SegmentInteractiveRow[];
285
-
286
- const disabled = SEGMENT_ORDER.filter(
287
- (segment) => !isEnabledSegment(segment.id),
288
- ).map((segment) => ({
289
- type: "segment",
290
- id: segment.id,
291
- })) as SegmentInteractiveRow[];
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({ type: "interactive", row, interactiveIndex });
350
- interactiveIndex++;
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
- for (const row of extensionRows) {
357
- renderRows.push({ type: "interactive", row, interactiveIndex });
358
- interactiveIndex++;
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
- } else {
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
- options.done(null);
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(`> ${query}`, width));
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 cursor = renderRow.interactiveIndex === selected ? ">" : " ";
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
- cursor,
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
- cursor,
553
- checkbox: enabled,
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
- cursor,
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 const DEFAULT_SEGMENTS: StatusLineSegmentId[] = [
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
- if (snapshot?.providerId !== "openai-codex") return null;
155
- const window = snapshot.windows.find((item) => item.key === key);
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
- ([a], [b]) => a.localeCompare(b),
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
- filter.mode === "all"
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))
@@ -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
- total === null ||
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": {
@@ -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
+ }