@pi-vault/pi-status 0.1.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/LICENSE +21 -0
- package/README.md +95 -0
- package/package.json +58 -0
- package/src/config.ts +272 -0
- package/src/index.ts +293 -0
- package/src/render.ts +343 -0
- package/src/ui/statusline-editor.ts +589 -0
- package/src/ui/statusline-theme.ts +67 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Key,
|
|
3
|
+
matchesKey,
|
|
4
|
+
truncateToWidth,
|
|
5
|
+
visibleWidth,
|
|
6
|
+
type Component,
|
|
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";
|
|
15
|
+
|
|
16
|
+
type SegmentMetadata = {
|
|
17
|
+
id: StatusLineSegmentId;
|
|
18
|
+
label: string;
|
|
19
|
+
description: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const SEGMENT_ORDER: readonly SegmentMetadata[] = [
|
|
23
|
+
{
|
|
24
|
+
id: "model",
|
|
25
|
+
label: "Model",
|
|
26
|
+
description: "Current model name",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "model-with-reasoning",
|
|
30
|
+
label: "Model + Reasoning",
|
|
31
|
+
description: "Current model name with reasoning level",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "project-name",
|
|
35
|
+
label: "Project Name",
|
|
36
|
+
description: "Project name (omitted when unavailable)",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "current-dir",
|
|
40
|
+
label: "Current Dir",
|
|
41
|
+
description: "Current working directory",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "git-branch",
|
|
45
|
+
label: "Git Branch",
|
|
46
|
+
description: "Current Git branch (omitted when unavailable)",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "run-state",
|
|
50
|
+
label: "Run State",
|
|
51
|
+
description: "Pi status (idle, queued, busy)",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "context-remaining",
|
|
55
|
+
label: "Context Remaining",
|
|
56
|
+
description:
|
|
57
|
+
"Percentage of context window remaining (omitted when unknown)",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "context-used",
|
|
61
|
+
label: "Context Used",
|
|
62
|
+
description: "Percentage of context window used (omitted when unknown)",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "context-window-size",
|
|
66
|
+
label: "Context Window",
|
|
67
|
+
description: "Total context window size in tokens (omitted when unknown)",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "used-tokens",
|
|
71
|
+
label: "Used Tokens",
|
|
72
|
+
description: "Total tokens used in session (omitted when zero)",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "total-input-tokens",
|
|
76
|
+
label: "Input Tokens",
|
|
77
|
+
description: "Total input tokens used in session",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "total-output-tokens",
|
|
81
|
+
label: "Output Tokens",
|
|
82
|
+
description: "Total output tokens used in session",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "session-id",
|
|
86
|
+
label: "Session ID",
|
|
87
|
+
description: "Current session ID (omitted when unavailable)",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "five-hour-limit",
|
|
91
|
+
label: "5h Limit",
|
|
92
|
+
description:
|
|
93
|
+
"Remaining usage on the primary usage limit (omitted when unavailable)",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "weekly-limit",
|
|
97
|
+
label: "Weekly Limit",
|
|
98
|
+
description:
|
|
99
|
+
"Remaining usage on the secondary usage limit (omitted when unavailable)",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "extension-statuses",
|
|
103
|
+
label: "Extension Statuses",
|
|
104
|
+
description:
|
|
105
|
+
"Visible extension status values (omitted when none are visible)",
|
|
106
|
+
},
|
|
107
|
+
] as const;
|
|
108
|
+
|
|
109
|
+
const SEGMENT_METADATA = new Map(
|
|
110
|
+
SEGMENT_ORDER.map((segment) => [segment.id, segment]),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
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";
|
|
116
|
+
const EMPTY_EXTENSION_STATUSES_HINT = "No extension statuses yet.";
|
|
117
|
+
const SEGMENT_SECTION_TITLE = "Status line items";
|
|
118
|
+
const STATUS_SECTION_TITLE = "Extension statuses";
|
|
119
|
+
|
|
120
|
+
const LABEL_COLUMN_WIDTH = 24;
|
|
121
|
+
const LAYOUT_GAP = " ";
|
|
122
|
+
const MIN_DESCRIPTION_WIDTH = 12;
|
|
123
|
+
|
|
124
|
+
const SHELL_TITLE = "Configure Status Line";
|
|
125
|
+
const SHELL_SUBTITLE = "Select which items to display in the status line.";
|
|
126
|
+
const SHELL_PLACEHOLDER = "Type to search";
|
|
127
|
+
const HELP_BASE =
|
|
128
|
+
"Toggle: Space • Reorder: ← / → • Save: Enter • Cancel: Esc";
|
|
129
|
+
const HELP_SEARCHING =
|
|
130
|
+
"Toggle: Space • Reorder: disabled while search is active • Save: Enter • Cancel: Esc";
|
|
131
|
+
|
|
132
|
+
type SegmentInteractiveRow = { type: "segment"; id: StatusLineSegmentId };
|
|
133
|
+
type StatusInteractiveRow = { type: "status"; key: string };
|
|
134
|
+
type PolicyInteractiveRow = { type: "policy" };
|
|
135
|
+
type InteractiveRow =
|
|
136
|
+
| SegmentInteractiveRow
|
|
137
|
+
| StatusInteractiveRow
|
|
138
|
+
| PolicyInteractiveRow;
|
|
139
|
+
|
|
140
|
+
type RenderRow =
|
|
141
|
+
| { type: "header"; text: string }
|
|
142
|
+
| { type: "divider" }
|
|
143
|
+
| { type: "hint"; text: string }
|
|
144
|
+
| { type: "interactive"; row: InteractiveRow; interactiveIndex: number };
|
|
145
|
+
|
|
146
|
+
export function mapStatusDraftToFilter(input: {
|
|
147
|
+
discoveredKeys: string[];
|
|
148
|
+
shownKeys: Iterable<string>;
|
|
149
|
+
newStatusesShown: boolean;
|
|
150
|
+
}): PiStatusConfig["filter"] {
|
|
151
|
+
const discovered = [...input.discoveredKeys].sort((a, b) =>
|
|
152
|
+
a.localeCompare(b),
|
|
153
|
+
);
|
|
154
|
+
const shown = new Set(input.shownKeys);
|
|
155
|
+
|
|
156
|
+
if (input.newStatusesShown) {
|
|
157
|
+
return { mode: "all", hidden: discovered.filter((k) => !shown.has(k)) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { mode: "only", shown: discovered.filter((k) => shown.has(k)) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function includesFuzzy(haystack: string, needle: string): boolean {
|
|
164
|
+
if (!needle) return true;
|
|
165
|
+
let j = 0;
|
|
166
|
+
const h = haystack.toLowerCase();
|
|
167
|
+
const n = needle.toLowerCase();
|
|
168
|
+
for (let i = 0; i < h.length && j < n.length; i++) if (h[i] === n[j]) j++;
|
|
169
|
+
return j === n.length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderRowLine(
|
|
173
|
+
row: {
|
|
174
|
+
cursor: string;
|
|
175
|
+
checkbox: string;
|
|
176
|
+
labelWithOrder: string;
|
|
177
|
+
description: string;
|
|
178
|
+
},
|
|
179
|
+
width: number,
|
|
180
|
+
theme: StatuslineMenuTheme,
|
|
181
|
+
): string {
|
|
182
|
+
if (width < 1) return "";
|
|
183
|
+
|
|
184
|
+
const prefix = `${row.cursor} ${row.checkbox} `;
|
|
185
|
+
const prefixWidth = visibleWidth(prefix);
|
|
186
|
+
const alignedMinWidth =
|
|
187
|
+
prefixWidth +
|
|
188
|
+
LABEL_COLUMN_WIDTH +
|
|
189
|
+
LAYOUT_GAP.length +
|
|
190
|
+
MIN_DESCRIPTION_WIDTH;
|
|
191
|
+
|
|
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
|
+
}
|
|
200
|
+
|
|
201
|
+
if (width >= alignedMinWidth) {
|
|
202
|
+
const labelFitted = truncateToWidth(row.labelWithOrder, LABEL_COLUMN_WIDTH);
|
|
203
|
+
const labelPadded = labelFitted.padEnd(LABEL_COLUMN_WIDTH);
|
|
204
|
+
const descWidth = Math.max(
|
|
205
|
+
1,
|
|
206
|
+
width - prefixWidth - LABEL_COLUMN_WIDTH - LAYOUT_GAP.length,
|
|
207
|
+
);
|
|
208
|
+
const desc = truncateToWidth(row.description, descWidth);
|
|
209
|
+
return `${prefix}${labelPadded}${LAYOUT_GAP}${theme.dim(desc)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const separator = " - ";
|
|
213
|
+
const remainingWidth = width - prefixWidth;
|
|
214
|
+
if (remainingWidth <= separator.length + 1) {
|
|
215
|
+
return truncateToWidth(`${prefix}${row.labelWithOrder}`, width);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
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));
|
|
222
|
+
const desc = truncateToWidth(row.description, fallbackDescWidth);
|
|
223
|
+
return `${fallbackBase}${theme.dim(desc)}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderSectionHeader(
|
|
227
|
+
text: string,
|
|
228
|
+
width: number,
|
|
229
|
+
theme: StatuslineMenuTheme,
|
|
230
|
+
): string {
|
|
231
|
+
return truncateToWidth(theme.dim(text), width);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderDivider(width: number, theme: StatuslineMenuTheme): string {
|
|
235
|
+
return truncateToWidth(
|
|
236
|
+
theme.fg("borderMuted", "─".repeat(Math.max(1, width))),
|
|
237
|
+
width,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderHint(
|
|
242
|
+
text: string,
|
|
243
|
+
width: number,
|
|
244
|
+
theme: StatuslineMenuTheme,
|
|
245
|
+
): string {
|
|
246
|
+
return truncateToWidth(theme.dim(text), width);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function createStatuslineEditor(options: {
|
|
250
|
+
config: PiStatusConfig;
|
|
251
|
+
discoveredStatuses: string[];
|
|
252
|
+
previewInput: Omit<FooterRenderInput, "segments" | "filter">;
|
|
253
|
+
theme: StatuslineMenuTheme;
|
|
254
|
+
done: (result: PiStatusConfig | null) => void;
|
|
255
|
+
requestRender: () => void;
|
|
256
|
+
}): Component {
|
|
257
|
+
const orderedStatuses = [...options.discoveredStatuses].sort((a, b) =>
|
|
258
|
+
a.localeCompare(b),
|
|
259
|
+
);
|
|
260
|
+
let enabledSegments = [...options.config.segments];
|
|
261
|
+
|
|
262
|
+
const shownNew = options.config.filter.mode === "all";
|
|
263
|
+
let newPolicyShown = shownNew;
|
|
264
|
+
|
|
265
|
+
const hiddenSet = new Set(
|
|
266
|
+
options.config.filter.mode === "all" ? options.config.filter.hidden : [],
|
|
267
|
+
);
|
|
268
|
+
const shown =
|
|
269
|
+
options.config.filter.mode === "all"
|
|
270
|
+
? new Set(orderedStatuses.filter((x) => !hiddenSet.has(x)))
|
|
271
|
+
: new Set(options.config.filter.shown);
|
|
272
|
+
|
|
273
|
+
let selected = 0;
|
|
274
|
+
let query = "";
|
|
275
|
+
|
|
276
|
+
function isEnabledSegment(id: StatusLineSegmentId): boolean {
|
|
277
|
+
return enabledSegments.includes(id);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
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[];
|
|
292
|
+
|
|
293
|
+
const policy: PolicyInteractiveRow = { type: "policy" };
|
|
294
|
+
const statuses = orderedStatuses.map((key) => ({
|
|
295
|
+
type: "status",
|
|
296
|
+
key,
|
|
297
|
+
})) as StatusInteractiveRow[];
|
|
298
|
+
|
|
299
|
+
return [...enabled, ...disabled, policy, ...statuses];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function rowMatchesQuery(row: InteractiveRow): boolean {
|
|
303
|
+
if (!query) return true;
|
|
304
|
+
|
|
305
|
+
if (row.type === "segment") {
|
|
306
|
+
const meta = SEGMENT_METADATA.get(row.id);
|
|
307
|
+
if (!meta) return false;
|
|
308
|
+
return includesFuzzy(`${meta.label} ${meta.description}`, query);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (row.type === "policy") {
|
|
312
|
+
return includesFuzzy(
|
|
313
|
+
`${POLICY_ROW_LABEL} ${POLICY_ROW_DESCRIPTION}`,
|
|
314
|
+
query,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return includesFuzzy(`${row.key} ${STATUS_ROW_DESCRIPTION}`, query);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function getFilteredInteractiveRows(): InteractiveRow[] {
|
|
322
|
+
return getInteractiveRows().filter((row) => rowMatchesQuery(row));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getRenderRows(): RenderRow[] {
|
|
326
|
+
const filtered = getFilteredInteractiveRows();
|
|
327
|
+
|
|
328
|
+
if (query) {
|
|
329
|
+
return filtered.map((row, interactiveIndex) => ({
|
|
330
|
+
type: "interactive",
|
|
331
|
+
row,
|
|
332
|
+
interactiveIndex,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const segmentRows = filtered.filter(
|
|
337
|
+
(row): row is SegmentInteractiveRow => row.type === "segment",
|
|
338
|
+
);
|
|
339
|
+
const extensionRows = filtered.filter(
|
|
340
|
+
(row): row is StatusInteractiveRow | PolicyInteractiveRow =>
|
|
341
|
+
row.type === "status" || row.type === "policy",
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const renderRows: RenderRow[] = [];
|
|
345
|
+
let interactiveIndex = 0;
|
|
346
|
+
|
|
347
|
+
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
|
+
|
|
353
|
+
renderRows.push({ type: "divider" });
|
|
354
|
+
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) {
|
|
362
|
+
renderRows.push({ type: "hint", text: EMPTY_EXTENSION_STATUSES_HINT });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return renderRows;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function clampSelection(): void {
|
|
369
|
+
const list = getFilteredInteractiveRows();
|
|
370
|
+
if (list.length === 0) {
|
|
371
|
+
selected = 0;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (selected < 0) selected = 0;
|
|
375
|
+
if (selected >= list.length) selected = list.length - 1;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function toggleRow(row: InteractiveRow): void {
|
|
379
|
+
if (row.type === "segment") {
|
|
380
|
+
if (isEnabledSegment(row.id)) {
|
|
381
|
+
enabledSegments = enabledSegments.filter((x) => x !== row.id);
|
|
382
|
+
} else {
|
|
383
|
+
enabledSegments = [...enabledSegments, row.id];
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (row.type === "status") {
|
|
389
|
+
if (shown.has(row.key)) shown.delete(row.key);
|
|
390
|
+
else shown.add(row.key);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
newPolicyShown = !newPolicyShown;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function moveSegment(delta: -1 | 1, row: InteractiveRow): void {
|
|
398
|
+
if (query) return;
|
|
399
|
+
if (row.type !== "segment") return;
|
|
400
|
+
const idx = enabledSegments.indexOf(row.id);
|
|
401
|
+
if (idx < 0) return;
|
|
402
|
+
const next = idx + delta;
|
|
403
|
+
if (next < 0 || next >= enabledSegments.length) return;
|
|
404
|
+
const copy = [...enabledSegments];
|
|
405
|
+
const [item] = copy.splice(idx, 1);
|
|
406
|
+
copy.splice(next, 0, item);
|
|
407
|
+
enabledSegments = copy;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function toConfig(): PiStatusConfig {
|
|
411
|
+
return {
|
|
412
|
+
segments: enabledSegments,
|
|
413
|
+
filter: mapStatusDraftToFilter({
|
|
414
|
+
discoveredKeys: orderedStatuses,
|
|
415
|
+
shownKeys: shown,
|
|
416
|
+
newStatusesShown: newPolicyShown,
|
|
417
|
+
}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
invalidate(): void {},
|
|
423
|
+
|
|
424
|
+
handleInput(data: string): void {
|
|
425
|
+
clampSelection();
|
|
426
|
+
const list = getFilteredInteractiveRows();
|
|
427
|
+
const current = list[selected];
|
|
428
|
+
|
|
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
|
+
}
|
|
437
|
+
if (matchesKey(data, Key.up)) {
|
|
438
|
+
selected--;
|
|
439
|
+
clampSelection();
|
|
440
|
+
options.requestRender();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (matchesKey(data, Key.down)) {
|
|
444
|
+
selected++;
|
|
445
|
+
clampSelection();
|
|
446
|
+
options.requestRender();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (matchesKey(data, Key.space) && current) {
|
|
450
|
+
toggleRow(current);
|
|
451
|
+
clampSelection();
|
|
452
|
+
options.requestRender();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (matchesKey(data, Key.left) && current) {
|
|
456
|
+
moveSegment(-1, current);
|
|
457
|
+
clampSelection();
|
|
458
|
+
options.requestRender();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (matchesKey(data, Key.right) && current) {
|
|
462
|
+
moveSegment(1, current);
|
|
463
|
+
clampSelection();
|
|
464
|
+
options.requestRender();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (matchesKey(data, Key.backspace)) {
|
|
468
|
+
if (query.length > 0) {
|
|
469
|
+
query = query.slice(0, -1);
|
|
470
|
+
clampSelection();
|
|
471
|
+
options.requestRender();
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (/^[\x21-\x7E]$/.test(data)) {
|
|
477
|
+
query += data;
|
|
478
|
+
clampSelection();
|
|
479
|
+
options.requestRender();
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
render(width: number): string[] {
|
|
484
|
+
clampSelection();
|
|
485
|
+
const renderRows = getRenderRows();
|
|
486
|
+
const cfg = toConfig();
|
|
487
|
+
const preview = buildFooterLine(
|
|
488
|
+
{
|
|
489
|
+
...options.previewInput,
|
|
490
|
+
segments: cfg.segments,
|
|
491
|
+
filter: cfg.filter,
|
|
492
|
+
},
|
|
493
|
+
options.theme,
|
|
494
|
+
width,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const lines: string[] = [];
|
|
498
|
+
lines.push(
|
|
499
|
+
truncateToWidth(
|
|
500
|
+
options.theme.fg("accent", options.theme.bold(SHELL_TITLE)),
|
|
501
|
+
width,
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
lines.push(truncateToWidth(options.theme.dim(SHELL_SUBTITLE), width));
|
|
505
|
+
lines.push(truncateToWidth("", width));
|
|
506
|
+
lines.push(truncateToWidth(options.theme.dim(SHELL_PLACEHOLDER), width));
|
|
507
|
+
lines.push(truncateToWidth(`> ${query}`, width));
|
|
508
|
+
|
|
509
|
+
for (const renderRow of renderRows) {
|
|
510
|
+
if (renderRow.type === "header") {
|
|
511
|
+
lines.push(renderSectionHeader(renderRow.text, width, options.theme));
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (renderRow.type === "divider") {
|
|
515
|
+
lines.push(renderDivider(width, options.theme));
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (renderRow.type === "hint") {
|
|
519
|
+
lines.push(renderHint(renderRow.text, width, options.theme));
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const row = renderRow.row;
|
|
524
|
+
const cursor = renderRow.interactiveIndex === selected ? ">" : " ";
|
|
525
|
+
if (row.type === "segment") {
|
|
526
|
+
const enabled = isEnabledSegment(row.id) ? "[x]" : "[ ]";
|
|
527
|
+
const order = isEnabledSegment(row.id)
|
|
528
|
+
? ` (${enabledSegments.indexOf(row.id) + 1})`
|
|
529
|
+
: "";
|
|
530
|
+
const meta = SEGMENT_METADATA.get(row.id);
|
|
531
|
+
if (!meta) continue;
|
|
532
|
+
const labelWithOrder = `${meta.label}${order}`;
|
|
533
|
+
lines.push(
|
|
534
|
+
renderRowLine(
|
|
535
|
+
{
|
|
536
|
+
cursor,
|
|
537
|
+
checkbox: enabled,
|
|
538
|
+
labelWithOrder,
|
|
539
|
+
description: meta.description,
|
|
540
|
+
},
|
|
541
|
+
width,
|
|
542
|
+
options.theme,
|
|
543
|
+
),
|
|
544
|
+
);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (row.type === "status") {
|
|
548
|
+
const enabled = shown.has(row.key) ? "[x]" : "[ ]";
|
|
549
|
+
lines.push(
|
|
550
|
+
renderRowLine(
|
|
551
|
+
{
|
|
552
|
+
cursor,
|
|
553
|
+
checkbox: enabled,
|
|
554
|
+
labelWithOrder: row.key,
|
|
555
|
+
description: STATUS_ROW_DESCRIPTION,
|
|
556
|
+
},
|
|
557
|
+
width,
|
|
558
|
+
options.theme,
|
|
559
|
+
),
|
|
560
|
+
);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const checkbox = `[${newPolicyShown ? "shown" : "hidden"}]`;
|
|
564
|
+
lines.push(
|
|
565
|
+
renderRowLine(
|
|
566
|
+
{
|
|
567
|
+
cursor,
|
|
568
|
+
checkbox,
|
|
569
|
+
labelWithOrder: POLICY_ROW_LABEL,
|
|
570
|
+
description: POLICY_ROW_DESCRIPTION,
|
|
571
|
+
},
|
|
572
|
+
width,
|
|
573
|
+
options.theme,
|
|
574
|
+
),
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
lines.push(truncateToWidth("", width));
|
|
579
|
+
lines.push(truncateToWidth(preview, width));
|
|
580
|
+
lines.push(
|
|
581
|
+
truncateToWidth(
|
|
582
|
+
options.theme.dim(query ? HELP_SEARCHING : HELP_BASE),
|
|
583
|
+
width,
|
|
584
|
+
),
|
|
585
|
+
);
|
|
586
|
+
return lines;
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Internal theme adapter for the `/statusline` configuration menu.
|
|
2
|
+
//
|
|
3
|
+
// This module owns the only place where the editor reads from Pi's live theme
|
|
4
|
+
// object. The editor itself only sees the narrow `StatuslineMenuTheme`
|
|
5
|
+
// surface so menu styling stays in one place, and the live Pi theme remains
|
|
6
|
+
// the single source of truth for menu colors while `/statusline` is open.
|
|
7
|
+
//
|
|
8
|
+
// The menu's own color usage is a narrow subset of Pi's palette
|
|
9
|
+
// (`accent`, `borderMuted`, `dim`); the union below is widened to also cover
|
|
10
|
+
// the `buildFooterLine` colors so the same adapted theme can feed the bottom
|
|
11
|
+
// preview line as well. The widened union is a strict superset of
|
|
12
|
+
// `FooterRenderColor` (defined in `../render.ts`), which makes a
|
|
13
|
+
// `StatuslineMenuTheme` assignable to the `ThemeLike` that `buildFooterLine`
|
|
14
|
+
// expects.
|
|
15
|
+
|
|
16
|
+
import type { FooterRenderColor } from "../render.ts";
|
|
17
|
+
|
|
18
|
+
export type StatuslineMenuColor = FooterRenderColor | "borderMuted";
|
|
19
|
+
|
|
20
|
+
export type StatuslineMenuTheme = {
|
|
21
|
+
fg: (color: StatuslineMenuColor, text: string) => string;
|
|
22
|
+
bold: (text: string) => string;
|
|
23
|
+
dim: (text: string) => string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Shape of the live Pi theme object we care about. Kept narrow on purpose:
|
|
27
|
+
// the adapter only needs `fg` and `bold`; everything else is derived from
|
|
28
|
+
// those two primitives.
|
|
29
|
+
type PiThemeLike = {
|
|
30
|
+
fg: (color: string, text: string) => string;
|
|
31
|
+
bold: (text: string) => string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function isPiThemeLike(value: unknown): value is PiThemeLike {
|
|
35
|
+
if (!value || typeof value !== "object") return false;
|
|
36
|
+
const candidate = value as { fg?: unknown; bold?: unknown };
|
|
37
|
+
return (
|
|
38
|
+
typeof candidate.fg === "function" && typeof candidate.bold === "function"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Passthrough fallback. Used for tests and for any runtime where the live Pi
|
|
43
|
+
// theme is missing the methods we need. All calls return the input text
|
|
44
|
+
// unchanged, so styled output collapses to plain text.
|
|
45
|
+
export const noTheme: StatuslineMenuTheme = {
|
|
46
|
+
fg: (_color, text) => text,
|
|
47
|
+
bold: (text) => text,
|
|
48
|
+
dim: (text) => text,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Adapt a Pi theme object into the narrow `StatuslineMenuTheme` surface used
|
|
52
|
+
// by the editor. Returns `noTheme` for anything that is missing the required
|
|
53
|
+
// `fg` or `bold` methods so the editor never has to deal with partial
|
|
54
|
+
// runtime themes. The returned object captures the Pi theme by reference, so
|
|
55
|
+
// when Pi swaps the live theme instance the next render picks up the new
|
|
56
|
+
// colors without reopening `/statusline`.
|
|
57
|
+
export function fromPiTheme(theme: unknown): StatuslineMenuTheme {
|
|
58
|
+
if (!isPiThemeLike(theme)) return noTheme;
|
|
59
|
+
const pi = theme;
|
|
60
|
+
return {
|
|
61
|
+
fg: (color, text) => pi.fg(color, text),
|
|
62
|
+
bold: (text) => pi.bold(text),
|
|
63
|
+
// Pi exposes "dim" as a foreground color role, not as a separate method,
|
|
64
|
+
// so the menu's `dim` helper is just a thin wrapper around `fg("dim", …)`.
|
|
65
|
+
dim: (text) => pi.fg("dim", text),
|
|
66
|
+
};
|
|
67
|
+
}
|