claudeup 4.35.0 → 4.36.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 (63) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/git-worktree.test.ts +108 -0
  3. package/src/__tests__/scope-squares.test.tsx +165 -0
  4. package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
  5. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  6. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  7. package/src/main.tsx +21 -5
  8. package/src/opentui.d.ts +21 -12
  9. package/src/services/claude-settings.ts +37 -7
  10. package/src/services/git-worktree.ts +129 -0
  11. package/src/services/plugin-manager.ts +10 -1
  12. package/src/ui/App.tsx +19 -12
  13. package/src/ui/adapters/pluginsAdapter.ts +174 -168
  14. package/src/ui/adapters/settingsAdapter.ts +119 -116
  15. package/src/ui/adapters/skillsAdapter.ts +203 -196
  16. package/src/ui/components/CategoryHeader.tsx +9 -8
  17. package/src/ui/components/EmptyFilterState.tsx +10 -5
  18. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  19. package/src/ui/components/ScopeIndicator.tsx +10 -6
  20. package/src/ui/components/ScrollableList.tsx +3 -2
  21. package/src/ui/components/SearchInput.tsx +2 -1
  22. package/src/ui/components/StyledText.tsx +5 -4
  23. package/src/ui/components/TabBar.tsx +4 -3
  24. package/src/ui/components/layout/FooterHints.tsx +37 -30
  25. package/src/ui/components/layout/Panel.tsx +6 -5
  26. package/src/ui/components/layout/ProgressBar.tsx +7 -6
  27. package/src/ui/components/layout/ScopeTabs.tsx +6 -5
  28. package/src/ui/components/layout/ScreenLayout.tsx +27 -24
  29. package/src/ui/components/layout/index.ts +3 -3
  30. package/src/ui/components/modals/ConfirmModal.tsx +12 -11
  31. package/src/ui/components/modals/InputModal.tsx +14 -6
  32. package/src/ui/components/modals/LoadingModal.tsx +6 -5
  33. package/src/ui/components/modals/MessageModal.tsx +9 -8
  34. package/src/ui/components/modals/SelectModal.tsx +11 -7
  35. package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
  36. package/src/ui/components/primitives/ActionHints.tsx +26 -26
  37. package/src/ui/components/primitives/DetailSection.tsx +13 -12
  38. package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
  39. package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
  40. package/src/ui/components/primitives/MetaText.tsx +3 -3
  41. package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
  42. package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
  43. package/src/ui/components/primitives/SelectableRow.tsx +22 -16
  44. package/src/ui/hooks/useGitignoreModal.ts +78 -74
  45. package/src/ui/registry.ts +11 -11
  46. package/src/ui/renderers/cliToolRenderers.tsx +260 -203
  47. package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
  48. package/src/ui/renderers/mcpRenderers.tsx +121 -117
  49. package/src/ui/renderers/pluginRenderers.tsx +528 -471
  50. package/src/ui/renderers/profileRenderers.tsx +346 -300
  51. package/src/ui/renderers/settingsRenderers.tsx +183 -176
  52. package/src/ui/renderers/skillRenderers.tsx +410 -326
  53. package/src/ui/screens/AliasScreen.tsx +1336 -1309
  54. package/src/ui/screens/CliToolsScreen.tsx +92 -40
  55. package/src/ui/screens/EnvVarsScreen.tsx +19 -13
  56. package/src/ui/screens/GitignoreScreen.tsx +510 -493
  57. package/src/ui/screens/McpRegistryScreen.tsx +28 -21
  58. package/src/ui/screens/McpScreen.tsx +12 -3
  59. package/src/ui/screens/PluginsScreen.tsx +17 -7
  60. package/src/ui/screens/ProfilesScreen.tsx +39 -23
  61. package/src/ui/screens/SkillsScreen.tsx +832 -688
  62. package/src/ui/theme-mode.ts +73 -0
  63. package/src/ui/theme.ts +147 -53
@@ -1,72 +1,79 @@
1
- import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
1
+ import React, {
2
+ useEffect,
3
+ useState,
4
+ useCallback,
5
+ useMemo,
6
+ useRef,
7
+ } from "react";
2
8
  import type { ScrollBoxRenderable } from "@opentui/core";
3
9
  import { useApp, useModal } from "../state/AppContext.js";
4
10
  import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
5
11
  import { ScreenLayout, type FooterHint } from "../components/layout/index.js";
6
12
  import { FlagDetailEditor } from "../components/FlagDetailEditor.js";
7
13
  import {
8
- ALIAS_FLAGS,
9
- FLAG_GROUPS,
10
- type AliasFlag,
14
+ ALIAS_FLAGS,
15
+ FLAG_GROUPS,
16
+ type AliasFlag,
11
17
  } from "../../data/alias-flags.js";
12
18
  import {
13
- defaultAliasConfig,
14
- defaultValueFor,
15
- validateAliasName,
16
- derivedChannelValues,
17
- withoutDerivedChannel,
18
- CHANNELS_FLAG_ID,
19
- DEFAULT_ALIAS_NAME,
20
- type AliasConfig,
21
- type FlagValue,
19
+ defaultAliasConfig,
20
+ defaultValueFor,
21
+ validateAliasName,
22
+ derivedChannelValues,
23
+ withoutDerivedChannel,
24
+ CHANNELS_FLAG_ID,
25
+ DEFAULT_ALIAS_NAME,
26
+ type AliasConfig,
27
+ type FlagValue,
22
28
  } from "../../services/alias-store.js";
23
29
  import { loadAliasName, saveAliasName } from "../../services/alias-settings.js";
24
30
  import {
25
- detectShells,
26
- renderArgs,
27
- validateConfig,
28
- writeAliasToShell,
29
- parseAliasFromRc,
30
- findAdoptableAlias,
31
- type ShellTarget,
32
- type FlagValidationIssue,
33
- type Segment,
34
- type AdoptableAlias,
31
+ detectShells,
32
+ renderArgs,
33
+ validateConfig,
34
+ writeAliasToShell,
35
+ parseAliasFromRc,
36
+ findAdoptableAlias,
37
+ type ShellTarget,
38
+ type FlagValidationIssue,
39
+ type Segment,
40
+ type AdoptableAlias,
35
41
  } from "../../services/alias-shell-writer.js";
36
42
  import { readFile } from "node:fs/promises";
37
43
  import { existsSync } from "node:fs";
44
+ import { theme } from "../theme.js";
38
45
 
39
46
  interface FlagRow {
40
- kind: "flag";
41
- flag: AliasFlag;
42
- value: FlagValue;
43
- /** Pre-computed validation issue for this row, if any. */
44
- issue?: FlagValidationIssue;
45
- /**
46
- * Override for the checkbox's enabled state when the rendered alias would
47
- * carry this flag even though its OWN value is disabled. Used by --channels:
48
- * a dev-loaded channel forces `--channels` to emit (the render-time union),
49
- * so the row must show `[x]` to match what gets written — even when the
50
- * user's own channels list is off. Undefined → use the flag's own enabled.
51
- */
52
- effectiveEnabled?: boolean;
53
- /**
54
- * Override for the trailing detail (e.g. the --channels union count: own +
55
- * derived). Undefined → derive from the flag's own value. Empty string is a
56
- * valid override meaning "show nothing".
57
- */
58
- effectiveDetail?: string;
59
- /**
60
- * Override for the detail-pane `State:` line. For --channels this is the
61
- * effective channel-list summary (the union of own + derived), so the detail
62
- * agrees with the list count and the Preview. Undefined → summarizeValue.
63
- */
64
- stateSummary?: string;
47
+ kind: "flag";
48
+ flag: AliasFlag;
49
+ value: FlagValue;
50
+ /** Pre-computed validation issue for this row, if any. */
51
+ issue?: FlagValidationIssue;
52
+ /**
53
+ * Override for the checkbox's enabled state when the rendered alias would
54
+ * carry this flag even though its OWN value is disabled. Used by --channels:
55
+ * a dev-loaded channel forces `--channels` to emit (the render-time union),
56
+ * so the row must show `[x]` to match what gets written — even when the
57
+ * user's own channels list is off. Undefined → use the flag's own enabled.
58
+ */
59
+ effectiveEnabled?: boolean;
60
+ /**
61
+ * Override for the trailing detail (e.g. the --channels union count: own +
62
+ * derived). Undefined → derive from the flag's own value. Empty string is a
63
+ * valid override meaning "show nothing".
64
+ */
65
+ effectiveDetail?: string;
66
+ /**
67
+ * Override for the detail-pane `State:` line. For --channels this is the
68
+ * effective channel-list summary (the union of own + derived), so the detail
69
+ * agrees with the list count and the Preview. Undefined → summarizeValue.
70
+ */
71
+ stateSummary?: string;
65
72
  }
66
73
 
67
74
  interface AliasNameRow {
68
- kind: "alias-name";
69
- value: string;
75
+ kind: "alias-name";
76
+ value: string;
70
77
  }
71
78
 
72
79
  type Row = AliasNameRow | FlagRow;
@@ -82,12 +89,12 @@ type Row = AliasNameRow | FlagRow;
82
89
  * content.
83
90
  */
84
91
  type Item =
85
- | { kind: "alias-name"; row: AliasNameRow }
86
- | { kind: "flag"; row: FlagRow }
87
- | { kind: "header"; label: string };
92
+ | { kind: "alias-name"; row: AliasNameRow }
93
+ | { kind: "flag"; row: FlagRow }
94
+ | { kind: "header"; label: string };
88
95
 
89
96
  function isSelectableItem(item: Item): boolean {
90
- return item.kind === "alias-name" || item.kind === "flag";
97
+ return item.kind === "alias-name" || item.kind === "flag";
91
98
  }
92
99
 
93
100
  /**
@@ -105,738 +112,741 @@ function isSelectableItem(item: Item): boolean {
105
112
  type ViewMode = { kind: "main" } | { kind: "flag-detail"; flagId: string };
106
113
 
107
114
  export function AliasScreen() {
108
- const { state } = useApp();
109
- const modal = useModal();
110
-
111
- const [config, setConfig] = useState<AliasConfig | null>(null);
112
- const [shells, setShells] = useState<ShellTarget[]>([]);
113
- const [selectedIdx, setSelectedIdx] = useState(0);
114
- const [error, setError] = useState<string | null>(null);
115
- const [viewMode, setViewMode] = useState<ViewMode>({ kind: "main" });
116
- // Snapshot of the rc file's flag values as parsed on mount / write. Used
117
- // to compute the dirty indicator: in-memory != rcSnapshot ⇒ unsaved.
118
- const [rcSnapshot, setRcSnapshot] = useState<Record<string, FlagValue> | null>(
119
- null,
120
- );
121
- // Transient write feedback shown on the header status line (not a blocking
122
- // modal). Auto-clears after a few seconds. Errors still use a modal since
123
- // they need to be read and acknowledged.
124
- const [writeStatus, setWriteStatus] = useState<string | null>(null);
125
-
126
- // A hand-written `claude` alias found OUTSIDE any managed block on mount.
127
- // Set when the rc file has no managed block but a stray alias we could
128
- // adopt drives the one-time "import it?" offer below. Cleared once the
129
- // offer has been shown (whether accepted or declined).
130
- const [adoptable, setAdoptable] = useState<AdoptableAlias | null>(null);
131
- // When the user accepts a LOSSLESS adoption, we remember the line range of
132
- // their original alias so the next write replaces it in place (no duplicate
133
- // alias). Cleared after that write absorbs it into the managed block.
134
- const [pendingAdoptRange, setPendingAdoptRange] = useState<
135
- [number, number] | null
136
- >(null);
137
-
138
- // Clear the write status line a few seconds after it appears.
139
- useEffect(() => {
140
- if (!writeStatus) return;
141
- const t = setTimeout(() => setWriteStatus(null), 4000);
142
- return () => clearTimeout(t);
143
- }, [writeStatus]);
144
-
145
- // One-time "adopt a hand-written alias?" offer. Fires when the mount load
146
- // found a stray `claude` alias outside any managed block. We import only the
147
- // flags we recognized; whether we also TAKE OWNERSHIP (remove the original
148
- // line on the next write) depends on `lossless`:
149
- // - lossless: every token round-trips safe to absorb. We stash the line
150
- // range so the next write replaces it in place (no duplicate alias).
151
- // - not lossless: we couldn't parse some tokens (e.g. an uncatalogued
152
- // flag like --model) import the known flags for convenience but LEAVE
153
- // the original line untouched. CRITICAL: we must NOT adopt its name here.
154
- // Shell alias resolution is last-definition-wins, so a managed block
155
- // sharing the name would shadow the surviving original at `source` time —
156
- // silently killing the very tokens that made it not-lossless. Keeping the
157
- // tool's own name lets both coexist: the original keeps working, the
158
- // managed block lives under a different name.
159
- // True once we've shown the offer. A ref (not state) so it survives re-render
160
- // without being a dependency critical because this effect mutates its own
161
- // `adoptable` dep, and tying "fire once" to state/deps would let the dep
162
- // change abort the in-flight modal mid-await (the cleanup would fire before
163
- // the user answered). The ref decouples one-shot from the dependency array.
164
- const didOfferAdoptRef = useRef(false);
165
- useEffect(() => {
166
- if (!adoptable || !config || didOfferAdoptRef.current) return;
167
- didOfferAdoptRef.current = true;
168
- (async () => {
169
- const a = adoptable;
170
-
171
- const body = a.rawLine.trim();
172
- const others =
173
- a.others.length > 0
174
- ? `\n\n(Also found ${a.others.length} other claude alias${
175
- a.others.length === 1 ? "" : "es"
176
- } leaving those alone.)`
177
- : "";
178
-
179
- // On a not-lossless adopt the managed block must NOT share the original's
180
- // name same name + last-wins shell resolution = the block shadows the
181
- // surviving original, killing its uncatalogued tokens. Compute a managed
182
- // name distinct from `a.name`: keep the tool's current name if it already
183
- // differs, else derive a suffixed variant that's free in the rc file.
184
- const managedName = a.lossless
185
- ? a.name
186
- : pickDistinctName(config.aliasName, a.name, a.others);
187
-
188
- const message = a.lossless
189
- ? `Found a hand-written alias:\n\n ${body}\n\nImport its flags into the editor? On the next write it'll be absorbed into a managed block (your original line is replaced — no duplicate).${others}`
190
- : `Found a hand-written alias:\n\n ${body}\n\nI can import the flags I recognize, but it uses ${
191
- a.leftovers.length
192
- } token${a.leftovers.length === 1 ? "" : "s"} I don't manage (${a.leftovers
193
- .map((t) => JSON.stringify(t))
194
- .join(", ")}). I'll import the known flags and LEAVE your original "${a.name}" line untouched so it keeps working. The managed alias will use the name "${managedName}" — distinct from "${a.name}" so it can't override it. Import the known flags?${others}`;
195
-
196
- const accepted = await modal.confirm("Adopt existing alias?", message);
197
- if (accepted) {
198
- // Lossless: adopt the name AND take ownership (replace the line on
199
- // write). Not-lossless: import flags under a distinct managed name and
200
- // never touch the original line — so it survives AND keeps resolving.
201
- setConfig((prev) =>
202
- prev
203
- ? {
204
- ...prev,
205
- aliasName: managedName,
206
- flags: { ...prev.flags, ...a.flags },
207
- }
208
- : prev,
209
- );
210
- if (a.lossless) {
211
- // Persist the adopted name and stash the line range so the next
212
- // write replaces the original in place (no duplicate, no shadow).
213
- void persistAliasName(a.name);
214
- setPendingAdoptRange(a.lineRange);
215
- } else {
216
- // Persist the distinct managed name so it survives a reopen.
217
- void persistAliasName(managedName);
218
- }
219
- }
220
- // Clear the candidate now that the decision is made — after the await,
221
- // never before (clearing before would abort this very async via the
222
- // dep-change cleanup, the bug this effect previously had).
223
- setAdoptable(null);
224
- })();
225
- // persistAliasName is stable (useCallback); excluded to keep this one-shot.
226
- // eslint-disable-next-line react-hooks/exhaustive-deps
227
- }, [adoptable, config, modal]);
228
-
229
- // ── Load alias name + parse rc file + detect shells on mount ─────────
230
- // The alias name is computer-wide and persisted in ~/.claude/settings.json.
231
- // Flag values are read directly from the shell rc file's managed block —
232
- // the rc file IS the source of truth so what the user sees matches what
233
- // they're actually running.
234
- useEffect(() => {
235
- let cancelled = false;
236
- (async () => {
237
- try {
238
- const [name, s] = await Promise.all([loadAliasName(), detectShells()]);
239
- if (cancelled) return;
240
-
241
- // Pick the same rc file we'd write to with `w` (default shell that
242
- // exists; else first existing). If none exists, start from defaults.
243
- const readTarget =
244
- s.find((sh) => sh.isDefault && sh.exists) ??
245
- s.find((sh) => sh.exists);
246
-
247
- const base = defaultAliasConfig();
248
- base.aliasName = name;
249
-
250
- let parsedFlags: Record<string, FlagValue> | null = null;
251
- let foundAdoptable: AdoptableAlias | null = null;
252
- if (readTarget && existsSync(readTarget.path)) {
253
- try {
254
- const rcText = await readFile(readTarget.path, "utf8");
255
- const parsed = parseAliasFromRc(rcText);
256
- if (parsed) {
257
- parsedFlags = { ...base.flags, ...parsed.flags };
258
- base.flags = parsedFlags;
259
- // Honour an existing alias name from the rc if settings is at default.
260
- // But the user's stored preference wins when set explicitly.
261
- // (No-op here — `name` already loaded above.)
262
- } else {
263
- // No managed block on disk. The user may still have a hand-written
264
- // `claude` alias elsewhere detect it so we can offer to adopt it
265
- // instead of looking broken. Non-destructive: just a candidate.
266
- foundAdoptable = findAdoptableAlias(rcText);
267
- }
268
- } catch {
269
- // rc unreadable — fall back to defaults; not a hard error.
270
- }
271
- }
272
-
273
- if (cancelled) return;
274
- // No mirror normalization: channels.values holds OWN channels only;
275
- // derived channels come from the dev-load flag at display/render time.
276
- // The parser already subtracts derived values from the parsed channels
277
- // list, so `base.flags` is correct as-is.
278
- setConfig(base);
279
- setShells(s);
280
- setRcSnapshot(parsedFlags ?? { ...base.flags });
281
- if (foundAdoptable) setAdoptable(foundAdoptable);
282
- } catch (err) {
283
- if (cancelled) return;
284
- setError(err instanceof Error ? err.message : String(err));
285
- }
286
- })();
287
- return () => {
288
- cancelled = true;
289
- };
290
- }, []);
291
-
292
- // ── Build the flat list of items ──────────────────────────────────────
293
- // Group headers and the shell legend live in the same flat array as
294
- // alias-name + flag rows so that ScrollableList can window them together.
295
- // Navigation skips non-selectable kinds.
296
- const items: Item[] = useMemo(() => {
297
- if (!config) return [];
298
- const issues = validateConfig(config);
299
- const issueMap = new Map(issues.map((i) => [i.flagId, i]));
300
-
301
- const out: Item[] = [
302
- {
303
- kind: "alias-name",
304
- row: { kind: "alias-name", value: config.aliasName },
305
- },
306
- ];
307
-
308
- for (const group of FLAG_GROUPS) {
309
- const groupFlags = ALIAS_FLAGS.filter((f) => f.group === group.id);
310
- if (groupFlags.length === 0) continue;
311
- out.push({ kind: "header", label: group.label });
312
- for (const flag of groupFlags) {
313
- // --channels shows enabled when the rendered alias would emit it —
314
- // i.e. own channels are on OR a dev-loaded channel is derived in. This
315
- // keeps the checkbox honest about what gets written without mutating
316
- // the own-channels enabled flag (which would silently re-emit own
317
- // values the user disabled).
318
- let effectiveEnabled: boolean | undefined;
319
- let effectiveDetail: string | undefined;
320
- let stateSummary: string | undefined;
321
- if (flag.id === CHANNELS_FLAG_ID) {
322
- const own = config.flags[flag.id];
323
- const ownValues =
324
- own && own.kind === "text-list" && own.enabled
325
- ? own.values.filter((v) => v.length > 0)
326
- : [];
327
- const derived = derivedChannelValues(config.flags);
328
- // Deduped union matches the writer's render-time union and the
329
- // detail-pane summary, so the count is consistent everywhere.
330
- const total = new Set([...ownValues, ...derived]).size;
331
- effectiveEnabled = total > 0;
332
- // Show the union count so the row matches what's written. When the
333
- // count is purely derived, this reads e.g. `[x] --channels 1`.
334
- effectiveDetail = total > 0 ? `${total}` : "";
335
- // The detail-pane State line shows the same final channel list.
336
- stateSummary = channelsStateSummary(config.flags);
337
- }
338
- out.push({
339
- kind: "flag",
340
- row: {
341
- kind: "flag",
342
- flag,
343
- value: config.flags[flag.id] ?? defaultValueFor(flag),
344
- issue: issueMap.get(flag.id),
345
- effectiveEnabled,
346
- effectiveDetail,
347
- stateSummary,
348
- },
349
- });
350
- }
351
- }
352
-
353
- return out;
354
- }, [config]);
355
-
356
- /** Indices in `items` that the cursor may land on. */
357
- const selectableIndices = useMemo(
358
- () => items.flatMap((it, i) => (isSelectableItem(it) ? [i] : [])),
359
- [items],
360
- );
361
-
362
- // ── Flag-detail sub-screen items ──────────────────────────────────────
363
- // Computed only when viewMode is flag-detail. Built off the flag's current
364
- // The flag-detail sub-screen is now self-contained in <FlagDetailEditor>.
365
- // We just resolve the flag + value pair here so the parent can render
366
- // the editor and the right-pane preview.
367
- const detailFlag: AliasFlag | null =
368
- viewMode.kind === "flag-detail"
369
- ? ALIAS_FLAGS.find((f) => f.id === viewMode.flagId) ?? null
370
- : null;
371
- const detailValue: FlagValue | null =
372
- detailFlag && config ? config.flags[detailFlag.id] ?? null : null;
373
-
374
- // ── Helper: check whether a row is gated (requires unmet) ─────────────
375
- const isGated = useCallback(
376
- (flag: AliasFlag): boolean => {
377
- if (!flag.requires || !config) return false;
378
- return !isFlagEnabled(config.flags[flag.requires]);
379
- },
380
- [config],
381
- );
382
-
383
- const selectedItem = items[selectedIdx];
384
- const selectedRow: Row | undefined =
385
- selectedItem &&
386
- (selectedItem.kind === "alias-name" || selectedItem.kind === "flag")
387
- ? selectedItem.row
388
- : undefined;
389
-
390
- // Clamp selection: snap to the nearest selectable index whenever items shift.
391
- useEffect(() => {
392
- if (selectableIndices.length === 0) return;
393
- if (!isSelectableItem(items[selectedIdx])) {
394
- // Find the nearest selectable index <= selectedIdx (or the first one)
395
- const fallback =
396
- [...selectableIndices].reverse().find((i) => i <= selectedIdx) ??
397
- selectableIndices[0];
398
- setSelectedIdx(fallback);
399
- } else if (selectedIdx >= items.length) {
400
- setSelectedIdx(selectableIndices[selectableIndices.length - 1]);
401
- }
402
- }, [items, selectableIndices, selectedIdx]);
403
-
404
- // ── Mutators ──────────────────────────────────────────────────────────
405
- // Flag mutations stay in memory. Only the alias name is persisted (to
406
- // ~/.claude/settings.json) — flag values reach disk only when the user
407
- // explicitly presses `w` to write the shell rc file.
408
- const persistAliasName = useCallback(
409
- async (name: string) => {
410
- try {
411
- await saveAliasName(name);
412
- } catch (err) {
413
- await modal.message(
414
- "Save failed",
415
- err instanceof Error ? err.message : String(err),
416
- "error",
417
- );
418
- }
419
- },
420
- [modal],
421
- );
422
-
423
- // Cascade dependencies on every mutation:
424
- // - Enabling a flag → enable its `requires` chain (no modal nagging).
425
- // - Disabling a flag → disable anything whose `requires === this flag id`,
426
- // because the renderer would silently drop them otherwise and the UI
427
- // would lie about what's going to be written.
428
- const updateFlag = useCallback(
429
- (id: string, next: FlagValue) => {
430
- setConfig((prev) => {
431
- if (!prev) return prev;
432
- const flags = { ...prev.flags, [id]: next };
433
- const target = ALIAS_FLAGS.find((f) => f.id === id);
434
- if (!target) return { ...prev, flags };
435
-
436
- const becomingEnabled = isFlagEnabled(next);
437
-
438
- if (becomingEnabled) {
439
- // Walk `requires` chain and force-enable each ancestor to its
440
- // catalog-default-enabled state if it's currently off.
441
- let cursor = target.requires;
442
- const seen = new Set<string>();
443
- while (cursor && !seen.has(cursor)) {
444
- seen.add(cursor);
445
- const dep = ALIAS_FLAGS.find((f) => f.id === cursor);
446
- if (!dep) break;
447
- if (!isFlagEnabled(flags[dep.id])) {
448
- flags[dep.id] = enableDefault(dep);
449
- }
450
- cursor = dep.requires;
451
- }
452
- } else {
453
- // Cascade-disable any flag that requires this one (one level deep is
454
- // enough today; recurse defensively in case a chain emerges).
455
- const queue = [id];
456
- const seen = new Set<string>();
457
- while (queue.length > 0) {
458
- const cur = queue.shift()!;
459
- if (seen.has(cur)) continue;
460
- seen.add(cur);
461
- for (const dependent of ALIAS_FLAGS) {
462
- if (dependent.requires !== cur) continue;
463
- if (!isFlagEnabled(flags[dependent.id])) continue;
464
- flags[dependent.id] = disableValue(flags[dependent.id]);
465
- queue.push(dependent.id);
466
- }
467
- }
468
- }
469
-
470
- return { ...prev, flags };
471
- });
472
- },
473
- [],
474
- );
475
-
476
- // Remove a value from the dev-load flag — invoked when the user deletes a
477
- // "Dev-loaded channels" row inside the --channels editor. The derived
478
- // section is sourced from this flag, so the deletion lands here, not in
479
- // channels.values. updateFlag keeps enabled-state and cascades consistent.
480
- const removeDevloadChannel = useCallback((val: string) => {
481
- setConfig((prev) =>
482
- prev ? { ...prev, flags: withoutDerivedChannel(prev.flags, val) } : prev,
483
- );
484
- }, []);
485
-
486
- const editAliasName = useCallback(async () => {
487
- if (!config) return;
488
- const v = await modal.input(
489
- "Alias name",
490
- `Shell alias name (default: "${DEFAULT_ALIAS_NAME}"). Letters, digits, _ or - only.`,
491
- config.aliasName,
492
- );
493
- if (v === null) return;
494
- const reason = validateAliasName(v);
495
- if (reason) {
496
- await modal.message("Invalid alias name", reason, "error");
497
- return;
498
- }
499
- setConfig((prev) => (prev ? { ...prev, aliasName: v } : prev));
500
- void persistAliasName(v);
501
- }, [config, modal, persistAliasName]);
502
-
503
- const editSelected = useCallback(async () => {
504
- if (!selectedRow) return;
505
- if (selectedRow.kind === "alias-name") {
506
- await editAliasName();
507
- return;
508
- }
509
- // No more "Requires another flag" modal — `updateFlag` auto-enables the
510
- // `requires` chain when the user activates a gated flag.
511
- //
512
- // Collection editors live in a dedicated sub-screen, not a modal —
513
- // each row gets its own keybindings (space toggle, a add, d delete)
514
- // so there's no "what does enter do here?" overloading.
515
- const k = selectedRow.value.kind;
516
- if (k === "text-list" || k === "multi-with-custom") {
517
- // FlagDetailEditor manages its own cursor — parent just flips mode.
518
- setViewMode({ kind: "flag-detail", flagId: selectedRow.flag.id });
519
- return;
520
- }
521
- const next = await editFlagValue(selectedRow.flag, selectedRow.value, modal);
522
- if (next) updateFlag(selectedRow.flag.id, next);
523
- }, [selectedRow, modal, updateFlag, editAliasName]);
524
-
525
- const toggleSelected = useCallback(async () => {
526
- if (!selectedRow) return;
527
- if (selectedRow.kind === "alias-name") {
528
- // No "toggle" semantic for the name — fall through to edit.
529
- await editAliasName();
530
- return;
531
- }
532
- // Only booleans and tri-states have a meaningful single-press cycle.
533
- // Everything else (select, text, list, multi-with-custom) needs a value
534
- // chooser; falling back to edit prevents the "I pressed space and got the
535
- // first option without seeing the choices" footgun.
536
- const k = selectedRow.value.kind;
537
- if (k !== "boolean" && k !== "tri-state") {
538
- await editSelected();
539
- return;
540
- }
541
- const next = quickToggle(selectedRow.flag, selectedRow.value);
542
- if (next) updateFlag(selectedRow.flag.id, next);
543
- }, [selectedRow, updateFlag, editSelected, editAliasName]);
544
-
545
- const handleWrite = useCallback(async () => {
546
- if (!config) return;
547
-
548
- // Pick the target shell automatically: default shell (matches $SHELL)
549
- // if its rc file exists; otherwise the first existing rc file. Users
550
- // who care about other shells edit them directly — the alias config
551
- // itself is stored in ~/.claude and the user can run `w` again from
552
- // a different $SHELL session if they want to mirror it.
553
- const target =
554
- shells.find((s) => s.isDefault && s.exists) ??
555
- shells.find((s) => s.exists);
556
-
557
- if (!target) {
558
- await modal.message(
559
- "No shells detected",
560
- "No supported rc files found. Create ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish first.",
561
- "info",
562
- );
563
- return;
564
- }
565
-
566
- try {
567
- // If we adopted a lossless hand-written alias, pass its line range so
568
- // the FIRST write replaces that line in place (absorbing it into the
569
- // managed block) rather than appending a second alias. Once the block
570
- // exists, the writer ignores the range and splices by markers.
571
- const r = await writeAliasToShell(
572
- config,
573
- target,
574
- pendingAdoptRange ? { adoptLineRange: pendingAdoptRange } : {},
575
- );
576
- // The adopted line is now gone (replaced by the block); drop the range
577
- // so subsequent writes don't try to re-target a line that moved.
578
- if (pendingAdoptRange) setPendingAdoptRange(null);
579
- // Non-blocking confirmation on the status line — no "press any key".
580
- const name = r.path.split("/").pop() || r.path;
581
- setWriteStatus(
582
- `✓ ${r.action === "created" ? "Created" : "Updated"} ${name} — source it or open a new shell`,
583
- );
584
- setShells(await detectShells());
585
- // We just wrote the rc file with current in-memory flag values, so
586
- // the snapshot now matches in-memory state. Dirty indicator clears.
587
- setRcSnapshot(config.flags);
588
- } catch (err) {
589
- await modal.message(
590
- "Write failed",
591
- err instanceof Error ? err.message : String(err),
592
- "error",
593
- );
594
- }
595
- }, [config, shells, modal, pendingAdoptRange]);
596
-
597
- // ── Keyboard ──────────────────────────────────────────────────────────
598
- // j/k navigate the selectable subset, jumping over headers/legend lines.
599
- const moveSelection = useCallback(
600
- (dir: 1 | -1) => {
601
- if (selectableIndices.length === 0) return;
602
- const cursorPos = selectableIndices.indexOf(selectedIdx);
603
- const nextPos =
604
- cursorPos === -1
605
- ? // Currently parked on a non-selectable; move to first/last selectable.
606
- dir === 1
607
- ? 0
608
- : selectableIndices.length - 1
609
- : Math.min(
610
- selectableIndices.length - 1,
611
- Math.max(0, cursorPos + dir),
612
- );
613
- setSelectedIdx(selectableIndices[nextPos]);
614
- },
615
- [selectableIndices, selectedIdx],
616
- );
617
-
618
- // The flag-detail sub-screen handles its own cursor + key + render
619
- // (see FlagDetailEditor component). The parent only needs to know how
620
- // to leave it.
621
- const exitDetail = useCallback(() => {
622
- setViewMode({ kind: "main" });
623
- }, []);
624
-
625
- // ── Keyboard handler ──────────────────────────────────────────────────
626
- // When the sub-screen is mounted, FlagDetailEditor installs its own
627
- // keyboard handler. Both handlers fire (OpenTUI's useKeyboard delivers
628
- // to every subscriber), so this handler must SKIP the keys the child
629
- // owns to avoid double-dispatch. We bail entirely while in detail mode.
630
- useKeyboardHandler((input, key) => {
631
- if (state.modal) return;
632
- if (viewMode.kind === "flag-detail") return;
633
-
634
- // Main mode.
635
- if (input === "j" || key.downArrow) {
636
- moveSelection(1);
637
- return;
638
- }
639
- if (input === "k" || key.upArrow) {
640
- moveSelection(-1);
641
- return;
642
- }
643
- if (key.return || input === " " || key.name === "space") {
644
- void toggleSelected();
645
- return;
646
- }
647
- if (input === "e") {
648
- void editSelected();
649
- return;
650
- }
651
- if (input === "w") {
652
- void handleWrite();
653
- return;
654
- }
655
- if (input === "r") {
656
- // Reset the selected row to its default.
657
- if (!selectedRow) return;
658
- if (selectedRow.kind === "alias-name") {
659
- setConfig((prev) =>
660
- prev ? { ...prev, aliasName: DEFAULT_ALIAS_NAME } : prev,
661
- );
662
- void persistAliasName(DEFAULT_ALIAS_NAME);
663
- return;
664
- }
665
- // Flag reset stays in memory until `w`.
666
- updateFlag(selectedRow.flag.id, defaultValueFor(selectedRow.flag));
667
- }
668
- });
669
-
670
- // ── Render ────────────────────────────────────────────────────────────
671
- const previewSegments = config ? renderArgs(config) : [];
672
- const previewAliasName = config?.aliasName ?? "";
673
-
674
- // Native OpenTUI scrollboxes own the scroll math. We render every item
675
- // into the scrollbox's content area and ask the renderable to scroll the
676
- // selected child into view whenever the cursor moves. The previous JS-
677
- // windowed approach (ScrollableList) overstrike-rendered rows when its
678
- // height computation drifted off the actual panel size.
679
- const mainScrollRef = useRef<ScrollBoxRenderable | null>(null);
680
-
681
- useEffect(() => {
682
- const item = items[selectedIdx];
683
- if (!item) return;
684
- mainScrollRef.current?.scrollChildIntoView(`alias-row-${getItemKey(item)}`);
685
- }, [items, selectedIdx]);
686
-
687
- const inDetail = viewMode.kind === "flag-detail" && detailFlag && detailValue;
688
- // FlagDetailEditor only handles text-list and multi-with-custom values;
689
- // narrow the type for the JSX below.
690
- const editableDetailValue =
691
- inDetail &&
692
- (detailValue.kind === "text-list" ||
693
- detailValue.kind === "multi-with-custom")
694
- ? detailValue
695
- : null;
696
-
697
- const mainListPanel = !config ? (
698
- <text fg="gray">{error ? `Error: ${error}` : "Loading..."}</text>
699
- ) : (
700
- <box flexDirection="column" height="100%">
701
- <scrollbox
702
- ref={mainScrollRef}
703
- scrollY={true}
704
- scrollX={false}
705
- flexGrow={1}
706
- >
707
- <box flexDirection="column">
708
- {items.map((item, idx) => (
709
- // id is content-derived (not positional `${idx}`) so OpenTUI's
710
- // reconciler stays in lockstep with React's key-based reconcile.
711
- <box key={getItemKey(item)} id={`alias-row-${getItemKey(item)}`}>
712
- {renderItem(item, idx === selectedIdx, isGated)}
713
- </box>
714
- ))}
715
- </box>
716
- </scrollbox>
717
- </box>
718
- );
719
-
720
- const listPanel =
721
- inDetail && editableDetailValue ? (
722
- <FlagDetailEditor
723
- flag={detailFlag}
724
- value={editableDetailValue}
725
- onChange={(next) => updateFlag(detailFlag.id, next)}
726
- onBack={exitDetail}
727
- derivedValues={
728
- detailFlag.id === CHANNELS_FLAG_ID && config
729
- ? derivedChannelValues(config.flags)
730
- : undefined
731
- }
732
- derivedLabel="From --dangerously-load:"
733
- toggleLabel={
734
- detailFlag.id === CHANNELS_FLAG_ID
735
- ? "Enable own channels"
736
- : undefined
737
- }
738
- onDerivedDelete={(v) => removeDevloadChannel(v)}
739
- />
740
- ) : (
741
- mainListPanel
742
- );
743
-
744
- const mainDetailPanel = renderDetail(
745
- selectedRow,
746
- isGated,
747
- previewAliasName,
748
- previewSegments,
749
- );
750
- // When editing --channels and there are derived rows, the right pane carries
751
- // the contextual instruction about what deleting a derived row does — that
752
- // explanation belongs here, not crammed into the left list.
753
- const channelsHasDerived =
754
- inDetail &&
755
- detailFlag.id === CHANNELS_FLAG_ID &&
756
- config &&
757
- derivedChannelValues(config.flags).length > 0;
758
- const derivedNote = channelsHasDerived
759
- ? "Rows under “From --dangerously-load:” are derived from the --dangerously-load-development-channels flag. Deleting one here removes it from that flag."
760
- : undefined;
761
- // The right-pane `State:` line is informational — it describes what the alias
762
- // actually runs. For --channels that's the FINAL channel list: the union of
763
- // the user's own channels and the derived (dev-loaded) ones. We show the
764
- // union count so it agrees with the list row's count and the Preview, and
765
- // annotate how many are derived so the source is clear. Computed only when
766
- // editing --channels; other flags fall back to their own summary.
767
- const stateOverride =
768
- inDetail && detailFlag.id === CHANNELS_FLAG_ID && config
769
- ? channelsStateSummary(config.flags)
770
- : undefined;
771
- const detailDetailPanel = inDetail
772
- ? renderFlagDetailRightPane(
773
- detailFlag,
774
- detailValue,
775
- previewAliasName,
776
- previewSegments,
777
- derivedNote,
778
- stateOverride,
779
- )
780
- : null;
781
- const detailPanel = inDetail ? detailDetailPanel : mainDetailPanel;
782
-
783
- const enabledCount = config
784
- ? Object.values(config.flags).filter(isFlagEnabled).length
785
- : 0;
786
- const subtitle = inDetail
787
- ? `editing ${detailFlag.flag}`
788
- : writeStatus
789
- ? writeStatus
790
- : config
791
- ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled`
792
- : "";
793
-
794
- // Resolve the rc-file name we'd actually write to (basename of the chosen
795
- // shell's path). Falls back to "shell" if no shells were detected — UI
796
- // surface mirrors the runtime behavior of `handleWrite`.
797
- const writeTarget =
798
- shells.find((s) => s.isDefault && s.exists) ??
799
- shells.find((s) => s.exists);
800
- const writeLabel = writeTarget
801
- ? writeTarget.path.split("/").pop() || "shell"
802
- : "shell";
803
-
804
- // Dirty when current in-memory flags differ from the rc-file snapshot.
805
- // Initial mount sets rcSnapshot from the parsed rc, so this is false
806
- // until the user edits.
807
- const dirty = config
808
- ? rcSnapshot !== null && !shallowFlagsEqual(config.flags, rcSnapshot)
809
- : false;
810
-
811
- const footerHints: FooterHint[] = inDetail
812
- ? [
813
- { keys: ["↑", "↓"], label: "move" },
814
- { keys: ["space"], label: "toggle" },
815
- { keys: ["a"], label: "add" },
816
- { keys: ["d"], label: "delete" },
817
- { keys: ["h"], label: "back" },
818
- ]
819
- : [
820
- { keys: ["", "↓"], label: "move" },
821
- { keys: ["space"], label: "toggle" },
822
- { keys: ["e"], label: "edit" },
823
- { keys: ["r"], label: "reset" },
824
- {
825
- keys: ["w"],
826
- label: `${dirty ? "*" : ""}write to ${writeLabel}`,
827
- },
828
- ];
829
-
830
- return (
831
- <ScreenLayout
832
- title="Claude Alias"
833
- subtitle={subtitle}
834
- currentScreen="alias"
835
- footerHints={footerHints}
836
- listPanel={listPanel}
837
- detailPanel={detailPanel}
838
- />
839
- );
115
+ const { state } = useApp();
116
+ const modal = useModal();
117
+
118
+ const [config, setConfig] = useState<AliasConfig | null>(null);
119
+ const [shells, setShells] = useState<ShellTarget[]>([]);
120
+ const [selectedIdx, setSelectedIdx] = useState(0);
121
+ const [error, setError] = useState<string | null>(null);
122
+ const [viewMode, setViewMode] = useState<ViewMode>({ kind: "main" });
123
+ // Snapshot of the rc file's flag values as parsed on mount / write. Used
124
+ // to compute the dirty indicator: in-memory != rcSnapshot ⇒ unsaved.
125
+ const [rcSnapshot, setRcSnapshot] = useState<Record<
126
+ string,
127
+ FlagValue
128
+ > | null>(null);
129
+ // Transient write feedback shown on the header status line (not a blocking
130
+ // modal). Auto-clears after a few seconds. Errors still use a modal since
131
+ // they need to be read and acknowledged.
132
+ const [writeStatus, setWriteStatus] = useState<string | null>(null);
133
+
134
+ // A hand-written `claude` alias found OUTSIDE any managed block on mount.
135
+ // Set when the rc file has no managed block but a stray alias we could
136
+ // adopt drives the one-time "import it?" offer below. Cleared once the
137
+ // offer has been shown (whether accepted or declined).
138
+ const [adoptable, setAdoptable] = useState<AdoptableAlias | null>(null);
139
+ // When the user accepts a LOSSLESS adoption, we remember the line range of
140
+ // their original alias so the next write replaces it in place (no duplicate
141
+ // alias). Cleared after that write absorbs it into the managed block.
142
+ const [pendingAdoptRange, setPendingAdoptRange] = useState<
143
+ [number, number] | null
144
+ >(null);
145
+
146
+ // Clear the write status line a few seconds after it appears.
147
+ useEffect(() => {
148
+ if (!writeStatus) return;
149
+ const t = setTimeout(() => setWriteStatus(null), 4000);
150
+ return () => clearTimeout(t);
151
+ }, [writeStatus]);
152
+
153
+ // One-time "adopt a hand-written alias?" offer. Fires when the mount load
154
+ // found a stray `claude` alias outside any managed block. We import only the
155
+ // flags we recognized; whether we also TAKE OWNERSHIP (remove the original
156
+ // line on the next write) depends on `lossless`:
157
+ // - lossless: every token round-trips safe to absorb. We stash the line
158
+ // range so the next write replaces it in place (no duplicate alias).
159
+ // - not lossless: we couldn't parse some tokens (e.g. an uncatalogued
160
+ // flag like --model) import the known flags for convenience but LEAVE
161
+ // the original line untouched. CRITICAL: we must NOT adopt its name here.
162
+ // Shell alias resolution is last-definition-wins, so a managed block
163
+ // sharing the name would shadow the surviving original at `source` time —
164
+ // silently killing the very tokens that made it not-lossless. Keeping the
165
+ // tool's own name lets both coexist: the original keeps working, the
166
+ // managed block lives under a different name.
167
+ // True once we've shown the offer. A ref (not state) so it survives re-render
168
+ // without being a dependency critical because this effect mutates its own
169
+ // `adoptable` dep, and tying "fire once" to state/deps would let the dep
170
+ // change abort the in-flight modal mid-await (the cleanup would fire before
171
+ // the user answered). The ref decouples one-shot from the dependency array.
172
+ const didOfferAdoptRef = useRef(false);
173
+ useEffect(() => {
174
+ if (!adoptable || !config || didOfferAdoptRef.current) return;
175
+ didOfferAdoptRef.current = true;
176
+ (async () => {
177
+ const a = adoptable;
178
+
179
+ const body = a.rawLine.trim();
180
+ const others =
181
+ a.others.length > 0
182
+ ? `\n\n(Also found ${a.others.length} other claude alias${
183
+ a.others.length === 1 ? "" : "es"
184
+ } — leaving those alone.)`
185
+ : "";
186
+
187
+ // On a not-lossless adopt the managed block must NOT share the original's
188
+ // name same name + last-wins shell resolution = the block shadows the
189
+ // surviving original, killing its uncatalogued tokens. Compute a managed
190
+ // name distinct from `a.name`: keep the tool's current name if it already
191
+ // differs, else derive a suffixed variant that's free in the rc file.
192
+ const managedName = a.lossless
193
+ ? a.name
194
+ : pickDistinctName(config.aliasName, a.name, a.others);
195
+
196
+ const message = a.lossless
197
+ ? `Found a hand-written alias:\n\n ${body}\n\nImport its flags into the editor? On the next write it'll be absorbed into a managed block (your original line is replaced — no duplicate).${others}`
198
+ : `Found a hand-written alias:\n\n ${body}\n\nI can import the flags I recognize, but it uses ${
199
+ a.leftovers.length
200
+ } token${a.leftovers.length === 1 ? "" : "s"} I don't manage (${a.leftovers
201
+ .map((t) => JSON.stringify(t))
202
+ .join(
203
+ ", ",
204
+ )}). I'll import the known flags and LEAVE your original "${a.name}" line untouched so it keeps working. The managed alias will use the name "${managedName}" — distinct from "${a.name}" so it can't override it. Import the known flags?${others}`;
205
+
206
+ const accepted = await modal.confirm("Adopt existing alias?", message);
207
+ if (accepted) {
208
+ // Lossless: adopt the name AND take ownership (replace the line on
209
+ // write). Not-lossless: import flags under a distinct managed name and
210
+ // never touch the original line — so it survives AND keeps resolving.
211
+ setConfig((prev) =>
212
+ prev
213
+ ? {
214
+ ...prev,
215
+ aliasName: managedName,
216
+ flags: { ...prev.flags, ...a.flags },
217
+ }
218
+ : prev,
219
+ );
220
+ if (a.lossless) {
221
+ // Persist the adopted name and stash the line range so the next
222
+ // write replaces the original in place (no duplicate, no shadow).
223
+ void persistAliasName(a.name);
224
+ setPendingAdoptRange(a.lineRange);
225
+ } else {
226
+ // Persist the distinct managed name so it survives a reopen.
227
+ void persistAliasName(managedName);
228
+ }
229
+ }
230
+ // Clear the candidate now that the decision is made — after the await,
231
+ // never before (clearing before would abort this very async via the
232
+ // dep-change cleanup, the bug this effect previously had).
233
+ setAdoptable(null);
234
+ })();
235
+ // persistAliasName is stable (useCallback); excluded to keep this one-shot.
236
+ // eslint-disable-next-line react-hooks/exhaustive-deps
237
+ }, [adoptable, config, modal]);
238
+
239
+ // ── Load alias name + parse rc file + detect shells on mount ─────────
240
+ // The alias name is computer-wide and persisted in ~/.claude/settings.json.
241
+ // Flag values are read directly from the shell rc file's managed block —
242
+ // the rc file IS the source of truth so what the user sees matches what
243
+ // they're actually running.
244
+ useEffect(() => {
245
+ let cancelled = false;
246
+ (async () => {
247
+ try {
248
+ const [name, s] = await Promise.all([loadAliasName(), detectShells()]);
249
+ if (cancelled) return;
250
+
251
+ // Pick the same rc file we'd write to with `w` (default shell that
252
+ // exists; else first existing). If none exists, start from defaults.
253
+ const readTarget =
254
+ s.find((sh) => sh.isDefault && sh.exists) ??
255
+ s.find((sh) => sh.exists);
256
+
257
+ const base = defaultAliasConfig();
258
+ base.aliasName = name;
259
+
260
+ let parsedFlags: Record<string, FlagValue> | null = null;
261
+ let foundAdoptable: AdoptableAlias | null = null;
262
+ if (readTarget && existsSync(readTarget.path)) {
263
+ try {
264
+ const rcText = await readFile(readTarget.path, "utf8");
265
+ const parsed = parseAliasFromRc(rcText);
266
+ if (parsed) {
267
+ parsedFlags = { ...base.flags, ...parsed.flags };
268
+ base.flags = parsedFlags;
269
+ // Honour an existing alias name from the rc if settings is at default.
270
+ // But the user's stored preference wins when set explicitly.
271
+ // (No-op here`name` already loaded above.)
272
+ } else {
273
+ // No managed block on disk. The user may still have a hand-written
274
+ // `claude` alias elsewhere — detect it so we can offer to adopt it
275
+ // instead of looking broken. Non-destructive: just a candidate.
276
+ foundAdoptable = findAdoptableAlias(rcText);
277
+ }
278
+ } catch {
279
+ // rc unreadable — fall back to defaults; not a hard error.
280
+ }
281
+ }
282
+
283
+ if (cancelled) return;
284
+ // No mirror normalization: channels.values holds OWN channels only;
285
+ // derived channels come from the dev-load flag at display/render time.
286
+ // The parser already subtracts derived values from the parsed channels
287
+ // list, so `base.flags` is correct as-is.
288
+ setConfig(base);
289
+ setShells(s);
290
+ setRcSnapshot(parsedFlags ?? { ...base.flags });
291
+ if (foundAdoptable) setAdoptable(foundAdoptable);
292
+ } catch (err) {
293
+ if (cancelled) return;
294
+ setError(err instanceof Error ? err.message : String(err));
295
+ }
296
+ })();
297
+ return () => {
298
+ cancelled = true;
299
+ };
300
+ }, []);
301
+
302
+ // ── Build the flat list of items ──────────────────────────────────────
303
+ // Group headers and the shell legend live in the same flat array as
304
+ // alias-name + flag rows so that ScrollableList can window them together.
305
+ // Navigation skips non-selectable kinds.
306
+ const items: Item[] = useMemo(() => {
307
+ if (!config) return [];
308
+ const issues = validateConfig(config);
309
+ const issueMap = new Map(issues.map((i) => [i.flagId, i]));
310
+
311
+ const out: Item[] = [
312
+ {
313
+ kind: "alias-name",
314
+ row: { kind: "alias-name", value: config.aliasName },
315
+ },
316
+ ];
317
+
318
+ for (const group of FLAG_GROUPS) {
319
+ const groupFlags = ALIAS_FLAGS.filter((f) => f.group === group.id);
320
+ if (groupFlags.length === 0) continue;
321
+ out.push({ kind: "header", label: group.label });
322
+ for (const flag of groupFlags) {
323
+ // --channels shows enabled when the rendered alias would emit it —
324
+ // i.e. own channels are on OR a dev-loaded channel is derived in. This
325
+ // keeps the checkbox honest about what gets written without mutating
326
+ // the own-channels enabled flag (which would silently re-emit own
327
+ // values the user disabled).
328
+ let effectiveEnabled: boolean | undefined;
329
+ let effectiveDetail: string | undefined;
330
+ let stateSummary: string | undefined;
331
+ if (flag.id === CHANNELS_FLAG_ID) {
332
+ const own = config.flags[flag.id];
333
+ const ownValues =
334
+ own && own.kind === "text-list" && own.enabled
335
+ ? own.values.filter((v) => v.length > 0)
336
+ : [];
337
+ const derived = derivedChannelValues(config.flags);
338
+ // Deduped union matches the writer's render-time union and the
339
+ // detail-pane summary, so the count is consistent everywhere.
340
+ const total = new Set([...ownValues, ...derived]).size;
341
+ effectiveEnabled = total > 0;
342
+ // Show the union count so the row matches what's written. When the
343
+ // count is purely derived, this reads e.g. `[x] --channels 1`.
344
+ effectiveDetail = total > 0 ? `${total}` : "";
345
+ // The detail-pane State line shows the same final channel list.
346
+ stateSummary = channelsStateSummary(config.flags);
347
+ }
348
+ out.push({
349
+ kind: "flag",
350
+ row: {
351
+ kind: "flag",
352
+ flag,
353
+ value: config.flags[flag.id] ?? defaultValueFor(flag),
354
+ issue: issueMap.get(flag.id),
355
+ effectiveEnabled,
356
+ effectiveDetail,
357
+ stateSummary,
358
+ },
359
+ });
360
+ }
361
+ }
362
+
363
+ return out;
364
+ }, [config]);
365
+
366
+ /** Indices in `items` that the cursor may land on. */
367
+ const selectableIndices = useMemo(
368
+ () => items.flatMap((it, i) => (isSelectableItem(it) ? [i] : [])),
369
+ [items],
370
+ );
371
+
372
+ // ── Flag-detail sub-screen items ──────────────────────────────────────
373
+ // Computed only when viewMode is flag-detail. Built off the flag's current
374
+ // The flag-detail sub-screen is now self-contained in <FlagDetailEditor>.
375
+ // We just resolve the flag + value pair here so the parent can render
376
+ // the editor and the right-pane preview.
377
+ const detailFlag: AliasFlag | null =
378
+ viewMode.kind === "flag-detail"
379
+ ? (ALIAS_FLAGS.find((f) => f.id === viewMode.flagId) ?? null)
380
+ : null;
381
+ const detailValue: FlagValue | null =
382
+ detailFlag && config ? (config.flags[detailFlag.id] ?? null) : null;
383
+
384
+ // ── Helper: check whether a row is gated (requires unmet) ─────────────
385
+ const isGated = useCallback(
386
+ (flag: AliasFlag): boolean => {
387
+ if (!flag.requires || !config) return false;
388
+ return !isFlagEnabled(config.flags[flag.requires]);
389
+ },
390
+ [config],
391
+ );
392
+
393
+ const selectedItem = items[selectedIdx];
394
+ const selectedRow: Row | undefined =
395
+ selectedItem &&
396
+ (selectedItem.kind === "alias-name" || selectedItem.kind === "flag")
397
+ ? selectedItem.row
398
+ : undefined;
399
+
400
+ // Clamp selection: snap to the nearest selectable index whenever items shift.
401
+ useEffect(() => {
402
+ if (selectableIndices.length === 0) return;
403
+ if (!isSelectableItem(items[selectedIdx])) {
404
+ // Find the nearest selectable index <= selectedIdx (or the first one)
405
+ const fallback =
406
+ [...selectableIndices].reverse().find((i) => i <= selectedIdx) ??
407
+ selectableIndices[0];
408
+ setSelectedIdx(fallback);
409
+ } else if (selectedIdx >= items.length) {
410
+ setSelectedIdx(selectableIndices[selectableIndices.length - 1]);
411
+ }
412
+ }, [items, selectableIndices, selectedIdx]);
413
+
414
+ // ── Mutators ──────────────────────────────────────────────────────────
415
+ // Flag mutations stay in memory. Only the alias name is persisted (to
416
+ // ~/.claude/settings.json) flag values reach disk only when the user
417
+ // explicitly presses `w` to write the shell rc file.
418
+ const persistAliasName = useCallback(
419
+ async (name: string) => {
420
+ try {
421
+ await saveAliasName(name);
422
+ } catch (err) {
423
+ await modal.message(
424
+ "Save failed",
425
+ err instanceof Error ? err.message : String(err),
426
+ "error",
427
+ );
428
+ }
429
+ },
430
+ [modal],
431
+ );
432
+
433
+ // Cascade dependencies on every mutation:
434
+ // - Enabling a flag enable its `requires` chain (no modal nagging).
435
+ // - Disabling a flag → disable anything whose `requires === this flag id`,
436
+ // because the renderer would silently drop them otherwise and the UI
437
+ // would lie about what's going to be written.
438
+ const updateFlag = useCallback((id: string, next: FlagValue) => {
439
+ setConfig((prev) => {
440
+ if (!prev) return prev;
441
+ const flags = { ...prev.flags, [id]: next };
442
+ const target = ALIAS_FLAGS.find((f) => f.id === id);
443
+ if (!target) return { ...prev, flags };
444
+
445
+ const becomingEnabled = isFlagEnabled(next);
446
+
447
+ if (becomingEnabled) {
448
+ // Walk `requires` chain and force-enable each ancestor to its
449
+ // catalog-default-enabled state if it's currently off.
450
+ let cursor = target.requires;
451
+ const seen = new Set<string>();
452
+ while (cursor && !seen.has(cursor)) {
453
+ seen.add(cursor);
454
+ const dep = ALIAS_FLAGS.find((f) => f.id === cursor);
455
+ if (!dep) break;
456
+ if (!isFlagEnabled(flags[dep.id])) {
457
+ flags[dep.id] = enableDefault(dep);
458
+ }
459
+ cursor = dep.requires;
460
+ }
461
+ } else {
462
+ // Cascade-disable any flag that requires this one (one level deep is
463
+ // enough today; recurse defensively in case a chain emerges).
464
+ const queue = [id];
465
+ const seen = new Set<string>();
466
+ while (queue.length > 0) {
467
+ const cur = queue.shift()!;
468
+ if (seen.has(cur)) continue;
469
+ seen.add(cur);
470
+ for (const dependent of ALIAS_FLAGS) {
471
+ if (dependent.requires !== cur) continue;
472
+ if (!isFlagEnabled(flags[dependent.id])) continue;
473
+ flags[dependent.id] = disableValue(flags[dependent.id]);
474
+ queue.push(dependent.id);
475
+ }
476
+ }
477
+ }
478
+
479
+ return { ...prev, flags };
480
+ });
481
+ }, []);
482
+
483
+ // Remove a value from the dev-load flag — invoked when the user deletes a
484
+ // "Dev-loaded channels" row inside the --channels editor. The derived
485
+ // section is sourced from this flag, so the deletion lands here, not in
486
+ // channels.values. updateFlag keeps enabled-state and cascades consistent.
487
+ const removeDevloadChannel = useCallback((val: string) => {
488
+ setConfig((prev) =>
489
+ prev ? { ...prev, flags: withoutDerivedChannel(prev.flags, val) } : prev,
490
+ );
491
+ }, []);
492
+
493
+ const editAliasName = useCallback(async () => {
494
+ if (!config) return;
495
+ const v = await modal.input(
496
+ "Alias name",
497
+ `Shell alias name (default: "${DEFAULT_ALIAS_NAME}"). Letters, digits, _ or - only.`,
498
+ config.aliasName,
499
+ );
500
+ if (v === null) return;
501
+ const reason = validateAliasName(v);
502
+ if (reason) {
503
+ await modal.message("Invalid alias name", reason, "error");
504
+ return;
505
+ }
506
+ setConfig((prev) => (prev ? { ...prev, aliasName: v } : prev));
507
+ void persistAliasName(v);
508
+ }, [config, modal, persistAliasName]);
509
+
510
+ const editSelected = useCallback(async () => {
511
+ if (!selectedRow) return;
512
+ if (selectedRow.kind === "alias-name") {
513
+ await editAliasName();
514
+ return;
515
+ }
516
+ // No more "Requires another flag" modal — `updateFlag` auto-enables the
517
+ // `requires` chain when the user activates a gated flag.
518
+ //
519
+ // Collection editors live in a dedicated sub-screen, not a modal —
520
+ // each row gets its own keybindings (space toggle, a add, d delete)
521
+ // so there's no "what does enter do here?" overloading.
522
+ const k = selectedRow.value.kind;
523
+ if (k === "text-list" || k === "multi-with-custom") {
524
+ // FlagDetailEditor manages its own cursor — parent just flips mode.
525
+ setViewMode({ kind: "flag-detail", flagId: selectedRow.flag.id });
526
+ return;
527
+ }
528
+ const next = await editFlagValue(
529
+ selectedRow.flag,
530
+ selectedRow.value,
531
+ modal,
532
+ );
533
+ if (next) updateFlag(selectedRow.flag.id, next);
534
+ }, [selectedRow, modal, updateFlag, editAliasName]);
535
+
536
+ const toggleSelected = useCallback(async () => {
537
+ if (!selectedRow) return;
538
+ if (selectedRow.kind === "alias-name") {
539
+ // No "toggle" semantic for the name fall through to edit.
540
+ await editAliasName();
541
+ return;
542
+ }
543
+ // Only booleans and tri-states have a meaningful single-press cycle.
544
+ // Everything else (select, text, list, multi-with-custom) needs a value
545
+ // chooser; falling back to edit prevents the "I pressed space and got the
546
+ // first option without seeing the choices" footgun.
547
+ const k = selectedRow.value.kind;
548
+ if (k !== "boolean" && k !== "tri-state") {
549
+ await editSelected();
550
+ return;
551
+ }
552
+ const next = quickToggle(selectedRow.flag, selectedRow.value);
553
+ if (next) updateFlag(selectedRow.flag.id, next);
554
+ }, [selectedRow, updateFlag, editSelected, editAliasName]);
555
+
556
+ const handleWrite = useCallback(async () => {
557
+ if (!config) return;
558
+
559
+ // Pick the target shell automatically: default shell (matches $SHELL)
560
+ // if its rc file exists; otherwise the first existing rc file. Users
561
+ // who care about other shells edit them directly the alias config
562
+ // itself is stored in ~/.claude and the user can run `w` again from
563
+ // a different $SHELL session if they want to mirror it.
564
+ const target =
565
+ shells.find((s) => s.isDefault && s.exists) ??
566
+ shells.find((s) => s.exists);
567
+
568
+ if (!target) {
569
+ await modal.message(
570
+ "No shells detected",
571
+ "No supported rc files found. Create ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish first.",
572
+ "info",
573
+ );
574
+ return;
575
+ }
576
+
577
+ try {
578
+ // If we adopted a lossless hand-written alias, pass its line range so
579
+ // the FIRST write replaces that line in place (absorbing it into the
580
+ // managed block) rather than appending a second alias. Once the block
581
+ // exists, the writer ignores the range and splices by markers.
582
+ const r = await writeAliasToShell(
583
+ config,
584
+ target,
585
+ pendingAdoptRange ? { adoptLineRange: pendingAdoptRange } : {},
586
+ );
587
+ // The adopted line is now gone (replaced by the block); drop the range
588
+ // so subsequent writes don't try to re-target a line that moved.
589
+ if (pendingAdoptRange) setPendingAdoptRange(null);
590
+ // Non-blocking confirmation on the status line — no "press any key".
591
+ const name = r.path.split("/").pop() || r.path;
592
+ setWriteStatus(
593
+ `✓ ${r.action === "created" ? "Created" : "Updated"} ${name} — source it or open a new shell`,
594
+ );
595
+ setShells(await detectShells());
596
+ // We just wrote the rc file with current in-memory flag values, so
597
+ // the snapshot now matches in-memory state. Dirty indicator clears.
598
+ setRcSnapshot(config.flags);
599
+ } catch (err) {
600
+ await modal.message(
601
+ "Write failed",
602
+ err instanceof Error ? err.message : String(err),
603
+ "error",
604
+ );
605
+ }
606
+ }, [config, shells, modal, pendingAdoptRange]);
607
+
608
+ // ── Keyboard ──────────────────────────────────────────────────────────
609
+ // j/k navigate the selectable subset, jumping over headers/legend lines.
610
+ const moveSelection = useCallback(
611
+ (dir: 1 | -1) => {
612
+ if (selectableIndices.length === 0) return;
613
+ const cursorPos = selectableIndices.indexOf(selectedIdx);
614
+ const nextPos =
615
+ cursorPos === -1
616
+ ? // Currently parked on a non-selectable; move to first/last selectable.
617
+ dir === 1
618
+ ? 0
619
+ : selectableIndices.length - 1
620
+ : Math.min(
621
+ selectableIndices.length - 1,
622
+ Math.max(0, cursorPos + dir),
623
+ );
624
+ setSelectedIdx(selectableIndices[nextPos]);
625
+ },
626
+ [selectableIndices, selectedIdx],
627
+ );
628
+
629
+ // The flag-detail sub-screen handles its own cursor + key + render
630
+ // (see FlagDetailEditor component). The parent only needs to know how
631
+ // to leave it.
632
+ const exitDetail = useCallback(() => {
633
+ setViewMode({ kind: "main" });
634
+ }, []);
635
+
636
+ // ── Keyboard handler ──────────────────────────────────────────────────
637
+ // When the sub-screen is mounted, FlagDetailEditor installs its own
638
+ // keyboard handler. Both handlers fire (OpenTUI's useKeyboard delivers
639
+ // to every subscriber), so this handler must SKIP the keys the child
640
+ // owns to avoid double-dispatch. We bail entirely while in detail mode.
641
+ useKeyboardHandler((input, key) => {
642
+ if (state.modal) return;
643
+ if (viewMode.kind === "flag-detail") return;
644
+
645
+ // Main mode.
646
+ if (input === "j" || key.downArrow) {
647
+ moveSelection(1);
648
+ return;
649
+ }
650
+ if (input === "k" || key.upArrow) {
651
+ moveSelection(-1);
652
+ return;
653
+ }
654
+ if (key.return || input === " " || key.name === "space") {
655
+ void toggleSelected();
656
+ return;
657
+ }
658
+ if (input === "e") {
659
+ void editSelected();
660
+ return;
661
+ }
662
+ if (input === "w") {
663
+ void handleWrite();
664
+ return;
665
+ }
666
+ if (input === "r") {
667
+ // Reset the selected row to its default.
668
+ if (!selectedRow) return;
669
+ if (selectedRow.kind === "alias-name") {
670
+ setConfig((prev) =>
671
+ prev ? { ...prev, aliasName: DEFAULT_ALIAS_NAME } : prev,
672
+ );
673
+ void persistAliasName(DEFAULT_ALIAS_NAME);
674
+ return;
675
+ }
676
+ // Flag reset stays in memory until `w`.
677
+ updateFlag(selectedRow.flag.id, defaultValueFor(selectedRow.flag));
678
+ }
679
+ });
680
+
681
+ // ── Render ────────────────────────────────────────────────────────────
682
+ const previewSegments = config ? renderArgs(config) : [];
683
+ const previewAliasName = config?.aliasName ?? "";
684
+
685
+ // Native OpenTUI scrollboxes own the scroll math. We render every item
686
+ // into the scrollbox's content area and ask the renderable to scroll the
687
+ // selected child into view whenever the cursor moves. The previous JS-
688
+ // windowed approach (ScrollableList) overstrike-rendered rows when its
689
+ // height computation drifted off the actual panel size.
690
+ const mainScrollRef = useRef<ScrollBoxRenderable | null>(null);
691
+
692
+ useEffect(() => {
693
+ const item = items[selectedIdx];
694
+ if (!item) return;
695
+ mainScrollRef.current?.scrollChildIntoView(`alias-row-${getItemKey(item)}`);
696
+ }, [items, selectedIdx]);
697
+
698
+ const inDetail = viewMode.kind === "flag-detail" && detailFlag && detailValue;
699
+ // FlagDetailEditor only handles text-list and multi-with-custom values;
700
+ // narrow the type for the JSX below.
701
+ const editableDetailValue =
702
+ inDetail &&
703
+ (detailValue.kind === "text-list" ||
704
+ detailValue.kind === "multi-with-custom")
705
+ ? detailValue
706
+ : null;
707
+
708
+ const mainListPanel = !config ? (
709
+ <text fg={theme.colors.muted}>
710
+ {error ? `Error: ${error}` : "Loading..."}
711
+ </text>
712
+ ) : (
713
+ <box flexDirection="column" height="100%">
714
+ <scrollbox
715
+ ref={mainScrollRef}
716
+ scrollY={true}
717
+ scrollX={false}
718
+ flexGrow={1}
719
+ >
720
+ <box flexDirection="column">
721
+ {items.map((item, idx) => (
722
+ // id is content-derived (not positional `${idx}`) so OpenTUI's
723
+ // reconciler stays in lockstep with React's key-based reconcile.
724
+ <box key={getItemKey(item)} id={`alias-row-${getItemKey(item)}`}>
725
+ {renderItem(item, idx === selectedIdx, isGated)}
726
+ </box>
727
+ ))}
728
+ </box>
729
+ </scrollbox>
730
+ </box>
731
+ );
732
+
733
+ const listPanel =
734
+ inDetail && editableDetailValue ? (
735
+ <FlagDetailEditor
736
+ flag={detailFlag}
737
+ value={editableDetailValue}
738
+ onChange={(next) => updateFlag(detailFlag.id, next)}
739
+ onBack={exitDetail}
740
+ derivedValues={
741
+ detailFlag.id === CHANNELS_FLAG_ID && config
742
+ ? derivedChannelValues(config.flags)
743
+ : undefined
744
+ }
745
+ derivedLabel="From --dangerously-load:"
746
+ toggleLabel={
747
+ detailFlag.id === CHANNELS_FLAG_ID ? "Enable own channels" : undefined
748
+ }
749
+ onDerivedDelete={(v) => removeDevloadChannel(v)}
750
+ />
751
+ ) : (
752
+ mainListPanel
753
+ );
754
+
755
+ const mainDetailPanel = renderDetail(
756
+ selectedRow,
757
+ isGated,
758
+ previewAliasName,
759
+ previewSegments,
760
+ );
761
+ // When editing --channels and there are derived rows, the right pane carries
762
+ // the contextual instruction about what deleting a derived row does — that
763
+ // explanation belongs here, not crammed into the left list.
764
+ const channelsHasDerived =
765
+ inDetail &&
766
+ detailFlag.id === CHANNELS_FLAG_ID &&
767
+ config &&
768
+ derivedChannelValues(config.flags).length > 0;
769
+ const derivedNote = channelsHasDerived
770
+ ? "Rows under “From --dangerously-load:” are derived from the --dangerously-load-development-channels flag. Deleting one here removes it from that flag."
771
+ : undefined;
772
+ // The right-pane `State:` line is informational it describes what the alias
773
+ // actually runs. For --channels that's the FINAL channel list: the union of
774
+ // the user's own channels and the derived (dev-loaded) ones. We show the
775
+ // union count so it agrees with the list row's count and the Preview, and
776
+ // annotate how many are derived so the source is clear. Computed only when
777
+ // editing --channels; other flags fall back to their own summary.
778
+ const stateOverride =
779
+ inDetail && detailFlag.id === CHANNELS_FLAG_ID && config
780
+ ? channelsStateSummary(config.flags)
781
+ : undefined;
782
+ const detailDetailPanel = inDetail
783
+ ? renderFlagDetailRightPane(
784
+ detailFlag,
785
+ detailValue,
786
+ previewAliasName,
787
+ previewSegments,
788
+ derivedNote,
789
+ stateOverride,
790
+ )
791
+ : null;
792
+ const detailPanel = inDetail ? detailDetailPanel : mainDetailPanel;
793
+
794
+ const enabledCount = config
795
+ ? Object.values(config.flags).filter(isFlagEnabled).length
796
+ : 0;
797
+ const subtitle = inDetail
798
+ ? `editing ${detailFlag.flag}`
799
+ : writeStatus
800
+ ? writeStatus
801
+ : config
802
+ ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled`
803
+ : "";
804
+
805
+ // Resolve the rc-file name we'd actually write to (basename of the chosen
806
+ // shell's path). Falls back to "shell" if no shells were detected — UI
807
+ // surface mirrors the runtime behavior of `handleWrite`.
808
+ const writeTarget =
809
+ shells.find((s) => s.isDefault && s.exists) ?? shells.find((s) => s.exists);
810
+ const writeLabel = writeTarget
811
+ ? writeTarget.path.split("/").pop() || "shell"
812
+ : "shell";
813
+
814
+ // Dirty when current in-memory flags differ from the rc-file snapshot.
815
+ // Initial mount sets rcSnapshot from the parsed rc, so this is false
816
+ // until the user edits.
817
+ const dirty = config
818
+ ? rcSnapshot !== null && !shallowFlagsEqual(config.flags, rcSnapshot)
819
+ : false;
820
+
821
+ const footerHints: FooterHint[] = inDetail
822
+ ? [
823
+ { keys: ["", "↓"], label: "move" },
824
+ { keys: ["space"], label: "toggle" },
825
+ { keys: ["a"], label: "add" },
826
+ { keys: ["d"], label: "delete" },
827
+ { keys: ["h"], label: "back" },
828
+ ]
829
+ : [
830
+ { keys: ["", "↓"], label: "move" },
831
+ { keys: ["space"], label: "toggle" },
832
+ { keys: ["e"], label: "edit" },
833
+ { keys: ["r"], label: "reset" },
834
+ {
835
+ keys: ["w"],
836
+ label: `${dirty ? "*" : ""}write to ${writeLabel}`,
837
+ },
838
+ ];
839
+
840
+ return (
841
+ <ScreenLayout
842
+ title="Claude Alias"
843
+ subtitle={subtitle}
844
+ currentScreen="alias"
845
+ footerHints={footerHints}
846
+ listPanel={listPanel}
847
+ detailPanel={detailPanel}
848
+ />
849
+ );
840
850
  }
841
851
 
842
852
  // ─── Helpers ────────────────────────────────────────────────────────────
@@ -853,24 +863,24 @@ export function AliasScreen() {
853
863
  * - "2 channels (1 from --dangerously-load)" (mixed)
854
864
  */
855
865
  function channelsStateSummary(flags: Record<string, FlagValue>): string {
856
- const ownValue = flags[CHANNELS_FLAG_ID];
857
- const ownValues =
858
- ownValue && ownValue.kind === "text-list" && ownValue.enabled
859
- ? ownValue.values.filter((v) => v.length > 0)
860
- : [];
861
- const derived = derivedChannelValues(flags);
862
- // Union, deduped, matching the writer's render-time union.
863
- const total = new Set([...ownValues, ...derived]).size;
864
- if (total === 0) return "off";
865
- const noun = total === 1 ? "channel" : "channels";
866
- if (derived.length === 0) return `${total} ${noun}`;
867
- // How many of the union come from the derived flag (after dedup with own).
868
- const derivedInUnion = new Set(derived).size;
869
- const derivedNote =
870
- derivedInUnion === total
871
- ? "from --dangerously-load"
872
- : `${derivedInUnion} from --dangerously-load`;
873
- return `${total} ${noun} (${derivedNote})`;
866
+ const ownValue = flags[CHANNELS_FLAG_ID];
867
+ const ownValues =
868
+ ownValue && ownValue.kind === "text-list" && ownValue.enabled
869
+ ? ownValue.values.filter((v) => v.length > 0)
870
+ : [];
871
+ const derived = derivedChannelValues(flags);
872
+ // Union, deduped, matching the writer's render-time union.
873
+ const total = new Set([...ownValues, ...derived]).size;
874
+ if (total === 0) return "off";
875
+ const noun = total === 1 ? "channel" : "channels";
876
+ if (derived.length === 0) return `${total} ${noun}`;
877
+ // How many of the union come from the derived flag (after dedup with own).
878
+ const derivedInUnion = new Set(derived).size;
879
+ const derivedNote =
880
+ derivedInUnion === total
881
+ ? "from --dangerously-load"
882
+ : `${derivedInUnion} from --dangerously-load`;
883
+ return `${total} ${noun} (${derivedNote})`;
874
884
  }
875
885
 
876
886
  /**
@@ -880,35 +890,35 @@ function channelsStateSummary(flags: Record<string, FlagValue>): string {
880
890
  * but the maps have ~14 entries — diff cost is negligible.
881
891
  */
882
892
  function shallowFlagsEqual(
883
- a: Record<string, FlagValue>,
884
- b: Record<string, FlagValue>,
893
+ a: Record<string, FlagValue>,
894
+ b: Record<string, FlagValue>,
885
895
  ): boolean {
886
- const aKeys = Object.keys(a).sort();
887
- const bKeys = Object.keys(b).sort();
888
- if (aKeys.length !== bKeys.length) return false;
889
- for (let i = 0; i < aKeys.length; i++) {
890
- if (aKeys[i] !== bKeys[i]) return false;
891
- }
892
- for (const k of aKeys) {
893
- if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
894
- }
895
- return true;
896
+ const aKeys = Object.keys(a).sort();
897
+ const bKeys = Object.keys(b).sort();
898
+ if (aKeys.length !== bKeys.length) return false;
899
+ for (let i = 0; i < aKeys.length; i++) {
900
+ if (aKeys[i] !== bKeys[i]) return false;
901
+ }
902
+ for (const k of aKeys) {
903
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
904
+ }
905
+ return true;
896
906
  }
897
907
 
898
908
  function isFlagEnabled(value: FlagValue | undefined): boolean {
899
- if (!value) return false;
900
- switch (value.kind) {
901
- case "boolean":
902
- return value.enabled;
903
- case "tri-state":
904
- return value.state !== "unset";
905
- case "select":
906
- case "text":
907
- case "optional-text":
908
- case "text-list":
909
- case "multi-with-custom":
910
- return value.enabled;
911
- }
909
+ if (!value) return false;
910
+ switch (value.kind) {
911
+ case "boolean":
912
+ return value.enabled;
913
+ case "tri-state":
914
+ return value.state !== "unset";
915
+ case "select":
916
+ case "text":
917
+ case "optional-text":
918
+ case "text-list":
919
+ case "multi-with-custom":
920
+ return value.enabled;
921
+ }
912
922
  }
913
923
 
914
924
  /**
@@ -925,31 +935,35 @@ function isFlagEnabled(value: FlagValue | undefined): boolean {
925
935
  * claude aliases. Falls back to a timestamp-free deterministic suffix.
926
936
  */
927
937
  function pickDistinctName(
928
- current: string,
929
- original: string,
930
- others: string[],
938
+ current: string,
939
+ original: string,
940
+ others: string[],
931
941
  ): string {
932
- // Names already taken by aliases we're leaving in place.
933
- const taken = new Set<string>([original]);
934
- for (const line of others) {
935
- const m = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=/.exec(line.trim());
936
- if (m) taken.add(m[1]);
937
- }
938
-
939
- // If our current name is already distinct + valid, just use it.
940
- if (current !== original && !taken.has(current) && !validateAliasName(current)) {
941
- return current;
942
- }
943
-
944
- // Derive a suffixed variant from the original.
945
- const base = `${original}m`;
946
- if (!taken.has(base) && !validateAliasName(base)) return base;
947
- for (let n = 2; n < 100; n++) {
948
- const cand = `${original}m${n}`;
949
- if (!taken.has(cand) && !validateAliasName(cand)) return cand;
950
- }
951
- // Pathological fallback should never be reached for sane inputs.
952
- return `${original}managed`;
942
+ // Names already taken by aliases we're leaving in place.
943
+ const taken = new Set<string>([original]);
944
+ for (const line of others) {
945
+ const m = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=/.exec(line.trim());
946
+ if (m) taken.add(m[1]);
947
+ }
948
+
949
+ // If our current name is already distinct + valid, just use it.
950
+ if (
951
+ current !== original &&
952
+ !taken.has(current) &&
953
+ !validateAliasName(current)
954
+ ) {
955
+ return current;
956
+ }
957
+
958
+ // Derive a suffixed variant from the original.
959
+ const base = `${original}m`;
960
+ if (!taken.has(base) && !validateAliasName(base)) return base;
961
+ for (let n = 2; n < 100; n++) {
962
+ const cand = `${original}m${n}`;
963
+ if (!taken.has(cand) && !validateAliasName(cand)) return cand;
964
+ }
965
+ // Pathological fallback — should never be reached for sane inputs.
966
+ return `${original}managed`;
953
967
  }
954
968
 
955
969
  /**
@@ -960,35 +974,35 @@ function pickDistinctName(
960
974
  * free to refine it after.
961
975
  */
962
976
  function enableDefault(flag: AliasFlag): FlagValue {
963
- switch (flag.kind) {
964
- case "boolean":
965
- return { kind: "boolean", enabled: true };
966
- case "tri-state":
967
- return { kind: "tri-state", state: "on" };
968
- case "select":
969
- return {
970
- kind: "select",
971
- enabled: true,
972
- value: flag.options?.[0]?.value ?? "",
973
- };
974
- case "text":
975
- return { kind: "text", enabled: true, value: "" };
976
- case "optional-text":
977
- return { kind: "optional-text", enabled: true, value: "" };
978
- case "text-list":
979
- return {
980
- kind: "text-list",
981
- enabled: true,
982
- values: [...(flag.defaultValues ?? [])],
983
- };
984
- case "multi-with-custom":
985
- return {
986
- kind: "multi-with-custom",
987
- enabled: true,
988
- picked: [],
989
- custom: [...(flag.defaultValues ?? [])],
990
- };
991
- }
977
+ switch (flag.kind) {
978
+ case "boolean":
979
+ return { kind: "boolean", enabled: true };
980
+ case "tri-state":
981
+ return { kind: "tri-state", state: "on" };
982
+ case "select":
983
+ return {
984
+ kind: "select",
985
+ enabled: true,
986
+ value: flag.options?.[0]?.value ?? "",
987
+ };
988
+ case "text":
989
+ return { kind: "text", enabled: true, value: "" };
990
+ case "optional-text":
991
+ return { kind: "optional-text", enabled: true, value: "" };
992
+ case "text-list":
993
+ return {
994
+ kind: "text-list",
995
+ enabled: true,
996
+ values: [...(flag.defaultValues ?? [])],
997
+ };
998
+ case "multi-with-custom":
999
+ return {
1000
+ kind: "multi-with-custom",
1001
+ enabled: true,
1002
+ picked: [],
1003
+ custom: [...(flag.defaultValues ?? [])],
1004
+ };
1005
+ }
992
1006
  }
993
1007
 
994
1008
  /**
@@ -998,47 +1012,47 @@ function enableDefault(flag: AliasFlag): FlagValue {
998
1012
  * the value reappears.
999
1013
  */
1000
1014
  function disableValue(value: FlagValue): FlagValue {
1001
- switch (value.kind) {
1002
- case "boolean":
1003
- return { kind: "boolean", enabled: false };
1004
- case "tri-state":
1005
- return { kind: "tri-state", state: "unset" };
1006
- case "select":
1007
- case "text":
1008
- case "optional-text":
1009
- case "text-list":
1010
- case "multi-with-custom":
1011
- return { ...value, enabled: false };
1012
- }
1015
+ switch (value.kind) {
1016
+ case "boolean":
1017
+ return { kind: "boolean", enabled: false };
1018
+ case "tri-state":
1019
+ return { kind: "tri-state", state: "unset" };
1020
+ case "select":
1021
+ case "text":
1022
+ case "optional-text":
1023
+ case "text-list":
1024
+ case "multi-with-custom":
1025
+ return { ...value, enabled: false };
1026
+ }
1013
1027
  }
1014
1028
 
1015
1029
  function summarizeValue(value: FlagValue): string {
1016
- switch (value.kind) {
1017
- case "boolean":
1018
- return value.enabled ? "on" : "off";
1019
- case "tri-state":
1020
- return value.state;
1021
- case "select":
1022
- return value.enabled ? value.value || "(bare)" : "off";
1023
- case "text":
1024
- case "optional-text":
1025
- return value.enabled
1026
- ? value.value
1027
- ? truncate(value.value, 32)
1028
- : "(empty)"
1029
- : "off";
1030
- case "text-list":
1031
- return value.enabled ? `${value.values.length} item(s)` : "off";
1032
- case "multi-with-custom": {
1033
- if (!value.enabled) return "off";
1034
- const total = new Set([...value.picked, ...value.custom]).size;
1035
- return total === 0 ? "(no filter)" : `${total} token(s)`;
1036
- }
1037
- }
1030
+ switch (value.kind) {
1031
+ case "boolean":
1032
+ return value.enabled ? "on" : "off";
1033
+ case "tri-state":
1034
+ return value.state;
1035
+ case "select":
1036
+ return value.enabled ? value.value || "(bare)" : "off";
1037
+ case "text":
1038
+ case "optional-text":
1039
+ return value.enabled
1040
+ ? value.value
1041
+ ? truncate(value.value, 32)
1042
+ : "(empty)"
1043
+ : "off";
1044
+ case "text-list":
1045
+ return value.enabled ? `${value.values.length} item(s)` : "off";
1046
+ case "multi-with-custom": {
1047
+ if (!value.enabled) return "off";
1048
+ const total = new Set([...value.picked, ...value.custom]).size;
1049
+ return total === 0 ? "(no filter)" : `${total} token(s)`;
1050
+ }
1051
+ }
1038
1052
  }
1039
1053
 
1040
1054
  function truncate(s: string, n: number): string {
1041
- return s.length <= n ? s : s.slice(0, n - 1) + "…";
1055
+ return s.length <= n ? s : s.slice(0, n - 1) + "…";
1042
1056
  }
1043
1057
 
1044
1058
  /**
@@ -1047,149 +1061,151 @@ function truncate(s: string, n: number): string {
1047
1061
  * user must press `e` to set the value.
1048
1062
  */
1049
1063
  function quickToggle(_flag: AliasFlag, value: FlagValue): FlagValue | null {
1050
- switch (value.kind) {
1051
- case "boolean":
1052
- return { kind: "boolean", enabled: !value.enabled };
1053
- case "tri-state": {
1054
- const next: "unset" | "on" | "off" =
1055
- value.state === "unset" ? "on" : value.state === "on" ? "off" : "unset";
1056
- return { kind: "tri-state", state: next };
1057
- }
1058
- case "select":
1059
- return { ...value, enabled: !value.enabled };
1060
- case "text":
1061
- case "optional-text":
1062
- return { ...value, enabled: !value.enabled };
1063
- case "text-list":
1064
- return { ...value, enabled: !value.enabled };
1065
- case "multi-with-custom":
1066
- return { ...value, enabled: !value.enabled };
1067
- }
1064
+ switch (value.kind) {
1065
+ case "boolean":
1066
+ return { kind: "boolean", enabled: !value.enabled };
1067
+ case "tri-state": {
1068
+ const next: "unset" | "on" | "off" =
1069
+ value.state === "unset" ? "on" : value.state === "on" ? "off" : "unset";
1070
+ return { kind: "tri-state", state: next };
1071
+ }
1072
+ case "select":
1073
+ return { ...value, enabled: !value.enabled };
1074
+ case "text":
1075
+ case "optional-text":
1076
+ return { ...value, enabled: !value.enabled };
1077
+ case "text-list":
1078
+ return { ...value, enabled: !value.enabled };
1079
+ case "multi-with-custom":
1080
+ return { ...value, enabled: !value.enabled };
1081
+ }
1068
1082
  }
1069
1083
 
1070
1084
  /**
1071
1085
  * `e` — edit the value. Modal flow varies by flag kind.
1072
1086
  */
1073
1087
  async function editFlagValue(
1074
- flag: AliasFlag,
1075
- value: FlagValue,
1076
- modal: ReturnType<typeof useModal>,
1088
+ flag: AliasFlag,
1089
+ value: FlagValue,
1090
+ modal: ReturnType<typeof useModal>,
1077
1091
  ): Promise<FlagValue | null> {
1078
- switch (value.kind) {
1079
- case "boolean":
1080
- return { kind: "boolean", enabled: !value.enabled };
1081
-
1082
- case "tri-state": {
1083
- const choice = await modal.select(
1084
- flag.label,
1085
- flag.description,
1086
- [
1087
- { label: "Unset (no opinion)", value: "unset" },
1088
- { label: `On (${flag.flag})`, value: "on" },
1089
- ...(flag.triStateOff
1090
- ? [{ label: `Off (${flag.triStateOff})`, value: "off" }]
1091
- : []),
1092
- ],
1093
- ["unset", "on", "off"].indexOf(value.state),
1094
- );
1095
- if (!choice) return null;
1096
- return { kind: "tri-state", state: choice as "unset" | "on" | "off" };
1097
- }
1098
-
1099
- case "select": {
1100
- const opts = flag.options ?? [];
1101
- const choice = await modal.select(
1102
- flag.label,
1103
- flag.description,
1104
- [
1105
- { label: "(disable)", value: "__off__" },
1106
- ...opts.map((o) => ({ label: o.label, value: `v:${o.value}` })),
1107
- ],
1108
- value.enabled
1109
- ? Math.max(0, opts.findIndex((o) => o.value === value.value)) + 1
1110
- : 0,
1111
- );
1112
- if (choice === null) return null;
1113
- if (choice === "__off__") return { ...value, enabled: false };
1114
- return {
1115
- kind: "select",
1116
- enabled: true,
1117
- value: choice.replace(/^v:/, ""),
1118
- };
1119
- }
1120
-
1121
- case "text": {
1122
- // Lead with a 2-option chooser so the user can disable without going
1123
- // through `r` reset. Selecting "Set value" jumps to the input modal.
1124
- const action = await modal.select(
1125
- flag.label,
1126
- flag.description,
1127
- value.enabled
1128
- ? [
1129
- { label: `Edit value (current: "${truncate(value.value, 32)}")`, value: "__edit__" },
1130
- { label: "Disable flag", value: "__off__" },
1131
- ]
1132
- : [
1133
- { label: "Enable with value...", value: "__edit__" },
1134
- { label: "Keep disabled", value: "__off__" },
1135
- ],
1136
- 0,
1137
- );
1138
- if (action === null) return null;
1139
- if (action === "__off__") {
1140
- return { kind: "text", enabled: false, value: value.value };
1141
- }
1142
- const v = await modal.input(flag.label, flag.description, value.value);
1143
- if (v === null) return null;
1144
- return { kind: "text", enabled: v.length > 0, value: v };
1145
- }
1146
-
1147
- case "optional-text": {
1148
- // Three-option lead: edit value, bare (no value), or disable.
1149
- const action = await modal.select(
1150
- flag.label,
1151
- flag.description,
1152
- [
1153
- {
1154
- label: value.enabled && value.value
1155
- ? `Edit value (current: "${truncate(value.value, 32)}")`
1156
- : "Set value...",
1157
- value: "__edit__",
1158
- },
1159
- { label: "Bare flag (no value)", value: "__bare__" },
1160
- { label: "Disable flag", value: "__off__" },
1161
- ],
1162
- value.enabled ? (value.value ? 0 : 1) : 2,
1163
- );
1164
- if (action === null) return null;
1165
- if (action === "__off__") {
1166
- return { kind: "optional-text", enabled: false, value: value.value };
1167
- }
1168
- if (action === "__bare__") {
1169
- return { kind: "optional-text", enabled: true, value: "" };
1170
- }
1171
- const v = await modal.input(
1172
- flag.label,
1173
- flag.description,
1174
- value.value,
1175
- );
1176
- if (v === null) return null;
1177
- return { kind: "optional-text", enabled: true, value: v };
1178
- }
1179
-
1180
- case "text-list":
1181
- case "multi-with-custom":
1182
- // These kinds are routed to the flag-detail sub-screen by
1183
- // `editSelected`, never to this modal-based editor. If you see this
1184
- // throw, the caller bypassed the routing in editSelected — fix that
1185
- // instead of re-introducing the modal cascade.
1186
- throw new Error(
1187
- `${flag.kind} flags use the flag-detail sub-screen, not the modal editor`,
1188
- );
1189
- }
1092
+ switch (value.kind) {
1093
+ case "boolean":
1094
+ return { kind: "boolean", enabled: !value.enabled };
1095
+
1096
+ case "tri-state": {
1097
+ const choice = await modal.select(
1098
+ flag.label,
1099
+ flag.description,
1100
+ [
1101
+ { label: "Unset (no opinion)", value: "unset" },
1102
+ { label: `On (${flag.flag})`, value: "on" },
1103
+ ...(flag.triStateOff
1104
+ ? [{ label: `Off (${flag.triStateOff})`, value: "off" }]
1105
+ : []),
1106
+ ],
1107
+ ["unset", "on", "off"].indexOf(value.state),
1108
+ );
1109
+ if (!choice) return null;
1110
+ return { kind: "tri-state", state: choice as "unset" | "on" | "off" };
1111
+ }
1112
+
1113
+ case "select": {
1114
+ const opts = flag.options ?? [];
1115
+ const choice = await modal.select(
1116
+ flag.label,
1117
+ flag.description,
1118
+ [
1119
+ { label: "(disable)", value: "__off__" },
1120
+ ...opts.map((o) => ({ label: o.label, value: `v:${o.value}` })),
1121
+ ],
1122
+ value.enabled
1123
+ ? Math.max(
1124
+ 0,
1125
+ opts.findIndex((o) => o.value === value.value),
1126
+ ) + 1
1127
+ : 0,
1128
+ );
1129
+ if (choice === null) return null;
1130
+ if (choice === "__off__") return { ...value, enabled: false };
1131
+ return {
1132
+ kind: "select",
1133
+ enabled: true,
1134
+ value: choice.replace(/^v:/, ""),
1135
+ };
1136
+ }
1137
+
1138
+ case "text": {
1139
+ // Lead with a 2-option chooser so the user can disable without going
1140
+ // through `r` reset. Selecting "Set value" jumps to the input modal.
1141
+ const action = await modal.select(
1142
+ flag.label,
1143
+ flag.description,
1144
+ value.enabled
1145
+ ? [
1146
+ {
1147
+ label: `Edit value (current: "${truncate(value.value, 32)}")`,
1148
+ value: "__edit__",
1149
+ },
1150
+ { label: "Disable flag", value: "__off__" },
1151
+ ]
1152
+ : [
1153
+ { label: "Enable with value...", value: "__edit__" },
1154
+ { label: "Keep disabled", value: "__off__" },
1155
+ ],
1156
+ 0,
1157
+ );
1158
+ if (action === null) return null;
1159
+ if (action === "__off__") {
1160
+ return { kind: "text", enabled: false, value: value.value };
1161
+ }
1162
+ const v = await modal.input(flag.label, flag.description, value.value);
1163
+ if (v === null) return null;
1164
+ return { kind: "text", enabled: v.length > 0, value: v };
1165
+ }
1166
+
1167
+ case "optional-text": {
1168
+ // Three-option lead: edit value, bare (no value), or disable.
1169
+ const action = await modal.select(
1170
+ flag.label,
1171
+ flag.description,
1172
+ [
1173
+ {
1174
+ label:
1175
+ value.enabled && value.value
1176
+ ? `Edit value (current: "${truncate(value.value, 32)}")`
1177
+ : "Set value...",
1178
+ value: "__edit__",
1179
+ },
1180
+ { label: "Bare flag (no value)", value: "__bare__" },
1181
+ { label: "Disable flag", value: "__off__" },
1182
+ ],
1183
+ value.enabled ? (value.value ? 0 : 1) : 2,
1184
+ );
1185
+ if (action === null) return null;
1186
+ if (action === "__off__") {
1187
+ return { kind: "optional-text", enabled: false, value: value.value };
1188
+ }
1189
+ if (action === "__bare__") {
1190
+ return { kind: "optional-text", enabled: true, value: "" };
1191
+ }
1192
+ const v = await modal.input(flag.label, flag.description, value.value);
1193
+ if (v === null) return null;
1194
+ return { kind: "optional-text", enabled: true, value: v };
1195
+ }
1196
+
1197
+ case "text-list":
1198
+ case "multi-with-custom":
1199
+ // These kinds are routed to the flag-detail sub-screen by
1200
+ // `editSelected`, never to this modal-based editor. If you see this
1201
+ // throw, the caller bypassed the routing in editSelected fix that
1202
+ // instead of re-introducing the modal cascade.
1203
+ throw new Error(
1204
+ `${flag.kind} flags use the flag-detail sub-screen, not the modal editor`,
1205
+ );
1206
+ }
1190
1207
  }
1191
1208
 
1192
-
1193
1209
  // ─── Render helpers ─────────────────────────────────────────────────────
1194
1210
 
1195
1211
  /**
@@ -1198,84 +1214,87 @@ async function editFlagValue(
1198
1214
  * hint, so it doesn't blend in with the flag rows below.
1199
1215
  */
1200
1216
  function renderAliasNameRow(name: string, selected: boolean): React.ReactNode {
1201
- if (selected) {
1202
- return (
1203
- <box key="alias-name-row" flexDirection="row">
1204
- <text bg="#7e57c2" fg="white">
1205
- {`▶ Alias name: ${name} [enter to edit]`}
1206
- </text>
1207
- </box>
1208
- );
1209
- }
1210
- return (
1211
- <text key="alias-name-row">
1212
- <span fg="gray">{" Alias name: "}</span>
1213
- <span fg="cyan">
1214
- <strong>{name}</strong>
1215
- </span>
1216
- <span fg="gray">{" [enter to edit]"}</span>
1217
- </text>
1218
- );
1217
+ if (selected) {
1218
+ return (
1219
+ <box key="alias-name-row" flexDirection="row">
1220
+ <text bg={theme.colors.accent} fg={theme.hints.fg}>
1221
+ {`▶ Alias name: ${name} [enter to edit]`}
1222
+ </text>
1223
+ </box>
1224
+ );
1225
+ }
1226
+ return (
1227
+ <text key="alias-name-row">
1228
+ <span fg={theme.colors.muted}>{" Alias name: "}</span>
1229
+ <span fg={theme.colors.info}>
1230
+ <strong>{name}</strong>
1231
+ </span>
1232
+ <span fg={theme.colors.muted}>{" [enter to edit]"}</span>
1233
+ </text>
1234
+ );
1219
1235
  }
1220
1236
 
1221
1237
  /** Stable key per item for the windowed list. */
1222
1238
  function getItemKey(item: Item): string {
1223
- switch (item.kind) {
1224
- case "alias-name":
1225
- return "alias-name";
1226
- case "flag":
1227
- return `flag:${item.row.flag.id}`;
1228
- case "header":
1229
- return `header:${item.label}`;
1230
- }
1239
+ switch (item.kind) {
1240
+ case "alias-name":
1241
+ return "alias-name";
1242
+ case "flag":
1243
+ return `flag:${item.row.flag.id}`;
1244
+ case "header":
1245
+ return `header:${item.label}`;
1246
+ }
1231
1247
  }
1232
1248
 
1233
1249
  /** Dispatch render for one flat-list item. */
1234
1250
  function renderItem(
1235
- item: Item,
1236
- isSelected: boolean,
1237
- isGated: (f: AliasFlag) => boolean,
1251
+ item: Item,
1252
+ isSelected: boolean,
1253
+ isGated: (f: AliasFlag) => boolean,
1238
1254
  ): React.ReactNode {
1239
- switch (item.kind) {
1240
- case "alias-name":
1241
- return renderAliasNameRow(item.row.value, isSelected);
1242
- case "flag":
1243
- return renderRow(item.row, isSelected, isGated(item.row.flag));
1244
- case "header":
1245
- return <text fg="#7e57c2">{item.label}</text>;
1246
- }
1255
+ switch (item.kind) {
1256
+ case "alias-name":
1257
+ return renderAliasNameRow(item.row.value, isSelected);
1258
+ case "flag":
1259
+ return renderRow(item.row, isSelected, isGated(item.row.flag));
1260
+ case "header":
1261
+ return <text fg={theme.colors.accent}>{item.label}</text>;
1262
+ }
1247
1263
  }
1248
1264
 
1249
-
1250
1265
  function renderRow(
1251
- row: FlagRow,
1252
- selected: boolean,
1253
- gated: boolean,
1266
+ row: FlagRow,
1267
+ selected: boolean,
1268
+ gated: boolean,
1254
1269
  ): React.ReactNode {
1255
- // Prefer the precomputed effective state (e.g. --channels shows enabled when
1256
- // a dev-loaded channel forces it to emit) over the flag's own enabled flag.
1257
- const enabled = row.effectiveEnabled ?? isFlagEnabled(row.value);
1258
- const marker = enabled ? "[x]" : "[ ]";
1259
- const issueMark = row.issue ? " ⚠" : "";
1260
- const fg = gated ? "gray" : enabled ? "white" : "gray";
1261
- // Only show a value suffix when the flag carries non-redundant detail.
1262
- // Boolean / tri-state state is fully conveyed by the checkbox. A precomputed
1263
- // effectiveDetail (e.g. --channels union count) wins over the own-value one.
1264
- const detail = row.effectiveDetail ?? enabledDetail(row.value);
1265
- const detailSuffix = detail ? ` ${detail}` : "";
1266
- const line = `${selected ? "▶ " : " "}${marker} ${row.flag.flag}${detailSuffix}${issueMark}`;
1267
- if (selected) {
1268
- return (
1269
- <text key={row.flag.id} bg="#7e57c2" fg="white">
1270
- {line}
1271
- </text>
1272
- );
1273
- }
1274
- return (
1275
- <text key={row.flag.id} fg={fg}>
1276
- {line}
1277
- </text>
1278
- );
1270
+ // Prefer the precomputed effective state (e.g. --channels shows enabled when
1271
+ // a dev-loaded channel forces it to emit) over the flag's own enabled flag.
1272
+ const enabled = row.effectiveEnabled ?? isFlagEnabled(row.value);
1273
+ const marker = enabled ? "[x]" : "[ ]";
1274
+ const issueMark = row.issue ? " ⚠" : "";
1275
+ const fg = gated
1276
+ ? theme.colors.muted
1277
+ : enabled
1278
+ ? theme.colors.text
1279
+ : theme.colors.muted;
1280
+ // Only show a value suffix when the flag carries non-redundant detail.
1281
+ // Boolean / tri-state state is fully conveyed by the checkbox. A precomputed
1282
+ // effectiveDetail (e.g. --channels union count) wins over the own-value one.
1283
+ const detail = row.effectiveDetail ?? enabledDetail(row.value);
1284
+ const detailSuffix = detail ? ` ${detail}` : "";
1285
+ const line = `${selected ? "▶ " : " "}${marker} ${row.flag.flag}${detailSuffix}${issueMark}`;
1286
+ if (selected) {
1287
+ return (
1288
+ <text key={row.flag.id} bg={theme.colors.accent} fg={theme.hints.fg}>
1289
+ {line}
1290
+ </text>
1291
+ );
1292
+ }
1293
+ return (
1294
+ <text key={row.flag.id} fg={fg}>
1295
+ {line}
1296
+ </text>
1297
+ );
1279
1298
  }
1280
1299
 
1281
1300
  /**
@@ -1285,111 +1304,111 @@ function renderRow(
1285
1304
  * doesn't capture.
1286
1305
  */
1287
1306
  function enabledDetail(value: FlagValue): string {
1288
- switch (value.kind) {
1289
- case "boolean":
1290
- case "tri-state":
1291
- return "";
1292
- case "select":
1293
- if (!value.enabled) return "";
1294
- return value.value === "" ? "(bare)" : value.value;
1295
- case "text":
1296
- case "optional-text":
1297
- if (!value.enabled) return "";
1298
- return value.value ? truncate(value.value, 24) : "(empty)";
1299
- case "text-list":
1300
- if (!value.enabled) return "";
1301
- return value.values.length === 0 ? "(empty)" : `${value.values.length}`;
1302
- case "multi-with-custom": {
1303
- if (!value.enabled) return "";
1304
- const total = new Set([...value.picked, ...value.custom]).size;
1305
- return total === 0 ? "(no filter)" : `${total} token(s)`;
1306
- }
1307
- }
1307
+ switch (value.kind) {
1308
+ case "boolean":
1309
+ case "tri-state":
1310
+ return "";
1311
+ case "select":
1312
+ if (!value.enabled) return "";
1313
+ return value.value === "" ? "(bare)" : value.value;
1314
+ case "text":
1315
+ case "optional-text":
1316
+ if (!value.enabled) return "";
1317
+ return value.value ? truncate(value.value, 24) : "(empty)";
1318
+ case "text-list":
1319
+ if (!value.enabled) return "";
1320
+ return value.values.length === 0 ? "(empty)" : `${value.values.length}`;
1321
+ case "multi-with-custom": {
1322
+ if (!value.enabled) return "";
1323
+ const total = new Set([...value.picked, ...value.custom]).size;
1324
+ return total === 0 ? "(no filter)" : `${total} token(s)`;
1325
+ }
1326
+ }
1308
1327
  }
1309
1328
 
1310
1329
  function renderDetail(
1311
- row: Row | undefined,
1312
- isGated: (f: AliasFlag) => boolean,
1313
- aliasName: string,
1314
- segments: Segment[],
1330
+ row: Row | undefined,
1331
+ isGated: (f: AliasFlag) => boolean,
1332
+ aliasName: string,
1333
+ segments: Segment[],
1315
1334
  ): React.ReactNode {
1316
- if (!row) {
1317
- return <text fg="gray">No row selected.</text>;
1318
- }
1319
-
1320
- const headerAndBody =
1321
- row.kind === "alias-name"
1322
- ? renderAliasNameDetail(row)
1323
- : renderFlagDetail(row, isGated);
1324
-
1325
- // Preview is the only "global" element kept in the detail panel — it
1326
- // updates as the user toggles, which is the strongest visual feedback for
1327
- // "what alias am I building". We show argv one-per-line rather than the
1328
- // raw POSIX-escaped alias body (which contains `'\''` sequences that look
1329
- // alarming to readers who don't know the close-escape-reopen pattern).
1330
- return (
1331
- <box flexDirection="column">
1332
- {headerAndBody}
1333
- <text> </text>
1334
- <text fg="gray">Preview:</text>
1335
- {renderPreviewLines(aliasName, segments)}
1336
- </box>
1337
- );
1335
+ if (!row) {
1336
+ return <text fg={theme.colors.muted}>No row selected.</text>;
1337
+ }
1338
+
1339
+ const headerAndBody =
1340
+ row.kind === "alias-name"
1341
+ ? renderAliasNameDetail(row)
1342
+ : renderFlagDetail(row, isGated);
1343
+
1344
+ // Preview is the only "global" element kept in the detail panel — it
1345
+ // updates as the user toggles, which is the strongest visual feedback for
1346
+ // "what alias am I building". We show argv one-per-line rather than the
1347
+ // raw POSIX-escaped alias body (which contains `'\''` sequences that look
1348
+ // alarming to readers who don't know the close-escape-reopen pattern).
1349
+ return (
1350
+ <box flexDirection="column">
1351
+ {headerAndBody}
1352
+ <text fg={theme.colors.text}> </text>
1353
+ <text fg={theme.colors.muted}>Preview:</text>
1354
+ {renderPreviewLines(aliasName, segments)}
1355
+ </box>
1356
+ );
1338
1357
  }
1339
1358
 
1340
1359
  function renderAliasNameDetail(row: AliasNameRow): React.ReactNode {
1341
- return (
1342
- <box flexDirection="column">
1343
- <text fg="white">
1344
- <strong>Alias name</strong>
1345
- </text>
1346
- <text fg="gray">
1347
- Shell name for the wrapped claude command. Default: "c". Letters,
1348
- digits, _ or - only.
1349
- </text>
1350
- <text> </text>
1351
- <text>
1352
- <span fg="gray">Current: </span>
1353
- <span fg="white">
1354
- <strong>{row.value}</strong>
1355
- </span>
1356
- </text>
1357
- <text> </text>
1358
- <text fg="gray">Enter to rename. r resets to "c".</text>
1359
- </box>
1360
- );
1360
+ return (
1361
+ <box flexDirection="column">
1362
+ <text fg={theme.colors.text}>
1363
+ <strong>Alias name</strong>
1364
+ </text>
1365
+ <text fg={theme.colors.muted}>
1366
+ Shell name for the wrapped claude command. Default: "c". Letters,
1367
+ digits, _ or - only.
1368
+ </text>
1369
+ <text fg={theme.colors.text}> </text>
1370
+ <text fg={theme.colors.text}>
1371
+ <span fg={theme.colors.muted}>Current: </span>
1372
+ <span fg={theme.colors.text}>
1373
+ <strong>{row.value}</strong>
1374
+ </span>
1375
+ </text>
1376
+ <text fg={theme.colors.text}> </text>
1377
+ <text fg={theme.colors.muted}>Enter to rename. r resets to "c".</text>
1378
+ </box>
1379
+ );
1361
1380
  }
1362
1381
 
1363
1382
  function renderFlagDetail(
1364
- row: FlagRow,
1365
- isGated: (f: AliasFlag) => boolean,
1383
+ row: FlagRow,
1384
+ isGated: (f: AliasFlag) => boolean,
1366
1385
  ): React.ReactNode {
1367
- const gated = isGated(row.flag);
1368
- // State must agree with the checkbox and the Preview. For --channels we show
1369
- // the effective channel-list summary (union of own + derived); other flags
1370
- // use their own value summary.
1371
- const stateText = row.stateSummary ?? summarizeValue(row.value);
1372
- return (
1373
- <box flexDirection="column">
1374
- <text fg="white">
1375
- <strong>{row.flag.flag}</strong>
1376
- </text>
1377
- <text fg="gray">{row.flag.description}</text>
1378
- <text> </text>
1379
- <text>
1380
- <span fg="gray">State: </span>
1381
- <span fg="white">{stateText}</span>
1382
- </text>
1383
- {gated && (
1384
- <text fg="gray">
1385
- {`Depends on --${row.flag.requires}. Enabling this will turn that on too.`}
1386
- </text>
1387
- )}
1388
- {row.issue && (
1389
- <text fg="yellow">{`Conflict: ${row.issue.reason}`}</text>
1390
- )}
1391
- </box>
1392
- );
1386
+ const gated = isGated(row.flag);
1387
+ // State must agree with the checkbox and the Preview. For --channels we show
1388
+ // the effective channel-list summary (union of own + derived); other flags
1389
+ // use their own value summary.
1390
+ const stateText = row.stateSummary ?? summarizeValue(row.value);
1391
+ return (
1392
+ <box flexDirection="column">
1393
+ <text fg={theme.colors.text}>
1394
+ <strong>{row.flag.flag}</strong>
1395
+ </text>
1396
+ <text fg={theme.colors.muted}>{row.flag.description}</text>
1397
+ <text fg={theme.colors.text}> </text>
1398
+ <text fg={theme.colors.text}>
1399
+ <span fg={theme.colors.muted}>State: </span>
1400
+ <span fg={theme.colors.text}>{stateText}</span>
1401
+ </text>
1402
+ {gated && (
1403
+ <text fg={theme.colors.muted}>
1404
+ {`Depends on --${row.flag.requires}. Enabling this will turn that on too.`}
1405
+ </text>
1406
+ )}
1407
+ {row.issue && (
1408
+ <text fg={theme.colors.warning}>{`Conflict: ${row.issue.reason}`}</text>
1409
+ )}
1410
+ </box>
1411
+ );
1393
1412
  }
1394
1413
 
1395
1414
  /**
@@ -1412,66 +1431,71 @@ function renderFlagDetail(
1412
1431
  * '
1413
1432
  */
1414
1433
  function renderPreviewLines(
1415
- aliasName: string,
1416
- segments: Segment[],
1434
+ aliasName: string,
1435
+ segments: Segment[],
1417
1436
  ): React.ReactNode {
1418
- if (segments.length === 0) {
1419
- return <text fg="white">{`alias ${aliasName || "?"}='claude'`}</text>;
1420
- }
1421
- const lines: React.ReactNode[] = [
1422
- <text key="head" fg="white">{`alias ${aliasName || "?"}='claude`}</text>,
1423
- ];
1424
- segments.forEach((seg, i) => {
1425
- lines.push(
1426
- <text key={`arg-${i}`} fg="white">
1427
- {" "}
1428
- {renderPreviewSegment(seg)}
1429
- </text>,
1430
- );
1431
- });
1432
- lines.push(
1433
- <text key="tail" fg="white">
1434
- {"'"}
1435
- </text>,
1436
- );
1437
- return <>{lines}</>;
1437
+ if (segments.length === 0) {
1438
+ return (
1439
+ <text fg={theme.colors.text}>{`alias ${aliasName || "?"}='claude'`}</text>
1440
+ );
1441
+ }
1442
+ const lines: React.ReactNode[] = [
1443
+ <text
1444
+ key="head"
1445
+ fg={theme.colors.text}
1446
+ >{`alias ${aliasName || "?"}='claude`}</text>,
1447
+ ];
1448
+ segments.forEach((seg, i) => {
1449
+ lines.push(
1450
+ <text key={`arg-${i}`} fg={theme.colors.text}>
1451
+ {" "}
1452
+ {renderPreviewSegment(seg)}
1453
+ </text>,
1454
+ );
1455
+ });
1456
+ lines.push(
1457
+ <text key="tail" fg={theme.colors.text}>
1458
+ {"'"}
1459
+ </text>,
1460
+ );
1461
+ return <>{lines}</>;
1438
1462
  }
1439
1463
 
1440
1464
  function renderPreviewSegment(seg: Segment): React.ReactNode {
1441
- if (seg.kind === "literal") {
1442
- return <span fg="white">{quoteForDisplay(seg.text)}</span>;
1443
- }
1444
- // Composite: literal parts as-is, raw parts as `{token}` reconstructed
1445
- // from the writer's TOKEN_TO_SUB map. We don't have direct access to the
1446
- // map here, so we reverse-lookup using the same forms.
1447
- const spans: React.ReactNode[] = [];
1448
- seg.parts.forEach((p, i) => {
1449
- if (p.kind === "literal") {
1450
- spans.push(
1451
- <span key={`p-${i}`} fg="white">
1452
- {p.text}
1453
- </span>,
1454
- );
1455
- } else {
1456
- spans.push(
1457
- <span key={`p-${i}`} fg="cyan">
1458
- {substitutionToToken(p.posix)}
1459
- </span>,
1460
- );
1461
- }
1462
- });
1463
- return <span>{spans}</span>;
1465
+ if (seg.kind === "literal") {
1466
+ return <span fg={theme.colors.text}>{quoteForDisplay(seg.text)}</span>;
1467
+ }
1468
+ // Composite: literal parts as-is, raw parts as `{token}` reconstructed
1469
+ // from the writer's TOKEN_TO_SUB map. We don't have direct access to the
1470
+ // map here, so we reverse-lookup using the same forms.
1471
+ const spans: React.ReactNode[] = [];
1472
+ seg.parts.forEach((p, i) => {
1473
+ if (p.kind === "literal") {
1474
+ spans.push(
1475
+ <span key={`p-${i}`} fg={theme.colors.text}>
1476
+ {p.text}
1477
+ </span>,
1478
+ );
1479
+ } else {
1480
+ spans.push(
1481
+ <span key={`p-${i}`} fg={theme.colors.info}>
1482
+ {substitutionToToken(p.posix)}
1483
+ </span>,
1484
+ );
1485
+ }
1486
+ });
1487
+ return <span>{spans}</span>;
1464
1488
  }
1465
1489
 
1466
1490
  const PREVIEW_SAFE = /^[A-Za-z0-9_\-=,:.\/!@%+]+$/;
1467
1491
 
1468
1492
  function quoteForDisplay(text: string): string {
1469
- if (text === "") return '""';
1470
- if (PREVIEW_SAFE.test(text)) return text;
1471
- // Display-only quoting. We don't have to escape anything for the shell
1472
- // since this string never reaches a parser — it's just for the user to
1473
- // read. We do escape an embedded `"` to keep visual balance.
1474
- return `"${text.replace(/"/g, '\\"')}"`;
1493
+ if (text === "") return '""';
1494
+ if (PREVIEW_SAFE.test(text)) return text;
1495
+ // Display-only quoting. We don't have to escape anything for the shell
1496
+ // since this string never reaches a parser — it's just for the user to
1497
+ // read. We do escape an embedded `"` to keep visual balance.
1498
+ return `"${text.replace(/"/g, '\\"')}"`;
1475
1499
  }
1476
1500
 
1477
1501
  /**
@@ -1479,56 +1503,59 @@ function quoteForDisplay(text: string): string {
1479
1503
  * forms. Mirror of `TOKEN_TO_SUB` in `alias-shell-writer.ts`.
1480
1504
  */
1481
1505
  function substitutionToToken(posixCode: string): string {
1482
- switch (posixCode) {
1483
- case '$(basename "$PWD")':
1484
- return "{folder}";
1485
- case "$(date +%d)":
1486
- return "{day}";
1487
- case "$(date +%m)":
1488
- return "{month}";
1489
- case "$(date +%Y)":
1490
- return "{year}";
1491
- default:
1492
- return posixCode;
1493
- }
1506
+ switch (posixCode) {
1507
+ case '$(basename "$PWD")':
1508
+ return "{folder}";
1509
+ case "$(date +%d)":
1510
+ return "{day}";
1511
+ case "$(date +%m)":
1512
+ return "{month}";
1513
+ case "$(date +%Y)":
1514
+ return "{year}";
1515
+ default:
1516
+ return posixCode;
1517
+ }
1494
1518
  }
1495
1519
 
1496
-
1497
-
1498
1520
  function renderFlagDetailRightPane(
1499
- flag: AliasFlag,
1500
- value: FlagValue,
1501
- aliasName: string,
1502
- segments: Segment[],
1503
- derivedNote?: string,
1504
- stateOverride?: string,
1521
+ flag: AliasFlag,
1522
+ value: FlagValue,
1523
+ aliasName: string,
1524
+ segments: Segment[],
1525
+ derivedNote?: string,
1526
+ stateOverride?: string,
1505
1527
  ): React.ReactNode {
1506
- return (
1507
- <box flexDirection="column">
1508
- <text fg="white">
1509
- <strong>{flag.flag}</strong>
1510
- </text>
1511
- <text fg="gray">{flag.description}</text>
1512
- {derivedNote && (
1513
- <>
1514
- <text> </text>
1515
- <text fg="gray">{derivedNote}</text>
1516
- </>
1517
- )}
1518
- <text> </text>
1519
- <text>
1520
- <span fg="gray">State: </span>
1521
- <span fg="white">{stateOverride ?? summarizeValue(value)}</span>
1522
- </text>
1523
- <text> </text>
1524
- <text fg="gray">Preview:</text>
1525
- {renderPreviewLines(aliasName, segments)}
1526
- <text> </text>
1527
- <text fg="gray">Keys:</text>
1528
- <text fg="white"> space/enter toggle the highlighted row</text>
1529
- <text fg="white"> a add a new value</text>
1530
- <text fg="white"> d delete the highlighted item</text>
1531
- <text fg="white"> h back to flag list</text>
1532
- </box>
1533
- );
1528
+ return (
1529
+ <box flexDirection="column">
1530
+ <text fg={theme.colors.text}>
1531
+ <strong>{flag.flag}</strong>
1532
+ </text>
1533
+ <text fg={theme.colors.muted}>{flag.description}</text>
1534
+ {derivedNote && (
1535
+ <>
1536
+ <text fg={theme.colors.text}> </text>
1537
+ <text fg={theme.colors.muted}>{derivedNote}</text>
1538
+ </>
1539
+ )}
1540
+ <text fg={theme.colors.text}> </text>
1541
+ <text fg={theme.colors.text}>
1542
+ <span fg={theme.colors.muted}>State: </span>
1543
+ <span fg={theme.colors.text}>
1544
+ {stateOverride ?? summarizeValue(value)}
1545
+ </span>
1546
+ </text>
1547
+ <text fg={theme.colors.text}> </text>
1548
+ <text fg={theme.colors.muted}>Preview:</text>
1549
+ {renderPreviewLines(aliasName, segments)}
1550
+ <text fg={theme.colors.text}> </text>
1551
+ <text fg={theme.colors.muted}>Keys:</text>
1552
+ <text fg={theme.colors.text}>
1553
+ {" "}
1554
+ space/enter toggle the highlighted row
1555
+ </text>
1556
+ <text fg={theme.colors.text}> a add a new value</text>
1557
+ <text fg={theme.colors.text}> d delete the highlighted item</text>
1558
+ <text fg={theme.colors.text}> h back to flag list</text>
1559
+ </box>
1560
+ );
1534
1561
  }