@groeponline/pi-wishcraft 0.20.0 → 0.22.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +83 -0
  3. package/ROADMAP.md +18 -20
  4. package/bash-mode/editor-ghost.ts +63 -0
  5. package/bash-mode/editor-history.ts +81 -0
  6. package/bash-mode/editor-input.ts +35 -0
  7. package/bash-mode/editor.ts +16 -134
  8. package/bash-mode/shell-session.ts +12 -15
  9. package/bash-mode/types.ts +2 -0
  10. package/docs/bash-mode.md +46 -0
  11. package/docs/commands.md +122 -0
  12. package/docs/configuration.md +307 -0
  13. package/docs/index.md +18 -0
  14. package/docs/segments.md +168 -0
  15. package/docs/skill-manager.md +7 -0
  16. package/docs/stash-and-shortcuts.md +64 -0
  17. package/docs/working-vibes.md +71 -0
  18. package/package.json +2 -1
  19. package/queue/store.ts +72 -3
  20. package/src/config/custom-items.ts +45 -0
  21. package/src/config/parse.ts +19 -3
  22. package/src/config/powerline-config.ts +5 -0
  23. package/src/config/primitives.ts +13 -1
  24. package/src/config/segment-options.ts +29 -24
  25. package/src/config/settings-patch.ts +23 -2
  26. package/src/config/types.ts +32 -17
  27. package/src/extension/commands/bash-mode-actions.ts +1 -0
  28. package/src/extension/commands/commands.ts +36 -18
  29. package/src/extension/commands/powerline-completions.ts +8 -0
  30. package/src/extension/commands/powerline-doctor.ts +247 -0
  31. package/src/extension/commands/powerline-export.ts +94 -0
  32. package/src/extension/commands/queue-commands.ts +29 -1
  33. package/src/extension/commands/vibe-command.ts +49 -1
  34. package/src/extension/core/constants.ts +1 -0
  35. package/src/extension/core/segment-context.ts +38 -2
  36. package/src/extension/core/state.ts +5 -1
  37. package/src/extension/core/status-export.ts +68 -0
  38. package/src/extension/core/types.ts +4 -0
  39. package/src/extension/hooks/repairs.ts +89 -16
  40. package/src/extension/session/activate.ts +1 -2
  41. package/src/extension/session/cost-alert.ts +36 -0
  42. package/src/extension/session/session-lifecycle.ts +82 -1
  43. package/src/extension/settings/settings-io.ts +23 -17
  44. package/src/extension/settings/wishcraft-config.ts +2 -1
  45. package/src/extension/shortcuts/shortcuts-config.ts +5 -0
  46. package/src/extension/ui/layout.ts +1 -1
  47. package/src/extension/ui/menu-items.ts +379 -0
  48. package/src/extension/ui/menu-views.ts +377 -191
  49. package/src/extension/ui/overlay-chrome.ts +163 -0
  50. package/src/extension/ui/powerline-menu-view.ts +2 -4
  51. package/src/extension/ui/token-overlays.ts +82 -0
  52. package/src/extension/welcome/welcome-integration.ts +5 -9
  53. package/src/segments/registry.ts +100 -39
  54. package/src/segments/system.ts +204 -64
  55. package/src/segments/usage.ts +10 -4
  56. package/src/usage/ledger.ts +6 -0
  57. package/src/usage/token-budget.ts +57 -0
  58. package/src/usage/tps-ring.ts +151 -0
  59. package/src/usage/usage-store.ts +351 -0
  60. package/src/welcome/banner.ts +2 -1
  61. package/src/welcome/index.ts +3 -1
  62. package/src/welcome/overlay.ts +2 -0
  63. package/src/welcome/renderer.ts +52 -10
  64. package/src/welcome/types.ts +1 -2
  65. package/src/welcome/whats-new.ts +156 -0
  66. package/src/welcome/widgets/index.ts +3 -0
  67. package/src/welcome/widgets/whats-new-widget.ts +19 -0
  68. package/src/working-vibes/generate.ts +144 -58
  69. package/src/working-vibes/index.ts +10 -2
  70. package/src/working-vibes/storage.ts +5 -0
@@ -1,107 +1,78 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
+ matchesKey,
3
4
  type SelectItem,
4
5
  SelectList,
5
6
  truncateToWidth,
6
7
  } from "@earendil-works/pi-tui";
7
8
  import { execSync } from "node:child_process";
8
9
 
9
- import type { StatusLinePreset } from "../../config/types.ts";
10
- import { getPreset, PRESETS } from "../../config/presets.ts";
11
- import { mergeSegmentsWithCustomItems } from "../../config/powerline-config.ts";
10
+ import type {
11
+ StatusLinePreset,
12
+ StatusLineSegmentId,
13
+ StatusLineSeparatorStyle,
14
+ } from "../../config/types.ts";
15
+ import { getPreset, PRESETS, registerCustomPresets } from "../../config/presets.ts";
16
+ import { SEPARATOR_STYLES } from "../../config/primitives.ts";
12
17
  import {
13
- writePowerlineDisabledSegmentSetting,
18
+ countListeningPorts,
19
+ listOpenPortProcesses,
20
+ sanitizeSshHost,
21
+ } from "../../segments/system.ts";
22
+ import {
23
+ writePowerlineCustomPresetSetting,
24
+ writePowerlineOptionSetting,
14
25
  writePowerlinePresetSetting,
15
26
  } from "../settings/settings-io.ts";
16
- import { renderSegmentWithWidth } from "./layout.ts";
17
27
  import {
18
28
  buildSegmentContext,
19
29
  requestImmediateStatusRender,
30
+ resetLayoutCache,
20
31
  } from "../core/segment-context.ts";
21
32
  import { config, setConfig } from "../core/state.ts";
33
+ import {
34
+ formatPortsStatusValue,
35
+ publishPowerlineStatuses,
36
+ } from "../core/status-export.ts";
22
37
  import type { RuntimeState } from "../core/types.ts";
38
+ import {
39
+ buildConfigureItems,
40
+ buildCustomPresetDef,
41
+ buildCustomPresetSegmentIds,
42
+ buildPresetEditorAddItems,
43
+ buildSegmentDetailLines,
44
+ buildSegmentItems,
45
+ collectSegmentIds,
46
+ configureItemsToSelectItems,
47
+ segmentItemsToSelectItems,
48
+ selectIndexForValue,
49
+ validatePresetName,
50
+ } from "./menu-items.ts";
51
+ import { overlaySelectListTheme, showSelectOverlay } from "./overlay-chrome.ts";
52
+ import { showTpsOverlay } from "./token-overlays.ts";
23
53
 
24
- export function overlaySelectListTheme(theme: Theme) {
25
- return {
26
- selectedPrefix: (text: string) => theme.fg("accent", text),
27
- selectedText: (text: string) => theme.fg("accent", text),
28
- description: (text: string) => theme.fg("muted", text),
29
- scrollInfo: (text: string) => theme.fg("dim", text),
30
- noMatch: (text: string) => theme.fg("warning", text),
31
- };
32
- }
33
-
34
- export async function showSelectOverlay(
35
- ctx: any,
36
- title: string,
37
- hint: string,
38
- items: SelectItem[],
39
- maxVisible: number,
40
- ): Promise<SelectItem | null> {
41
- return ctx.ui.custom(
42
- (
43
- tui: any,
44
- theme: Theme,
45
- _keybindings: any,
46
- done: (result: SelectItem | null) => void,
47
- ) => {
48
- const selectList = new SelectList(
49
- items,
50
- maxVisible,
51
- overlaySelectListTheme(theme),
52
- );
53
- const border = (text: string) => theme.fg("dim", text);
54
- const wrapRow = (text: string, innerWidth: number): string => {
55
- return `${border("│")}${truncateToWidth(text, innerWidth, "…", true)}${border("│")}`;
56
- };
57
-
58
- selectList.onSelect = (item) => done(item);
59
- selectList.onCancel = () => done(null);
60
-
61
- return {
62
- render: (width: number) => {
63
- const innerWidth = Math.max(1, width - 2);
64
- const lines: string[] = [];
65
-
66
- lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
67
- lines.push(
68
- wrapRow(theme.fg("accent", theme.bold(title)), innerWidth),
69
- );
70
- lines.push(border(`├${"─".repeat(innerWidth)}┤`));
71
-
72
- for (const line of selectList.render(innerWidth)) {
73
- lines.push(wrapRow(line, innerWidth));
74
- }
75
-
76
- lines.push(border(`├${"─".repeat(innerWidth)}┤`));
77
- lines.push(wrapRow(theme.fg("dim", hint), innerWidth));
78
- lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
54
+ export { overlaySelectListTheme, showSelectOverlay } from "./overlay-chrome.ts";
79
55
 
80
- return lines;
81
- },
82
- invalidate: () => selectList.invalidate(),
83
- handleInput: (data: string) => {
84
- selectList.handleInput(data);
85
- tui.requestRender();
86
- },
87
- };
88
- },
89
- {
90
- overlay: true,
91
- overlayOptions: () => ({
92
- verticalAlign: "center",
93
- horizontalAlign: "center",
94
- }),
95
- },
96
- );
97
- }
56
+ /** How often the open segment navigator re-reads live segment state. */
57
+ const SEGMENT_NAVIGATOR_REFRESH_MS = 1000;
98
58
 
99
59
  /** Full open-ports list as a scrollable overlay (the Info view). */
100
60
  export async function showOpenPortsList(ctx: any): Promise<void> {
101
61
  try {
102
62
  const includeUdp = config.segmentOptions?.openPorts?.includeUdp === true;
103
- const proto = includeUdp ? "-tuln" : "-tln";
104
- const stdout = execSync(`ss ${proto} 2>/dev/null`, { encoding: "utf8" });
63
+ const host = config.segmentOptions?.openPorts?.host;
64
+ if (host && !sanitizeSshHost(host)) {
65
+ ctx.ui.notify(`Invalid open-ports host: ${host}`, "error");
66
+ return;
67
+ }
68
+ const proto = includeUdp ? "-tulnp" : "-tlnp";
69
+ const command = host
70
+ ? `ssh -o ConnectTimeout=3 -o BatchMode=yes ${host} "ss ${proto} 2>/dev/null" 2>/dev/null`
71
+ : `ss ${proto} 2>/dev/null`;
72
+ const stdout = execSync(command, { encoding: "utf8" });
73
+ publishPowerlineStatuses(ctx, {
74
+ ports: formatPortsStatusValue(countListeningPorts(includeUdp, host)),
75
+ });
105
76
  const lines = stdout
106
77
  .split("\n")
107
78
  .map((l) => l.trim())
@@ -118,7 +89,7 @@ export async function showOpenPortsList(ctx: any): Promise<void> {
118
89
  }));
119
90
  const picked = await showSelectOverlay(
120
91
  ctx,
121
- "Open ports",
92
+ host ? `Open ports · ${host}` : "Open ports",
122
93
  "↑↓ navigate · enter copy · esc close",
123
94
  items,
124
95
  Math.min(items.length, 24),
@@ -132,47 +103,37 @@ export async function showOpenPortsList(ctx: any): Promise<void> {
132
103
  }
133
104
  }
134
105
 
135
- /** Alle segment-ids van de huidige preset + custom items (voor menu's). */
136
- function mergedIds(): string[] {
137
- const presetDef = getPreset(config.preset);
138
- const merged = mergeSegmentsWithCustomItems(presetDef, config.customItems, {
139
- layout: config.layout,
140
- disabledSegments: [],
141
- });
142
- return [
143
- ...new Set([
144
- ...merged.leftSegments,
145
- ...merged.rightSegments,
146
- ...merged.secondarySegments,
147
- ]),
148
- ] as string[];
149
- }
150
-
151
106
  /** Configure sub-menu: preset, TPS value/label, ports UDP, segment labels. */
152
107
  export async function configurePowerline(
153
108
  rt: RuntimeState,
154
109
  ctx: any,
155
110
  ): Promise<void> {
156
- const choice = await ctx.ui.select("Powerline · configure", [
157
- "Change preset",
158
- "Set TPS value (POWERLINE_TPS)",
159
- "Clear TPS override (use live)",
160
- "Toggle UDP in open-ports",
161
- "Set segment label…",
162
- "Toggle segment visibility…",
163
- "Show current config",
164
- ]);
111
+ const picked = await showSelectOverlay(
112
+ ctx,
113
+ "Powerline · configure",
114
+ "↑↓ navigate · enter select · esc back",
115
+ configureItemsToSelectItems(),
116
+ Math.min(buildConfigureItems().length, 12),
117
+ );
118
+ const choice = picked?.value;
165
119
  if (!choice) return;
166
120
  if (choice === "Change preset") {
167
121
  const names = Object.keys(PRESETS) as StatusLinePreset[];
168
- const picked = await ctx.ui.select("Preset", names);
122
+ const picked = await showSelectOverlay(
123
+ ctx,
124
+ "Preset",
125
+ "↑↓ navigate · enter apply · esc back",
126
+ names.map((name) => ({ label: name, value: name })),
127
+ Math.min(names.length, 12),
128
+ );
169
129
  if (picked) {
170
- setConfig({ ...config, preset: picked as StatusLinePreset });
130
+ setConfig({ ...config, preset: picked.value as StatusLinePreset });
171
131
  writePowerlinePresetSetting(
172
- picked as StatusLinePreset,
132
+ picked.value as StatusLinePreset,
173
133
  ctx.cwd ?? process.cwd(),
174
134
  );
175
- ctx.ui.notify(`Preset: ${picked} (saved)`, "info");
135
+ publishPowerlineStatuses(ctx, { preset: picked.value });
136
+ ctx.ui.notify(`Preset: ${picked.value} (saved)`, "info");
176
137
  requestImmediateStatusRender(rt, { deferDuringTyping: false });
177
138
  }
178
139
  return;
@@ -184,6 +145,7 @@ export async function configurePowerline(
184
145
  );
185
146
  if (val && val.trim()) {
186
147
  process.env.POWERLINE_TPS = val.trim();
148
+ publishPowerlineStatuses(ctx, { tps: val.trim() });
187
149
  ctx.ui.notify(`TPS override set: ${val.trim()}`, "info");
188
150
  requestImmediateStatusRender(rt, { deferDuringTyping: false });
189
151
  }
@@ -191,23 +153,59 @@ export async function configurePowerline(
191
153
  }
192
154
  if (choice === "Clear TPS override (use live)") {
193
155
  delete process.env.POWERLINE_TPS;
156
+ publishPowerlineStatuses(ctx, { tps: undefined });
194
157
  ctx.ui.notify("TPS override cleared (live rate)", "info");
195
158
  requestImmediateStatusRender(rt, { deferDuringTyping: false });
196
159
  return;
197
160
  }
198
161
  if (choice === "Toggle UDP in open-ports") {
199
162
  const now = config.segmentOptions?.openPorts?.includeUdp === true;
163
+ const host = config.segmentOptions?.openPorts?.host;
200
164
  setConfig({
201
165
  ...config,
202
166
  segmentOptions: {
203
167
  ...config.segmentOptions,
204
- openPorts: { includeUdp: !now },
168
+ openPorts: { ...config.segmentOptions?.openPorts, includeUdp: !now },
205
169
  },
206
170
  });
171
+ publishPowerlineStatuses(ctx, {
172
+ ports: formatPortsStatusValue(countListeningPorts(!now, host)),
173
+ });
207
174
  ctx.ui.notify(`Open-ports UDP: ${!now ? "on" : "off"}`, "info");
208
175
  requestImmediateStatusRender(rt, { deferDuringTyping: false });
209
176
  return;
210
177
  }
178
+ if (choice === "Toggle segment visibility…") {
179
+ const ids = collectSegmentIds(config);
180
+ const items: SelectItem[] = ids.map((id) => ({
181
+ label: `${config.disabledSegments.includes(id) ? "◌" : "●"} ${id}`,
182
+ value: id,
183
+ }));
184
+ const picked = await showSelectOverlay(
185
+ ctx,
186
+ "Toggle segments",
187
+ "↑↓ navigate · enter toggle · esc close",
188
+ items,
189
+ Math.min(items.length, 20),
190
+ );
191
+ if (!picked) return;
192
+ const id = picked.value as StatusLineSegmentId;
193
+ const next = config.disabledSegments.includes(id)
194
+ ? config.disabledSegments.filter((x) => x !== id)
195
+ : [...config.disabledSegments, id];
196
+ setConfig({ ...config, disabledSegments: next });
197
+ const saved = writePowerlineOptionSetting(
198
+ ctx.cwd ?? process.cwd(),
199
+ { disabledSegments: next },
200
+ config.preset,
201
+ );
202
+ ctx.ui.notify(
203
+ `${next.includes(id) ? "Disabled" : "Enabled"}: ${id}${saved ? " (saved)" : " (not persisted; check settings.json)"}`,
204
+ "info",
205
+ );
206
+ requestImmediateStatusRender(rt, { deferDuringTyping: false });
207
+ return;
208
+ }
211
209
  if (choice === "Set segment label…") {
212
210
  const id = await ctx.ui.input("Segment id", "tps");
213
211
  if (!id) return;
@@ -224,28 +222,8 @@ export async function configurePowerline(
224
222
  requestImmediateStatusRender(rt, { deferDuringTyping: false });
225
223
  return;
226
224
  }
227
- if (choice === "Toggle segment visibility…") {
228
- const ids = [
229
- ...mergedIds(),
230
- ];
231
- const names = ids.map(
232
- (id) =>
233
- `${config.disabledSegments.includes(id as any) ? "[ ]" : "[x]"} ${id}`,
234
- );
235
- const picked = await ctx.ui.select("Segment aan/uit (enter wisselt)", names);
236
- if (!picked) return;
237
- const id = picked.replace(/^\[.\] /, "");
238
- const disabled = new Set(config.disabledSegments);
239
- if (disabled.has(id)) disabled.delete(id);
240
- else disabled.add(id);
241
- const list = [...disabled];
242
- setConfig({ ...config, disabledSegments: list });
243
- writePowerlineDisabledSegmentSetting(list, ctx.cwd ?? process.cwd());
244
- ctx.ui.notify(
245
- `${id}: ${disabled.has(id) ? "verborgen" : "zichtbaar"} (opgeslagen)`,
246
- "info",
247
- );
248
- requestImmediateStatusRender(rt, { deferDuringTyping: false });
225
+ if (choice === "Build custom preset…") {
226
+ await runPresetEditor(rt, ctx);
249
227
  return;
250
228
  }
251
229
  if (choice === "Show current config") {
@@ -262,6 +240,109 @@ export async function configurePowerline(
262
240
  }
263
241
  }
264
242
 
243
+ /**
244
+ * Interactive loop for one preset group (left/right/secondary): pick segments
245
+ * one at a time from the remaining ids, choosing the done sentinel to finish.
246
+ * Returns null when the user cancels with esc.
247
+ */
248
+ async function pickPresetGroup(
249
+ ctx: any,
250
+ group: string,
251
+ all: readonly StatusLineSegmentId[],
252
+ initial: readonly StatusLineSegmentId[],
253
+ ): Promise<StatusLineSegmentId[] | null> {
254
+ const chosen: StatusLineSegmentId[] = [...initial];
255
+ for (;;) {
256
+ const items = buildPresetEditorAddItems(all, chosen);
257
+ const picked = await showSelectOverlay(
258
+ ctx,
259
+ `Preset · ${group} segments`,
260
+ "↑↓ pick · enter add · done to finish · esc cancel",
261
+ items,
262
+ Math.min(items.length, 20),
263
+ );
264
+ if (!picked || picked.value === "__done__") break;
265
+ chosen.push(picked.value as StatusLineSegmentId);
266
+ }
267
+ return chosen;
268
+ }
269
+
270
+ /** Build a custom preset interactively and save it to settings + runtime. */
271
+ export async function runPresetEditor(
272
+ rt: RuntimeState,
273
+ ctx: any,
274
+ ): Promise<void> {
275
+ const rawName = await ctx.ui.input("Preset name", "");
276
+ if (!rawName) return;
277
+ const name = validatePresetName(rawName);
278
+ if (!name) {
279
+ ctx.ui.notify("Invalid preset name (a-z, 0-9, -, _)", "error");
280
+ return;
281
+ }
282
+
283
+ const baseNames = Object.keys(PRESETS) as StatusLinePreset[];
284
+ const basePick = await showSelectOverlay(
285
+ ctx,
286
+ "Base preset (colors + options)",
287
+ "↑↓ navigate · enter select · esc cancel",
288
+ baseNames.map((name) => ({ label: name, value: name })),
289
+ Math.min(baseNames.length, 12),
290
+ );
291
+ if (!basePick) return;
292
+ const base = getPreset(basePick.value as StatusLinePreset);
293
+
294
+ const all = buildCustomPresetSegmentIds(config);
295
+ const left = await pickPresetGroup(ctx, "left", all, base.leftSegments);
296
+ if (!left) return;
297
+ const right = await pickPresetGroup(ctx, "right", all, base.rightSegments);
298
+ if (!right) return;
299
+ const secondary = await pickPresetGroup(
300
+ ctx,
301
+ "secondary",
302
+ all,
303
+ base.secondarySegments ?? [],
304
+ );
305
+ if (!secondary) return;
306
+
307
+ const separator = await showSelectOverlay(
308
+ ctx,
309
+ "Separator",
310
+ "↑↓ navigate · enter select · esc cancel",
311
+ (SEPARATOR_STYLES as unknown as string[]).map((name) => ({
312
+ label: name,
313
+ value: name,
314
+ })),
315
+ Math.min(SEPARATOR_STYLES.length, 12),
316
+ );
317
+ if (!separator) return;
318
+
319
+ const def = buildCustomPresetDef(
320
+ base,
321
+ left,
322
+ right,
323
+ secondary,
324
+ separator.value as StatusLineSeparatorStyle,
325
+ );
326
+
327
+ const saved = writePowerlineCustomPresetSetting(
328
+ ctx.cwd ?? process.cwd(),
329
+ name,
330
+ def,
331
+ );
332
+ const presets = { ...config.presets, [name]: def };
333
+ registerCustomPresets(presets);
334
+ setConfig({ ...config, preset: name as StatusLinePreset, presets });
335
+ resetLayoutCache(rt);
336
+ requestImmediateStatusRender(rt, { deferDuringTyping: false });
337
+ publishPowerlineStatuses(ctx, { preset: name });
338
+ ctx.ui.notify(
339
+ saved
340
+ ? `Preset "${name}" saved and applied`
341
+ : `Preset "${name}" applied (not persisted; check settings.json)`,
342
+ saved ? "info" : "warning",
343
+ );
344
+ }
345
+
265
346
  /** Activate a segment picked from the navigator (Enter). */
266
347
  export async function activateSegment(
267
348
  rt: RuntimeState,
@@ -271,11 +352,7 @@ export async function activateSegment(
271
352
  const id = picked.id;
272
353
  if (id === "__none__") return;
273
354
  if (id === "tps") {
274
- const current = process.env.POWERLINE_TPS || "(live, auto)";
275
- ctx.ui.notify(
276
- `TPS: ${current} — set via Configure or /tps <value>`,
277
- "info",
278
- );
355
+ await showTpsOverlay(rt, ctx);
279
356
  return;
280
357
  }
281
358
  if (id === "open_ports") {
@@ -305,7 +382,11 @@ export async function activateSegment(
305
382
  ctx.ui.notify(`${id}: ${value}`, "info");
306
383
  }
307
384
 
308
- /** Navigable overlay that mirrors the live powerline segments; arrow keys move, Enter activates. */
385
+ /**
386
+ * Navigable overlay that mirrors the live powerline segments. Arrow keys move,
387
+ * Enter activates, and →/tab opens a stacked per-segment detail view (←/esc
388
+ * returns to the list). Pure item/detail building lives in `menu-items.ts`.
389
+ */
309
390
  export async function showSegmentNavigator(
310
391
  rt: RuntimeState,
311
392
  ctx: any,
@@ -317,70 +398,175 @@ export async function showSegmentNavigator(
317
398
  _keybindings: any,
318
399
  done: (result: { id: string; label: string } | null) => void,
319
400
  ) => {
320
- // Build live segment values now that we have a theme
321
- const segCtx = buildSegmentContext(rt, ctx, theme);
322
- const presetDef = getPreset(config.preset);
323
- const merged = mergeSegmentsWithCustomItems(
324
- presetDef,
325
- config.customItems,
326
- {
327
- layout: config.layout,
328
- disabledSegments: config.disabledSegments,
329
- },
330
- );
331
- const ids = [
332
- ...merged.leftSegments,
333
- ...merged.rightSegments,
334
- ...merged.secondarySegments,
335
- ];
336
- const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
337
- const items: SelectItem[] = [];
338
- for (const id of ids) {
339
- const rendered = renderSegmentWithWidth(id, segCtx);
340
- if (!rendered.visible) continue;
341
- const value = stripAnsi(rendered.content).trim() || "(empty)";
342
- items.push({ label: `${id} ${value}`, value: id });
343
- }
344
- if (items.length === 0) {
345
- items.push({ label: "(no visible segments)", value: "__none__" });
346
- }
347
- const selectList = new SelectList(
348
- items,
349
- Math.min(items.length, 20),
350
- overlaySelectListTheme(theme),
351
- );
401
+ let segCtx = buildSegmentContext(rt, ctx, theme);
402
+ let items = segmentItemsToSelectItems(buildSegmentItems(segCtx, config));
403
+ let detailId: StatusLineSegmentId | null = null;
404
+ let detailLines: ReturnType<typeof buildSegmentDetailLines> = [];
405
+ let timer: ReturnType<typeof setInterval> | null = null;
406
+
352
407
  const border = (text: string) => theme.fg("dim", text);
353
408
  const wrapRow = (text: string, innerWidth: number) =>
354
409
  `${border("│")}${truncateToWidth(text, innerWidth, "…", true)}${border("│")}`;
355
- selectList.onSelect = (item) =>
356
- done({ id: item.value, label: item.label });
357
- selectList.onCancel = () => done(null);
358
- return {
359
- render: (width: number) => {
360
- const innerWidth = Math.max(1, width - 2);
361
- const lines: string[] = [];
362
- lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
363
- lines.push(
364
- wrapRow(
365
- theme.fg("accent", theme.bold("Powerline segments")),
366
- innerWidth,
367
- ),
410
+
411
+ const buildDetail = (id: StatusLineSegmentId) =>
412
+ id === "open_ports"
413
+ ? buildSegmentDetailLines(
414
+ id,
415
+ segCtx,
416
+ listOpenPortProcesses(
417
+ config.segmentOptions?.openPorts?.includeUdp === true,
418
+ config.segmentOptions?.openPorts?.host,
419
+ ),
420
+ )
421
+ : buildSegmentDetailLines(id, segCtx);
422
+
423
+ const openDetail = (id: string) => {
424
+ if (id === "__none__") return;
425
+ detailId = id as StatusLineSegmentId;
426
+ detailLines = buildDetail(detailId);
427
+ };
428
+
429
+ const finish = (result: { id: string; label: string } | null) => {
430
+ if (timer !== null) {
431
+ clearInterval(timer);
432
+ timer = null;
433
+ }
434
+ done(result);
435
+ };
436
+
437
+ const makeSelectList = () => {
438
+ const list = new SelectList(
439
+ items,
440
+ Math.min(items.length, 20),
441
+ overlaySelectListTheme(theme),
442
+ );
443
+ list.onSelect = (item) =>
444
+ finish(
445
+ item.value === "__none__"
446
+ ? null
447
+ : { id: item.value, label: item.label },
368
448
  );
369
- lines.push(border(`├${"─".repeat(innerWidth)}┤`));
370
- for (const line of selectList.render(innerWidth))
371
- lines.push(wrapRow(line, innerWidth));
372
- lines.push(border(`├${"─".repeat(innerWidth)}┤`));
449
+ list.onCancel = () => finish(null);
450
+ return list;
451
+ };
452
+
453
+ let selectList = makeSelectList();
454
+
455
+ // Re-read live segment state (TPS, context %, cost, git, queue, …) while
456
+ // the overlay stays open, preserving the selected segment across rebuilds.
457
+ const refresh = () => {
458
+ const previous = selectList.getSelectedItem()?.value ?? null;
459
+ segCtx = buildSegmentContext(rt, ctx, theme);
460
+ items = segmentItemsToSelectItems(buildSegmentItems(segCtx, config));
461
+ selectList = makeSelectList();
462
+ selectList.setSelectedIndex(selectIndexForValue(items, previous));
463
+ if (detailId !== null) {
464
+ detailLines = buildDetail(detailId);
465
+ }
466
+ tui.requestRender();
467
+ };
468
+
469
+ timer = setInterval(refresh, SEGMENT_NAVIGATOR_REFRESH_MS);
470
+
471
+ const renderList = (innerWidth: number): string[] => {
472
+ const lines: string[] = [];
473
+ lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
474
+ lines.push(
475
+ wrapRow(
476
+ theme.fg("accent", theme.bold("Powerline segments")),
477
+ innerWidth,
478
+ ),
479
+ );
480
+ lines.push(border(`├${"─".repeat(innerWidth)}┤`));
481
+ for (const line of selectList.render(innerWidth))
482
+ lines.push(wrapRow(line, innerWidth));
483
+ lines.push(border(`├${"─".repeat(innerWidth)}┤`));
484
+ lines.push(
485
+ wrapRow(
486
+ theme.fg(
487
+ "dim",
488
+ "↑↓ navigate · enter activate · →/tab detail · esc cancel",
489
+ ),
490
+ innerWidth,
491
+ ),
492
+ );
493
+ lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
494
+ return lines;
495
+ };
496
+
497
+ const renderDetail = (innerWidth: number): string[] => {
498
+ const lines: string[] = [];
499
+ lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
500
+ lines.push(
501
+ wrapRow(
502
+ theme.fg(
503
+ "accent",
504
+ theme.bold(`Segment detail: ${detailId ?? ""}`),
505
+ ),
506
+ innerWidth,
507
+ ),
508
+ );
509
+ lines.push(border(`├${"─".repeat(innerWidth)}┤`));
510
+ const labelWidth = detailLines.reduce(
511
+ (max, line) => Math.max(max, line.label.length),
512
+ 0,
513
+ );
514
+ for (const line of detailLines) {
373
515
  lines.push(
374
516
  wrapRow(
375
- theme.fg("dim", "↑↓ navigate · enter activate · esc cancel"),
517
+ ` ${line.label.padEnd(labelWidth)} ${line.value}`,
376
518
  innerWidth,
377
519
  ),
378
520
  );
379
- lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
380
- return lines;
521
+ }
522
+ lines.push(border(`├${"─".repeat(innerWidth)}┤`));
523
+ lines.push(
524
+ wrapRow(
525
+ theme.fg("dim", "← back · enter copy · esc close"),
526
+ innerWidth,
527
+ ),
528
+ );
529
+ lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
530
+ return lines;
531
+ };
532
+
533
+ return {
534
+ render: (width: number) => {
535
+ const innerWidth = Math.max(1, width - 2);
536
+ return detailId
537
+ ? renderDetail(innerWidth)
538
+ : renderList(innerWidth);
381
539
  },
382
540
  invalidate: () => selectList.invalidate(),
383
541
  handleInput: (data: string) => {
542
+ if (detailId !== null) {
543
+ if (
544
+ matchesKey(data, "left") ||
545
+ matchesKey(data, "escape") ||
546
+ matchesKey(data, "backspace")
547
+ ) {
548
+ detailId = null;
549
+ } else if (matchesKey(data, "enter")) {
550
+ const summary = detailLines
551
+ .map((line) => `${line.label}: ${line.value}`)
552
+ .join(" · ");
553
+ ctx.ui.notify(`${detailId}: ${summary}`, "info");
554
+ finish(null);
555
+ return;
556
+ }
557
+ tui.requestRender();
558
+ return;
559
+ }
560
+
561
+ if (matchesKey(data, "right") || matchesKey(data, "tab")) {
562
+ const selected = selectList.getSelectedItem();
563
+ if (selected && selected.value !== "__none__") {
564
+ openDetail(selected.value);
565
+ tui.requestRender();
566
+ return;
567
+ }
568
+ }
569
+
384
570
  selectList.handleInput(data);
385
571
  tui.requestRender();
386
572
  },