@gajae-code/coding-agent 0.16.3 → 0.16.4

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 (133) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  35. package/dist/types/sdk/session-api-key.d.ts +26 -0
  36. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +9 -0
  38. package/dist/types/setup/paseo/announce.d.ts +111 -0
  39. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  40. package/dist/types/system-prompt.d.ts +2 -0
  41. package/dist/types/task/executor.d.ts +2 -1
  42. package/dist/types/tools/fetch.d.ts +7 -0
  43. package/dist/types/tools/index.d.ts +8 -0
  44. package/dist/types/tools/path-utils.d.ts +2 -0
  45. package/dist/types/tools/search.d.ts +6 -0
  46. package/package.json +7 -7
  47. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  48. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  49. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  50. package/src/cli/args.ts +20 -9
  51. package/src/cli/root-flags.ts +5 -3
  52. package/src/config/autorouting-tier-map.ts +1 -0
  53. package/src/config/file-lock.ts +181 -17
  54. package/src/config/model-preset-registry.ts +5 -3
  55. package/src/config/model-profiles.ts +40 -0
  56. package/src/config/model-registry.ts +57 -11
  57. package/src/config/model-resolver.ts +48 -0
  58. package/src/config/settings-schema.ts +33 -1
  59. package/src/config/settings.ts +1 -0
  60. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  61. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  62. package/src/discovery/builtin.ts +13 -4
  63. package/src/discovery/claude-plugins.ts +12 -4
  64. package/src/discovery/helpers.ts +10 -2
  65. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  66. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  67. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  68. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  69. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  70. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  71. package/src/extensibility/shared-events.ts +1 -1
  72. package/src/extensibility/skills.ts +44 -16
  73. package/src/gjc-runtime/repository-binding.ts +1 -1
  74. package/src/gjc-runtime/session-state-lock.ts +92 -2
  75. package/src/internal-urls/docs-index.generated.ts +7 -7
  76. package/src/main.ts +72 -34
  77. package/src/modes/DESIGN.md +54 -0
  78. package/src/modes/components/assistant-message.ts +22 -9
  79. package/src/modes/components/model-selector.ts +166 -33
  80. package/src/modes/components/settings-selector.ts +1075 -235
  81. package/src/modes/components/status-line/command.ts +190 -0
  82. package/src/modes/components/status-line/index.ts +1 -0
  83. package/src/modes/components/status-line/segments.ts +32 -5
  84. package/src/modes/components/status-line/types.ts +5 -0
  85. package/src/modes/components/tool-status-header.ts +393 -21
  86. package/src/modes/controllers/command-controller.ts +2 -0
  87. package/src/modes/controllers/event-controller.ts +148 -5
  88. package/src/modes/controllers/selector-controller.ts +21 -0
  89. package/src/modes/interactive-mode.ts +47 -1
  90. package/src/modes/types.ts +1 -0
  91. package/src/modes/utils/ui-helpers.ts +1 -0
  92. package/src/prompts/system/system-prompt.md +8 -0
  93. package/src/prompts/tools/search.md +2 -0
  94. package/src/runtime-mcp/client.ts +60 -17
  95. package/src/runtime-mcp/manager.ts +171 -14
  96. package/src/runtime-mcp/pool.test.ts +209 -0
  97. package/src/runtime-mcp/pool.ts +224 -23
  98. package/src/runtime-mcp/transports/stdio.ts +158 -24
  99. package/src/runtime-mcp/types.ts +24 -0
  100. package/src/sdk/broker/broker.ts +5 -4
  101. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  102. package/src/sdk/bus/chat-daemon-runtime.ts +37 -1
  103. package/src/sdk/bus/slack-daemon.ts +92 -2
  104. package/src/sdk/bus/slack-live-provider.ts +12 -0
  105. package/src/sdk/bus/slack-provider.ts +5 -0
  106. package/src/sdk/cli/session-cli.ts +9 -6
  107. package/src/sdk/host/session-runtime.test.ts +2 -2
  108. package/src/sdk/host/session-runtime.ts +275 -43
  109. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  110. package/src/sdk/session-api-key.ts +94 -0
  111. package/src/sdk/session.ts +73 -29
  112. package/src/sdk/transport/serve-cli.ts +3 -3
  113. package/src/session/agent-session.ts +123 -38
  114. package/src/session/contribution-prep.ts +214 -21
  115. package/src/setup/paseo/announce.ts +450 -0
  116. package/src/setup/paseo/setup-deps.ts +15 -3
  117. package/src/slash-commands/builtin-registry.ts +1 -0
  118. package/src/system-prompt.ts +4 -0
  119. package/src/task/executor.ts +2 -1
  120. package/src/task/index.ts +17 -4
  121. package/src/tools/bash.ts +35 -8
  122. package/src/tools/cron.ts +1 -1
  123. package/src/tools/fetch.ts +29 -1
  124. package/src/tools/index.ts +8 -0
  125. package/src/tools/job.ts +8 -2
  126. package/src/tools/monitor.ts +8 -2
  127. package/src/tools/path-utils.ts +7 -0
  128. package/src/tools/search.ts +116 -58
  129. package/src/tools/skill.ts +22 -2
  130. package/src/tools/subagent.ts +3 -1
  131. package/src/tools/tool-catalog.generated.ts +8 -1
  132. package/src/web/insane/bridge.ts +113 -23
  133. package/src/web/search/providers/perplexity.ts +102 -41
@@ -3,6 +3,8 @@ import type { Effort } from "@gajae-code/ai/core";
3
3
  import {
4
4
  type Component,
5
5
  Container,
6
+ Ellipsis,
7
+ getKeybindings,
6
8
  Input,
7
9
  matchesKey,
8
10
  resolvePetMode,
@@ -14,6 +16,8 @@ import {
14
16
  type Tab,
15
17
  TabBar,
16
18
  Text,
19
+ truncateToWidth,
20
+ visibleWidth,
17
21
  } from "@gajae-code/tui";
18
22
  import { type SettingPath, settings } from "../../config/settings";
19
23
  import type {
@@ -41,7 +45,7 @@ import { normalizeProviderOrder } from "./provider-order-context";
41
45
  import { getSettingsForTab, type SettingDef } from "./settings-defs";
42
46
  import { getPreset } from "./status-line/presets";
43
47
  import { ALL_SEGMENT_IDS } from "./status-line/segments";
44
- import type { StatusLineSegmentOptions } from "./tool-status-header";
48
+ import type { StatusLinePreviewParts, StatusLineSegmentOptions } from "./tool-status-header";
45
49
 
46
50
  /**
47
51
  * A submenu component for selecting from a list of options.
@@ -84,6 +88,14 @@ class TextInputSubmenu extends Container {
84
88
  handleInput(data: string): void {
85
89
  handleInputOrEscape(data, this.#input, this.onCancel);
86
90
  }
91
+
92
+ submit(): void {
93
+ this.onSubmit(this.#input.getValue());
94
+ }
95
+
96
+ cancel(): void {
97
+ this.onCancel();
98
+ }
87
99
  }
88
100
 
89
101
  class SelectSubmenu extends Container {
@@ -210,11 +222,15 @@ function mergeSegmentOptions(
210
222
  overrides: StatusLineSegmentOptions | undefined,
211
223
  ): StatusLineSegmentOptions {
212
224
  return {
225
+ ...base,
226
+ ...overrides,
213
227
  model: base?.model || overrides?.model ? { ...(base?.model ?? {}), ...(overrides?.model ?? {}) } : undefined,
214
228
  path: base?.path || overrides?.path ? { ...(base?.path ?? {}), ...(overrides?.path ?? {}) } : undefined,
215
229
  git: base?.git || overrides?.git ? { ...(base?.git ?? {}), ...(overrides?.git ?? {}) } : undefined,
216
230
  time: base?.time || overrides?.time ? { ...(base?.time ?? {}), ...(overrides?.time ?? {}) } : undefined,
217
231
  usage: base?.usage || overrides?.usage ? { ...(base?.usage ?? {}), ...(overrides?.usage ?? {}) } : undefined,
232
+ command:
233
+ base?.command || overrides?.command ? { ...(base?.command ?? {}), ...(overrides?.command ?? {}) } : undefined,
218
234
  };
219
235
  }
220
236
 
@@ -225,13 +241,31 @@ function effectiveSegmentOptions(
225
241
  return mergeSegmentOptions(getPreset(preset).segmentOptions, options);
226
242
  }
227
243
 
244
+ function normalizeEditorSegments(value: unknown): StatusLineSegmentId[] {
245
+ if (!Array.isArray(value)) return [];
246
+ const seen = new Set<string>();
247
+ const normalized: StatusLineSegmentId[] = [];
248
+ for (const raw of value) {
249
+ const id = typeof raw === "string" ? raw : String(raw);
250
+ if (!id || seen.has(id)) continue;
251
+ seen.add(id);
252
+ normalized.push(id as StatusLineSegmentId);
253
+ }
254
+ return normalized;
255
+ }
256
+
228
257
  function effectiveCustomSegments(
229
258
  preset: StatusLinePreset,
230
- leftSegments: StatusLineSegmentId[],
231
- rightSegments: StatusLineSegmentId[],
259
+ leftSegments: unknown,
260
+ rightSegments: unknown,
232
261
  ): { leftSegments: StatusLineSegmentId[]; rightSegments: StatusLineSegmentId[] } {
233
262
  if (preset === "custom") {
234
- return { leftSegments: [...leftSegments], rightSegments: [...rightSegments] };
263
+ const left = normalizeEditorSegments(leftSegments);
264
+ const leftIds = new Set(left);
265
+ return {
266
+ leftSegments: left,
267
+ rightSegments: normalizeEditorSegments(rightSegments).filter(id => !leftIds.has(id)),
268
+ };
235
269
  }
236
270
  const presetDef = getPreset(preset);
237
271
  return {
@@ -261,20 +295,77 @@ function statusSegmentLabel(id: StatusLineSegmentId): string {
261
295
  return id.replace(/_/g, " ");
262
296
  }
263
297
 
264
- function segmentPlacement(
265
- id: StatusLineSegmentId,
266
- leftSegments: StatusLineSegmentId[],
267
- rightSegments: StatusLineSegmentId[],
268
- ): "hidden" | "left" | "right" {
269
- if (leftSegments.includes(id)) return "left";
270
- if (rightSegments.includes(id)) return "right";
271
- return "hidden";
298
+ type StatusLineSide = "left" | "right";
299
+ type StatusLineOptionPath =
300
+ | "model.showThinkingLevel"
301
+ | "path.abbreviate"
302
+ | "path.maxLength"
303
+ | "path.stripWorkPrefix"
304
+ | "git.showBranch"
305
+ | "git.showStaged"
306
+ | "git.showUnstaged"
307
+ | "git.showUntracked"
308
+ | "time.format"
309
+ | "time.showSeconds"
310
+ | "usage.mode"
311
+ | "command.command";
312
+ type StatusLineChoiceTarget = "separator" | StatusLineOptionPath;
313
+ type StatusLineRootFocus =
314
+ | { kind: "statusbar"; side: StatusLineSide; index: number }
315
+ | { kind: "palette"; index: number }
316
+ | { kind: "separator-control" }
317
+ | { kind: "option-control"; path: StatusLineOptionPath }
318
+ | { kind: "confirm" }
319
+ | { kind: "exit" };
320
+ type StatusLineFocus =
321
+ | StatusLineRootFocus
322
+ | { kind: "choice"; target: StatusLineChoiceTarget; focusedIndex: number; returnFocus: StatusLineRootFocus };
323
+
324
+ interface PickedStatusLineSegment {
325
+ id: StatusLineSegmentId;
326
+ origin: { kind: "statusbar"; side: StatusLineSide; index: number } | { kind: "palette" };
327
+ }
328
+
329
+ interface StatusLineChoiceDescriptor {
330
+ target: StatusLineChoiceTarget;
331
+ label: string;
332
+ values: SelectItem[];
333
+ currentValue: () => string;
334
+ apply: (value: string) => void;
335
+ highlightSegment?: StatusLineSegmentId;
336
+ }
337
+
338
+ const SEPARATOR_OPTIONS: SelectItem[] = [
339
+ { value: "powerline", label: "Powerline" },
340
+ { value: "powerline-thin", label: "Thin chevron" },
341
+ { value: "slash", label: "Slash" },
342
+ { value: "pipe", label: "Pipe" },
343
+ { value: "block", label: "Block" },
344
+ { value: "none", label: "None" },
345
+ { value: "ascii", label: "ASCII" },
346
+ ];
347
+
348
+ function isDeleteKey(data: string): boolean {
349
+ const kb = getKeybindings();
350
+ return (
351
+ kb.matches(data, "tui.editor.deleteCharForward") ||
352
+ matchesKey(data, "delete") ||
353
+ matchesKey(data, "shift+delete") ||
354
+ data === "\x1b[3~"
355
+ );
356
+ }
357
+
358
+ function cloneRootFocus(focus: StatusLineRootFocus): StatusLineRootFocus {
359
+ return { ...focus };
272
360
  }
273
361
 
274
362
  class StatusLineCustomEditor extends Container {
275
- #list!: SettingsList;
276
363
  #draft: StatusLineDraft;
277
364
  #previewHighlightSegment: StatusLineSegmentId | undefined;
365
+ #focus: StatusLineFocus = { kind: "statusbar", side: "left", index: 0 };
366
+ #picked: PickedStatusLineSegment | undefined;
367
+ #pickedStatusbarFocus: Extract<StatusLineRootFocus, { kind: "statusbar" }> | undefined;
368
+ #textInput: TextInputSubmenu | undefined;
278
369
 
279
370
  constructor(
280
371
  private readonly callbacks: SettingsCallbacks,
@@ -297,259 +388,846 @@ class StatusLineCustomEditor extends Container {
297
388
  settings.get("statusLine.segmentOptions") as StatusLineSegmentOptions,
298
389
  ),
299
390
  };
300
- this.#preview();
301
- this.#build();
302
- }
303
-
304
- #build(): void {
305
- this.clear();
306
- this.addChild(new Text(theme.bold(theme.fg("accent", "Status Line Custom Editor")), 0, 0));
307
- this.addChild(new Spacer(1));
308
- this.#list = new SettingsList(
309
- this.#items(),
310
- 14,
311
- getSettingsListTheme(),
312
- (id, value) => this.#handleChange(id, value),
313
- () => this.#cancel(),
314
- item => this.#setSelectedItem(item),
315
- 2,
316
- );
317
- this.addChild(this.#list);
318
- }
319
-
320
- #refresh(): void {
321
- this.#list.setItems(this.#items());
322
- }
323
- #setSelectedItem(item: SettingItem | undefined): void {
324
- this.#previewHighlightSegment = this.#highlightSegmentForItem(item);
391
+ this.#normalizeFocus();
325
392
  this.#preview();
326
393
  }
327
394
 
328
- #highlightSegmentForItem(item: SettingItem | undefined): StatusLineSegmentId | undefined {
329
- if (!item) return undefined;
330
- if (item.id.startsWith("segment.")) {
331
- return item.id.slice("segment.".length) as StatusLineSegmentId;
332
- }
333
- if (item.id.startsWith("moveup.")) {
334
- return item.id.slice("moveup.".length) as StatusLineSegmentId;
335
- }
336
- if (item.id.startsWith("movedown.")) {
337
- return item.id.slice("movedown.".length) as StatusLineSegmentId;
338
- }
339
- if (item.id.startsWith("option.")) {
340
- const [segment] = item.id.slice("option.".length).split(".");
341
- return segment as StatusLineSegmentId;
342
- }
343
- return undefined;
395
+ get navigationLocked(): boolean {
396
+ return true;
344
397
  }
345
398
 
346
- #items(): SettingItem[] {
347
- const items: SettingItem[] = [
348
- {
349
- id: "action.save",
350
- label: "Save custom status line",
351
- description: "Persist the previewed custom status line to user config.",
352
- currentValue: "approve",
353
- values: ["approve"],
354
- },
355
- {
356
- id: "action.cancel",
357
- label: "Cancel and restore",
358
- description: "Close without persisting draft settings and restore the previous preview.",
359
- currentValue: "restore",
360
- values: ["restore"],
361
- },
399
+ #choiceDescriptors(): StatusLineChoiceDescriptor[] {
400
+ return [
362
401
  {
363
- id: "separator",
402
+ target: "separator",
364
403
  label: "Separator",
365
- description: "Separator style between status line segments.",
366
- currentValue: this.#draft.separator,
367
- submenu: (currentValue, done) =>
368
- new SelectSubmenu(
369
- "Status Line Separator",
370
- "Style of separators between segments.",
371
- [
372
- { value: "powerline", label: "Powerline" },
373
- { value: "powerline-thin", label: "Thin chevron" },
374
- { value: "slash", label: "Slash" },
375
- { value: "pipe", label: "Pipe" },
376
- { value: "block", label: "Block" },
377
- { value: "none", label: "None" },
378
- { value: "ascii", label: "ASCII" },
379
- ],
380
- currentValue,
381
- done,
382
- () => done(),
383
- ),
404
+ values: SEPARATOR_OPTIONS,
405
+ currentValue: () => this.#draft.separator,
406
+ apply: value => {
407
+ this.#draft.separator = value as StatusLineSeparatorStyle;
408
+ },
384
409
  },
385
- ];
386
-
387
- for (const id of PUBLIC_STATUS_SEGMENTS) {
388
- items.push({
389
- id: `segment.${id}`,
390
- label: `Segment: ${statusSegmentLabel(id)}`,
391
- description: "Cycle placement: hidden → left → right. Use move actions below to reorder visible segments.",
392
- currentValue: segmentPlacement(id, this.#draft.leftSegments, this.#draft.rightSegments),
393
- values: ["hidden", "left", "right"],
394
- });
395
- if (segmentPlacement(id, this.#draft.leftSegments, this.#draft.rightSegments) !== "hidden") {
396
- items.push(
397
- {
398
- id: `moveup.${id}`,
399
- label: `Move left: ${statusSegmentLabel(id)}`,
400
- currentValue: "←",
401
- values: ["←"],
402
- },
403
- {
404
- id: `movedown.${id}`,
405
- label: `Move right: ${statusSegmentLabel(id)}`,
406
- currentValue: "→",
407
- values: ["→"],
408
- },
409
- );
410
- }
411
- if (id === "usage") {
412
- items.push({
413
- id: "option.usage.mode",
414
- label: "Usage: mode",
415
- currentValue: this.#draft.segmentOptions.usage?.mode ?? "used",
416
- submenu: (currentValue, done) =>
417
- new SelectSubmenu(
418
- "Usage mode",
419
- "Show used quota or remaining quota in the usage segment.",
420
- USAGE_MODE_OPTIONS,
421
- currentValue,
422
- done,
423
- () => done(),
424
- ),
425
- });
426
- }
427
- }
428
-
429
- items.push(
430
410
  {
431
- id: "option.model.showThinkingLevel",
411
+ target: "model.showThinkingLevel",
432
412
  label: "Model: show thinking level",
433
- currentValue: String(this.#draft.segmentOptions.model?.showThinkingLevel !== false),
434
- values: BOOL_VALUES,
413
+ values: BOOL_VALUES.map(value => ({ value, label: value })),
414
+ currentValue: () => String(this.#draft.segmentOptions.model?.showThinkingLevel !== false),
415
+ apply: value => this.#setOption("model.showThinkingLevel", value),
416
+ highlightSegment: "model",
435
417
  },
436
418
  {
437
- id: "option.path.abbreviate",
419
+ target: "path.abbreviate",
438
420
  label: "Path: abbreviate",
439
- currentValue: String(this.#draft.segmentOptions.path?.abbreviate !== false),
440
- values: BOOL_VALUES,
421
+ values: BOOL_VALUES.map(value => ({ value, label: value })),
422
+ currentValue: () => String(this.#draft.segmentOptions.path?.abbreviate !== false),
423
+ apply: value => this.#setOption("path.abbreviate", value),
424
+ highlightSegment: "path",
441
425
  },
442
426
  {
443
- id: "option.path.maxLength",
427
+ target: "path.maxLength",
444
428
  label: "Path: max length",
445
- currentValue: String(this.#draft.segmentOptions.path?.maxLength ?? 32),
446
- submenu: (currentValue, done) =>
447
- new SelectSubmenu(
448
- "Path max length",
449
- "Maximum displayed path length.",
450
- PATH_LENGTH_OPTIONS,
451
- currentValue,
452
- done,
453
- () => done(),
454
- ),
429
+ values: PATH_LENGTH_OPTIONS,
430
+ currentValue: () => String(this.#draft.segmentOptions.path?.maxLength ?? 32),
431
+ apply: value => this.#setOption("path.maxLength", value),
432
+ highlightSegment: "path",
455
433
  },
456
434
  {
457
- id: "option.path.stripWorkPrefix",
435
+ target: "path.stripWorkPrefix",
458
436
  label: "Path: strip work prefix",
459
- currentValue: String(this.#draft.segmentOptions.path?.stripWorkPrefix === true),
460
- values: BOOL_VALUES,
437
+ values: BOOL_VALUES.map(value => ({ value, label: value })),
438
+ currentValue: () => String(this.#draft.segmentOptions.path?.stripWorkPrefix === true),
439
+ apply: value => this.#setOption("path.stripWorkPrefix", value),
440
+ highlightSegment: "path",
461
441
  },
462
442
  {
463
- id: "option.git.showBranch",
443
+ target: "git.showBranch",
464
444
  label: "Git: show branch",
465
- currentValue: String(this.#draft.segmentOptions.git?.showBranch !== false),
466
- values: BOOL_VALUES,
445
+ values: BOOL_VALUES.map(value => ({ value, label: value })),
446
+ currentValue: () => String(this.#draft.segmentOptions.git?.showBranch !== false),
447
+ apply: value => this.#setOption("git.showBranch", value),
448
+ highlightSegment: "git",
467
449
  },
468
450
  {
469
- id: "option.git.showStaged",
451
+ target: "git.showStaged",
470
452
  label: "Git: show staged",
471
- currentValue: String(this.#draft.segmentOptions.git?.showStaged !== false),
472
- values: BOOL_VALUES,
453
+ values: BOOL_VALUES.map(value => ({ value, label: value })),
454
+ currentValue: () => String(this.#draft.segmentOptions.git?.showStaged !== false),
455
+ apply: value => this.#setOption("git.showStaged", value),
456
+ highlightSegment: "git",
473
457
  },
474
458
  {
475
- id: "option.git.showUnstaged",
459
+ target: "git.showUnstaged",
476
460
  label: "Git: show unstaged",
477
- currentValue: String(this.#draft.segmentOptions.git?.showUnstaged !== false),
478
- values: BOOL_VALUES,
461
+ values: BOOL_VALUES.map(value => ({ value, label: value })),
462
+ currentValue: () => String(this.#draft.segmentOptions.git?.showUnstaged !== false),
463
+ apply: value => this.#setOption("git.showUnstaged", value),
464
+ highlightSegment: "git",
479
465
  },
480
466
  {
481
- id: "option.git.showUntracked",
467
+ target: "git.showUntracked",
482
468
  label: "Git: show untracked",
483
- currentValue: String(this.#draft.segmentOptions.git?.showUntracked !== false),
484
- values: BOOL_VALUES,
469
+ values: BOOL_VALUES.map(value => ({ value, label: value })),
470
+ currentValue: () => String(this.#draft.segmentOptions.git?.showUntracked !== false),
471
+ apply: value => this.#setOption("git.showUntracked", value),
472
+ highlightSegment: "git",
485
473
  },
486
474
  {
487
- id: "option.time.format",
475
+ target: "time.format",
488
476
  label: "Time: format",
489
- currentValue: this.#draft.segmentOptions.time?.format ?? "24h",
490
- submenu: (currentValue, done) =>
491
- new SelectSubmenu(
492
- "Time format",
493
- "Clock format for the time segment.",
494
- TIME_FORMAT_OPTIONS,
495
- currentValue,
496
- done,
497
- () => done(),
498
- ),
477
+ values: TIME_FORMAT_OPTIONS,
478
+ currentValue: () => this.#draft.segmentOptions.time?.format ?? "24h",
479
+ apply: value => this.#setOption("time.format", value),
499
480
  },
500
481
  {
501
- id: "option.time.showSeconds",
482
+ target: "time.showSeconds",
502
483
  label: "Time: show seconds",
503
- currentValue: String(this.#draft.segmentOptions.time?.showSeconds === true),
504
- values: BOOL_VALUES,
484
+ values: BOOL_VALUES.map(value => ({ value, label: value })),
485
+ currentValue: () => String(this.#draft.segmentOptions.time?.showSeconds === true),
486
+ apply: value => this.#setOption("time.showSeconds", value),
505
487
  },
506
- );
488
+ {
489
+ target: "usage.mode",
490
+ label: "Usage: mode",
491
+ values: USAGE_MODE_OPTIONS,
492
+ currentValue: () => this.#draft.segmentOptions.usage?.mode ?? "used",
493
+ apply: value => this.#setOption("usage.mode", value),
494
+ highlightSegment: "usage",
495
+ },
496
+ ];
497
+ }
507
498
 
508
- return items;
499
+ #descriptor(target: StatusLineChoiceTarget): StatusLineChoiceDescriptor | undefined {
500
+ return this.#choiceDescriptors().find(descriptor => descriptor.target === target);
501
+ }
502
+
503
+ #controlTargets(): StatusLineChoiceTarget[] {
504
+ return [...this.#choiceDescriptors().map(descriptor => descriptor.target), "command.command"];
505
+ }
506
+
507
+ #hiddenSegments(): StatusLineSegmentId[] {
508
+ const visible = new Set([...this.#draft.leftSegments, ...this.#draft.rightSegments]);
509
+ return PUBLIC_STATUS_SEGMENTS.filter(id => !visible.has(id));
510
+ }
511
+
512
+ #paletteSegments(includePicked = this.#focus.kind === "palette"): StatusLineSegmentId[] {
513
+ const visible = new Set([...this.#draft.leftSegments, ...this.#draft.rightSegments]);
514
+ const selected = includePicked ? this.#picked?.id : undefined;
515
+ return PUBLIC_STATUS_SEGMENTS.filter(id => !visible.has(id) || id === selected);
509
516
  }
510
517
 
511
- #handleChange(id: string, value: string): void {
512
- if (id === "action.save") {
513
- this.#save();
518
+ #focusSegment(): StatusLineSegmentId | undefined {
519
+ if (this.#focus.kind !== "statusbar") return undefined;
520
+ return this.#draft[`${this.#focus.side}Segments`][this.#focus.index];
521
+ }
522
+
523
+ #segmentCount(side: StatusLineSide, omitPicked = false): number {
524
+ const segments = this.#draft[`${side}Segments`];
525
+ if (
526
+ !omitPicked ||
527
+ !this.#picked ||
528
+ this.#picked.origin.kind !== "statusbar" ||
529
+ this.#picked.origin.side !== side
530
+ ) {
531
+ return segments.length;
532
+ }
533
+ return Math.max(0, segments.length - 1);
534
+ }
535
+
536
+ #normalizeFocus(): void {
537
+ if (this.#focus.kind === "choice") {
538
+ const descriptor = this.#descriptor(this.#focus.target);
539
+ if (!descriptor) {
540
+ this.#focus = { kind: "separator-control" };
541
+ } else {
542
+ this.#focus.focusedIndex = Math.max(0, Math.min(this.#focus.focusedIndex, descriptor.values.length - 1));
543
+ }
514
544
  return;
515
545
  }
516
- if (id === "action.cancel") {
517
- this.#cancel();
546
+ if (this.#focus.kind === "statusbar") {
547
+ const max = this.#picked
548
+ ? this.#segmentCount(this.#focus.side, true)
549
+ : this.#draft[`${this.#focus.side}Segments`].length - 1;
550
+ if (max < 0) {
551
+ const otherSide: StatusLineSide = this.#focus.side === "left" ? "right" : "left";
552
+ if (this.#draft[`${otherSide}Segments`].length > 0) {
553
+ this.#focus = { kind: "statusbar", side: otherSide, index: 0 };
554
+ } else if (this.#paletteSegments().length > 0) {
555
+ this.#focus = { kind: "palette", index: 0 };
556
+ } else {
557
+ this.#focus = { kind: "separator-control" };
558
+ }
559
+ } else {
560
+ this.#focus.index = Math.max(0, Math.min(this.#focus.index, max));
561
+ }
562
+ } else if (this.#focus.kind === "palette") {
563
+ const palette = this.#paletteSegments();
564
+ if (palette.length === 0) {
565
+ this.#focus = { kind: "separator-control" };
566
+ } else {
567
+ this.#focus.index = Math.max(0, Math.min(this.#focus.index, palette.length - 1));
568
+ }
569
+ } else if (
570
+ this.#focus.kind === "option-control" &&
571
+ !this.#controlTargets().includes((this.#focus as { path: StatusLineOptionPath }).path)
572
+ ) {
573
+ this.#focus = { kind: "separator-control" };
574
+ }
575
+ }
576
+
577
+ #rootOrder(): StatusLineRootFocus[] {
578
+ const roots: StatusLineRootFocus[] = [];
579
+ roots.push(this.#nearestStatusbarFocus());
580
+ if (this.#paletteSegments().length > 0) roots.push({ kind: "palette", index: 0 });
581
+ roots.push({ kind: "separator-control" });
582
+ roots.push({ kind: "confirm" }, { kind: "exit" });
583
+ return roots;
584
+ }
585
+
586
+ #nearestStatusbarFocus(): StatusLineRootFocus {
587
+ if (this.#focus.kind === "statusbar") {
588
+ return { kind: "statusbar", side: this.#focus.side, index: this.#focus.index };
589
+ }
590
+ if (this.#draft.leftSegments.length > 0 || this.#picked) {
591
+ return {
592
+ kind: "statusbar",
593
+ side: "left",
594
+ index: this.#picked ? this.#segmentCount("left", true) : 0,
595
+ };
596
+ }
597
+ if (this.#draft.rightSegments.length > 0) {
598
+ return { kind: "statusbar", side: "right", index: 0 };
599
+ }
600
+ return { kind: "statusbar", side: "left", index: 0 };
601
+ }
602
+
603
+ #rootFocusKey(focus: StatusLineRootFocus): string {
604
+ switch (focus.kind) {
605
+ case "statusbar":
606
+ return `${focus.kind}:${focus.side}:${focus.index}`;
607
+ case "palette":
608
+ return `${focus.kind}:${focus.index}`;
609
+ case "option-control":
610
+ return `${focus.kind}:${focus.path}`;
611
+ default:
612
+ return focus.kind;
613
+ }
614
+ }
615
+
616
+ #moveVertical(delta: -1 | 1): void {
617
+ if (this.#focus.kind === "choice") {
618
+ const descriptor = this.#descriptor(this.#focus.target);
619
+ if (!descriptor || descriptor.values.length === 0) return;
620
+ this.#focus.focusedIndex =
621
+ (this.#focus.focusedIndex + delta + descriptor.values.length) % descriptor.values.length;
518
622
  return;
519
623
  }
520
- if (id === "separator") {
521
- this.#draft.separator = value as StatusLineSeparatorStyle;
522
- } else if (id.startsWith("segment.")) {
523
- this.#setSegmentPlacement(
524
- id.slice("segment.".length) as StatusLineSegmentId,
525
- value as "hidden" | "left" | "right",
526
- );
527
- } else if (id.startsWith("moveup.")) {
528
- this.#moveSegment(id.slice("moveup.".length) as StatusLineSegmentId, -1);
529
- } else if (id.startsWith("movedown.")) {
530
- this.#moveSegment(id.slice("movedown.".length) as StatusLineSegmentId, 1);
531
- } else if (id.startsWith("option.")) {
532
- this.#setOption(id.slice("option.".length), value);
624
+ if (this.#picked) {
625
+ if (this.#focus.kind === "palette") {
626
+ if (delta === -1) this.#focus = this.#pickedStatusbarFocus ?? this.#nearestStatusbarFocus();
627
+ } else if (delta === 1) {
628
+ if (this.#focus.kind === "statusbar") this.#pickedStatusbarFocus = { ...this.#focus };
629
+ this.#focus = { kind: "palette", index: this.#focusPaletteIndex() };
630
+ }
631
+ this.#normalizeFocus();
632
+ return;
533
633
  }
634
+ const order = this.#rootOrder();
635
+ const currentKey = this.#rootFocusKey(this.#focus);
636
+ const currentIndex = Math.max(
637
+ 0,
638
+ order.findIndex(item => this.#rootFocusKey(item) === currentKey),
639
+ );
640
+ this.#focus = cloneRootFocus(order[(currentIndex + delta + order.length) % order.length] ?? order[0]);
641
+ this.#normalizeFocus();
642
+ }
643
+
644
+ #focusPaletteIndex(): number {
645
+ if (this.#focus.kind === "palette") return this.#focus.index;
646
+ if (this.#picked) return Math.max(0, this.#paletteSegments(true).indexOf(this.#picked.id));
647
+ return Math.max(0, Math.min(this.#paletteSegments(false).length - 1, 0));
648
+ }
649
+
650
+ #linearVisibleFocus(): Array<{ side: StatusLineSide; index: number }> {
651
+ return [
652
+ ...this.#draft.leftSegments.map((_, index) => ({ side: "left" as const, index })),
653
+ ...this.#draft.rightSegments.map((_, index) => ({ side: "right" as const, index })),
654
+ ];
655
+ }
656
+
657
+ #moveVisibleFocus(delta: -1 | 1): void {
658
+ if (this.#focus.kind !== "statusbar") return;
659
+ const focus = this.#focus;
660
+ const items = this.#linearVisibleFocus();
661
+ if (items.length === 0) return;
662
+ const currentIndex = Math.max(
663
+ 0,
664
+ items.findIndex(item => item.side === focus.side && item.index === focus.index),
665
+ );
666
+ this.#focus = { kind: "statusbar", ...items[(currentIndex + delta + items.length) % items.length] };
667
+ }
668
+
669
+ #slotOrder(): Array<{ side: StatusLineSide; index: number }> {
670
+ const leftCount = this.#segmentCount("left", true);
671
+ const rightCount = this.#segmentCount("right", true);
672
+ return [
673
+ ...Array.from({ length: leftCount + 1 }, (_, index) => ({ side: "left" as const, index })),
674
+ ...Array.from({ length: rightCount + 1 }, (_, index) => ({ side: "right" as const, index })),
675
+ ];
676
+ }
677
+
678
+ #movePickedSlot(delta: -1 | 1): void {
679
+ if (!this.#picked || this.#focus.kind !== "statusbar") return;
680
+ const focus = this.#focus;
681
+ const slots = this.#slotOrder();
682
+ const currentIndex = Math.max(
683
+ 0,
684
+ slots.findIndex(item => item.side === focus.side && item.index === focus.index),
685
+ );
686
+ this.#focus = { kind: "statusbar", ...slots[Math.max(0, Math.min(slots.length - 1, currentIndex + delta))] };
687
+ this.#pickedStatusbarFocus = { ...this.#focus };
688
+ this.#updatePreviewHighlight();
689
+ }
690
+
691
+ #movePaletteFocus(delta: -1 | 1): void {
692
+ if (this.#focus.kind !== "palette") return;
693
+ const palette = this.#paletteSegments();
694
+ if (palette.length === 0) return;
695
+ this.#focus.index = (this.#focus.index + delta + palette.length) % palette.length;
696
+ }
697
+
698
+ #moveChoiceControl(delta: -1 | 1): void {
699
+ if (this.#focus.kind !== "separator-control" && this.#focus.kind !== "option-control") return;
700
+ const targets = this.#controlTargets();
701
+ const currentTarget = this.#focus.kind === "separator-control" ? "separator" : this.#focus.path;
702
+ const currentIndex = Math.max(0, targets.indexOf(currentTarget));
703
+ const next = targets[(currentIndex + delta + targets.length) % targets.length];
704
+ if (!next) return;
705
+ this.#focus =
706
+ next === "separator"
707
+ ? { kind: "separator-control" }
708
+ : { kind: "option-control", path: next as StatusLineOptionPath };
709
+ }
710
+
711
+ #moveChoiceTarget(delta: -1 | 1): void {
712
+ if (this.#focus.kind !== "choice") return;
713
+ const focus = this.#focus;
714
+ const descriptors = this.#choiceDescriptors();
715
+ const currentIndex = Math.max(
716
+ 0,
717
+ descriptors.findIndex(descriptor => descriptor.target === focus.target),
718
+ );
719
+ const next = descriptors[(currentIndex + delta + descriptors.length) % descriptors.length];
720
+ if (!next) return;
721
+ const currentValue = next.currentValue();
722
+ this.#focus = {
723
+ kind: "choice",
724
+ target: next.target,
725
+ focusedIndex: Math.max(
726
+ 0,
727
+ next.values.findIndex(value => value.value === currentValue),
728
+ ),
729
+ returnFocus:
730
+ next.target === "separator"
731
+ ? { kind: "separator-control" }
732
+ : { kind: "option-control", path: next.target as StatusLineOptionPath },
733
+ };
734
+ }
735
+
736
+ #pickFocusedStatusbar(): void {
737
+ if (this.#focus.kind !== "statusbar") return;
738
+ const id = this.#focusSegment();
739
+ if (!id) return;
740
+ this.#picked = { id, origin: { kind: "statusbar", side: this.#focus.side, index: this.#focus.index } };
741
+ this.#pickedStatusbarFocus = { kind: "statusbar", side: this.#focus.side, index: this.#focus.index };
742
+ this.#updatePreviewHighlight();
534
743
  this.#preview();
535
- this.#refresh();
536
744
  }
537
745
 
538
- #setSegmentPlacement(id: StatusLineSegmentId, placement: "hidden" | "left" | "right"): void {
746
+ #pickFocusedPalette(): void {
747
+ if (this.#focus.kind !== "palette") return;
748
+ const id = this.#paletteSegments()[this.#focus.index];
749
+ if (!id) return;
750
+ this.#picked = { id, origin: { kind: "palette" } };
751
+ this.#focus = { kind: "statusbar", side: "left", index: this.#segmentCount("left", true) };
752
+ this.#pickedStatusbarFocus = { ...this.#focus };
753
+ this.#updatePreviewHighlight();
754
+ this.#preview();
755
+ }
756
+
757
+ #dropPicked(): void {
758
+ if (!this.#picked || this.#focus.kind !== "statusbar") return;
759
+ const id = this.#picked.id;
760
+ const nextLeft = this.#draft.leftSegments.filter(segment => segment !== id);
761
+ const nextRight = this.#draft.rightSegments.filter(segment => segment !== id);
762
+ const target = this.#focus.side === "left" ? nextLeft : nextRight;
763
+ target.splice(Math.max(0, Math.min(target.length, this.#focus.index)), 0, id);
764
+ this.#draft.leftSegments = nextLeft;
765
+ this.#draft.rightSegments = nextRight;
766
+ this.#focus = { kind: "statusbar", side: this.#focus.side, index: target.indexOf(id) };
767
+ this.#picked = undefined;
768
+ this.#pickedStatusbarFocus = undefined;
769
+ this.#updatePreviewHighlight();
770
+ this.#preview();
771
+ }
772
+
773
+ #dropPickedToPalette(): void {
774
+ if (!this.#picked || this.#focus.kind !== "palette") return;
775
+ const id = this.#picked.id;
539
776
  this.#draft.leftSegments = this.#draft.leftSegments.filter(segment => segment !== id);
540
777
  this.#draft.rightSegments = this.#draft.rightSegments.filter(segment => segment !== id);
541
- if (placement === "left") this.#draft.leftSegments.push(id);
542
- if (placement === "right") this.#draft.rightSegments.push(id);
778
+ this.#picked = undefined;
779
+ this.#pickedStatusbarFocus = undefined;
780
+ const hidden = this.#hiddenSegments();
781
+ this.#focus = { kind: "palette", index: Math.max(0, hidden.indexOf(id)) };
782
+ this.#updatePreviewHighlight();
783
+ this.#preview();
784
+ }
785
+
786
+ #hideFocusedSegment(): void {
787
+ if (this.#picked || this.#focus.kind !== "statusbar") return;
788
+ const group = this.#draft[`${this.#focus.side}Segments`];
789
+ const removed = group[this.#focus.index];
790
+ if (!removed) return;
791
+ group.splice(this.#focus.index, 1);
792
+ const hidden = this.#hiddenSegments();
793
+ this.#focus = hidden.length
794
+ ? { kind: "palette", index: Math.max(0, hidden.indexOf(removed)) }
795
+ : { kind: "separator-control" };
796
+ this.#updatePreviewHighlight();
797
+ this.#preview();
543
798
  }
544
799
 
545
- #moveSegment(id: StatusLineSegmentId, delta: -1 | 1): void {
546
- const group = this.#draft.leftSegments.includes(id) ? this.#draft.leftSegments : this.#draft.rightSegments;
547
- const index = group.indexOf(id);
548
- if (index < 0) return;
549
- const nextIndex = Math.max(0, Math.min(group.length - 1, index + delta));
550
- if (nextIndex === index) return;
551
- const [segment] = group.splice(index, 1);
552
- if (segment) group.splice(nextIndex, 0, segment);
800
+ #openChoice(target: StatusLineChoiceTarget, returnFocus: StatusLineRootFocus): void {
801
+ const descriptor = this.#descriptor(target);
802
+ if (!descriptor) return;
803
+ const currentValue = descriptor.currentValue();
804
+ this.#focus = {
805
+ kind: "choice",
806
+ target,
807
+ focusedIndex: Math.max(
808
+ 0,
809
+ descriptor.values.findIndex(value => value.value === currentValue),
810
+ ),
811
+ returnFocus,
812
+ };
813
+ }
814
+
815
+ #openCommandInput(): void {
816
+ this.#textInput = new TextInputSubmenu(
817
+ "Status line command",
818
+ "Runs in the configured shell with a short timeout and cached refreshes.",
819
+ this.#draft.segmentOptions.command?.command ?? "",
820
+ value => {
821
+ this.#setOption("command.command", value);
822
+ this.#textInput = undefined;
823
+ this.#focus = { kind: "option-control", path: "command.command" };
824
+ this.#updatePreviewHighlight();
825
+ this.#preview();
826
+ },
827
+ () => {
828
+ this.#textInput = undefined;
829
+ this.#focus = { kind: "option-control", path: "command.command" };
830
+ this.#updatePreviewHighlight();
831
+ this.#preview();
832
+ },
833
+ );
834
+ }
835
+
836
+ #applyChoice(): void {
837
+ if (this.#focus.kind !== "choice") return;
838
+ const descriptor = this.#descriptor(this.#focus.target);
839
+ if (!descriptor) return;
840
+ const value = descriptor.values[this.#focus.focusedIndex]?.value;
841
+ if (value === undefined) return;
842
+ descriptor.apply(value);
843
+ this.#focus = cloneRootFocus(this.#focus.returnFocus);
844
+ this.#updatePreviewHighlight();
845
+ this.#preview();
846
+ }
847
+
848
+ #updatePreviewHighlight(): void {
849
+ if (this.#picked) {
850
+ this.#previewHighlightSegment = this.#picked.id;
851
+ return;
852
+ }
853
+ if (this.#focus.kind === "statusbar") {
854
+ this.#previewHighlightSegment = this.#focusSegment();
855
+ return;
856
+ }
857
+ if (this.#focus.kind === "palette") {
858
+ this.#previewHighlightSegment = this.#paletteSegments()[this.#focus.index];
859
+ return;
860
+ }
861
+ if (this.#focus.kind === "option-control") {
862
+ this.#previewHighlightSegment =
863
+ this.#focus.path === "command.command" ? "command" : this.#descriptor(this.#focus.path)?.highlightSegment;
864
+ return;
865
+ }
866
+ if (this.#focus.kind === "choice") {
867
+ this.#previewHighlightSegment = this.#descriptor(this.#focus.target)?.highlightSegment;
868
+ return;
869
+ }
870
+ this.#previewHighlightSegment = undefined;
871
+ }
872
+
873
+ #focusLabel(): string {
874
+ switch (this.#focus.kind) {
875
+ case "statusbar":
876
+ return `statusbar:${this.#focus.side}:${this.#focus.index}`;
877
+ case "palette":
878
+ return `palette:${this.#focus.index}`;
879
+ case "option-control":
880
+ return `option:${this.#focus.path}`;
881
+ case "choice":
882
+ return `choice:${this.#focus.target}`;
883
+ default:
884
+ return this.#focus.kind;
885
+ }
886
+ }
887
+
888
+ #separatorGlyph(): string {
889
+ if (theme.getSymbolPreset() === "ascii") {
890
+ return this.#draft.separator === "none" ? " " : this.#draft.separator === "pipe" ? " | " : " / ";
891
+ }
892
+ switch (this.#draft.separator) {
893
+ case "pipe":
894
+ return " | ";
895
+ case "block":
896
+ return " █ ";
897
+ case "none":
898
+ return " ";
899
+ case "ascii":
900
+ return " > ";
901
+ case "powerline":
902
+ return "  ";
903
+ case "powerline-thin":
904
+ return " ❯ ";
905
+ case "slash":
906
+ return " / ";
907
+ }
908
+ }
909
+
910
+ #fit(line: string, width: number): string {
911
+ const ascii = theme.getSymbolPreset() === "ascii";
912
+ const fitted = truncateToWidth(
913
+ line,
914
+ ascii ? Math.max(0, width - 2) : width,
915
+ ascii ? Ellipsis.Ascii : Ellipsis.Unicode,
916
+ );
917
+ if (!ascii) return fitted;
918
+ return fitted.replace(/·/g, "-").replace(/│/g, "|").replace(/…/g, "...");
919
+ }
920
+
921
+ #padEndVisible(line: string, width: number): string {
922
+ return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
923
+ }
924
+
925
+ #padStartVisible(line: string, width: number): string {
926
+ return " ".repeat(Math.max(0, width - visibleWidth(line))) + line;
927
+ }
928
+
929
+ #centerVisible(line: string, width: number): string {
930
+ const left = Math.floor(Math.max(0, width - visibleWidth(line)) / 2);
931
+ return " ".repeat(left) + line;
932
+ }
933
+
934
+ #simulationWidth(width: number): number {
935
+ return Math.max(1, width - 2);
936
+ }
937
+
938
+ #simulationIndent(width: number): string {
939
+ return " ".repeat(Math.max(0, Math.floor((width - this.#simulationWidth(width)) / 2)));
940
+ }
941
+
942
+ #highlightBox(text: string, style: "focus" | "selected"): string {
943
+ const selectedText = `> ${text} <`;
944
+ const bgColor = style === "selected" ? "warning" : "text";
945
+ const brightBg = theme.getFgAnsi(bgColor).replace("\x1b[38;", "\x1b[48;");
946
+ const darkFg = theme.getBgAnsi("selectedBg").replace("\x1b[48;", "\x1b[38;");
947
+ return `${brightBg}${darkFg}${selectedText}\x1b[0m`;
948
+ }
949
+
950
+ #focusBox(text: string): string {
951
+ return this.#highlightBox(text, "focus");
952
+ }
953
+
954
+ #selectedBox(text: string): string {
955
+ return this.#highlightBox(text, "selected");
956
+ }
957
+
958
+ #dashedBox(text: string): string {
959
+ return theme.fg("dim", theme.getSymbolPreset() === "ascii" ? `[ ${text} ]` : `┆ ${text} ┆`);
960
+ }
961
+
962
+ #renderSegment(id: StatusLineSegmentId, selected: boolean, visibleInStatusbar: boolean): string {
963
+ const label = statusSegmentLabel(id);
964
+ if (selected) return this.#focusBox(label);
965
+ if (visibleInStatusbar) return label;
966
+ return this.#dashedBox(label);
967
+ }
968
+
969
+ #renderSide(side: StatusLineSide, visibleIds: ReadonlySet<StatusLineSegmentId> | undefined): string {
970
+ const segments = this.#draft[`${side}Segments`];
971
+ const rendered: string[] = [];
972
+ for (let index = 0; index < segments.length; index++) {
973
+ const id = segments[index];
974
+ if (!id) continue;
975
+ const isOrigin =
976
+ this.#picked?.origin.kind === "statusbar" &&
977
+ this.#picked.origin.side === side &&
978
+ this.#picked.origin.index === index;
979
+ if (isOrigin) continue;
980
+ const selected =
981
+ !this.#picked &&
982
+ this.#focus.kind === "statusbar" &&
983
+ this.#focus.side === side &&
984
+ this.#focus.index === index;
985
+ rendered.push(this.#renderSegment(id, selected, visibleIds ? visibleIds.has(id) : true));
986
+ }
987
+ if (this.#picked && this.#focus.kind === "statusbar" && this.#focus.side === side) {
988
+ rendered.splice(
989
+ Math.max(0, Math.min(rendered.length, this.#focus.index)),
990
+ 0,
991
+ this.#selectedBox(statusSegmentLabel(this.#picked.id)),
992
+ );
993
+ }
994
+ return rendered.length
995
+ ? rendered.join(theme.fg("statusLineSep", this.#separatorGlyph()))
996
+ : theme.fg("muted", "(empty)");
997
+ }
998
+
999
+ #draftPreviewSettings(includePickedPlacement = false): StatusLinePreviewSettings {
1000
+ let leftSegments = [...this.#draft.leftSegments];
1001
+ let rightSegments = [...this.#draft.rightSegments];
1002
+ let previewHighlightSegment = this.#previewHighlightSegment;
1003
+ if (includePickedPlacement && this.#picked) {
1004
+ const id = this.#picked.id;
1005
+ leftSegments = leftSegments.filter(segment => segment !== id);
1006
+ rightSegments = rightSegments.filter(segment => segment !== id);
1007
+ if (this.#focus.kind === "statusbar") {
1008
+ const target = this.#focus.side === "left" ? leftSegments : rightSegments;
1009
+ target.splice(Math.max(0, Math.min(target.length, this.#focus.index)), 0, id);
1010
+ previewHighlightSegment = id;
1011
+ }
1012
+ }
1013
+ return {
1014
+ preset: "custom",
1015
+ leftSegments,
1016
+ rightSegments,
1017
+ separator: this.#draft.separator,
1018
+ segmentOptions: cloneSegmentOptions(this.#draft.segmentOptions),
1019
+ sessionAccent: settings.get("statusLine.sessionAccent"),
1020
+ maxRows: settings.get("statusLine.maxRows"),
1021
+ previewHighlightSegment,
1022
+ previewHighlightStyle: this.#picked ? "selected" : "focus",
1023
+ };
1024
+ }
1025
+
1026
+ #actualStatusbarParts(width: number): StatusLinePreviewParts | undefined {
1027
+ return this.callbacks.getStatusLinePreviewPartsForSettings?.(
1028
+ this.#draftPreviewSettings(true),
1029
+ this.#simulationWidth(width),
1030
+ );
1031
+ }
1032
+
1033
+ #renderActualStatusbar(width: number, parts: StatusLinePreviewParts | undefined): string[] {
1034
+ const rendered = this.callbacks.getStatusLinePreviewForSettings?.(
1035
+ this.#draftPreviewSettings(true),
1036
+ this.#simulationWidth(width),
1037
+ );
1038
+ if (rendered) {
1039
+ return rendered
1040
+ .split("\n")
1041
+ .map(line => line.trimEnd())
1042
+ .filter(line => line.length > 0)
1043
+ .map(line => this.#centerVisible(this.#fit(line, this.#simulationWidth(width)), width));
1044
+ }
1045
+ return parts ? this.#renderActualStatusbarParts(width, parts) : [];
1046
+ }
1047
+
1048
+ #renderActualStatusbarParts(width: number, parts: StatusLinePreviewParts): string[] {
1049
+ const barWidth = this.#simulationWidth(width);
1050
+ const indent = this.#simulationIndent(width);
1051
+ const leftSeparator = theme.fg("statusLineSep", ` ${parts.separator.left} `);
1052
+ const rightSeparator = theme.fg("statusLineSep", ` ${parts.separator.right} `);
1053
+ const left = parts.left.join(leftSeparator);
1054
+ const right = parts.right.join(rightSeparator);
1055
+ const combinedGap = Math.max(1, barWidth - visibleWidth(left) - visibleWidth(right));
1056
+ if (visibleWidth(left) + visibleWidth(right) + 1 <= barWidth) {
1057
+ return [this.#fit(`${indent}${left}${" ".repeat(combinedGap)}${right}`, width)];
1058
+ }
1059
+ return [
1060
+ this.#fit(
1061
+ `${indent}${this.#padEndVisible(truncateToWidth(left, barWidth, Ellipsis.Unicode), barWidth)}`,
1062
+ width,
1063
+ ),
1064
+ this.#fit(
1065
+ `${indent}${this.#padStartVisible(truncateToWidth(right, barWidth, Ellipsis.Unicode), barWidth)}`,
1066
+ width,
1067
+ ),
1068
+ ];
1069
+ }
1070
+
1071
+ #renderSlotRows(width: number, parts: StatusLinePreviewParts | undefined): string[] {
1072
+ const barWidth = this.#simulationWidth(width);
1073
+ const indent = this.#simulationIndent(width);
1074
+ const leftVisible = parts ? new Set(parts.leftIds) : undefined;
1075
+ const rightVisible = parts
1076
+ ? new Set(parts.rightIds.filter((id): id is StatusLineSegmentId => id !== null))
1077
+ : undefined;
1078
+ const left = `${theme.bold("left")} ${this.#renderSide("left", leftVisible)}`;
1079
+ const rightExtras =
1080
+ parts?.right
1081
+ .filter((part, index) => parts.rightIds[index] === null && /^v\d/.test(Bun.stripANSI(part)))
1082
+ .map(part => theme.fg("dim", Bun.stripANSI(part))) ?? [];
1083
+ const rightCore = this.#renderSide("right", rightVisible);
1084
+ const right =
1085
+ rightExtras.length > 0
1086
+ ? `${theme.bold("right")} ${rightCore}${theme.fg("statusLineSep", this.#separatorGlyph())}${rightExtras.join(
1087
+ theme.fg("statusLineSep", this.#separatorGlyph()),
1088
+ )}`
1089
+ : `${theme.bold("right")} ${rightCore}`;
1090
+ const combinedGap = Math.max(1, barWidth - visibleWidth(left) - visibleWidth(right));
1091
+ if (visibleWidth(left) + visibleWidth(right) + 1 <= barWidth) {
1092
+ return [this.#fit(`${indent}${left}${" ".repeat(combinedGap)}${right}`, width)];
1093
+ }
1094
+ return [
1095
+ this.#fit(
1096
+ `${indent}${this.#padEndVisible(truncateToWidth(left, barWidth, Ellipsis.Unicode), barWidth)}`,
1097
+ width,
1098
+ ),
1099
+ this.#fit(
1100
+ `${indent}${this.#padStartVisible(truncateToWidth(right, barWidth, Ellipsis.Unicode), barWidth)}`,
1101
+ width,
1102
+ ),
1103
+ ];
1104
+ }
1105
+
1106
+ #renderChoicePanel(width: number): string[] {
1107
+ if (this.#focus.kind !== "choice") return [];
1108
+ const focus = this.#focus;
1109
+ const descriptor = this.#descriptor(focus.target);
1110
+ if (!descriptor) return [];
1111
+ const values = descriptor.values.map((value, index) => {
1112
+ const selected = index === focus.focusedIndex;
1113
+ const active = value.value === descriptor.currentValue();
1114
+ const activeMarker = theme.getSymbolPreset() === "ascii" ? "v " : "✓ ";
1115
+ const label = `${active ? activeMarker : ""}${value.label}`;
1116
+ if (selected) return this.#focusBox(label);
1117
+ if (active) return this.#selectedBox(label);
1118
+ return label;
1119
+ });
1120
+ return [
1121
+ truncateToWidth(theme.bold(`Choices: ${descriptor.label}: ${values.join(" ")}`), width),
1122
+ truncateToWidth(theme.fg("dim", " Left/Right value · Up/Down option · Enter apply · Esc return"), width),
1123
+ ];
1124
+ }
1125
+
1126
+ override render(width: number): string[] {
1127
+ if (this.#textInput) return this.#textInput.render(width);
1128
+ this.#normalizeFocus();
1129
+ const lines: string[] = [];
1130
+ lines.push(truncateToWidth(theme.bold(theme.fg("accent", "Status Line Custom Editor")), width));
1131
+ const focusLine = `Focus: ${this.#focusLabel()}`;
1132
+ lines.push(truncateToWidth(theme.fg("muted", focusLine), width));
1133
+ if (this.#picked) {
1134
+ lines.push(truncateToWidth(theme.fg("accent", `Selected: ${statusSegmentLabel(this.#picked.id)}`), width));
1135
+ }
1136
+ lines.push("");
1137
+ lines.push(this.#centerVisible(theme.bold("Simulated statusbar"), width));
1138
+ const actualStatusbarParts = this.#actualStatusbarParts(width);
1139
+ const actualStatusbar = this.#renderActualStatusbar(width, actualStatusbarParts);
1140
+ if (actualStatusbar.length > 0) {
1141
+ lines.push(...actualStatusbar);
1142
+ }
1143
+ const slotRows = this.#renderSlotRows(width, actualStatusbarParts);
1144
+ lines.push(...slotRows);
1145
+ if (actualStatusbar.length > 1 || slotRows.length > 1) {
1146
+ lines.push(this.#fit(theme.fg("warning", " Warning: statusbar wrapped to 2 rows"), width));
1147
+ if (this.#focus.kind === "statusbar") {
1148
+ const focused = this.#picked ? this.#picked.id : this.#focusSegment();
1149
+ if (focused) lines.push(this.#fit(` Focused target: ${statusSegmentLabel(focused)}`, width));
1150
+ }
1151
+ }
1152
+ lines.push("");
1153
+ const paletteSegments = this.#paletteSegments();
1154
+ const paletteTokens = paletteSegments.map((id, index) => {
1155
+ const selected = this.#picked?.id === id;
1156
+ const focused = this.#focus.kind === "palette" && this.#focus.index === index;
1157
+ const label = `{${statusSegmentLabel(id)}}`;
1158
+ if (selected) return this.#selectedBox(label);
1159
+ return focused ? this.#focusBox(label) : theme.fg("muted", label);
1160
+ });
1161
+ lines.push(truncateToWidth(theme.bold("Hidden segment palette"), width));
1162
+ lines.push(truncateToWidth(` ${paletteTokens.length ? paletteTokens.join(" ") : "(empty)"}`, width));
1163
+ if (width < 64 && this.#focus.kind === "palette") {
1164
+ const focusedPalette = paletteSegments[this.#focus.index];
1165
+ if (focusedPalette)
1166
+ lines.push(truncateToWidth(` Focused palette target: ${statusSegmentLabel(focusedPalette)}`, width));
1167
+ }
1168
+ lines.push("");
1169
+ lines.push(truncateToWidth(theme.bold("Visible choices"), width));
1170
+ const descriptors = this.#choiceDescriptors();
1171
+ const focusedDescriptor = descriptors.find(
1172
+ descriptor =>
1173
+ (this.#focus.kind === "separator-control" && descriptor.target === "separator") ||
1174
+ (this.#focus.kind === "option-control" && descriptor.target === this.#focus.path) ||
1175
+ (this.#focus.kind === "choice" && descriptor.target === this.#focus.target),
1176
+ );
1177
+ const summaryRows = [descriptors.slice(0, 6), descriptors.slice(6)];
1178
+ for (const row of summaryRows) {
1179
+ lines.push(
1180
+ truncateToWidth(
1181
+ ` ${row
1182
+ .map(descriptor => {
1183
+ const focused = focusedDescriptor?.target === descriptor.target;
1184
+ return focused ? this.#focusBox(descriptor.label) : descriptor.label;
1185
+ })
1186
+ .join(" · ")}`,
1187
+ width,
1188
+ ),
1189
+ );
1190
+ }
1191
+ const commandFocused = this.#focus.kind === "option-control" && this.#focus.path === "command.command";
1192
+ lines.push(
1193
+ truncateToWidth(` ${commandFocused ? this.#focusBox("Command: command") : "Command: command"}`, width),
1194
+ );
1195
+ if (focusedDescriptor) {
1196
+ const current = focusedDescriptor.currentValue();
1197
+ const choices = focusedDescriptor.values
1198
+ .map(value => (value.value === current ? `[${value.label}]` : value.label))
1199
+ .join(" | ");
1200
+ lines.push(
1201
+ truncateToWidth(`${theme.fg("accent", theme.nav.cursor)} ${focusedDescriptor.label}: ${choices}`, width),
1202
+ );
1203
+ } else if (commandFocused) {
1204
+ const command = this.#draft.segmentOptions.command?.command ?? "";
1205
+ lines.push(truncateToWidth(`${theme.fg("accent", theme.nav.cursor)} Command: ${command || "(empty)"}`, width));
1206
+ } else {
1207
+ const separator = descriptors[0];
1208
+ if (separator) {
1209
+ const current = separator.currentValue();
1210
+ const choices = separator.values
1211
+ .map(value => (value.value === current ? `[${value.label}]` : value.label))
1212
+ .join(" | ");
1213
+ lines.push(truncateToWidth(` ${separator.label}: ${choices}`, width));
1214
+ }
1215
+ }
1216
+ lines.push("");
1217
+ const confirm = this.#focus.kind === "confirm" ? theme.bold(theme.fg("accent", "[Confirm]")) : "[Confirm]";
1218
+ const exit = this.#focus.kind === "exit" ? theme.bold(theme.fg("accent", "[Exit]")) : "[Exit]";
1219
+ lines.push(truncateToWidth(` ${confirm} ${exit}`, width));
1220
+ lines.push(...this.#renderChoicePanel(width));
1221
+ lines.push(
1222
+ truncateToWidth(
1223
+ theme.fg(
1224
+ "dim",
1225
+ " Enter select/drop/apply · Left/Right segment/palette/value · Up/Down slots/palette/options/actions · Delete hide · Esc exit",
1226
+ ),
1227
+ width,
1228
+ ),
1229
+ );
1230
+ return lines.map(line => this.#fit(line, width));
553
1231
  }
554
1232
 
555
1233
  #setOption(path: string, value: string): void {
@@ -594,20 +1272,21 @@ class StatusLineCustomEditor extends Container {
594
1272
  mode: value === "remaining" ? "remaining" : "used",
595
1273
  };
596
1274
  break;
1275
+ case "command.command":
1276
+ this.#draft.segmentOptions.command = {
1277
+ ...(this.#draft.segmentOptions.command ?? {}),
1278
+ command: value,
1279
+ };
1280
+ break;
597
1281
  }
598
1282
  }
599
1283
 
600
1284
  #preview(): void {
601
- this.callbacks.onStatusLinePreview?.({
602
- preset: "custom",
603
- leftSegments: [...this.#draft.leftSegments],
604
- rightSegments: [...this.#draft.rightSegments],
605
- separator: this.#draft.separator,
606
- segmentOptions: cloneSegmentOptions(this.#draft.segmentOptions),
607
- sessionAccent: settings.get("statusLine.sessionAccent"),
608
- maxRows: settings.get("statusLine.maxRows"),
609
- previewHighlightSegment: this.#previewHighlightSegment,
610
- });
1285
+ this.callbacks.onRenderRequested?.();
1286
+ }
1287
+
1288
+ #emitDraftToParentPreview(): void {
1289
+ this.callbacks.onStatusLinePreview?.(this.#draftPreviewSettings());
611
1290
  }
612
1291
 
613
1292
  #restorePreview(): void {
@@ -641,7 +1320,7 @@ class StatusLineCustomEditor extends Container {
641
1320
  this.callbacks.onChange("statusLine.separator", this.#draft.separator);
642
1321
  this.callbacks.onChange("statusLine.segmentOptions", cloneSegmentOptions(this.#draft.segmentOptions));
643
1322
  this.#previewHighlightSegment = undefined;
644
- this.#preview();
1323
+ this.#emitDraftToParentPreview();
645
1324
  this.done("saved");
646
1325
  }
647
1326
 
@@ -651,7 +1330,124 @@ class StatusLineCustomEditor extends Container {
651
1330
  }
652
1331
 
653
1332
  handleInput(data: string): void {
654
- this.#list.handleInput(data);
1333
+ const kb = getKeybindings();
1334
+ const selectUp = kb.matches(data, "tui.select.up");
1335
+ const selectDown = kb.matches(data, "tui.select.down");
1336
+ const selectConfirm = kb.matches(data, "tui.select.confirm") || data === "\n";
1337
+ const selectCancel = kb.matches(data, "tui.select.cancel");
1338
+ if (this.#textInput) {
1339
+ if (selectCancel) this.#textInput.cancel();
1340
+ else if (selectConfirm) this.#textInput.submit();
1341
+ else this.#textInput.handleInput(data);
1342
+ return;
1343
+ }
1344
+ if (this.#focus.kind === "choice") {
1345
+ if (matchesKey(data, "left")) {
1346
+ this.#moveVertical(-1);
1347
+ this.#updatePreviewHighlight();
1348
+ this.#preview();
1349
+ return;
1350
+ }
1351
+ if (matchesKey(data, "right")) {
1352
+ this.#moveVertical(1);
1353
+ this.#updatePreviewHighlight();
1354
+ this.#preview();
1355
+ return;
1356
+ }
1357
+ if (selectUp) {
1358
+ this.#moveChoiceTarget(-1);
1359
+ this.#updatePreviewHighlight();
1360
+ this.#preview();
1361
+ return;
1362
+ }
1363
+ if (selectDown) {
1364
+ this.#moveChoiceTarget(1);
1365
+ this.#updatePreviewHighlight();
1366
+ this.#preview();
1367
+ return;
1368
+ }
1369
+ if (selectConfirm) {
1370
+ this.#applyChoice();
1371
+ return;
1372
+ }
1373
+ if (selectCancel) {
1374
+ this.#focus = cloneRootFocus(this.#focus.returnFocus);
1375
+ this.#updatePreviewHighlight();
1376
+ this.#preview();
1377
+ return;
1378
+ }
1379
+ return;
1380
+ }
1381
+
1382
+ if (selectCancel) {
1383
+ this.#cancel();
1384
+ return;
1385
+ }
1386
+ if (selectUp) {
1387
+ this.#moveVertical(-1);
1388
+ this.#updatePreviewHighlight();
1389
+ this.#preview();
1390
+ return;
1391
+ }
1392
+ if (selectDown) {
1393
+ this.#moveVertical(1);
1394
+ this.#updatePreviewHighlight();
1395
+ this.#preview();
1396
+ return;
1397
+ }
1398
+ if (matchesKey(data, "left")) {
1399
+ if (this.#focus.kind === "palette") this.#movePaletteFocus(-1);
1400
+ else if (this.#focus.kind === "separator-control" || this.#focus.kind === "option-control")
1401
+ this.#moveChoiceControl(-1);
1402
+ else if (this.#focus.kind === "confirm" || this.#focus.kind === "exit") {
1403
+ this.#focus = this.#focus.kind === "confirm" ? { kind: "exit" } : { kind: "confirm" };
1404
+ } else if (this.#picked) this.#movePickedSlot(-1);
1405
+ else this.#moveVisibleFocus(-1);
1406
+ this.#updatePreviewHighlight();
1407
+ this.#preview();
1408
+ return;
1409
+ }
1410
+ if (matchesKey(data, "right")) {
1411
+ if (this.#focus.kind === "palette") this.#movePaletteFocus(1);
1412
+ else if (this.#focus.kind === "separator-control" || this.#focus.kind === "option-control")
1413
+ this.#moveChoiceControl(1);
1414
+ else if (this.#focus.kind === "confirm" || this.#focus.kind === "exit") {
1415
+ this.#focus = this.#focus.kind === "confirm" ? { kind: "exit" } : { kind: "confirm" };
1416
+ } else if (this.#picked) this.#movePickedSlot(1);
1417
+ else this.#moveVisibleFocus(1);
1418
+ this.#updatePreviewHighlight();
1419
+ this.#preview();
1420
+ return;
1421
+ }
1422
+ if (isDeleteKey(data)) {
1423
+ this.#hideFocusedSegment();
1424
+ return;
1425
+ }
1426
+ if (selectConfirm) {
1427
+ switch (this.#focus.kind) {
1428
+ case "statusbar":
1429
+ if (this.#picked) this.#dropPicked();
1430
+ else this.#pickFocusedStatusbar();
1431
+ return;
1432
+ case "palette":
1433
+ if (this.#picked) this.#dropPickedToPalette();
1434
+ else this.#pickFocusedPalette();
1435
+ return;
1436
+ case "separator-control":
1437
+ this.#openChoice("separator", { kind: "separator-control" });
1438
+ return;
1439
+ case "option-control":
1440
+ if (this.#focus.path === "command.command") this.#openCommandInput();
1441
+ else this.#openChoice(this.#focus.path, { kind: "option-control", path: this.#focus.path });
1442
+ return;
1443
+ case "confirm":
1444
+ this.#save();
1445
+ return;
1446
+ case "exit":
1447
+ this.#cancel();
1448
+ return;
1449
+ }
1450
+ }
655
1451
  }
656
1452
  }
657
1453
 
@@ -704,6 +1500,7 @@ export interface StatusLinePreviewSettings {
704
1500
  separator?: StatusLineSeparatorStyle;
705
1501
  segmentOptions?: StatusLineSegmentOptions;
706
1502
  previewHighlightSegment?: StatusLineSegmentId;
1503
+ previewHighlightStyle?: "focus" | "selected";
707
1504
  sessionAccent?: boolean;
708
1505
  maxRows?: number;
709
1506
  }
@@ -733,6 +1530,13 @@ export interface SettingsCallbacks {
733
1530
  onStatusLinePreview?: (settings: StatusLinePreviewSettings) => void;
734
1531
  /** Get current rendered status line for inline preview */
735
1532
  getStatusLinePreview?: (width?: number) => string;
1533
+ /** Render a status-line preview for supplied draft settings without mutating the live status line. */
1534
+ getStatusLinePreviewForSettings?: (settings: StatusLinePreviewSettings, width?: number) => string;
1535
+ /** Render status-line segment groups for supplied draft settings without mutating the live status line. */
1536
+ getStatusLinePreviewPartsForSettings?: (
1537
+ settings: StatusLinePreviewSettings,
1538
+ width?: number,
1539
+ ) => StatusLinePreviewParts;
736
1540
  /** Called when plugins change */
737
1541
  onPluginsChanged?: () => void;
738
1542
  /** Called when asynchronously rebuilt settings content needs a repaint. */
@@ -1229,14 +2033,7 @@ export class SettingsSelectorComponent extends Container {
1229
2033
 
1230
2034
  // Add status line preview for appearance tab
1231
2035
  if (tabId === "appearance") {
1232
- this.#statusPreviewContainer = new Container();
1233
- this.#statusPreviewContainer.addChild(new Spacer(1));
1234
- this.#statusPreviewContainer.addChild(
1235
- new DynamicThemeText(() => theme.fg("muted", uiString(settings.get("ui.language"), "settings.preview"))),
1236
- );
1237
- this.#statusPreviewText = new Text(this.#getStatusPreviewString(), 0, 0);
1238
- this.#statusPreviewContainer.addChild(this.#statusPreviewText);
1239
- this.#statusPreviewContainer.addChild(new Spacer(1));
2036
+ this.#statusPreviewContainer = this.#createStatusPreviewContainer();
1240
2037
  this.addChild(this.#statusPreviewContainer);
1241
2038
  }
1242
2039
 
@@ -1299,6 +2096,39 @@ export class SettingsSelectorComponent extends Container {
1299
2096
  this.addChild(this.#currentList);
1300
2097
  }
1301
2098
 
2099
+ #createStatusPreviewContainer(): Container {
2100
+ const container = new Container();
2101
+ container.addChild(new Spacer(1));
2102
+ container.addChild(
2103
+ new DynamicThemeText(() => theme.fg("muted", uiString(settings.get("ui.language"), "settings.preview"))),
2104
+ );
2105
+ this.#statusPreviewText = new Text(this.#getStatusPreviewString(), 0, 0);
2106
+ container.addChild(this.#statusPreviewText);
2107
+ container.addChild(new Spacer(1));
2108
+ return container;
2109
+ }
2110
+
2111
+ #hideStatusPreview(): void {
2112
+ if (!this.#statusPreviewContainer) return;
2113
+ this.removeChild(this.#statusPreviewContainer);
2114
+ this.#statusPreviewContainer = null;
2115
+ this.#statusPreviewText = null;
2116
+ }
2117
+
2118
+ #showStatusPreview(): void {
2119
+ if (this.#statusPreviewContainer || this.#currentTabId !== "appearance") return;
2120
+ const container = this.#createStatusPreviewContainer();
2121
+ this.#statusPreviewContainer = container;
2122
+ const children = [...this.children];
2123
+ const listIndex = this.#currentList ? children.indexOf(this.#currentList) : -1;
2124
+ if (listIndex >= 0) {
2125
+ children.splice(listIndex, 0, container);
2126
+ this.replaceChildren(children);
2127
+ } else {
2128
+ this.addChild(container);
2129
+ }
2130
+ }
2131
+
1302
2132
  /** Map a definition list to UI items, dropping any whose condition is false. */
1303
2133
  #buildItemsForDefs(defs: SettingDef[]): SettingItem[] {
1304
2134
  const items: SettingItem[] = [];
@@ -1341,9 +2171,15 @@ export class SettingsSelectorComponent extends Container {
1341
2171
  id: STATUS_LINE_CUSTOM_EDITOR_ID,
1342
2172
  label: "Status Line Custom Editor",
1343
2173
  description:
1344
- "Edit custom status line segments, placement, separator, and typed segment options with live previews.",
2174
+ "Edit custom status line segments, placement, separator, and typed segment options in a simulated statusbar.",
1345
2175
  currentValue: "open",
1346
- submenu: (_currentValue, done) => new StatusLineCustomEditor(customEditorCallbacks, done),
2176
+ submenu: (_currentValue, done) => {
2177
+ this.#hideStatusPreview();
2178
+ return new StatusLineCustomEditor(customEditorCallbacks, value => {
2179
+ this.#showStatusPreview();
2180
+ done(value);
2181
+ });
2182
+ },
1347
2183
  };
1348
2184
  const presetIndex = items.findIndex(item => item.id === "statusLine.preset");
1349
2185
  if (presetIndex >= 0) {
@@ -1488,6 +2324,10 @@ export class SettingsSelectorComponent extends Container {
1488
2324
  // Handle tab switching — but NOT when a text input is active, since
1489
2325
  // arrow keys must reach the cursor and Tab must not switch tabs.
1490
2326
  if (!this.#textInputActive && tabNavigation) {
2327
+ if (this.#currentList?.navigationLocked) {
2328
+ this.#currentList.handleInput(data);
2329
+ return;
2330
+ }
1491
2331
  this.#tabBar.handleInput(data);
1492
2332
  return;
1493
2333
  }