claudeup 4.18.0 → 4.19.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-adopt.test.ts +364 -0
- package/src/__tests__/alias-parser.test.ts +92 -0
- package/src/__tests__/alias-shell-writer.test.ts +7 -0
- package/src/__tests__/alias-store.test.ts +77 -0
- package/src/__tests__/plugin-setup.test.ts +111 -0
- package/src/data/alias-flags.js +10 -1
- package/src/data/alias-flags.ts +11 -1
- package/src/services/alias-shell-writer.js +262 -8
- package/src/services/alias-shell-writer.ts +382 -8
- package/src/services/alias-store.js +52 -0
- package/src/services/alias-store.ts +60 -0
- package/src/services/plugin-setup.js +59 -4
- package/src/services/plugin-setup.ts +61 -4
- package/src/ui/App.js +16 -7
- package/src/ui/App.tsx +16 -7
- package/src/ui/components/FlagDetailEditor.js +0 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/modals/ConfirmModal.js +1 -1
- package/src/ui/components/modals/ConfirmModal.tsx +1 -1
- package/src/ui/screens/AliasScreen.js +380 -277
- package/src/ui/screens/AliasScreen.tsx +491 -359
- package/src/ui/screens/PluginsScreen.js +4 -1
- package/src/ui/screens/PluginsScreen.tsx +3 -1
- package/src/ui/state/reducer.js +5 -1
- package/src/ui/state/reducer.ts +6 -1
- package/src/ui/state/types.ts +8 -0
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
3
3
|
import { useApp, useModal } from "../state/AppContext.js";
|
|
4
4
|
import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
|
|
5
5
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
6
|
+
import { FlagDetailEditor } from "../components/FlagDetailEditor.js";
|
|
6
7
|
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 { defaultAliasConfig, defaultValueFor, validateAliasName, derivedChannelValues, withoutDerivedChannel, CHANNELS_FLAG_ID, DEFAULT_ALIAS_NAME, } from "../../services/alias-store.js";
|
|
8
9
|
import { loadAliasName, saveAliasName } from "../../services/alias-settings.js";
|
|
9
|
-
import { detectShells,
|
|
10
|
+
import { detectShells, renderArgs, validateConfig, writeAliasToShell, parseAliasFromRc, findAdoptableAlias, } from "../../services/alias-shell-writer.js";
|
|
10
11
|
import { readFile } from "node:fs/promises";
|
|
11
12
|
import { existsSync } from "node:fs";
|
|
12
13
|
function isSelectableItem(item) {
|
|
13
14
|
return item.kind === "alias-name" || item.kind === "flag";
|
|
14
15
|
}
|
|
15
|
-
function isSelectableDetailItem(item) {
|
|
16
|
-
return item.kind !== "header";
|
|
17
|
-
}
|
|
18
16
|
export function AliasScreen() {
|
|
19
17
|
const { state } = useApp();
|
|
20
18
|
const modal = useModal();
|
|
@@ -23,10 +21,103 @@ export function AliasScreen() {
|
|
|
23
21
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
24
22
|
const [error, setError] = useState(null);
|
|
25
23
|
const [viewMode, setViewMode] = useState({ kind: "main" });
|
|
26
|
-
const [detailSelectedIdx, setDetailSelectedIdx] = useState(0);
|
|
27
24
|
// Snapshot of the rc file's flag values as parsed on mount / write. Used
|
|
28
25
|
// to compute the dirty indicator: in-memory != rcSnapshot ⇒ unsaved.
|
|
29
26
|
const [rcSnapshot, setRcSnapshot] = useState(null);
|
|
27
|
+
// Transient write feedback shown on the header status line (not a blocking
|
|
28
|
+
// modal). Auto-clears after a few seconds. Errors still use a modal since
|
|
29
|
+
// they need to be read and acknowledged.
|
|
30
|
+
const [writeStatus, setWriteStatus] = useState(null);
|
|
31
|
+
// A hand-written `claude` alias found OUTSIDE any managed block on mount.
|
|
32
|
+
// Set when the rc file has no managed block but a stray alias we could
|
|
33
|
+
// adopt — drives the one-time "import it?" offer below. Cleared once the
|
|
34
|
+
// offer has been shown (whether accepted or declined).
|
|
35
|
+
const [adoptable, setAdoptable] = useState(null);
|
|
36
|
+
// When the user accepts a LOSSLESS adoption, we remember the line range of
|
|
37
|
+
// their original alias so the next write replaces it in place (no duplicate
|
|
38
|
+
// alias). Cleared after that write absorbs it into the managed block.
|
|
39
|
+
const [pendingAdoptRange, setPendingAdoptRange] = useState(null);
|
|
40
|
+
// Clear the write status line a few seconds after it appears.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!writeStatus)
|
|
43
|
+
return;
|
|
44
|
+
const t = setTimeout(() => setWriteStatus(null), 4000);
|
|
45
|
+
return () => clearTimeout(t);
|
|
46
|
+
}, [writeStatus]);
|
|
47
|
+
// One-time "adopt a hand-written alias?" offer. Fires when the mount load
|
|
48
|
+
// found a stray `claude` alias outside any managed block. We import only the
|
|
49
|
+
// flags we recognized; whether we also TAKE OWNERSHIP (remove the original
|
|
50
|
+
// line on the next write) depends on `lossless`:
|
|
51
|
+
// - lossless: every token round-trips → safe to absorb. We stash the line
|
|
52
|
+
// range so the next write replaces it in place (no duplicate alias).
|
|
53
|
+
// - not lossless: we couldn't parse some tokens (e.g. an uncatalogued
|
|
54
|
+
// flag like --model) → import the known flags for convenience but LEAVE
|
|
55
|
+
// the original line untouched. CRITICAL: we must NOT adopt its name here.
|
|
56
|
+
// Shell alias resolution is last-definition-wins, so a managed block
|
|
57
|
+
// sharing the name would shadow the surviving original at `source` time —
|
|
58
|
+
// silently killing the very tokens that made it not-lossless. Keeping the
|
|
59
|
+
// tool's own name lets both coexist: the original keeps working, the
|
|
60
|
+
// managed block lives under a different name.
|
|
61
|
+
// True once we've shown the offer. A ref (not state) so it survives re-render
|
|
62
|
+
// without being a dependency — critical because this effect mutates its own
|
|
63
|
+
// `adoptable` dep, and tying "fire once" to state/deps would let the dep
|
|
64
|
+
// change abort the in-flight modal mid-await (the cleanup would fire before
|
|
65
|
+
// the user answered). The ref decouples one-shot from the dependency array.
|
|
66
|
+
const didOfferAdoptRef = useRef(false);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!adoptable || !config || didOfferAdoptRef.current)
|
|
69
|
+
return;
|
|
70
|
+
didOfferAdoptRef.current = true;
|
|
71
|
+
(async () => {
|
|
72
|
+
const a = adoptable;
|
|
73
|
+
const body = a.rawLine.trim();
|
|
74
|
+
const others = a.others.length > 0
|
|
75
|
+
? `\n\n(Also found ${a.others.length} other claude alias${a.others.length === 1 ? "" : "es"} — leaving those alone.)`
|
|
76
|
+
: "";
|
|
77
|
+
// On a not-lossless adopt the managed block must NOT share the original's
|
|
78
|
+
// name — same name + last-wins shell resolution = the block shadows the
|
|
79
|
+
// surviving original, killing its uncatalogued tokens. Compute a managed
|
|
80
|
+
// name distinct from `a.name`: keep the tool's current name if it already
|
|
81
|
+
// differs, else derive a suffixed variant that's free in the rc file.
|
|
82
|
+
const managedName = a.lossless
|
|
83
|
+
? a.name
|
|
84
|
+
: pickDistinctName(config.aliasName, a.name, a.others);
|
|
85
|
+
const message = a.lossless
|
|
86
|
+
? `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}`
|
|
87
|
+
: `Found a hand-written alias:\n\n ${body}\n\nI can import the flags I recognize, but it uses ${a.leftovers.length} token${a.leftovers.length === 1 ? "" : "s"} I don't manage (${a.leftovers
|
|
88
|
+
.map((t) => JSON.stringify(t))
|
|
89
|
+
.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}`;
|
|
90
|
+
const accepted = await modal.confirm("Adopt existing alias?", message);
|
|
91
|
+
if (accepted) {
|
|
92
|
+
// Lossless: adopt the name AND take ownership (replace the line on
|
|
93
|
+
// write). Not-lossless: import flags under a distinct managed name and
|
|
94
|
+
// never touch the original line — so it survives AND keeps resolving.
|
|
95
|
+
setConfig((prev) => prev
|
|
96
|
+
? {
|
|
97
|
+
...prev,
|
|
98
|
+
aliasName: managedName,
|
|
99
|
+
flags: { ...prev.flags, ...a.flags },
|
|
100
|
+
}
|
|
101
|
+
: prev);
|
|
102
|
+
if (a.lossless) {
|
|
103
|
+
// Persist the adopted name and stash the line range so the next
|
|
104
|
+
// write replaces the original in place (no duplicate, no shadow).
|
|
105
|
+
void persistAliasName(a.name);
|
|
106
|
+
setPendingAdoptRange(a.lineRange);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Persist the distinct managed name so it survives a reopen.
|
|
110
|
+
void persistAliasName(managedName);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Clear the candidate now that the decision is made — after the await,
|
|
114
|
+
// never before (clearing before would abort this very async via the
|
|
115
|
+
// dep-change cleanup, the bug this effect previously had).
|
|
116
|
+
setAdoptable(null);
|
|
117
|
+
})();
|
|
118
|
+
// persistAliasName is stable (useCallback); excluded to keep this one-shot.
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
+
}, [adoptable, config, modal]);
|
|
30
121
|
// ── Load alias name + parse rc file + detect shells on mount ─────────
|
|
31
122
|
// The alias name is computer-wide and persisted in ~/.claude/settings.json.
|
|
32
123
|
// Flag values are read directly from the shell rc file's managed block —
|
|
@@ -46,6 +137,7 @@ export function AliasScreen() {
|
|
|
46
137
|
const base = defaultAliasConfig();
|
|
47
138
|
base.aliasName = name;
|
|
48
139
|
let parsedFlags = null;
|
|
140
|
+
let foundAdoptable = null;
|
|
49
141
|
if (readTarget && existsSync(readTarget.path)) {
|
|
50
142
|
try {
|
|
51
143
|
const rcText = await readFile(readTarget.path, "utf8");
|
|
@@ -57,6 +149,12 @@ export function AliasScreen() {
|
|
|
57
149
|
// But the user's stored preference wins when set explicitly.
|
|
58
150
|
// (No-op here — `name` already loaded above.)
|
|
59
151
|
}
|
|
152
|
+
else {
|
|
153
|
+
// No managed block on disk. The user may still have a hand-written
|
|
154
|
+
// `claude` alias elsewhere — detect it so we can offer to adopt it
|
|
155
|
+
// instead of looking broken. Non-destructive: just a candidate.
|
|
156
|
+
foundAdoptable = findAdoptableAlias(rcText);
|
|
157
|
+
}
|
|
60
158
|
}
|
|
61
159
|
catch {
|
|
62
160
|
// rc unreadable — fall back to defaults; not a hard error.
|
|
@@ -64,9 +162,15 @@ export function AliasScreen() {
|
|
|
64
162
|
}
|
|
65
163
|
if (cancelled)
|
|
66
164
|
return;
|
|
165
|
+
// No mirror normalization: channels.values holds OWN channels only;
|
|
166
|
+
// derived channels come from the dev-load flag at display/render time.
|
|
167
|
+
// The parser already subtracts derived values from the parsed channels
|
|
168
|
+
// list, so `base.flags` is correct as-is.
|
|
67
169
|
setConfig(base);
|
|
68
170
|
setShells(s);
|
|
69
|
-
setRcSnapshot(parsedFlags ?? base.flags);
|
|
171
|
+
setRcSnapshot(parsedFlags ?? { ...base.flags });
|
|
172
|
+
if (foundAdoptable)
|
|
173
|
+
setAdoptable(foundAdoptable);
|
|
70
174
|
}
|
|
71
175
|
catch (err) {
|
|
72
176
|
if (cancelled)
|
|
@@ -99,6 +203,30 @@ export function AliasScreen() {
|
|
|
99
203
|
continue;
|
|
100
204
|
out.push({ kind: "header", label: group.label });
|
|
101
205
|
for (const flag of groupFlags) {
|
|
206
|
+
// --channels shows enabled when the rendered alias would emit it —
|
|
207
|
+
// i.e. own channels are on OR a dev-loaded channel is derived in. This
|
|
208
|
+
// keeps the checkbox honest about what gets written without mutating
|
|
209
|
+
// the own-channels enabled flag (which would silently re-emit own
|
|
210
|
+
// values the user disabled).
|
|
211
|
+
let effectiveEnabled;
|
|
212
|
+
let effectiveDetail;
|
|
213
|
+
let stateSummary;
|
|
214
|
+
if (flag.id === CHANNELS_FLAG_ID) {
|
|
215
|
+
const own = config.flags[flag.id];
|
|
216
|
+
const ownValues = own && own.kind === "text-list" && own.enabled
|
|
217
|
+
? own.values.filter((v) => v.length > 0)
|
|
218
|
+
: [];
|
|
219
|
+
const derived = derivedChannelValues(config.flags);
|
|
220
|
+
// Deduped union — matches the writer's render-time union and the
|
|
221
|
+
// detail-pane summary, so the count is consistent everywhere.
|
|
222
|
+
const total = new Set([...ownValues, ...derived]).size;
|
|
223
|
+
effectiveEnabled = total > 0;
|
|
224
|
+
// Show the union count so the row matches what's written. When the
|
|
225
|
+
// count is purely derived, this reads e.g. `[x] --channels 1`.
|
|
226
|
+
effectiveDetail = total > 0 ? `${total}` : "";
|
|
227
|
+
// The detail-pane State line shows the same final channel list.
|
|
228
|
+
stateSummary = channelsStateSummary(config.flags);
|
|
229
|
+
}
|
|
102
230
|
out.push({
|
|
103
231
|
kind: "flag",
|
|
104
232
|
row: {
|
|
@@ -106,6 +234,9 @@ export function AliasScreen() {
|
|
|
106
234
|
flag,
|
|
107
235
|
value: config.flags[flag.id] ?? defaultValueFor(flag),
|
|
108
236
|
issue: issueMap.get(flag.id),
|
|
237
|
+
effectiveEnabled,
|
|
238
|
+
effectiveDetail,
|
|
239
|
+
stateSummary,
|
|
109
240
|
},
|
|
110
241
|
});
|
|
111
242
|
}
|
|
@@ -116,57 +247,13 @@ export function AliasScreen() {
|
|
|
116
247
|
const selectableIndices = useMemo(() => items.flatMap((it, i) => (isSelectableItem(it) ? [i] : [])), [items]);
|
|
117
248
|
// ── Flag-detail sub-screen items ──────────────────────────────────────
|
|
118
249
|
// Computed only when viewMode is flag-detail. Built off the flag's current
|
|
119
|
-
//
|
|
120
|
-
//
|
|
250
|
+
// The flag-detail sub-screen is now self-contained in <FlagDetailEditor>.
|
|
251
|
+
// We just resolve the flag + value pair here so the parent can render
|
|
252
|
+
// the editor and the right-pane preview.
|
|
121
253
|
const detailFlag = viewMode.kind === "flag-detail"
|
|
122
254
|
? ALIAS_FLAGS.find((f) => f.id === viewMode.flagId) ?? null
|
|
123
255
|
: null;
|
|
124
256
|
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
257
|
// ── Helper: check whether a row is gated (requires unmet) ─────────────
|
|
171
258
|
const isGated = useCallback((flag) => {
|
|
172
259
|
if (!flag.requires || !config)
|
|
@@ -257,6 +344,13 @@ export function AliasScreen() {
|
|
|
257
344
|
return { ...prev, flags };
|
|
258
345
|
});
|
|
259
346
|
}, []);
|
|
347
|
+
// Remove a value from the dev-load flag — invoked when the user deletes a
|
|
348
|
+
// "Dev-loaded channels" row inside the --channels editor. The derived
|
|
349
|
+
// section is sourced from this flag, so the deletion lands here, not in
|
|
350
|
+
// channels.values. updateFlag keeps enabled-state and cascades consistent.
|
|
351
|
+
const removeDevloadChannel = useCallback((val) => {
|
|
352
|
+
setConfig((prev) => prev ? { ...prev, flags: withoutDerivedChannel(prev.flags, val) } : prev);
|
|
353
|
+
}, []);
|
|
260
354
|
const editAliasName = useCallback(async () => {
|
|
261
355
|
if (!config)
|
|
262
356
|
return;
|
|
@@ -286,7 +380,7 @@ export function AliasScreen() {
|
|
|
286
380
|
// so there's no "what does enter do here?" overloading.
|
|
287
381
|
const k = selectedRow.value.kind;
|
|
288
382
|
if (k === "text-list" || k === "multi-with-custom") {
|
|
289
|
-
|
|
383
|
+
// FlagDetailEditor manages its own cursor — parent just flips mode.
|
|
290
384
|
setViewMode({ kind: "flag-detail", flagId: selectedRow.flag.id });
|
|
291
385
|
return;
|
|
292
386
|
}
|
|
@@ -330,8 +424,18 @@ export function AliasScreen() {
|
|
|
330
424
|
return;
|
|
331
425
|
}
|
|
332
426
|
try {
|
|
333
|
-
|
|
334
|
-
|
|
427
|
+
// If we adopted a lossless hand-written alias, pass its line range so
|
|
428
|
+
// the FIRST write replaces that line in place (absorbing it into the
|
|
429
|
+
// managed block) rather than appending a second alias. Once the block
|
|
430
|
+
// exists, the writer ignores the range and splices by markers.
|
|
431
|
+
const r = await writeAliasToShell(config, target, pendingAdoptRange ? { adoptLineRange: pendingAdoptRange } : {});
|
|
432
|
+
// The adopted line is now gone (replaced by the block); drop the range
|
|
433
|
+
// so subsequent writes don't try to re-target a line that moved.
|
|
434
|
+
if (pendingAdoptRange)
|
|
435
|
+
setPendingAdoptRange(null);
|
|
436
|
+
// Non-blocking confirmation on the status line — no "press any key".
|
|
437
|
+
const name = r.path.split("/").pop() || r.path;
|
|
438
|
+
setWriteStatus(`✓ ${r.action === "created" ? "Created" : "Updated"} ${name} — source it or open a new shell`);
|
|
335
439
|
setShells(await detectShells());
|
|
336
440
|
// We just wrote the rc file with current in-memory flag values, so
|
|
337
441
|
// the snapshot now matches in-memory state. Dirty indicator clears.
|
|
@@ -340,7 +444,7 @@ export function AliasScreen() {
|
|
|
340
444
|
catch (err) {
|
|
341
445
|
await modal.message("Write failed", err instanceof Error ? err.message : String(err), "error");
|
|
342
446
|
}
|
|
343
|
-
}, [config, shells, modal]);
|
|
447
|
+
}, [config, shells, modal, pendingAdoptRange]);
|
|
344
448
|
// ── Keyboard ──────────────────────────────────────────────────────────
|
|
345
449
|
// j/k navigate the selectable subset, jumping over headers/legend lines.
|
|
346
450
|
const moveSelection = useCallback((dir) => {
|
|
@@ -355,144 +459,22 @@ export function AliasScreen() {
|
|
|
355
459
|
: Math.min(selectableIndices.length - 1, Math.max(0, cursorPos + dir));
|
|
356
460
|
setSelectedIdx(selectableIndices[nextPos]);
|
|
357
461
|
}, [selectableIndices, selectedIdx]);
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
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]);
|
|
462
|
+
// The flag-detail sub-screen handles its own cursor + key + render
|
|
463
|
+
// (see FlagDetailEditor component). The parent only needs to know how
|
|
464
|
+
// to leave it.
|
|
458
465
|
const exitDetail = useCallback(() => {
|
|
459
466
|
setViewMode({ kind: "main" });
|
|
460
|
-
setDetailSelectedIdx(0);
|
|
461
467
|
}, []);
|
|
462
468
|
// ── Keyboard handler ──────────────────────────────────────────────────
|
|
463
|
-
//
|
|
469
|
+
// When the sub-screen is mounted, FlagDetailEditor installs its own
|
|
470
|
+
// keyboard handler. Both handlers fire (OpenTUI's useKeyboard delivers
|
|
471
|
+
// to every subscriber), so this handler must SKIP the keys the child
|
|
472
|
+
// owns to avoid double-dispatch. We bail entirely while in detail mode.
|
|
464
473
|
useKeyboardHandler((input, key) => {
|
|
465
474
|
if (state.modal)
|
|
466
475
|
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".
|
|
476
|
+
if (viewMode.kind === "flag-detail")
|
|
494
477
|
return;
|
|
495
|
-
}
|
|
496
478
|
// Main mode.
|
|
497
479
|
if (input === "j" || key.downArrow) {
|
|
498
480
|
moveSelection(1);
|
|
@@ -528,29 +510,59 @@ export function AliasScreen() {
|
|
|
528
510
|
}
|
|
529
511
|
});
|
|
530
512
|
// ── Render ────────────────────────────────────────────────────────────
|
|
531
|
-
const
|
|
513
|
+
const previewSegments = config ? renderArgs(config) : [];
|
|
514
|
+
const previewAliasName = config?.aliasName ?? "";
|
|
532
515
|
// Native OpenTUI scrollboxes own the scroll math. We render every item
|
|
533
516
|
// into the scrollbox's content area and ask the renderable to scroll the
|
|
534
517
|
// selected child into view whenever the cursor moves. The previous JS-
|
|
535
518
|
// windowed approach (ScrollableList) overstrike-rendered rows when its
|
|
536
519
|
// height computation drifted off the actual panel size.
|
|
537
520
|
const mainScrollRef = useRef(null);
|
|
538
|
-
const detailScrollRef = useRef(null);
|
|
539
|
-
useEffect(() => {
|
|
540
|
-
mainScrollRef.current?.scrollChildIntoView(`alias-row-${selectedIdx}`);
|
|
541
|
-
}, [selectedIdx]);
|
|
542
521
|
useEffect(() => {
|
|
543
|
-
|
|
522
|
+
const item = items[selectedIdx];
|
|
523
|
+
if (!item)
|
|
544
524
|
return;
|
|
545
|
-
|
|
546
|
-
}, [
|
|
525
|
+
mainScrollRef.current?.scrollChildIntoView(`alias-row-${getItemKey(item)}`);
|
|
526
|
+
}, [items, selectedIdx]);
|
|
547
527
|
const inDetail = viewMode.kind === "flag-detail" && detailFlag && detailValue;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
528
|
+
// FlagDetailEditor only handles text-list and multi-with-custom values;
|
|
529
|
+
// narrow the type for the JSX below.
|
|
530
|
+
const editableDetailValue = inDetail &&
|
|
531
|
+
(detailValue.kind === "text-list" ||
|
|
532
|
+
detailValue.kind === "multi-with-custom")
|
|
533
|
+
? detailValue
|
|
534
|
+
: null;
|
|
535
|
+
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) => (
|
|
536
|
+
// id is content-derived (not positional `${idx}`) so OpenTUI's
|
|
537
|
+
// reconciler stays in lockstep with React's key-based reconcile.
|
|
538
|
+
_jsx("box", { id: `alias-row-${getItemKey(item)}`, children: renderItem(item, idx === selectedIdx, isGated) }, getItemKey(item)))) }) }) }));
|
|
539
|
+
const listPanel = inDetail && editableDetailValue ? (_jsx(FlagDetailEditor, { flag: detailFlag, value: editableDetailValue, onChange: (next) => updateFlag(detailFlag.id, next), onBack: exitDetail, derivedValues: detailFlag.id === CHANNELS_FLAG_ID && config
|
|
540
|
+
? derivedChannelValues(config.flags)
|
|
541
|
+
: undefined, derivedLabel: "From --dangerously-load:", toggleLabel: detailFlag.id === CHANNELS_FLAG_ID
|
|
542
|
+
? "Enable own channels"
|
|
543
|
+
: undefined, onDerivedDelete: (v) => removeDevloadChannel(v) })) : (mainListPanel);
|
|
544
|
+
const mainDetailPanel = renderDetail(selectedRow, isGated, previewAliasName, previewSegments);
|
|
545
|
+
// When editing --channels and there are derived rows, the right pane carries
|
|
546
|
+
// the contextual instruction about what deleting a derived row does — that
|
|
547
|
+
// explanation belongs here, not crammed into the left list.
|
|
548
|
+
const channelsHasDerived = inDetail &&
|
|
549
|
+
detailFlag.id === CHANNELS_FLAG_ID &&
|
|
550
|
+
config &&
|
|
551
|
+
derivedChannelValues(config.flags).length > 0;
|
|
552
|
+
const derivedNote = channelsHasDerived
|
|
553
|
+
? "Rows under “From --dangerously-load:” are derived from the --dangerously-load-development-channels flag. Deleting one here removes it from that flag."
|
|
554
|
+
: undefined;
|
|
555
|
+
// The right-pane `State:` line is informational — it describes what the alias
|
|
556
|
+
// actually runs. For --channels that's the FINAL channel list: the union of
|
|
557
|
+
// the user's own channels and the derived (dev-loaded) ones. We show the
|
|
558
|
+
// union count so it agrees with the list row's count and the Preview, and
|
|
559
|
+
// annotate how many are derived so the source is clear. Computed only when
|
|
560
|
+
// editing --channels; other flags fall back to their own summary.
|
|
561
|
+
const stateOverride = inDetail && detailFlag.id === CHANNELS_FLAG_ID && config
|
|
562
|
+
? channelsStateSummary(config.flags)
|
|
563
|
+
: undefined;
|
|
552
564
|
const detailDetailPanel = inDetail
|
|
553
|
-
? renderFlagDetailRightPane(detailFlag, detailValue,
|
|
565
|
+
? renderFlagDetailRightPane(detailFlag, detailValue, previewAliasName, previewSegments, derivedNote, stateOverride)
|
|
554
566
|
: null;
|
|
555
567
|
const detailPanel = inDetail ? detailDetailPanel : mainDetailPanel;
|
|
556
568
|
const enabledCount = config
|
|
@@ -558,9 +570,11 @@ export function AliasScreen() {
|
|
|
558
570
|
: 0;
|
|
559
571
|
const subtitle = inDetail
|
|
560
572
|
? `editing ${detailFlag.flag}`
|
|
561
|
-
:
|
|
562
|
-
?
|
|
563
|
-
:
|
|
573
|
+
: writeStatus
|
|
574
|
+
? writeStatus
|
|
575
|
+
: config
|
|
576
|
+
? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled`
|
|
577
|
+
: "";
|
|
564
578
|
// Resolve the rc-file name we'd actually write to (basename of the chosen
|
|
565
579
|
// shell's path). Falls back to "shell" if no shells were detected — UI
|
|
566
580
|
// surface mirrors the runtime behavior of `handleWrite`.
|
|
@@ -576,14 +590,14 @@ export function AliasScreen() {
|
|
|
576
590
|
? rcSnapshot !== null && !shallowFlagsEqual(config.flags, rcSnapshot)
|
|
577
591
|
: false;
|
|
578
592
|
const footerHints = inDetail
|
|
579
|
-
?
|
|
593
|
+
? [
|
|
580
594
|
{ keys: ["↑", "↓"], label: "move" },
|
|
581
595
|
{ keys: ["space"], label: "toggle" },
|
|
582
596
|
{ keys: ["a"], label: "add" },
|
|
583
597
|
{ keys: ["d"], label: "delete" },
|
|
584
598
|
{ keys: ["h"], label: "back" },
|
|
585
|
-
]
|
|
586
|
-
:
|
|
599
|
+
]
|
|
600
|
+
: [
|
|
587
601
|
{ keys: ["↑", "↓"], label: "move" },
|
|
588
602
|
{ keys: ["space"], label: "toggle" },
|
|
589
603
|
{ keys: ["e"], label: "edit" },
|
|
@@ -592,10 +606,41 @@ export function AliasScreen() {
|
|
|
592
606
|
keys: ["w"],
|
|
593
607
|
label: `${dirty ? "*" : ""}write to ${writeLabel}`,
|
|
594
608
|
},
|
|
595
|
-
]
|
|
609
|
+
];
|
|
596
610
|
return (_jsx(ScreenLayout, { title: "Claude Alias", subtitle: subtitle, currentScreen: "alias", footerHints: footerHints, listPanel: listPanel, detailPanel: detailPanel }));
|
|
597
611
|
}
|
|
598
612
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
613
|
+
/**
|
|
614
|
+
* Summarize the EFFECTIVE state of `--channels` for the detail `State:` line:
|
|
615
|
+
* the final channel list that the alias will actually run, i.e. the union of
|
|
616
|
+
* the user's own (enabled) channels and the derived dev-loaded ones. This must
|
|
617
|
+
* agree with the list row's union count and the Preview.
|
|
618
|
+
*
|
|
619
|
+
* Returns "off" when nothing is emitted, or e.g.:
|
|
620
|
+
* - "2 channels" (all own, no derived)
|
|
621
|
+
* - "1 channel (from --dangerously-load)" (purely derived, own off/empty)
|
|
622
|
+
* - "2 channels (1 from --dangerously-load)" (mixed)
|
|
623
|
+
*/
|
|
624
|
+
function channelsStateSummary(flags) {
|
|
625
|
+
const ownValue = flags[CHANNELS_FLAG_ID];
|
|
626
|
+
const ownValues = ownValue && ownValue.kind === "text-list" && ownValue.enabled
|
|
627
|
+
? ownValue.values.filter((v) => v.length > 0)
|
|
628
|
+
: [];
|
|
629
|
+
const derived = derivedChannelValues(flags);
|
|
630
|
+
// Union, deduped, matching the writer's render-time union.
|
|
631
|
+
const total = new Set([...ownValues, ...derived]).size;
|
|
632
|
+
if (total === 0)
|
|
633
|
+
return "off";
|
|
634
|
+
const noun = total === 1 ? "channel" : "channels";
|
|
635
|
+
if (derived.length === 0)
|
|
636
|
+
return `${total} ${noun}`;
|
|
637
|
+
// How many of the union come from the derived flag (after dedup with own).
|
|
638
|
+
const derivedInUnion = new Set(derived).size;
|
|
639
|
+
const derivedNote = derivedInUnion === total
|
|
640
|
+
? "from --dangerously-load"
|
|
641
|
+
: `${derivedInUnion} from --dangerously-load`;
|
|
642
|
+
return `${total} ${noun} (${derivedNote})`;
|
|
643
|
+
}
|
|
599
644
|
/**
|
|
600
645
|
* Compare two flag maps for value equality. We render-then-diff via JSON
|
|
601
646
|
* because FlagValues are small, fully serialisable, and order-insensitive
|
|
@@ -633,6 +678,43 @@ function isFlagEnabled(value) {
|
|
|
633
678
|
return value.enabled;
|
|
634
679
|
}
|
|
635
680
|
}
|
|
681
|
+
/**
|
|
682
|
+
* Pick a managed-alias name that can't shadow a surviving hand-written alias.
|
|
683
|
+
*
|
|
684
|
+
* On a not-lossless adoption we keep the user's original `<original>` line in
|
|
685
|
+
* the rc file, so the managed block must NOT share its name (shell aliases are
|
|
686
|
+
* last-definition-wins — a same-named block would override the original and
|
|
687
|
+
* silently drop the very tokens that made it not-lossless).
|
|
688
|
+
*
|
|
689
|
+
* Strategy: prefer the tool's `current` name if it already differs from the
|
|
690
|
+
* original. Otherwise derive `<original>m`, `<original>m2`, … until we find one
|
|
691
|
+
* that is valid AND collides with neither the original nor the other detected
|
|
692
|
+
* claude aliases. Falls back to a timestamp-free deterministic suffix.
|
|
693
|
+
*/
|
|
694
|
+
function pickDistinctName(current, original, others) {
|
|
695
|
+
// Names already taken by aliases we're leaving in place.
|
|
696
|
+
const taken = new Set([original]);
|
|
697
|
+
for (const line of others) {
|
|
698
|
+
const m = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=/.exec(line.trim());
|
|
699
|
+
if (m)
|
|
700
|
+
taken.add(m[1]);
|
|
701
|
+
}
|
|
702
|
+
// If our current name is already distinct + valid, just use it.
|
|
703
|
+
if (current !== original && !taken.has(current) && !validateAliasName(current)) {
|
|
704
|
+
return current;
|
|
705
|
+
}
|
|
706
|
+
// Derive a suffixed variant from the original.
|
|
707
|
+
const base = `${original}m`;
|
|
708
|
+
if (!taken.has(base) && !validateAliasName(base))
|
|
709
|
+
return base;
|
|
710
|
+
for (let n = 2; n < 100; n++) {
|
|
711
|
+
const cand = `${original}m${n}`;
|
|
712
|
+
if (!taken.has(cand) && !validateAliasName(cand))
|
|
713
|
+
return cand;
|
|
714
|
+
}
|
|
715
|
+
// Pathological fallback — should never be reached for sane inputs.
|
|
716
|
+
return `${original}managed`;
|
|
717
|
+
}
|
|
636
718
|
/**
|
|
637
719
|
* Produce an "enabled by default" value for a flag — what we'd give it when
|
|
638
720
|
* the user activates a dependent that needs this one on. For booleans this
|
|
@@ -871,13 +953,16 @@ function renderItem(item, isSelected, isGated) {
|
|
|
871
953
|
}
|
|
872
954
|
}
|
|
873
955
|
function renderRow(row, selected, gated) {
|
|
874
|
-
|
|
956
|
+
// Prefer the precomputed effective state (e.g. --channels shows enabled when
|
|
957
|
+
// a dev-loaded channel forces it to emit) over the flag's own enabled flag.
|
|
958
|
+
const enabled = row.effectiveEnabled ?? isFlagEnabled(row.value);
|
|
875
959
|
const marker = enabled ? "[x]" : "[ ]";
|
|
876
960
|
const issueMark = row.issue ? " ⚠" : "";
|
|
877
961
|
const fg = gated ? "gray" : enabled ? "white" : "gray";
|
|
878
962
|
// Only show a value suffix when the flag carries non-redundant detail.
|
|
879
|
-
// Boolean / tri-state state is fully conveyed by the checkbox.
|
|
880
|
-
|
|
963
|
+
// Boolean / tri-state state is fully conveyed by the checkbox. A precomputed
|
|
964
|
+
// effectiveDetail (e.g. --channels union count) wins over the own-value one.
|
|
965
|
+
const detail = row.effectiveDetail ?? enabledDetail(row.value);
|
|
881
966
|
const detailSuffix = detail ? ` ${detail}` : "";
|
|
882
967
|
const line = `${selected ? "▶ " : " "}${marker} ${row.flag.flag}${detailSuffix}${issueMark}`;
|
|
883
968
|
if (selected) {
|
|
@@ -917,7 +1002,7 @@ function enabledDetail(value) {
|
|
|
917
1002
|
}
|
|
918
1003
|
}
|
|
919
1004
|
}
|
|
920
|
-
function renderDetail(row, isGated,
|
|
1005
|
+
function renderDetail(row, isGated, aliasName, segments) {
|
|
921
1006
|
if (!row) {
|
|
922
1007
|
return _jsx("text", { fg: "gray", children: "No row selected." });
|
|
923
1008
|
}
|
|
@@ -926,83 +1011,101 @@ function renderDetail(row, isGated, previewBlock) {
|
|
|
926
1011
|
: renderFlagDetail(row, isGated);
|
|
927
1012
|
// Preview is the only "global" element kept in the detail panel — it
|
|
928
1013
|
// updates as the user toggles, which is the strongest visual feedback for
|
|
929
|
-
// "what alias am I building".
|
|
930
|
-
|
|
1014
|
+
// "what alias am I building". We show argv one-per-line rather than the
|
|
1015
|
+
// raw POSIX-escaped alias body (which contains `'\''` sequences that look
|
|
1016
|
+
// alarming to readers who don't know the close-escape-reopen pattern).
|
|
1017
|
+
return (_jsxs("box", { flexDirection: "column", children: [headerAndBody, _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: "Preview:" }), renderPreviewLines(aliasName, segments)] }));
|
|
931
1018
|
}
|
|
932
1019
|
function renderAliasNameDetail(row) {
|
|
933
1020
|
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
1021
|
}
|
|
935
1022
|
function renderFlagDetail(row, isGated) {
|
|
936
1023
|
const gated = isGated(row.flag);
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
const
|
|
941
|
-
return
|
|
1024
|
+
// State must agree with the checkbox and the Preview. For --channels we show
|
|
1025
|
+
// the effective channel-list summary (union of own + derived); other flags
|
|
1026
|
+
// use their own value summary.
|
|
1027
|
+
const stateText = row.stateSummary ?? summarizeValue(row.value);
|
|
1028
|
+
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: stateText })] }), 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}` }))] }));
|
|
942
1029
|
}
|
|
943
1030
|
/**
|
|
944
|
-
* Render
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
1031
|
+
* Render the alias preview as one argv arg per line. This replaces the raw
|
|
1032
|
+
* POSIX-escaped alias body (`alias c='claude '\''text with spaces'\'' …'`),
|
|
1033
|
+
* which is correct but visually alarming — readers without POSIX shell
|
|
1034
|
+
* fluency mistake `'\''` for broken quoting.
|
|
1035
|
+
*
|
|
1036
|
+
* We show each argv token on its own row, with values that need quoting
|
|
1037
|
+
* wrapped in plain double quotes (display-only — these are NOT what gets
|
|
1038
|
+
* written to the rc file). Composite segments (templated values) display
|
|
1039
|
+
* with their `{token}` placeholders intact in cyan so users see exactly
|
|
1040
|
+
* what will expand at shell-eval time.
|
|
1041
|
+
*
|
|
1042
|
+
* Example output:
|
|
1043
|
+
* alias c='claude
|
|
1044
|
+
* --dangerously-skip-permissions
|
|
1045
|
+
* --append-system-prompt "you ae the smaretes model ever"
|
|
1046
|
+
* --debug plugins
|
|
1047
|
+
* '
|
|
948
1048
|
*/
|
|
949
|
-
function
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
|
|
1049
|
+
function renderPreviewLines(aliasName, segments) {
|
|
1050
|
+
if (segments.length === 0) {
|
|
1051
|
+
return _jsx("text", { fg: "white", children: `alias ${aliasName || "?"}='claude'` });
|
|
1052
|
+
}
|
|
1053
|
+
const lines = [
|
|
1054
|
+
_jsx("text", { fg: "white", children: `alias ${aliasName || "?"}='claude` }, "head"),
|
|
1055
|
+
];
|
|
1056
|
+
segments.forEach((seg, i) => {
|
|
1057
|
+
lines.push(_jsxs("text", { fg: "white", children: [" ", renderPreviewSegment(seg)] }, `arg-${i}`));
|
|
957
1058
|
});
|
|
958
|
-
|
|
1059
|
+
lines.push(_jsx("text", { fg: "white", children: "'" }, "tail"));
|
|
1060
|
+
return _jsx(_Fragment, { children: lines });
|
|
959
1061
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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}`;
|
|
1062
|
+
function renderPreviewSegment(seg) {
|
|
1063
|
+
if (seg.kind === "literal") {
|
|
1064
|
+
return _jsx("span", { fg: "white", children: quoteForDisplay(seg.text) });
|
|
975
1065
|
}
|
|
1066
|
+
// Composite: literal parts as-is, raw parts as `{token}` reconstructed
|
|
1067
|
+
// from the writer's TOKEN_TO_SUB map. We don't have direct access to the
|
|
1068
|
+
// map here, so we reverse-lookup using the same forms.
|
|
1069
|
+
const spans = [];
|
|
1070
|
+
seg.parts.forEach((p, i) => {
|
|
1071
|
+
if (p.kind === "literal") {
|
|
1072
|
+
spans.push(_jsx("span", { fg: "white", children: p.text }, `p-${i}`));
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
spans.push(_jsx("span", { fg: "cyan", children: substitutionToToken(p.posix) }, `p-${i}`));
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
return _jsx("span", { children: spans });
|
|
976
1079
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
case "
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1080
|
+
const PREVIEW_SAFE = /^[A-Za-z0-9_\-=,:.\/!@%+]+$/;
|
|
1081
|
+
function quoteForDisplay(text) {
|
|
1082
|
+
if (text === "")
|
|
1083
|
+
return '""';
|
|
1084
|
+
if (PREVIEW_SAFE.test(text))
|
|
1085
|
+
return text;
|
|
1086
|
+
// Display-only quoting. We don't have to escape anything for the shell
|
|
1087
|
+
// since this string never reaches a parser — it's just for the user to
|
|
1088
|
+
// read. We do escape an embedded `"` to keep visual balance.
|
|
1089
|
+
return `"${text.replace(/"/g, '\\"')}"`;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Reverse-lookup the writer's POSIX substitution codes to their `{token}`
|
|
1093
|
+
* forms. Mirror of `TOKEN_TO_SUB` in `alias-shell-writer.ts`.
|
|
1094
|
+
*/
|
|
1095
|
+
function substitutionToToken(posixCode) {
|
|
1096
|
+
switch (posixCode) {
|
|
1097
|
+
case '$(basename "$PWD")':
|
|
1098
|
+
return "{folder}";
|
|
1099
|
+
case "$(date +%d)":
|
|
1100
|
+
return "{day}";
|
|
1101
|
+
case "$(date +%m)":
|
|
1102
|
+
return "{month}";
|
|
1103
|
+
case "$(date +%Y)":
|
|
1104
|
+
return "{year}";
|
|
1105
|
+
default:
|
|
1106
|
+
return posixCode;
|
|
1003
1107
|
}
|
|
1004
|
-
return _jsx("text", { fg: "white", children: line });
|
|
1005
1108
|
}
|
|
1006
|
-
function renderFlagDetailRightPane(flag, value,
|
|
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
|
|
1109
|
+
function renderFlagDetailRightPane(flag, value, aliasName, segments, derivedNote, stateOverride) {
|
|
1110
|
+
return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: "white", children: _jsx("strong", { children: flag.flag }) }), _jsx("text", { fg: "gray", children: flag.description }), derivedNote && (_jsxs(_Fragment, { children: [_jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: derivedNote })] })), _jsx("text", { children: " " }), _jsxs("text", { children: [_jsx("span", { fg: "gray", children: "State: " }), _jsx("span", { fg: "white", children: stateOverride ?? summarizeValue(value) })] }), _jsx("text", { children: " " }), _jsx("text", { fg: "gray", children: "Preview:" }), renderPreviewLines(aliasName, segments), _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
1111
|
}
|