claudeup 4.17.0 → 4.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/alias-parser.test.ts +317 -0
- package/src/__tests__/alias-shell-writer.test.ts +661 -0
- package/src/__tests__/alias-store.test.ts +86 -0
- package/src/__tests__/gitignore-fixer.test.ts +64 -1
- package/src/__tests__/gitignore-prerun.test.ts +2 -2
- package/src/__tests__/gitignore-service.test.ts +42 -0
- package/src/__tests__/marketplaces.test.ts +40 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +120 -0
- package/src/__tests__/useGitignoreModal.test.ts +2 -2
- package/src/data/alias-flags.js +196 -0
- package/src/data/alias-flags.ts +291 -0
- package/src/data/gitignore-reasons.js +97 -0
- package/src/data/gitignore-reasons.ts +103 -0
- package/src/data/marketplaces.js +5 -3
- package/src/data/marketplaces.ts +5 -4
- package/src/services/alias-settings.js +51 -0
- package/src/services/alias-settings.ts +63 -0
- package/src/services/alias-shell-writer.js +764 -0
- package/src/services/alias-shell-writer.ts +873 -0
- package/src/services/alias-store.js +77 -0
- package/src/services/alias-store.ts +112 -0
- package/src/services/gitignore-fixer.js +70 -10
- package/src/services/gitignore-fixer.ts +76 -9
- package/src/services/gitignore-prerun.js +3 -3
- package/src/services/gitignore-prerun.ts +3 -3
- package/src/services/gitignore-service.js +20 -2
- package/src/services/gitignore-service.ts +23 -1
- package/src/services/marketplace-fetcher.js +96 -0
- package/src/services/marketplace-fetcher.ts +137 -0
- package/src/services/plugin-manager.js +6 -59
- package/src/services/plugin-manager.ts +16 -91
- package/src/services/skillsmp-client.js +29 -9
- package/src/services/skillsmp-client.ts +38 -8
- package/src/types/gitignore.ts +1 -1
- package/src/ui/App.js +10 -4
- package/src/ui/App.tsx +9 -3
- package/src/ui/components/TabBar.js +2 -1
- package/src/ui/components/TabBar.tsx +2 -1
- package/src/ui/components/layout/FooterHints.js +29 -0
- package/src/ui/components/layout/FooterHints.tsx +52 -0
- package/src/ui/components/layout/ScreenLayout.js +2 -1
- package/src/ui/components/layout/ScreenLayout.tsx +12 -3
- package/src/ui/components/layout/index.js +1 -0
- package/src/ui/components/layout/index.ts +5 -0
- package/src/ui/components/modals/SelectModal.js +8 -1
- package/src/ui/components/modals/SelectModal.tsx +12 -1
- package/src/ui/hooks/useGitignoreModal.js +7 -8
- package/src/ui/hooks/useGitignoreModal.ts +8 -9
- package/src/ui/renderers/gitignoreRenderers.js +36 -23
- package/src/ui/renderers/gitignoreRenderers.tsx +50 -41
- package/src/ui/screens/AliasScreen.js +1008 -0
- package/src/ui/screens/AliasScreen.tsx +1402 -0
- package/src/ui/screens/CliToolsScreen.js +6 -1
- package/src/ui/screens/CliToolsScreen.tsx +6 -1
- package/src/ui/screens/EnvVarsScreen.js +6 -1
- package/src/ui/screens/EnvVarsScreen.tsx +6 -1
- package/src/ui/screens/GitignoreScreen.js +189 -88
- package/src/ui/screens/GitignoreScreen.tsx +312 -132
- package/src/ui/screens/McpRegistryScreen.js +13 -2
- package/src/ui/screens/McpRegistryScreen.tsx +13 -2
- package/src/ui/screens/McpScreen.js +6 -1
- package/src/ui/screens/McpScreen.tsx +6 -1
- package/src/ui/screens/ModelSelectorScreen.js +8 -2
- package/src/ui/screens/ModelSelectorScreen.tsx +8 -2
- package/src/ui/screens/PluginsScreen.js +13 -2
- package/src/ui/screens/PluginsScreen.tsx +13 -2
- package/src/ui/screens/ProfilesScreen.js +8 -1
- package/src/ui/screens/ProfilesScreen.tsx +8 -1
- package/src/ui/screens/SkillsScreen.js +21 -4
- package/src/ui/screens/SkillsScreen.tsx +39 -5
- package/src/ui/screens/StatusLineScreen.js +7 -1
- package/src/ui/screens/StatusLineScreen.tsx +7 -1
- package/src/ui/screens/index.js +1 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/types.ts +4 -2
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
3
|
+
import { useApp, useModal } from "../state/AppContext.js";
|
|
4
|
+
import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
|
|
5
|
+
import { ScreenLayout } from "../components/layout/index.js";
|
|
6
|
+
import { ALIAS_FLAGS, FLAG_GROUPS, } from "../../data/alias-flags.js";
|
|
7
|
+
import { defaultAliasConfig, defaultValueFor, validateAliasName, DEFAULT_ALIAS_NAME, } from "../../services/alias-store.js";
|
|
8
|
+
import { loadAliasName, saveAliasName } from "../../services/alias-settings.js";
|
|
9
|
+
import { detectShells, renderAlias, validateConfig, writeAliasToShell, parseAliasFromRc, } from "../../services/alias-shell-writer.js";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
function isSelectableItem(item) {
|
|
13
|
+
return item.kind === "alias-name" || item.kind === "flag";
|
|
14
|
+
}
|
|
15
|
+
function isSelectableDetailItem(item) {
|
|
16
|
+
return item.kind !== "header";
|
|
17
|
+
}
|
|
18
|
+
export function AliasScreen() {
|
|
19
|
+
const { state } = useApp();
|
|
20
|
+
const modal = useModal();
|
|
21
|
+
const [config, setConfig] = useState(null);
|
|
22
|
+
const [shells, setShells] = useState([]);
|
|
23
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
const [viewMode, setViewMode] = useState({ kind: "main" });
|
|
26
|
+
const [detailSelectedIdx, setDetailSelectedIdx] = useState(0);
|
|
27
|
+
// Snapshot of the rc file's flag values as parsed on mount / write. Used
|
|
28
|
+
// to compute the dirty indicator: in-memory != rcSnapshot ⇒ unsaved.
|
|
29
|
+
const [rcSnapshot, setRcSnapshot] = useState(null);
|
|
30
|
+
// ── Load alias name + parse rc file + detect shells on mount ─────────
|
|
31
|
+
// The alias name is computer-wide and persisted in ~/.claude/settings.json.
|
|
32
|
+
// Flag values are read directly from the shell rc file's managed block —
|
|
33
|
+
// the rc file IS the source of truth so what the user sees matches what
|
|
34
|
+
// they're actually running.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let cancelled = false;
|
|
37
|
+
(async () => {
|
|
38
|
+
try {
|
|
39
|
+
const [name, s] = await Promise.all([loadAliasName(), detectShells()]);
|
|
40
|
+
if (cancelled)
|
|
41
|
+
return;
|
|
42
|
+
// Pick the same rc file we'd write to with `w` (default shell that
|
|
43
|
+
// exists; else first existing). If none exists, start from defaults.
|
|
44
|
+
const readTarget = s.find((sh) => sh.isDefault && sh.exists) ??
|
|
45
|
+
s.find((sh) => sh.exists);
|
|
46
|
+
const base = defaultAliasConfig();
|
|
47
|
+
base.aliasName = name;
|
|
48
|
+
let parsedFlags = null;
|
|
49
|
+
if (readTarget && existsSync(readTarget.path)) {
|
|
50
|
+
try {
|
|
51
|
+
const rcText = await readFile(readTarget.path, "utf8");
|
|
52
|
+
const parsed = parseAliasFromRc(rcText);
|
|
53
|
+
if (parsed) {
|
|
54
|
+
parsedFlags = { ...base.flags, ...parsed.flags };
|
|
55
|
+
base.flags = parsedFlags;
|
|
56
|
+
// Honour an existing alias name from the rc if settings is at default.
|
|
57
|
+
// But the user's stored preference wins when set explicitly.
|
|
58
|
+
// (No-op here — `name` already loaded above.)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// rc unreadable — fall back to defaults; not a hard error.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (cancelled)
|
|
66
|
+
return;
|
|
67
|
+
setConfig(base);
|
|
68
|
+
setShells(s);
|
|
69
|
+
setRcSnapshot(parsedFlags ?? base.flags);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (cancelled)
|
|
73
|
+
return;
|
|
74
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
return () => {
|
|
78
|
+
cancelled = true;
|
|
79
|
+
};
|
|
80
|
+
}, []);
|
|
81
|
+
// ── Build the flat list of items ──────────────────────────────────────
|
|
82
|
+
// Group headers and the shell legend live in the same flat array as
|
|
83
|
+
// alias-name + flag rows so that ScrollableList can window them together.
|
|
84
|
+
// Navigation skips non-selectable kinds.
|
|
85
|
+
const items = useMemo(() => {
|
|
86
|
+
if (!config)
|
|
87
|
+
return [];
|
|
88
|
+
const issues = validateConfig(config);
|
|
89
|
+
const issueMap = new Map(issues.map((i) => [i.flagId, i]));
|
|
90
|
+
const out = [
|
|
91
|
+
{
|
|
92
|
+
kind: "alias-name",
|
|
93
|
+
row: { kind: "alias-name", value: config.aliasName },
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
for (const group of FLAG_GROUPS) {
|
|
97
|
+
const groupFlags = ALIAS_FLAGS.filter((f) => f.group === group.id);
|
|
98
|
+
if (groupFlags.length === 0)
|
|
99
|
+
continue;
|
|
100
|
+
out.push({ kind: "header", label: group.label });
|
|
101
|
+
for (const flag of groupFlags) {
|
|
102
|
+
out.push({
|
|
103
|
+
kind: "flag",
|
|
104
|
+
row: {
|
|
105
|
+
kind: "flag",
|
|
106
|
+
flag,
|
|
107
|
+
value: config.flags[flag.id] ?? defaultValueFor(flag),
|
|
108
|
+
issue: issueMap.get(flag.id),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}, [config]);
|
|
115
|
+
/** Indices in `items` that the cursor may land on. */
|
|
116
|
+
const selectableIndices = useMemo(() => items.flatMap((it, i) => (isSelectableItem(it) ? [i] : [])), [items]);
|
|
117
|
+
// ── Flag-detail sub-screen items ──────────────────────────────────────
|
|
118
|
+
// Computed only when viewMode is flag-detail. Built off the flag's current
|
|
119
|
+
// value in `config.flags[flagId]`. Kinds vary by flag.kind (text-list vs
|
|
120
|
+
// multi-with-custom).
|
|
121
|
+
const detailFlag = viewMode.kind === "flag-detail"
|
|
122
|
+
? ALIAS_FLAGS.find((f) => f.id === viewMode.flagId) ?? null
|
|
123
|
+
: null;
|
|
124
|
+
const detailValue = detailFlag && config ? config.flags[detailFlag.id] ?? null : null;
|
|
125
|
+
const detailItems = useMemo(() => {
|
|
126
|
+
if (!detailFlag || !detailValue)
|
|
127
|
+
return [];
|
|
128
|
+
if (detailValue.kind === "text-list") {
|
|
129
|
+
const out = [
|
|
130
|
+
{ kind: "toggle", enabled: detailValue.enabled },
|
|
131
|
+
];
|
|
132
|
+
if (detailValue.values.length > 0) {
|
|
133
|
+
out.push({ kind: "header", label: "Values" });
|
|
134
|
+
detailValue.values.forEach((v, i) => out.push({ kind: "value", index: i, text: v }));
|
|
135
|
+
}
|
|
136
|
+
out.push({ kind: "add" });
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
if (detailValue.kind === "multi-with-custom") {
|
|
140
|
+
const pickedSet = new Set(detailValue.picked);
|
|
141
|
+
const out = [
|
|
142
|
+
{ kind: "toggle", enabled: detailValue.enabled },
|
|
143
|
+
];
|
|
144
|
+
if ((detailFlag.picklist?.length ?? 0) > 0) {
|
|
145
|
+
out.push({ kind: "header", label: "Common filters" });
|
|
146
|
+
for (const tok of detailFlag.picklist) {
|
|
147
|
+
out.push({ kind: "picklist", token: tok, picked: pickedSet.has(tok) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (detailValue.custom.length > 0) {
|
|
151
|
+
out.push({ kind: "header", label: "Custom tokens" });
|
|
152
|
+
detailValue.custom.forEach((v, i) => out.push({ kind: "custom", index: i, text: v }));
|
|
153
|
+
}
|
|
154
|
+
out.push({ kind: "add" });
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
}, [detailFlag, detailValue]);
|
|
159
|
+
const detailSelectableIndices = useMemo(() => detailItems.flatMap((it, i) => (isSelectableDetailItem(it) ? [i] : [])), [detailItems]);
|
|
160
|
+
// Reset detail selection on entering / on items shape change.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (viewMode.kind !== "flag-detail")
|
|
163
|
+
return;
|
|
164
|
+
if (detailSelectableIndices.length === 0)
|
|
165
|
+
return;
|
|
166
|
+
if (!detailItems[detailSelectedIdx] || !isSelectableDetailItem(detailItems[detailSelectedIdx])) {
|
|
167
|
+
setDetailSelectedIdx(detailSelectableIndices[0]);
|
|
168
|
+
}
|
|
169
|
+
}, [viewMode, detailItems, detailSelectableIndices, detailSelectedIdx]);
|
|
170
|
+
// ── Helper: check whether a row is gated (requires unmet) ─────────────
|
|
171
|
+
const isGated = useCallback((flag) => {
|
|
172
|
+
if (!flag.requires || !config)
|
|
173
|
+
return false;
|
|
174
|
+
return !isFlagEnabled(config.flags[flag.requires]);
|
|
175
|
+
}, [config]);
|
|
176
|
+
const selectedItem = items[selectedIdx];
|
|
177
|
+
const selectedRow = selectedItem &&
|
|
178
|
+
(selectedItem.kind === "alias-name" || selectedItem.kind === "flag")
|
|
179
|
+
? selectedItem.row
|
|
180
|
+
: undefined;
|
|
181
|
+
// Clamp selection: snap to the nearest selectable index whenever items shift.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (selectableIndices.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
if (!isSelectableItem(items[selectedIdx])) {
|
|
186
|
+
// Find the nearest selectable index <= selectedIdx (or the first one)
|
|
187
|
+
const fallback = [...selectableIndices].reverse().find((i) => i <= selectedIdx) ??
|
|
188
|
+
selectableIndices[0];
|
|
189
|
+
setSelectedIdx(fallback);
|
|
190
|
+
}
|
|
191
|
+
else if (selectedIdx >= items.length) {
|
|
192
|
+
setSelectedIdx(selectableIndices[selectableIndices.length - 1]);
|
|
193
|
+
}
|
|
194
|
+
}, [items, selectableIndices, selectedIdx]);
|
|
195
|
+
// ── Mutators ──────────────────────────────────────────────────────────
|
|
196
|
+
// Flag mutations stay in memory. Only the alias name is persisted (to
|
|
197
|
+
// ~/.claude/settings.json) — flag values reach disk only when the user
|
|
198
|
+
// explicitly presses `w` to write the shell rc file.
|
|
199
|
+
const persistAliasName = useCallback(async (name) => {
|
|
200
|
+
try {
|
|
201
|
+
await saveAliasName(name);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
await modal.message("Save failed", err instanceof Error ? err.message : String(err), "error");
|
|
205
|
+
}
|
|
206
|
+
}, [modal]);
|
|
207
|
+
// Cascade dependencies on every mutation:
|
|
208
|
+
// - Enabling a flag → enable its `requires` chain (no modal nagging).
|
|
209
|
+
// - Disabling a flag → disable anything whose `requires === this flag id`,
|
|
210
|
+
// because the renderer would silently drop them otherwise and the UI
|
|
211
|
+
// would lie about what's going to be written.
|
|
212
|
+
const updateFlag = useCallback((id, next) => {
|
|
213
|
+
setConfig((prev) => {
|
|
214
|
+
if (!prev)
|
|
215
|
+
return prev;
|
|
216
|
+
const flags = { ...prev.flags, [id]: next };
|
|
217
|
+
const target = ALIAS_FLAGS.find((f) => f.id === id);
|
|
218
|
+
if (!target)
|
|
219
|
+
return { ...prev, flags };
|
|
220
|
+
const becomingEnabled = isFlagEnabled(next);
|
|
221
|
+
if (becomingEnabled) {
|
|
222
|
+
// Walk `requires` chain and force-enable each ancestor to its
|
|
223
|
+
// catalog-default-enabled state if it's currently off.
|
|
224
|
+
let cursor = target.requires;
|
|
225
|
+
const seen = new Set();
|
|
226
|
+
while (cursor && !seen.has(cursor)) {
|
|
227
|
+
seen.add(cursor);
|
|
228
|
+
const dep = ALIAS_FLAGS.find((f) => f.id === cursor);
|
|
229
|
+
if (!dep)
|
|
230
|
+
break;
|
|
231
|
+
if (!isFlagEnabled(flags[dep.id])) {
|
|
232
|
+
flags[dep.id] = enableDefault(dep);
|
|
233
|
+
}
|
|
234
|
+
cursor = dep.requires;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
// Cascade-disable any flag that requires this one (one level deep is
|
|
239
|
+
// enough today; recurse defensively in case a chain emerges).
|
|
240
|
+
const queue = [id];
|
|
241
|
+
const seen = new Set();
|
|
242
|
+
while (queue.length > 0) {
|
|
243
|
+
const cur = queue.shift();
|
|
244
|
+
if (seen.has(cur))
|
|
245
|
+
continue;
|
|
246
|
+
seen.add(cur);
|
|
247
|
+
for (const dependent of ALIAS_FLAGS) {
|
|
248
|
+
if (dependent.requires !== cur)
|
|
249
|
+
continue;
|
|
250
|
+
if (!isFlagEnabled(flags[dependent.id]))
|
|
251
|
+
continue;
|
|
252
|
+
flags[dependent.id] = disableValue(flags[dependent.id]);
|
|
253
|
+
queue.push(dependent.id);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { ...prev, flags };
|
|
258
|
+
});
|
|
259
|
+
}, []);
|
|
260
|
+
const editAliasName = useCallback(async () => {
|
|
261
|
+
if (!config)
|
|
262
|
+
return;
|
|
263
|
+
const v = await modal.input("Alias name", `Shell alias name (default: "${DEFAULT_ALIAS_NAME}"). Letters, digits, _ or - only.`, config.aliasName);
|
|
264
|
+
if (v === null)
|
|
265
|
+
return;
|
|
266
|
+
const reason = validateAliasName(v);
|
|
267
|
+
if (reason) {
|
|
268
|
+
await modal.message("Invalid alias name", reason, "error");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
setConfig((prev) => (prev ? { ...prev, aliasName: v } : prev));
|
|
272
|
+
void persistAliasName(v);
|
|
273
|
+
}, [config, modal, persistAliasName]);
|
|
274
|
+
const editSelected = useCallback(async () => {
|
|
275
|
+
if (!selectedRow)
|
|
276
|
+
return;
|
|
277
|
+
if (selectedRow.kind === "alias-name") {
|
|
278
|
+
await editAliasName();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// No more "Requires another flag" modal — `updateFlag` auto-enables the
|
|
282
|
+
// `requires` chain when the user activates a gated flag.
|
|
283
|
+
//
|
|
284
|
+
// Collection editors live in a dedicated sub-screen, not a modal —
|
|
285
|
+
// each row gets its own keybindings (space toggle, a add, d delete)
|
|
286
|
+
// so there's no "what does enter do here?" overloading.
|
|
287
|
+
const k = selectedRow.value.kind;
|
|
288
|
+
if (k === "text-list" || k === "multi-with-custom") {
|
|
289
|
+
setDetailSelectedIdx(0);
|
|
290
|
+
setViewMode({ kind: "flag-detail", flagId: selectedRow.flag.id });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const next = await editFlagValue(selectedRow.flag, selectedRow.value, modal);
|
|
294
|
+
if (next)
|
|
295
|
+
updateFlag(selectedRow.flag.id, next);
|
|
296
|
+
}, [selectedRow, modal, updateFlag, editAliasName]);
|
|
297
|
+
const toggleSelected = useCallback(async () => {
|
|
298
|
+
if (!selectedRow)
|
|
299
|
+
return;
|
|
300
|
+
if (selectedRow.kind === "alias-name") {
|
|
301
|
+
// No "toggle" semantic for the name — fall through to edit.
|
|
302
|
+
await editAliasName();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Only booleans and tri-states have a meaningful single-press cycle.
|
|
306
|
+
// Everything else (select, text, list, multi-with-custom) needs a value
|
|
307
|
+
// chooser; falling back to edit prevents the "I pressed space and got the
|
|
308
|
+
// first option without seeing the choices" footgun.
|
|
309
|
+
const k = selectedRow.value.kind;
|
|
310
|
+
if (k !== "boolean" && k !== "tri-state") {
|
|
311
|
+
await editSelected();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const next = quickToggle(selectedRow.flag, selectedRow.value);
|
|
315
|
+
if (next)
|
|
316
|
+
updateFlag(selectedRow.flag.id, next);
|
|
317
|
+
}, [selectedRow, updateFlag, editSelected, editAliasName]);
|
|
318
|
+
const handleWrite = useCallback(async () => {
|
|
319
|
+
if (!config)
|
|
320
|
+
return;
|
|
321
|
+
// Pick the target shell automatically: default shell (matches $SHELL)
|
|
322
|
+
// if its rc file exists; otherwise the first existing rc file. Users
|
|
323
|
+
// who care about other shells edit them directly — the alias config
|
|
324
|
+
// itself is stored in ~/.claude and the user can run `w` again from
|
|
325
|
+
// a different $SHELL session if they want to mirror it.
|
|
326
|
+
const target = shells.find((s) => s.isDefault && s.exists) ??
|
|
327
|
+
shells.find((s) => s.exists);
|
|
328
|
+
if (!target) {
|
|
329
|
+
await modal.message("No shells detected", "No supported rc files found. Create ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish first.", "info");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const r = await writeAliasToShell(config, target);
|
|
334
|
+
await modal.message("Alias written", `${r.action === "created" ? "Created" : "Updated"} ${r.path}\n\nRun \`source ${r.path}\` or open a new shell to use it.`, "success");
|
|
335
|
+
setShells(await detectShells());
|
|
336
|
+
// We just wrote the rc file with current in-memory flag values, so
|
|
337
|
+
// the snapshot now matches in-memory state. Dirty indicator clears.
|
|
338
|
+
setRcSnapshot(config.flags);
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
await modal.message("Write failed", err instanceof Error ? err.message : String(err), "error");
|
|
342
|
+
}
|
|
343
|
+
}, [config, shells, modal]);
|
|
344
|
+
// ── Keyboard ──────────────────────────────────────────────────────────
|
|
345
|
+
// j/k navigate the selectable subset, jumping over headers/legend lines.
|
|
346
|
+
const moveSelection = useCallback((dir) => {
|
|
347
|
+
if (selectableIndices.length === 0)
|
|
348
|
+
return;
|
|
349
|
+
const cursorPos = selectableIndices.indexOf(selectedIdx);
|
|
350
|
+
const nextPos = cursorPos === -1
|
|
351
|
+
? // Currently parked on a non-selectable; move to first/last selectable.
|
|
352
|
+
dir === 1
|
|
353
|
+
? 0
|
|
354
|
+
: selectableIndices.length - 1
|
|
355
|
+
: Math.min(selectableIndices.length - 1, Math.max(0, cursorPos + dir));
|
|
356
|
+
setSelectedIdx(selectableIndices[nextPos]);
|
|
357
|
+
}, [selectableIndices, selectedIdx]);
|
|
358
|
+
// ── Flag-detail sub-screen mutators ───────────────────────────────────
|
|
359
|
+
const moveDetailSelection = useCallback((dir) => {
|
|
360
|
+
if (detailSelectableIndices.length === 0)
|
|
361
|
+
return;
|
|
362
|
+
const cursorPos = detailSelectableIndices.indexOf(detailSelectedIdx);
|
|
363
|
+
const nextPos = cursorPos === -1
|
|
364
|
+
? dir === 1
|
|
365
|
+
? 0
|
|
366
|
+
: detailSelectableIndices.length - 1
|
|
367
|
+
: Math.min(detailSelectableIndices.length - 1, Math.max(0, cursorPos + dir));
|
|
368
|
+
setDetailSelectedIdx(detailSelectableIndices[nextPos]);
|
|
369
|
+
}, [detailSelectableIndices, detailSelectedIdx]);
|
|
370
|
+
const handleDetailToggle = useCallback(() => {
|
|
371
|
+
if (!detailFlag || !detailValue)
|
|
372
|
+
return;
|
|
373
|
+
const item = detailItems[detailSelectedIdx];
|
|
374
|
+
if (!item)
|
|
375
|
+
return;
|
|
376
|
+
if (item.kind === "toggle") {
|
|
377
|
+
if (detailValue.kind === "text-list") {
|
|
378
|
+
updateFlag(detailFlag.id, { ...detailValue, enabled: !detailValue.enabled });
|
|
379
|
+
}
|
|
380
|
+
else if (detailValue.kind === "multi-with-custom") {
|
|
381
|
+
updateFlag(detailFlag.id, { ...detailValue, enabled: !detailValue.enabled });
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (item.kind === "picklist" && detailValue.kind === "multi-with-custom") {
|
|
386
|
+
const pickedSet = new Set(detailValue.picked);
|
|
387
|
+
const next = pickedSet.has(item.token)
|
|
388
|
+
? detailValue.picked.filter((p) => p !== item.token)
|
|
389
|
+
: [...detailValue.picked, item.token];
|
|
390
|
+
updateFlag(detailFlag.id, {
|
|
391
|
+
...detailValue,
|
|
392
|
+
enabled: true,
|
|
393
|
+
picked: next,
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (item.kind === "add") {
|
|
398
|
+
void handleDetailAdd();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// value / custom rows: enter does nothing destructive; use `d` to delete.
|
|
402
|
+
}, [detailFlag, detailValue, detailItems, detailSelectedIdx, updateFlag]);
|
|
403
|
+
const handleDetailAdd = useCallback(async () => {
|
|
404
|
+
if (!detailFlag || !detailValue)
|
|
405
|
+
return;
|
|
406
|
+
if (detailValue.kind === "text-list") {
|
|
407
|
+
const v = await modal.input(`Add to ${detailFlag.flag}`, "Value");
|
|
408
|
+
if (v === null || v.length === 0)
|
|
409
|
+
return;
|
|
410
|
+
updateFlag(detailFlag.id, {
|
|
411
|
+
...detailValue,
|
|
412
|
+
enabled: true,
|
|
413
|
+
values: [...detailValue.values, v],
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (detailValue.kind === "multi-with-custom") {
|
|
418
|
+
const v = await modal.input(`Add custom token`, 'Token (e.g. "router" or "!file" for negation)');
|
|
419
|
+
if (v === null || v.length === 0)
|
|
420
|
+
return;
|
|
421
|
+
updateFlag(detailFlag.id, {
|
|
422
|
+
...detailValue,
|
|
423
|
+
enabled: true,
|
|
424
|
+
custom: [...detailValue.custom, v],
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}, [detailFlag, detailValue, modal, updateFlag]);
|
|
429
|
+
const handleDetailDelete = useCallback(async () => {
|
|
430
|
+
if (!detailFlag || !detailValue)
|
|
431
|
+
return;
|
|
432
|
+
const item = detailItems[detailSelectedIdx];
|
|
433
|
+
if (!item)
|
|
434
|
+
return;
|
|
435
|
+
if (item.kind === "value" && detailValue.kind === "text-list") {
|
|
436
|
+
const target = detailValue.values[item.index];
|
|
437
|
+
const ok = await modal.confirm("Remove item?", `Remove "${truncate(target, 60)}" from ${detailFlag.flag}?`);
|
|
438
|
+
if (!ok)
|
|
439
|
+
return;
|
|
440
|
+
updateFlag(detailFlag.id, {
|
|
441
|
+
...detailValue,
|
|
442
|
+
values: detailValue.values.filter((_, i) => i !== item.index),
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (item.kind === "custom" && detailValue.kind === "multi-with-custom") {
|
|
447
|
+
const target = detailValue.custom[item.index];
|
|
448
|
+
const ok = await modal.confirm("Remove custom token?", `Remove "${truncate(target, 60)}" from ${detailFlag.flag}?`);
|
|
449
|
+
if (!ok)
|
|
450
|
+
return;
|
|
451
|
+
updateFlag(detailFlag.id, {
|
|
452
|
+
...detailValue,
|
|
453
|
+
custom: detailValue.custom.filter((_, i) => i !== item.index),
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
}, [detailFlag, detailValue, detailItems, detailSelectedIdx, modal, updateFlag]);
|
|
458
|
+
const exitDetail = useCallback(() => {
|
|
459
|
+
setViewMode({ kind: "main" });
|
|
460
|
+
setDetailSelectedIdx(0);
|
|
461
|
+
}, []);
|
|
462
|
+
// ── Keyboard handler ──────────────────────────────────────────────────
|
|
463
|
+
// Dispatches on viewMode so sub-screen keybindings don't leak into main.
|
|
464
|
+
useKeyboardHandler((input, key) => {
|
|
465
|
+
if (state.modal)
|
|
466
|
+
return;
|
|
467
|
+
if (viewMode.kind === "flag-detail") {
|
|
468
|
+
if (input === "j" || key.downArrow) {
|
|
469
|
+
moveDetailSelection(1);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (input === "k" || key.upArrow) {
|
|
473
|
+
moveDetailSelection(-1);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (key.return || input === " " || key.name === "space") {
|
|
477
|
+
handleDetailToggle();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (input === "a") {
|
|
481
|
+
void handleDetailAdd();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (input === "d") {
|
|
485
|
+
void handleDetailDelete();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (input === "h") {
|
|
489
|
+
exitDetail();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
// Esc is handled by the global handler — it'll exit the entire screen
|
|
493
|
+
// back to plugins, which is fine. `h` is the documented "back to main".
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Main mode.
|
|
497
|
+
if (input === "j" || key.downArrow) {
|
|
498
|
+
moveSelection(1);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (input === "k" || key.upArrow) {
|
|
502
|
+
moveSelection(-1);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (key.return || input === " " || key.name === "space") {
|
|
506
|
+
void toggleSelected();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (input === "e") {
|
|
510
|
+
void editSelected();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (input === "w") {
|
|
514
|
+
void handleWrite();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (input === "r") {
|
|
518
|
+
// Reset the selected row to its default.
|
|
519
|
+
if (!selectedRow)
|
|
520
|
+
return;
|
|
521
|
+
if (selectedRow.kind === "alias-name") {
|
|
522
|
+
setConfig((prev) => prev ? { ...prev, aliasName: DEFAULT_ALIAS_NAME } : prev);
|
|
523
|
+
void persistAliasName(DEFAULT_ALIAS_NAME);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// Flag reset stays in memory until `w`.
|
|
527
|
+
updateFlag(selectedRow.flag.id, defaultValueFor(selectedRow.flag));
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
// ── Render ────────────────────────────────────────────────────────────
|
|
531
|
+
const previewBlock = config ? renderAlias(config, "zsh").block : "";
|
|
532
|
+
// Native OpenTUI scrollboxes own the scroll math. We render every item
|
|
533
|
+
// into the scrollbox's content area and ask the renderable to scroll the
|
|
534
|
+
// selected child into view whenever the cursor moves. The previous JS-
|
|
535
|
+
// windowed approach (ScrollableList) overstrike-rendered rows when its
|
|
536
|
+
// height computation drifted off the actual panel size.
|
|
537
|
+
const mainScrollRef = useRef(null);
|
|
538
|
+
const detailScrollRef = useRef(null);
|
|
539
|
+
useEffect(() => {
|
|
540
|
+
mainScrollRef.current?.scrollChildIntoView(`alias-row-${selectedIdx}`);
|
|
541
|
+
}, [selectedIdx]);
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
if (viewMode.kind !== "flag-detail")
|
|
544
|
+
return;
|
|
545
|
+
detailScrollRef.current?.scrollChildIntoView(`alias-detail-row-${detailSelectedIdx}`);
|
|
546
|
+
}, [detailSelectedIdx, viewMode]);
|
|
547
|
+
const inDetail = viewMode.kind === "flag-detail" && detailFlag && detailValue;
|
|
548
|
+
const mainListPanel = !config ? (_jsx("text", { fg: "gray", children: error ? `Error: ${error}` : "Loading..." })) : (_jsx("box", { flexDirection: "column", height: "100%", children: _jsx("scrollbox", { ref: mainScrollRef, scrollY: true, scrollX: false, flexGrow: 1, children: _jsx("box", { flexDirection: "column", children: items.map((item, idx) => (_jsx("box", { id: `alias-row-${idx}`, children: renderItem(item, idx === selectedIdx, isGated) }, getItemKey(item)))) }) }) }));
|
|
549
|
+
const detailListPanel = inDetail ? (_jsxs("box", { flexDirection: "column", height: "100%", children: [_jsx("text", { fg: "#7e57c2", children: _jsx("strong", { children: detailFlag.flag }) }), _jsx("text", { fg: "gray", children: `(h to go back to all flags)` }), _jsx("text", { children: " " }), _jsx("scrollbox", { ref: detailScrollRef, scrollY: true, scrollX: false, flexGrow: 1, children: _jsx("box", { flexDirection: "column", children: detailItems.map((item, idx) => (_jsx("box", { id: `alias-detail-row-${idx}`, children: renderDetailItem(item, idx === detailSelectedIdx) }, getDetailItemKey(item, idx)))) }) })] })) : null;
|
|
550
|
+
const listPanel = inDetail ? detailListPanel : mainListPanel;
|
|
551
|
+
const mainDetailPanel = renderDetail(selectedRow, isGated, previewBlock);
|
|
552
|
+
const detailDetailPanel = inDetail
|
|
553
|
+
? renderFlagDetailRightPane(detailFlag, detailValue, previewBlock)
|
|
554
|
+
: null;
|
|
555
|
+
const detailPanel = inDetail ? detailDetailPanel : mainDetailPanel;
|
|
556
|
+
const enabledCount = config
|
|
557
|
+
? Object.values(config.flags).filter(isFlagEnabled).length
|
|
558
|
+
: 0;
|
|
559
|
+
const subtitle = inDetail
|
|
560
|
+
? `editing ${detailFlag.flag}`
|
|
561
|
+
: config
|
|
562
|
+
? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled`
|
|
563
|
+
: "";
|
|
564
|
+
// Resolve the rc-file name we'd actually write to (basename of the chosen
|
|
565
|
+
// shell's path). Falls back to "shell" if no shells were detected — UI
|
|
566
|
+
// surface mirrors the runtime behavior of `handleWrite`.
|
|
567
|
+
const writeTarget = shells.find((s) => s.isDefault && s.exists) ??
|
|
568
|
+
shells.find((s) => s.exists);
|
|
569
|
+
const writeLabel = writeTarget
|
|
570
|
+
? writeTarget.path.split("/").pop() || "shell"
|
|
571
|
+
: "shell";
|
|
572
|
+
// Dirty when current in-memory flags differ from the rc-file snapshot.
|
|
573
|
+
// Initial mount sets rcSnapshot from the parsed rc, so this is false
|
|
574
|
+
// until the user edits.
|
|
575
|
+
const dirty = config
|
|
576
|
+
? rcSnapshot !== null && !shallowFlagsEqual(config.flags, rcSnapshot)
|
|
577
|
+
: false;
|
|
578
|
+
const footerHints = inDetail
|
|
579
|
+
? renderFooterHints([
|
|
580
|
+
{ keys: ["↑", "↓"], label: "move" },
|
|
581
|
+
{ keys: ["space"], label: "toggle" },
|
|
582
|
+
{ keys: ["a"], label: "add" },
|
|
583
|
+
{ keys: ["d"], label: "delete" },
|
|
584
|
+
{ keys: ["h"], label: "back" },
|
|
585
|
+
])
|
|
586
|
+
: renderFooterHints([
|
|
587
|
+
{ keys: ["↑", "↓"], label: "move" },
|
|
588
|
+
{ keys: ["space"], label: "toggle" },
|
|
589
|
+
{ keys: ["e"], label: "edit" },
|
|
590
|
+
{ keys: ["r"], label: "reset" },
|
|
591
|
+
{
|
|
592
|
+
keys: ["w"],
|
|
593
|
+
label: `${dirty ? "*" : ""}write to ${writeLabel}`,
|
|
594
|
+
},
|
|
595
|
+
]);
|
|
596
|
+
return (_jsx(ScreenLayout, { title: "Claude Alias", subtitle: subtitle, currentScreen: "alias", footerHints: footerHints, listPanel: listPanel, detailPanel: detailPanel }));
|
|
597
|
+
}
|
|
598
|
+
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
599
|
+
/**
|
|
600
|
+
* Compare two flag maps for value equality. We render-then-diff via JSON
|
|
601
|
+
* because FlagValues are small, fully serialisable, and order-insensitive
|
|
602
|
+
* inside the maps. A property-by-property deep-equal would be more efficient
|
|
603
|
+
* but the maps have ~14 entries — diff cost is negligible.
|
|
604
|
+
*/
|
|
605
|
+
function shallowFlagsEqual(a, b) {
|
|
606
|
+
const aKeys = Object.keys(a).sort();
|
|
607
|
+
const bKeys = Object.keys(b).sort();
|
|
608
|
+
if (aKeys.length !== bKeys.length)
|
|
609
|
+
return false;
|
|
610
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
611
|
+
if (aKeys[i] !== bKeys[i])
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
for (const k of aKeys) {
|
|
615
|
+
if (JSON.stringify(a[k]) !== JSON.stringify(b[k]))
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
function isFlagEnabled(value) {
|
|
621
|
+
if (!value)
|
|
622
|
+
return false;
|
|
623
|
+
switch (value.kind) {
|
|
624
|
+
case "boolean":
|
|
625
|
+
return value.enabled;
|
|
626
|
+
case "tri-state":
|
|
627
|
+
return value.state !== "unset";
|
|
628
|
+
case "select":
|
|
629
|
+
case "text":
|
|
630
|
+
case "optional-text":
|
|
631
|
+
case "text-list":
|
|
632
|
+
case "multi-with-custom":
|
|
633
|
+
return value.enabled;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Produce an "enabled by default" value for a flag — what we'd give it when
|
|
638
|
+
* the user activates a dependent that needs this one on. For booleans this
|
|
639
|
+
* is just `enabled: true`; for kinds that carry a value (select/text/etc.)
|
|
640
|
+
* we set `enabled: true` with the catalog default value, leaving the user
|
|
641
|
+
* free to refine it after.
|
|
642
|
+
*/
|
|
643
|
+
function enableDefault(flag) {
|
|
644
|
+
switch (flag.kind) {
|
|
645
|
+
case "boolean":
|
|
646
|
+
return { kind: "boolean", enabled: true };
|
|
647
|
+
case "tri-state":
|
|
648
|
+
return { kind: "tri-state", state: "on" };
|
|
649
|
+
case "select":
|
|
650
|
+
return {
|
|
651
|
+
kind: "select",
|
|
652
|
+
enabled: true,
|
|
653
|
+
value: flag.options?.[0]?.value ?? "",
|
|
654
|
+
};
|
|
655
|
+
case "text":
|
|
656
|
+
return { kind: "text", enabled: true, value: "" };
|
|
657
|
+
case "optional-text":
|
|
658
|
+
return { kind: "optional-text", enabled: true, value: "" };
|
|
659
|
+
case "text-list":
|
|
660
|
+
return {
|
|
661
|
+
kind: "text-list",
|
|
662
|
+
enabled: true,
|
|
663
|
+
values: [...(flag.defaultValues ?? [])],
|
|
664
|
+
};
|
|
665
|
+
case "multi-with-custom":
|
|
666
|
+
return {
|
|
667
|
+
kind: "multi-with-custom",
|
|
668
|
+
enabled: true,
|
|
669
|
+
picked: [],
|
|
670
|
+
custom: [...(flag.defaultValues ?? [])],
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Disable a flag while preserving any values the user has already entered.
|
|
676
|
+
* Used by the cascade-disable path: turning off `--worktree` shouldn't lose
|
|
677
|
+
* the user's `--tmux=classic` choice — only re-enable the dependency and
|
|
678
|
+
* the value reappears.
|
|
679
|
+
*/
|
|
680
|
+
function disableValue(value) {
|
|
681
|
+
switch (value.kind) {
|
|
682
|
+
case "boolean":
|
|
683
|
+
return { kind: "boolean", enabled: false };
|
|
684
|
+
case "tri-state":
|
|
685
|
+
return { kind: "tri-state", state: "unset" };
|
|
686
|
+
case "select":
|
|
687
|
+
case "text":
|
|
688
|
+
case "optional-text":
|
|
689
|
+
case "text-list":
|
|
690
|
+
case "multi-with-custom":
|
|
691
|
+
return { ...value, enabled: false };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function summarizeValue(value) {
|
|
695
|
+
switch (value.kind) {
|
|
696
|
+
case "boolean":
|
|
697
|
+
return value.enabled ? "on" : "off";
|
|
698
|
+
case "tri-state":
|
|
699
|
+
return value.state;
|
|
700
|
+
case "select":
|
|
701
|
+
return value.enabled ? value.value || "(bare)" : "off";
|
|
702
|
+
case "text":
|
|
703
|
+
case "optional-text":
|
|
704
|
+
return value.enabled
|
|
705
|
+
? value.value
|
|
706
|
+
? truncate(value.value, 32)
|
|
707
|
+
: "(empty)"
|
|
708
|
+
: "off";
|
|
709
|
+
case "text-list":
|
|
710
|
+
return value.enabled ? `${value.values.length} item(s)` : "off";
|
|
711
|
+
case "multi-with-custom": {
|
|
712
|
+
if (!value.enabled)
|
|
713
|
+
return "off";
|
|
714
|
+
const total = new Set([...value.picked, ...value.custom]).size;
|
|
715
|
+
return total === 0 ? "(no filter)" : `${total} token(s)`;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function truncate(s, n) {
|
|
720
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Quick toggle (enter/space). For boolean → flip. For tri-state → cycle
|
|
724
|
+
* unset → on → off → unset. For others, just flip the `enabled` field; the
|
|
725
|
+
* user must press `e` to set the value.
|
|
726
|
+
*/
|
|
727
|
+
function quickToggle(_flag, value) {
|
|
728
|
+
switch (value.kind) {
|
|
729
|
+
case "boolean":
|
|
730
|
+
return { kind: "boolean", enabled: !value.enabled };
|
|
731
|
+
case "tri-state": {
|
|
732
|
+
const next = value.state === "unset" ? "on" : value.state === "on" ? "off" : "unset";
|
|
733
|
+
return { kind: "tri-state", state: next };
|
|
734
|
+
}
|
|
735
|
+
case "select":
|
|
736
|
+
return { ...value, enabled: !value.enabled };
|
|
737
|
+
case "text":
|
|
738
|
+
case "optional-text":
|
|
739
|
+
return { ...value, enabled: !value.enabled };
|
|
740
|
+
case "text-list":
|
|
741
|
+
return { ...value, enabled: !value.enabled };
|
|
742
|
+
case "multi-with-custom":
|
|
743
|
+
return { ...value, enabled: !value.enabled };
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* `e` — edit the value. Modal flow varies by flag kind.
|
|
748
|
+
*/
|
|
749
|
+
async function editFlagValue(flag, value, modal) {
|
|
750
|
+
switch (value.kind) {
|
|
751
|
+
case "boolean":
|
|
752
|
+
return { kind: "boolean", enabled: !value.enabled };
|
|
753
|
+
case "tri-state": {
|
|
754
|
+
const choice = await modal.select(flag.label, flag.description, [
|
|
755
|
+
{ label: "Unset (no opinion)", value: "unset" },
|
|
756
|
+
{ label: `On (${flag.flag})`, value: "on" },
|
|
757
|
+
...(flag.triStateOff
|
|
758
|
+
? [{ label: `Off (${flag.triStateOff})`, value: "off" }]
|
|
759
|
+
: []),
|
|
760
|
+
], ["unset", "on", "off"].indexOf(value.state));
|
|
761
|
+
if (!choice)
|
|
762
|
+
return null;
|
|
763
|
+
return { kind: "tri-state", state: choice };
|
|
764
|
+
}
|
|
765
|
+
case "select": {
|
|
766
|
+
const opts = flag.options ?? [];
|
|
767
|
+
const choice = await modal.select(flag.label, flag.description, [
|
|
768
|
+
{ label: "(disable)", value: "__off__" },
|
|
769
|
+
...opts.map((o) => ({ label: o.label, value: `v:${o.value}` })),
|
|
770
|
+
], value.enabled
|
|
771
|
+
? Math.max(0, opts.findIndex((o) => o.value === value.value)) + 1
|
|
772
|
+
: 0);
|
|
773
|
+
if (choice === null)
|
|
774
|
+
return null;
|
|
775
|
+
if (choice === "__off__")
|
|
776
|
+
return { ...value, enabled: false };
|
|
777
|
+
return {
|
|
778
|
+
kind: "select",
|
|
779
|
+
enabled: true,
|
|
780
|
+
value: choice.replace(/^v:/, ""),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
case "text": {
|
|
784
|
+
// Lead with a 2-option chooser so the user can disable without going
|
|
785
|
+
// through `r` reset. Selecting "Set value" jumps to the input modal.
|
|
786
|
+
const action = await modal.select(flag.label, flag.description, value.enabled
|
|
787
|
+
? [
|
|
788
|
+
{ label: `Edit value (current: "${truncate(value.value, 32)}")`, value: "__edit__" },
|
|
789
|
+
{ label: "Disable flag", value: "__off__" },
|
|
790
|
+
]
|
|
791
|
+
: [
|
|
792
|
+
{ label: "Enable with value...", value: "__edit__" },
|
|
793
|
+
{ label: "Keep disabled", value: "__off__" },
|
|
794
|
+
], 0);
|
|
795
|
+
if (action === null)
|
|
796
|
+
return null;
|
|
797
|
+
if (action === "__off__") {
|
|
798
|
+
return { kind: "text", enabled: false, value: value.value };
|
|
799
|
+
}
|
|
800
|
+
const v = await modal.input(flag.label, flag.description, value.value);
|
|
801
|
+
if (v === null)
|
|
802
|
+
return null;
|
|
803
|
+
return { kind: "text", enabled: v.length > 0, value: v };
|
|
804
|
+
}
|
|
805
|
+
case "optional-text": {
|
|
806
|
+
// Three-option lead: edit value, bare (no value), or disable.
|
|
807
|
+
const action = await modal.select(flag.label, flag.description, [
|
|
808
|
+
{
|
|
809
|
+
label: value.enabled && value.value
|
|
810
|
+
? `Edit value (current: "${truncate(value.value, 32)}")`
|
|
811
|
+
: "Set value...",
|
|
812
|
+
value: "__edit__",
|
|
813
|
+
},
|
|
814
|
+
{ label: "Bare flag (no value)", value: "__bare__" },
|
|
815
|
+
{ label: "Disable flag", value: "__off__" },
|
|
816
|
+
], value.enabled ? (value.value ? 0 : 1) : 2);
|
|
817
|
+
if (action === null)
|
|
818
|
+
return null;
|
|
819
|
+
if (action === "__off__") {
|
|
820
|
+
return { kind: "optional-text", enabled: false, value: value.value };
|
|
821
|
+
}
|
|
822
|
+
if (action === "__bare__") {
|
|
823
|
+
return { kind: "optional-text", enabled: true, value: "" };
|
|
824
|
+
}
|
|
825
|
+
const v = await modal.input(flag.label, flag.description, value.value);
|
|
826
|
+
if (v === null)
|
|
827
|
+
return null;
|
|
828
|
+
return { kind: "optional-text", enabled: true, value: v };
|
|
829
|
+
}
|
|
830
|
+
case "text-list":
|
|
831
|
+
case "multi-with-custom":
|
|
832
|
+
// These kinds are routed to the flag-detail sub-screen by
|
|
833
|
+
// `editSelected`, never to this modal-based editor. If you see this
|
|
834
|
+
// throw, the caller bypassed the routing in editSelected — fix that
|
|
835
|
+
// instead of re-introducing the modal cascade.
|
|
836
|
+
throw new Error(`${flag.kind} flags use the flag-detail sub-screen, not the modal editor`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// ─── Render helpers ─────────────────────────────────────────────────────
|
|
840
|
+
/**
|
|
841
|
+
* The alias-name row sits above all flag groups. We render it with a clear
|
|
842
|
+
* "Alias:" label and the value styled as the editable field, plus an `[edit]`
|
|
843
|
+
* hint, so it doesn't blend in with the flag rows below.
|
|
844
|
+
*/
|
|
845
|
+
function renderAliasNameRow(name, selected) {
|
|
846
|
+
if (selected) {
|
|
847
|
+
return (_jsx("box", { flexDirection: "row", children: _jsx("text", { bg: "#7e57c2", fg: "white", children: `▶ Alias name: ${name} [enter to edit]` }) }, "alias-name-row"));
|
|
848
|
+
}
|
|
849
|
+
return (_jsxs("text", { children: [_jsx("span", { fg: "gray", children: " Alias name: " }), _jsx("span", { fg: "cyan", children: _jsx("strong", { children: name }) }), _jsx("span", { fg: "gray", children: " [enter to edit]" })] }, "alias-name-row"));
|
|
850
|
+
}
|
|
851
|
+
/** Stable key per item for the windowed list. */
|
|
852
|
+
function getItemKey(item) {
|
|
853
|
+
switch (item.kind) {
|
|
854
|
+
case "alias-name":
|
|
855
|
+
return "alias-name";
|
|
856
|
+
case "flag":
|
|
857
|
+
return `flag:${item.row.flag.id}`;
|
|
858
|
+
case "header":
|
|
859
|
+
return `header:${item.label}`;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/** Dispatch render for one flat-list item. */
|
|
863
|
+
function renderItem(item, isSelected, isGated) {
|
|
864
|
+
switch (item.kind) {
|
|
865
|
+
case "alias-name":
|
|
866
|
+
return renderAliasNameRow(item.row.value, isSelected);
|
|
867
|
+
case "flag":
|
|
868
|
+
return renderRow(item.row, isSelected, isGated(item.row.flag));
|
|
869
|
+
case "header":
|
|
870
|
+
return _jsx("text", { fg: "#7e57c2", children: item.label });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
function renderRow(row, selected, gated) {
|
|
874
|
+
const enabled = isFlagEnabled(row.value);
|
|
875
|
+
const marker = enabled ? "[x]" : "[ ]";
|
|
876
|
+
const issueMark = row.issue ? " ⚠" : "";
|
|
877
|
+
const fg = gated ? "gray" : enabled ? "white" : "gray";
|
|
878
|
+
// Only show a value suffix when the flag carries non-redundant detail.
|
|
879
|
+
// Boolean / tri-state state is fully conveyed by the checkbox.
|
|
880
|
+
const detail = enabledDetail(row.value);
|
|
881
|
+
const detailSuffix = detail ? ` ${detail}` : "";
|
|
882
|
+
const line = `${selected ? "▶ " : " "}${marker} ${row.flag.flag}${detailSuffix}${issueMark}`;
|
|
883
|
+
if (selected) {
|
|
884
|
+
return (_jsx("text", { bg: "#7e57c2", fg: "white", children: line }, row.flag.id));
|
|
885
|
+
}
|
|
886
|
+
return (_jsx("text", { fg: fg, children: line }, row.flag.id));
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Per-row trailing detail. Returns "" for booleans and tri-states (their state
|
|
890
|
+
* is already in the checkbox) and for disabled flags (no need to advertise an
|
|
891
|
+
* unset value). Non-empty for flags that carry an extra value the checkbox
|
|
892
|
+
* doesn't capture.
|
|
893
|
+
*/
|
|
894
|
+
function enabledDetail(value) {
|
|
895
|
+
switch (value.kind) {
|
|
896
|
+
case "boolean":
|
|
897
|
+
case "tri-state":
|
|
898
|
+
return "";
|
|
899
|
+
case "select":
|
|
900
|
+
if (!value.enabled)
|
|
901
|
+
return "";
|
|
902
|
+
return value.value === "" ? "(bare)" : value.value;
|
|
903
|
+
case "text":
|
|
904
|
+
case "optional-text":
|
|
905
|
+
if (!value.enabled)
|
|
906
|
+
return "";
|
|
907
|
+
return value.value ? truncate(value.value, 24) : "(empty)";
|
|
908
|
+
case "text-list":
|
|
909
|
+
if (!value.enabled)
|
|
910
|
+
return "";
|
|
911
|
+
return value.values.length === 0 ? "(empty)" : `${value.values.length}`;
|
|
912
|
+
case "multi-with-custom": {
|
|
913
|
+
if (!value.enabled)
|
|
914
|
+
return "";
|
|
915
|
+
const total = new Set([...value.picked, ...value.custom]).size;
|
|
916
|
+
return total === 0 ? "(no filter)" : `${total} token(s)`;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
function renderDetail(row, isGated, previewBlock) {
|
|
921
|
+
if (!row) {
|
|
922
|
+
return _jsx("text", { fg: "gray", children: "No row selected." });
|
|
923
|
+
}
|
|
924
|
+
const headerAndBody = row.kind === "alias-name"
|
|
925
|
+
? renderAliasNameDetail(row)
|
|
926
|
+
: renderFlagDetail(row, isGated);
|
|
927
|
+
// Preview is the only "global" element kept in the detail panel — it
|
|
928
|
+
// updates as the user toggles, which is the strongest visual feedback for
|
|
929
|
+
// "what alias am I building". Shells live in the list-panel legend.
|
|
930
|
+
return (_jsxs("box", { flexDirection: "column", children: [headerAndBody, _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: "Preview (zsh/bash):" }), _jsx("text", { fg: "white", children: firstLine(previewBlock, 1) })] }));
|
|
931
|
+
}
|
|
932
|
+
function renderAliasNameDetail(row) {
|
|
933
|
+
return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "white", children: _jsx("strong", { children: "Alias name" }) }), _jsx("text", { fg: "gray", children: "Shell name for the wrapped claude command. Default: \"c\". Letters, digits, _ or - only." }), _jsx("text", { children: " " }), _jsxs("text", { children: [_jsx("span", { fg: "gray", children: "Current: " }), _jsx("span", { fg: "white", children: _jsx("strong", { children: row.value }) })] }), _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: "Enter to rename. r resets to \"c\"." })] }));
|
|
934
|
+
}
|
|
935
|
+
function renderFlagDetail(row, isGated) {
|
|
936
|
+
const gated = isGated(row.flag);
|
|
937
|
+
return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "white", children: _jsx("strong", { children: row.flag.flag }) }), _jsx("text", { fg: "gray", children: row.flag.description }), _jsx("text", { children: " " }), _jsxs("text", { children: [_jsx("span", { fg: "gray", children: "State: " }), _jsx("span", { fg: "white", children: summarizeValue(row.value) })] }), gated && (_jsx("text", { fg: "gray", children: `Depends on --${row.flag.requires}. Enabling this will turn that on too.` })), row.issue && (_jsx("text", { fg: "yellow", children: `Conflict: ${row.issue.reason}` }))] }));
|
|
938
|
+
}
|
|
939
|
+
function firstLine(block, skip) {
|
|
940
|
+
const lines = block.split("\n");
|
|
941
|
+
return lines[skip] ?? "";
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Render footer hints as keycap badges + labels. Keys are rendered with an
|
|
945
|
+
* inverted color (white bg, dark fg) so they read as visual chips rather
|
|
946
|
+
* than letters dropped into prose. Multi-key bindings (`↑↓`) render as one
|
|
947
|
+
* combined badge.
|
|
948
|
+
*/
|
|
949
|
+
function renderFooterHints(hints) {
|
|
950
|
+
const nodes = [];
|
|
951
|
+
hints.forEach((hint, i) => {
|
|
952
|
+
if (i > 0) {
|
|
953
|
+
nodes.push(_jsx("span", { fg: "gray", children: " " }, `gap-${i}`));
|
|
954
|
+
}
|
|
955
|
+
nodes.push(_jsx("span", { bg: "#3f3f46", fg: "#fafafa", children: ` ${hint.keys.join("")} ` }, `k-${i}`));
|
|
956
|
+
nodes.push(_jsx("span", { fg: "gray", children: ` ${hint.label}` }, `l-${i}`));
|
|
957
|
+
});
|
|
958
|
+
return _jsx("text", { children: nodes });
|
|
959
|
+
}
|
|
960
|
+
// ─── Flag-detail sub-screen render helpers ─────────────────────────────
|
|
961
|
+
function getDetailItemKey(item, idx) {
|
|
962
|
+
switch (item.kind) {
|
|
963
|
+
case "toggle":
|
|
964
|
+
return "toggle";
|
|
965
|
+
case "value":
|
|
966
|
+
return `value:${item.index}`;
|
|
967
|
+
case "picklist":
|
|
968
|
+
return `pick:${item.token}`;
|
|
969
|
+
case "custom":
|
|
970
|
+
return `custom:${item.index}`;
|
|
971
|
+
case "add":
|
|
972
|
+
return "add";
|
|
973
|
+
case "header":
|
|
974
|
+
return `header:${item.label}:${idx}`;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
function renderDetailItem(item, isSelected) {
|
|
978
|
+
if (item.kind === "header") {
|
|
979
|
+
return _jsx("text", { fg: "#7e57c2", children: item.label });
|
|
980
|
+
}
|
|
981
|
+
const cursor = isSelected ? "▶ " : " ";
|
|
982
|
+
let body;
|
|
983
|
+
switch (item.kind) {
|
|
984
|
+
case "toggle":
|
|
985
|
+
body = `[${item.enabled ? "x" : " "}] Enable flag`;
|
|
986
|
+
break;
|
|
987
|
+
case "value":
|
|
988
|
+
body = item.text;
|
|
989
|
+
break;
|
|
990
|
+
case "picklist":
|
|
991
|
+
body = `[${item.picked ? "x" : " "}] ${item.token}`;
|
|
992
|
+
break;
|
|
993
|
+
case "custom":
|
|
994
|
+
body = item.text;
|
|
995
|
+
break;
|
|
996
|
+
case "add":
|
|
997
|
+
body = "+ Add new...";
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
const line = `${cursor}${body}`;
|
|
1001
|
+
if (isSelected) {
|
|
1002
|
+
return (_jsx("text", { bg: "#7e57c2", fg: "white", children: line }));
|
|
1003
|
+
}
|
|
1004
|
+
return _jsx("text", { fg: "white", children: line });
|
|
1005
|
+
}
|
|
1006
|
+
function renderFlagDetailRightPane(flag, value, previewBlock) {
|
|
1007
|
+
return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "white", children: _jsx("strong", { children: flag.flag }) }), _jsx("text", { fg: "gray", children: flag.description }), _jsx("text", { children: " " }), _jsxs("text", { children: [_jsx("span", { fg: "gray", children: "State: " }), _jsx("span", { fg: "white", children: summarizeValue(value) })] }), _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: "Preview (zsh/bash):" }), _jsx("text", { fg: "white", children: firstLine(previewBlock, 1) }), _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: "Keys:" }), _jsx("text", { fg: "white", children: " space/enter toggle the highlighted row" }), _jsx("text", { fg: "white", children: " a add a new value" }), _jsx("text", { fg: "white", children: " d delete the highlighted item" }), _jsx("text", { fg: "white", children: " h back to flag list" })] }));
|
|
1008
|
+
}
|