@aemonge-dev/pi-stardeck 0.0.0-stage → 0.5.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 +239 -2
- package/demo/pi-stardeck.tape +59 -0
- package/docs/architecture.md +103 -0
- package/docs/assets/pi-stardeck.gif +0 -0
- package/docs/development.md +128 -0
- package/licenses/README.md +17 -0
- package/licenses/pi-fancy-footer-LICENSE +21 -0
- package/licenses/pi-vim-LICENSE +21 -0
- package/package.json +81 -4
- package/packages/pi-fancy-footer-full-palette/LICENSE +21 -0
- package/packages/pi-fancy-footer-full-palette/LOCAL-CHANGES.md +41 -0
- package/packages/pi-fancy-footer-full-palette/README.md +468 -0
- package/packages/pi-fancy-footer-full-palette/package.json +52 -0
- package/packages/pi-fancy-footer-full-palette/src/api.ts +204 -0
- package/packages/pi-fancy-footer-full-palette/src/config-editor.ts +634 -0
- package/packages/pi-fancy-footer-full-palette/src/config.ts +852 -0
- package/packages/pi-fancy-footer-full-palette/src/data-widgets.ts +259 -0
- package/packages/pi-fancy-footer-full-palette/src/git.ts +130 -0
- package/packages/pi-fancy-footer-full-palette/src/index.ts +479 -0
- package/packages/pi-fancy-footer-full-palette/src/layout.ts +309 -0
- package/packages/pi-fancy-footer-full-palette/src/provider-status.ts +1466 -0
- package/packages/pi-fancy-footer-full-palette/src/render.ts +1061 -0
- package/packages/pi-fancy-footer-full-palette/src/shared.ts +903 -0
- package/packages/pi-stardeck/README.md +219 -0
- package/packages/pi-stardeck/index.ts +691 -0
- package/packages/pi-stardeck/package.json +32 -0
- package/packages/pi-stardeck/src/activity-age.ts +184 -0
- package/packages/pi-stardeck/src/capabilities.ts +105 -0
- package/packages/pi-stardeck/src/deck.ts +807 -0
- package/packages/pi-stardeck/src/gauge.ts +1323 -0
- package/packages/pi-stardeck/src/git-refresh.ts +5 -0
- package/packages/pi-stardeck/src/process-resources.ts +428 -0
- package/packages/pi-stardeck/src/title-state.ts +195 -0
- package/packages/pi-stardeck/src/title.ts +443 -0
- package/packages/pi-vim-top-border/LICENSE +21 -0
- package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
- package/packages/pi-vim-top-border/README.md +577 -0
- package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
- package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
- package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
- package/packages/pi-vim-top-border/index.ts +4195 -0
- package/packages/pi-vim-top-border/input-keys.ts +49 -0
- package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
- package/packages/pi-vim-top-border/mode-colors.ts +97 -0
- package/packages/pi-vim-top-border/mode-label.ts +62 -0
- package/packages/pi-vim-top-border/motions.ts +297 -0
- package/packages/pi-vim-top-border/package.json +47 -0
- package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
- package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
- package/packages/pi-vim-top-border/prompt-status.ts +131 -0
- package/packages/pi-vim-top-border/settings.ts +287 -0
- package/packages/pi-vim-top-border/text-objects.ts +389 -0
- package/packages/pi-vim-top-border/types.ts +51 -0
- package/packages/pi-vim-top-border/visual.ts +65 -0
- package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
- package/packages/status/README.md +398 -0
- package/packages/status/index.ts +1717 -0
- package/packages/status/package.json +33 -0
- package/packages/status/src/active-work.ts +235 -0
- package/packages/status/src/config.ts +218 -0
- package/packages/status/src/diagnostics.ts +238 -0
- package/packages/status/src/goal-block.ts +377 -0
- package/packages/status/src/goal.ts +170 -0
- package/packages/status/src/ledger.ts +101 -0
- package/packages/status/src/openspec.ts +394 -0
- package/packages/status/src/orchestration.ts +237 -0
- package/packages/status/src/publisher.ts +1294 -0
- package/packages/status/src/reducer.ts +190 -0
- package/packages/status/src/runtime-runs.ts +267 -0
- package/packages/status/src/state.ts +178 -0
- package/packages/status/src/title-activity.ts +224 -0
- package/packages/status/src/title-state.ts +218 -0
- package/packages/status/src/turn-cost.ts +63 -0
- package/packages/status/src/types.ts +228 -0
- package/src/deck-surface.ts +46 -0
- package/src/health.ts +228 -0
- package/src/index.ts +28 -0
- package/src/pi-status.ts +334 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Key,
|
|
4
|
+
matchesKey,
|
|
5
|
+
truncateToWidth,
|
|
6
|
+
visibleWidth,
|
|
7
|
+
wrapTextWithAnsi,
|
|
8
|
+
type Component,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import {
|
|
11
|
+
buildConfigurableWidgets,
|
|
12
|
+
genericFooterSettingsItems,
|
|
13
|
+
plainSettingValue,
|
|
14
|
+
widgetSettingsSubmenu,
|
|
15
|
+
type ConfigurableWidgetMeta,
|
|
16
|
+
} from "./config.ts";
|
|
17
|
+
import {
|
|
18
|
+
buildLayoutModel,
|
|
19
|
+
cycleAlign,
|
|
20
|
+
moveHorizontal,
|
|
21
|
+
moveVertical,
|
|
22
|
+
setBenched,
|
|
23
|
+
toggleFill,
|
|
24
|
+
type LayoutChip,
|
|
25
|
+
type LayoutModel,
|
|
26
|
+
type LayoutRow,
|
|
27
|
+
} from "./layout.ts";
|
|
28
|
+
import {
|
|
29
|
+
isGaugeStyleId,
|
|
30
|
+
MIN_GAUGE_WIDTH,
|
|
31
|
+
MAX_GAUGE_WIDTH,
|
|
32
|
+
isFooterIconFamily,
|
|
33
|
+
isFooterWidgetColor,
|
|
34
|
+
type FooterConfigSnapshot,
|
|
35
|
+
} from "./shared.ts";
|
|
36
|
+
import type { NormalizedFancyFooterDataWidget } from "./data-widgets.ts";
|
|
37
|
+
|
|
38
|
+
interface OpenFooterConfigEditorOptions {
|
|
39
|
+
ctx: ExtensionContext;
|
|
40
|
+
configPath: string;
|
|
41
|
+
draft: FooterConfigSnapshot;
|
|
42
|
+
extensionWidgets: readonly NormalizedFancyFooterDataWidget[];
|
|
43
|
+
applyDraft: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Selection =
|
|
47
|
+
{ area: "grid" | "bench"; widgetId: string } | { area: "globals"; index: number };
|
|
48
|
+
|
|
49
|
+
interface ChipSpan {
|
|
50
|
+
start: number;
|
|
51
|
+
end: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Each chip row independently degrades until it fits: full widget names,
|
|
55
|
+
// then short names, then icons only. The status line under the preview
|
|
56
|
+
// always shows the selected widget's full name.
|
|
57
|
+
type ChipMode = "full" | "short" | "icon";
|
|
58
|
+
const CHIP_MODES: readonly ChipMode[] = ["full", "short", "icon"];
|
|
59
|
+
const CHIP_GAP = 2;
|
|
60
|
+
|
|
61
|
+
export async function openFooterConfigEditor({
|
|
62
|
+
ctx,
|
|
63
|
+
configPath,
|
|
64
|
+
draft,
|
|
65
|
+
extensionWidgets,
|
|
66
|
+
applyDraft,
|
|
67
|
+
}: OpenFooterConfigEditorOptions): Promise<void> {
|
|
68
|
+
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
69
|
+
let submenu: Component | undefined;
|
|
70
|
+
// Screen columns of the last selected chip's center, used to pick the
|
|
71
|
+
// nearest chip when moving between rows.
|
|
72
|
+
let preferredColumn = 0;
|
|
73
|
+
let chipSpans = new Map<string, ChipSpan>();
|
|
74
|
+
|
|
75
|
+
const widgets = (): ConfigurableWidgetMeta[] =>
|
|
76
|
+
buildConfigurableWidgets(draft, extensionWidgets);
|
|
77
|
+
|
|
78
|
+
const model = (): LayoutModel => buildLayoutModel(draft, widgets());
|
|
79
|
+
|
|
80
|
+
const globalsItems = () => genericFooterSettingsItems(draft, theme);
|
|
81
|
+
|
|
82
|
+
const initialSelection = (): Selection => {
|
|
83
|
+
const current = model();
|
|
84
|
+
for (const row of current.rows) {
|
|
85
|
+
if (row.ordered.length > 0) {
|
|
86
|
+
return { area: "grid", widgetId: row.ordered[0]!.widget.id };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (current.bench.length > 0) {
|
|
90
|
+
return { area: "bench", widgetId: current.bench[0]!.widget.id };
|
|
91
|
+
}
|
|
92
|
+
return { area: "globals", index: 0 };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
let selection: Selection = initialSelection();
|
|
96
|
+
|
|
97
|
+
// ── selection helpers ────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
// Non-empty chip rows in navigation order: grid rows, then the bench.
|
|
100
|
+
type NavRow =
|
|
101
|
+
{ kind: "grid"; row: LayoutRow } | { kind: "bench"; chips: LayoutChip[] };
|
|
102
|
+
|
|
103
|
+
const navRows = (current: LayoutModel): NavRow[] => {
|
|
104
|
+
const rows: NavRow[] = current.rows
|
|
105
|
+
.filter((row) => row.ordered.length > 0)
|
|
106
|
+
.map((row) => ({ kind: "grid" as const, row }));
|
|
107
|
+
if (current.bench.length > 0) {
|
|
108
|
+
rows.push({ kind: "bench", chips: current.bench });
|
|
109
|
+
}
|
|
110
|
+
return rows;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const navRowChips = (row: NavRow): LayoutChip[] =>
|
|
114
|
+
row.kind === "grid" ? row.row.ordered : row.chips;
|
|
115
|
+
|
|
116
|
+
const findNavIndex = (rows: NavRow[], widgetId: string): number =>
|
|
117
|
+
rows.findIndex((row) =>
|
|
118
|
+
navRowChips(row).some((chip) => chip.widget.id === widgetId),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const chipCenter = (widgetId: string): number => {
|
|
122
|
+
const span = chipSpans.get(widgetId);
|
|
123
|
+
if (!span) return preferredColumn;
|
|
124
|
+
return Math.floor((span.start + span.end) / 2);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const nearestChip = (row: NavRow, column: number): LayoutChip => {
|
|
128
|
+
const chips = navRowChips(row);
|
|
129
|
+
let best = chips[0]!;
|
|
130
|
+
let bestDistance = Infinity;
|
|
131
|
+
for (const chip of chips) {
|
|
132
|
+
const distance = Math.abs(chipCenter(chip.widget.id) - column);
|
|
133
|
+
if (distance < bestDistance) {
|
|
134
|
+
best = chip;
|
|
135
|
+
bestDistance = distance;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return best;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const selectChip = (chip: LayoutChip) => {
|
|
142
|
+
selection = {
|
|
143
|
+
area: chip.placement.benched ? "bench" : "grid",
|
|
144
|
+
widgetId: chip.widget.id,
|
|
145
|
+
};
|
|
146
|
+
preferredColumn = chipCenter(chip.widget.id);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// After a mutation the selected widget may have changed rows or moved
|
|
150
|
+
// between the grid and the bench; recompute which area it lives in.
|
|
151
|
+
const syncSelection = () => {
|
|
152
|
+
if (selection.area === "globals") return;
|
|
153
|
+
const widgetId = selection.widgetId;
|
|
154
|
+
const current = model();
|
|
155
|
+
const benched = current.bench.some((chip) => chip.widget.id === widgetId);
|
|
156
|
+
selection = { area: benched ? "bench" : "grid", widgetId };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const moveSelectionHorizontal = (direction: -1 | 1) => {
|
|
160
|
+
if (selection.area === "globals") return;
|
|
161
|
+
const rows = navRows(model());
|
|
162
|
+
const index = findNavIndex(rows, selection.widgetId);
|
|
163
|
+
if (index < 0) return;
|
|
164
|
+
const chips = navRowChips(rows[index]!);
|
|
165
|
+
const chipIndex = chips.findIndex(
|
|
166
|
+
(chip) => chip.widget.id === (selection as { widgetId: string }).widgetId,
|
|
167
|
+
);
|
|
168
|
+
const next = chips[chipIndex + direction];
|
|
169
|
+
if (next) selectChip(next);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const moveSelectionVertical = (direction: -1 | 1) => {
|
|
173
|
+
const current = model();
|
|
174
|
+
const rows = navRows(current);
|
|
175
|
+
const globals = globalsItems();
|
|
176
|
+
|
|
177
|
+
if (selection.area === "globals") {
|
|
178
|
+
const next = selection.index + direction;
|
|
179
|
+
if (next >= 0 && next < globals.length) {
|
|
180
|
+
selection = { area: "globals", index: next };
|
|
181
|
+
} else if (next < 0 && rows.length > 0) {
|
|
182
|
+
selectChip(nearestChip(rows[rows.length - 1]!, preferredColumn));
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const index = findNavIndex(rows, selection.widgetId);
|
|
188
|
+
if (index < 0) return;
|
|
189
|
+
const target = index + direction;
|
|
190
|
+
if (target >= 0 && target < rows.length) {
|
|
191
|
+
preferredColumn = chipCenter(selection.widgetId);
|
|
192
|
+
selectChip(nearestChip(rows[target]!, preferredColumn));
|
|
193
|
+
} else if (target >= rows.length && globals.length > 0) {
|
|
194
|
+
preferredColumn = chipCenter(selection.widgetId);
|
|
195
|
+
selection = { area: "globals", index: 0 };
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ── widget actions ───────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const chipActions: {
|
|
202
|
+
keys: string[];
|
|
203
|
+
hint?: string;
|
|
204
|
+
run: (widgetId: string) => void;
|
|
205
|
+
}[] = [
|
|
206
|
+
{
|
|
207
|
+
keys: ["l"],
|
|
208
|
+
run: (id) => moveHorizontal(draft, widgets(), id, -1),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
keys: ["r"],
|
|
212
|
+
hint: "l/r move",
|
|
213
|
+
run: (id) => moveHorizontal(draft, widgets(), id, 1),
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
keys: ["u"],
|
|
217
|
+
run: (id) => moveVertical(draft, widgets(), id, -1),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
keys: ["d"],
|
|
221
|
+
hint: "u/d row",
|
|
222
|
+
run: (id) => moveVertical(draft, widgets(), id, 1),
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
keys: ["a"],
|
|
226
|
+
hint: "a align",
|
|
227
|
+
run: (id) => cycleAlign(draft, widgets(), id),
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
keys: ["f"],
|
|
231
|
+
hint: "f fill",
|
|
232
|
+
run: (id) => toggleFill(draft, widgets(), id),
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
keys: ["x", " "],
|
|
236
|
+
hint: "x hide",
|
|
237
|
+
run: (id) => {
|
|
238
|
+
const benched = model().bench.some((chip) => chip.widget.id === id);
|
|
239
|
+
setBenched(draft, widgets(), id, !benched);
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
const chipHints = [
|
|
245
|
+
"←→↑↓ select",
|
|
246
|
+
...chipActions.map((action) => action.hint).filter(Boolean),
|
|
247
|
+
"⏎ settings",
|
|
248
|
+
"esc close",
|
|
249
|
+
].join(" · ");
|
|
250
|
+
|
|
251
|
+
const globalsHints = "↑↓ navigate · ⏎/space change · esc close";
|
|
252
|
+
|
|
253
|
+
// ── globals mutation (value cycling) ─────────────────────────────
|
|
254
|
+
|
|
255
|
+
const setGenericField = (fieldId: string, newValue: string) => {
|
|
256
|
+
switch (fieldId) {
|
|
257
|
+
case "refreshMs": {
|
|
258
|
+
const parsed = Number(newValue);
|
|
259
|
+
if (Number.isInteger(parsed)) draft.refreshMs = parsed;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case "iconFamily":
|
|
263
|
+
if (isFooterIconFamily(newValue)) draft.iconFamily = newValue;
|
|
264
|
+
break;
|
|
265
|
+
case "gaugeStyle": {
|
|
266
|
+
const style = plainSettingValue(newValue);
|
|
267
|
+
if (isGaugeStyleId(style)) draft.gaugeStyle = style;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case "gaugeColorOk": {
|
|
271
|
+
const color = plainSettingValue(newValue);
|
|
272
|
+
if (isFooterWidgetColor(color)) draft.gaugeColors.ok = color;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
case "gaugeColorWarning": {
|
|
276
|
+
const color = plainSettingValue(newValue);
|
|
277
|
+
if (isFooterWidgetColor(color)) draft.gaugeColors.warning = color;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
case "gaugeColorError": {
|
|
281
|
+
const color = plainSettingValue(newValue);
|
|
282
|
+
if (isFooterWidgetColor(color)) draft.gaugeColors.error = color;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "gaugeWidth": {
|
|
286
|
+
const parsed = Number(newValue);
|
|
287
|
+
if (
|
|
288
|
+
Number.isInteger(parsed) &&
|
|
289
|
+
parsed >= MIN_GAUGE_WIDTH &&
|
|
290
|
+
parsed <= MAX_GAUGE_WIDTH
|
|
291
|
+
) {
|
|
292
|
+
draft.gaugeWidth = parsed;
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "defaultTextColor": {
|
|
297
|
+
const color = plainSettingValue(newValue);
|
|
298
|
+
if (isFooterWidgetColor(color)) draft.defaultTextColor = color;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case "defaultIconColor": {
|
|
302
|
+
const color = plainSettingValue(newValue);
|
|
303
|
+
if (isFooterWidgetColor(color)) draft.defaultIconColor = color;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const cycleGlobalValue = () => {
|
|
310
|
+
if (selection.area !== "globals") return;
|
|
311
|
+
const item = globalsItems()[selection.index];
|
|
312
|
+
if (!item?.values || item.values.length === 0) return;
|
|
313
|
+
const currentIndex = item.values.indexOf(item.currentValue);
|
|
314
|
+
const nextValue =
|
|
315
|
+
item.values[(currentIndex + 1 + item.values.length) % item.values.length];
|
|
316
|
+
if (nextValue === undefined) return;
|
|
317
|
+
setGenericField(item.id, nextValue);
|
|
318
|
+
applyDraft();
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const activateSelection = () => {
|
|
322
|
+
if (selection.area === "globals") {
|
|
323
|
+
cycleGlobalValue();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const widget = widgets().find(
|
|
327
|
+
(entry) => entry.id === (selection as { widgetId: string }).widgetId,
|
|
328
|
+
);
|
|
329
|
+
if (!widget) return;
|
|
330
|
+
submenu = widgetSettingsSubmenu(draft, theme, widget, () => {
|
|
331
|
+
applyDraft();
|
|
332
|
+
tui.requestRender();
|
|
333
|
+
})("", () => {
|
|
334
|
+
submenu = undefined;
|
|
335
|
+
tui.requestRender();
|
|
336
|
+
});
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// ── preview rendering ────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
const chipText = (chip: LayoutChip, mode: ChipMode): string => {
|
|
342
|
+
const icon = chip.widget.defaultIcon?.text ?? "◌";
|
|
343
|
+
if (mode === "icon") return icon;
|
|
344
|
+
const label = mode === "full" ? chip.widget.label : chip.widget.shortLabel;
|
|
345
|
+
const grow = chip.placement.fill === "grow" ? " ↔" : "";
|
|
346
|
+
return `${icon} ${label}${grow}`;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const styledChip = (
|
|
350
|
+
chip: LayoutChip,
|
|
351
|
+
mode: ChipMode,
|
|
352
|
+
selected: boolean,
|
|
353
|
+
): string => {
|
|
354
|
+
const plain = chipText(chip, mode);
|
|
355
|
+
if (selected) return theme.inverse(plain);
|
|
356
|
+
if (chip.placement.benched) return theme.fg("dim", plain);
|
|
357
|
+
|
|
358
|
+
const icon = chip.widget.defaultIcon?.text ?? "◌";
|
|
359
|
+
const iconColor = chip.widget.defaultIcon ? draft.defaultIconColor : "dim";
|
|
360
|
+
if (mode === "icon") return theme.fg(iconColor, icon);
|
|
361
|
+
const label = mode === "full" ? chip.widget.label : chip.widget.shortLabel;
|
|
362
|
+
const grow = chip.placement.fill === "grow" ? theme.fg("dim", " ↔") : "";
|
|
363
|
+
return `${theme.fg(iconColor, icon)} ${label}${grow}`;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
interface PlacedChip {
|
|
367
|
+
chip: LayoutChip;
|
|
368
|
+
column: number;
|
|
369
|
+
width: number;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const groupWidth = (chips: LayoutChip[], mode: ChipMode) =>
|
|
373
|
+
chips.reduce(
|
|
374
|
+
(acc, chip, index) =>
|
|
375
|
+
acc + visibleWidth(chipText(chip, mode)) + (index > 0 ? CHIP_GAP : 0),
|
|
376
|
+
0,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const rowFits = (contentWidth: number, row: LayoutRow, mode: ChipMode): boolean => {
|
|
380
|
+
const blocks = [
|
|
381
|
+
groupWidth(row.groups.left, mode),
|
|
382
|
+
groupWidth(row.groups.middle, mode),
|
|
383
|
+
groupWidth(row.groups.right, mode),
|
|
384
|
+
].filter((width) => width > 0);
|
|
385
|
+
return (
|
|
386
|
+
blocks.reduce((acc, width) => acc + width, 0) +
|
|
387
|
+
(blocks.length - 1) * CHIP_GAP <=
|
|
388
|
+
contentWidth
|
|
389
|
+
);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// The widest mode in which the row fits; icons-only as a last resort.
|
|
393
|
+
const rowChipMode = (contentWidth: number, row: LayoutRow): ChipMode =>
|
|
394
|
+
CHIP_MODES.find((mode) => rowFits(contentWidth, row, mode)) ?? "icon";
|
|
395
|
+
|
|
396
|
+
// Mirrors composeAlignedRow() in render.ts: left group flush left, right
|
|
397
|
+
// group flush right, middle group centered. Falls back to a flat join
|
|
398
|
+
// when the row overflows even icons-only.
|
|
399
|
+
const placeRowChips = (
|
|
400
|
+
contentWidth: number,
|
|
401
|
+
row: LayoutRow,
|
|
402
|
+
mode: ChipMode,
|
|
403
|
+
): PlacedChip[] => {
|
|
404
|
+
const leftWidth = groupWidth(row.groups.left, mode);
|
|
405
|
+
const middleWidth = groupWidth(row.groups.middle, mode);
|
|
406
|
+
const rightWidth = groupWidth(row.groups.right, mode);
|
|
407
|
+
|
|
408
|
+
const placed: PlacedChip[] = [];
|
|
409
|
+
const placeGroup = (chips: LayoutChip[], start: number) => {
|
|
410
|
+
let column = start;
|
|
411
|
+
for (const chip of chips) {
|
|
412
|
+
const width = visibleWidth(chipText(chip, mode));
|
|
413
|
+
placed.push({ chip, column, width });
|
|
414
|
+
column += width + CHIP_GAP;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
if (!rowFits(contentWidth, row, mode)) {
|
|
419
|
+
placeGroup(row.ordered, 0);
|
|
420
|
+
return placed;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
placeGroup(row.groups.left, 0);
|
|
424
|
+
const rightStart = contentWidth - rightWidth;
|
|
425
|
+
let middleStart = Math.floor((contentWidth - middleWidth) / 2);
|
|
426
|
+
middleStart = Math.max(
|
|
427
|
+
leftWidth > 0 ? leftWidth + CHIP_GAP : 0,
|
|
428
|
+
Math.min(
|
|
429
|
+
middleStart,
|
|
430
|
+
rightWidth > 0
|
|
431
|
+
? rightStart - CHIP_GAP - middleWidth
|
|
432
|
+
: contentWidth - middleWidth,
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
placeGroup(row.groups.middle, middleStart);
|
|
436
|
+
placeGroup(row.groups.right, rightStart);
|
|
437
|
+
return placed;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const renderChipLine = (
|
|
441
|
+
width: number,
|
|
442
|
+
gutter: string,
|
|
443
|
+
placed: PlacedChip[],
|
|
444
|
+
mode: ChipMode,
|
|
445
|
+
selectedId: string | undefined,
|
|
446
|
+
): string => {
|
|
447
|
+
const gutterWidth = visibleWidth(gutter);
|
|
448
|
+
let line = gutter;
|
|
449
|
+
let column = 0;
|
|
450
|
+
for (const entry of placed) {
|
|
451
|
+
if (entry.column > column) {
|
|
452
|
+
line += " ".repeat(entry.column - column);
|
|
453
|
+
column = entry.column;
|
|
454
|
+
}
|
|
455
|
+
line += styledChip(entry.chip, mode, entry.chip.widget.id === selectedId);
|
|
456
|
+
chipSpans.set(entry.chip.widget.id, {
|
|
457
|
+
start: gutterWidth + entry.column,
|
|
458
|
+
end: gutterWidth + entry.column + entry.width,
|
|
459
|
+
});
|
|
460
|
+
column += entry.width;
|
|
461
|
+
}
|
|
462
|
+
return truncateToWidth(line, width);
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const selectedDescription = (): string | undefined => {
|
|
466
|
+
if (selection.area === "globals") {
|
|
467
|
+
return globalsItems()[selection.index]?.description;
|
|
468
|
+
}
|
|
469
|
+
const widgetId = selection.widgetId;
|
|
470
|
+
return widgets().find((entry) => entry.id === widgetId)?.description;
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
render(width: number) {
|
|
475
|
+
if (submenu) return submenu.render(width);
|
|
476
|
+
|
|
477
|
+
const current = model();
|
|
478
|
+
const selectedId =
|
|
479
|
+
selection.area === "globals" ? undefined : selection.widgetId;
|
|
480
|
+
chipSpans = new Map();
|
|
481
|
+
|
|
482
|
+
const lines = [
|
|
483
|
+
truncateToWidth(
|
|
484
|
+
theme.fg("accent", theme.bold("Fancy Footer Configuration")),
|
|
485
|
+
width,
|
|
486
|
+
),
|
|
487
|
+
truncateToWidth(theme.fg("dim", configPath), width),
|
|
488
|
+
"",
|
|
489
|
+
truncateToWidth(theme.bold("Widgets"), width),
|
|
490
|
+
];
|
|
491
|
+
|
|
492
|
+
const gutterDigits = String(current.rows.length - 1).length;
|
|
493
|
+
for (const row of current.rows) {
|
|
494
|
+
const gutter = theme.fg("dim", `${String(row.row).padStart(gutterDigits)}│ `);
|
|
495
|
+
if (row.ordered.length === 0) {
|
|
496
|
+
lines.push(
|
|
497
|
+
truncateToWidth(`${gutter}${theme.fg("dim", "(empty)")}`, width),
|
|
498
|
+
);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const contentWidth = Math.max(10, width - gutterDigits - 2);
|
|
502
|
+
const mode = rowChipMode(contentWidth, row);
|
|
503
|
+
const placed = placeRowChips(contentWidth, row, mode);
|
|
504
|
+
lines.push(renderChipLine(width, gutter, placed, mode, selectedId));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (current.bench.length > 0) {
|
|
508
|
+
lines.push(truncateToWidth(theme.fg("dim", "hidden"), width));
|
|
509
|
+
const gutter = " ".repeat(gutterDigits + 2);
|
|
510
|
+
const contentWidth = Math.max(10, width - gutterDigits - 2);
|
|
511
|
+
const mode =
|
|
512
|
+
CHIP_MODES.find(
|
|
513
|
+
(candidate) => groupWidth(current.bench, candidate) <= contentWidth,
|
|
514
|
+
) ?? "icon";
|
|
515
|
+
const placedBench: PlacedChip[] = [];
|
|
516
|
+
let column = 0;
|
|
517
|
+
for (const chip of current.bench) {
|
|
518
|
+
const chipWidth = visibleWidth(chipText(chip, mode));
|
|
519
|
+
placedBench.push({ chip, column, width: chipWidth });
|
|
520
|
+
column += chipWidth + CHIP_GAP;
|
|
521
|
+
}
|
|
522
|
+
lines.push(renderChipLine(width, gutter, placedBench, mode, selectedId));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (selection.area !== "globals") {
|
|
526
|
+
const widgetId = selection.widgetId;
|
|
527
|
+
const chip =
|
|
528
|
+
current.rows
|
|
529
|
+
.flatMap((row) => row.ordered)
|
|
530
|
+
.find((entry) => entry.widget.id === widgetId) ??
|
|
531
|
+
current.bench.find((entry) => entry.widget.id === widgetId);
|
|
532
|
+
if (chip) {
|
|
533
|
+
const placement = chip.placement;
|
|
534
|
+
const group = placement.benched
|
|
535
|
+
? current.bench
|
|
536
|
+
: current.rows[placement.row]!.groups[placement.align];
|
|
537
|
+
const index = group.findIndex((entry) => entry.widget.id === widgetId);
|
|
538
|
+
const info = placement.benched
|
|
539
|
+
? "hidden"
|
|
540
|
+
: `row ${placement.row} · ${placement.align} · pos ${index}${
|
|
541
|
+
placement.fill === "grow" ? " · grow" : ""
|
|
542
|
+
}`;
|
|
543
|
+
lines.push("");
|
|
544
|
+
lines.push(
|
|
545
|
+
truncateToWidth(
|
|
546
|
+
`${theme.inverse(chip.widget.label)} ${theme.fg("dim", `— ${info}`)}`,
|
|
547
|
+
width,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
lines.push("");
|
|
554
|
+
lines.push(truncateToWidth(theme.bold("General"), width));
|
|
555
|
+
const globals = globalsItems();
|
|
556
|
+
const labelWidth = Math.min(
|
|
557
|
+
Math.max(...globals.map((item) => visibleWidth(item.label)), 0),
|
|
558
|
+
Math.max(8, width - 12),
|
|
559
|
+
);
|
|
560
|
+
for (const [index, item] of globals.entries()) {
|
|
561
|
+
const selected = selection.area === "globals" && selection.index === index;
|
|
562
|
+
const prefix = selected ? theme.fg("accent", "→ ") : " ";
|
|
563
|
+
const label = truncateToWidth(item.label, labelWidth, "");
|
|
564
|
+
const paddedLabel =
|
|
565
|
+
label + " ".repeat(Math.max(0, labelWidth - visibleWidth(label)));
|
|
566
|
+
const valueWidth = Math.max(4, width - 2 - labelWidth - 2);
|
|
567
|
+
const value = truncateToWidth(item.currentValue, valueWidth, "");
|
|
568
|
+
lines.push(
|
|
569
|
+
truncateToWidth(
|
|
570
|
+
`${prefix}${selected ? theme.fg("accent", paddedLabel) : paddedLabel} ${selected ? theme.fg("accent", value) : theme.fg("dim", value)}`,
|
|
571
|
+
width,
|
|
572
|
+
),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const description = selectedDescription();
|
|
577
|
+
if (description) {
|
|
578
|
+
lines.push("");
|
|
579
|
+
for (const line of wrapTextWithAnsi(description, Math.max(10, width - 2))) {
|
|
580
|
+
lines.push(truncateToWidth(theme.fg("dim", line), width));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
lines.push("");
|
|
585
|
+
lines.push(
|
|
586
|
+
truncateToWidth(
|
|
587
|
+
theme.fg("dim", selection.area === "globals" ? globalsHints : chipHints),
|
|
588
|
+
width,
|
|
589
|
+
),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
return lines;
|
|
593
|
+
},
|
|
594
|
+
invalidate() {
|
|
595
|
+
submenu?.invalidate?.();
|
|
596
|
+
},
|
|
597
|
+
handleInput(data: string) {
|
|
598
|
+
if (submenu) {
|
|
599
|
+
submenu.handleInput?.(data);
|
|
600
|
+
tui.requestRender();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
605
|
+
done(undefined);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (matchesKey(data, Key.left)) {
|
|
610
|
+
moveSelectionHorizontal(-1);
|
|
611
|
+
} else if (matchesKey(data, Key.right)) {
|
|
612
|
+
moveSelectionHorizontal(1);
|
|
613
|
+
} else if (keybindings.matches(data, "tui.select.up")) {
|
|
614
|
+
moveSelectionVertical(-1);
|
|
615
|
+
} else if (keybindings.matches(data, "tui.select.down")) {
|
|
616
|
+
moveSelectionVertical(1);
|
|
617
|
+
} else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
618
|
+
activateSelection();
|
|
619
|
+
} else if (selection.area === "globals" && data === " ") {
|
|
620
|
+
cycleGlobalValue();
|
|
621
|
+
} else if (selection.area !== "globals") {
|
|
622
|
+
const action = chipActions.find((entry) => entry.keys.includes(data));
|
|
623
|
+
if (action) {
|
|
624
|
+
action.run(selection.widgetId);
|
|
625
|
+
applyDraft();
|
|
626
|
+
syncSelection();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
tui.requestRender();
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
});
|
|
634
|
+
}
|