magus-cli 7.4.0 → 7.4.1

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.
@@ -135,16 +135,22 @@ type CheckedMap<V> = unknown extends V
135
135
 
136
136
  /**
137
137
  * {@link writableMap} for a list the caller pushes into or filters —
138
- * `enabledMcpjsonServers`. Present but not an array throws the same way.
138
+ * `enabledMcpjsonServers`, a gitignore manifest's `ignore` and `track`. Present
139
+ * but not an array throws the same way.
139
140
  */
140
141
  export function writableList<S extends object, K extends keyof S & string>(
141
142
  settings: S,
142
143
  key: K,
143
144
  settingsPath: string,
144
- ): S[K] {
145
- return checkedEntry(settings, key, settingsPath, key, "array");
145
+ ): CheckedList<S[K]> {
146
+ return checkedEntry(settings, key, settingsPath, key, "array") as CheckedList<
147
+ S[K]
148
+ >;
146
149
  }
147
150
 
151
+ /** {@link CheckedMap} for a list: an untyped key comes back as the array it is. */
152
+ type CheckedList<V> = unknown extends V ? unknown[] | undefined : V;
153
+
148
154
  function checkedEntry<S extends object, K extends keyof S & string>(
149
155
  settings: S,
150
156
  key: K,
@@ -1439,6 +1439,36 @@ async function isNpmDefaultPrefix(
1439
1439
  return real !== null && path.resolve(real) === root;
1440
1440
  }
1441
1441
 
1442
+ /**
1443
+ * Is `onPath` — the entry PATH actually runs, before any link is followed —
1444
+ * the launcher the native installer maintains beside `dataHome`?
1445
+ *
1446
+ * The launcher lives one level up from the data home, in `bin`:
1447
+ * `~/.local/share` → `~/.local/bin/claude`. The comparison is on the
1448
+ * DIRECTORIES, never on the files: both the launcher and a hand-made link
1449
+ * resolve into `versions/<version>`, which is precisely what has to be told
1450
+ * apart. Their real paths are compared too, so a symlinked `~/.local` still
1451
+ * matches.
1452
+ */
1453
+ async function isNativeLauncher(
1454
+ s: ProbeSession,
1455
+ onPath: string,
1456
+ dataHome: string,
1457
+ ): Promise<boolean> {
1458
+ const launcherDir = path.resolve(dataHome, "..", "bin");
1459
+ const entryDir = path.resolve(path.dirname(onPath));
1460
+ if (launcherDir === entryDir) return true;
1461
+ const [launcherReal, entryReal] = await Promise.all([
1462
+ s.io.realpath(launcherDir),
1463
+ s.io.realpath(entryDir),
1464
+ ]);
1465
+ return (
1466
+ launcherReal !== null &&
1467
+ entryReal !== null &&
1468
+ path.resolve(launcherReal) === path.resolve(entryReal)
1469
+ );
1470
+ }
1471
+
1442
1472
  /**
1443
1473
  * Claude Code's native installer keeps every release side by side under
1444
1474
  * `<data>/claude/versions/<version>` and points PATH (`~/.local/bin/claude`) at
@@ -1451,12 +1481,23 @@ async function isNpmDefaultPrefix(
1451
1481
  * number. The version is that name; the latest is npm's `latest` dist-tag for
1452
1482
  * Claude Code, the channel the native installer tracks by default.
1453
1483
  *
1484
+ * **Resolving into `versions/` is not enough: the entry PATH runs has to BE the
1485
+ * native launcher.** A hand-made link earlier on PATH — `/usr/local/bin/claude`
1486
+ * pointed at a release directory by hand, a wrapper someone wrote — resolves
1487
+ * into exactly the same place, and planning `claude update` for it would be a
1488
+ * plan that changes nothing: the updater rewrites the launcher, and PATH goes
1489
+ * on running the other file at the old version. Every run would find it behind
1490
+ * and plan the same no-op again. So the check is on `onPath`, the unresolved
1491
+ * entry, and a copy that is not the launcher falls through to the spec's own
1492
+ * installer — which reports it as a second copy it does not own.
1493
+ *
1454
1494
  * True when it recognised the layout; `f` then holds the whole answer.
1455
1495
  */
1456
1496
  async function probeClaudeNative(
1457
1497
  s: ProbeSession,
1458
1498
  spec: BinRequirement,
1459
1499
  f: ToolFacts,
1500
+ onPath: string,
1460
1501
  resolvedPath: string,
1461
1502
  ): Promise<boolean> {
1462
1503
  // The cheap, pure shape test first, so no other tool pays for the IO below.
@@ -1471,7 +1512,10 @@ async function probeClaudeNative(
1471
1512
  let owned = false;
1472
1513
  for (const home of dataHomes) {
1473
1514
  const versions = path.resolve(home, "claude", "versions");
1474
- owned = versions === dir || (await s.io.realpath(versions)) === dir;
1515
+ if (versions !== dir && (await s.io.realpath(versions)) !== dir) continue;
1516
+ // The directory is the installer's. Only the launcher it maintains
1517
+ // there speaks for it.
1518
+ owned = await isNativeLauncher(s, onPath, home);
1475
1519
  if (owned) break;
1476
1520
  }
1477
1521
  if (!owned) return false;
@@ -1561,8 +1605,9 @@ async function probeInto(
1561
1605
  f.resolvedPath = resolvedPath;
1562
1606
 
1563
1607
  // A file Claude Code's native installer owns answers for itself, whatever
1564
- // installer the spec names.
1565
- if (await probeClaudeNative(s, spec, f, resolvedPath)) return;
1608
+ // installer the spec names. `onPath` as well as `resolvedPath`: ownership
1609
+ // turns on which file PATH runs, not only on where it points.
1610
+ if (await probeClaudeNative(s, spec, f, onPath, resolvedPath)) return;
1566
1611
 
1567
1612
  const pinned = pinOf(spec) !== undefined;
1568
1613
  const via: BinInstaller = spec.via;
@@ -1,6 +1,6 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core";
2
2
  import React, { useRef, useState } from "react";
3
- import { useKeyLayer } from "../../input/KeyRouter.js";
3
+ import { useKeyLayer, usePasteLayer } from "../../input/KeyRouter.js";
4
4
  import { type KeyPress, isScrollKey } from "../../input/key-dispatch.js";
5
5
  import { useApp, useModal } from "../../state/AppContext.js";
6
6
  import { useDimensions } from "../../state/DimensionsContext.js";
@@ -23,6 +23,12 @@ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
23
23
  * Enter that confirms a dialog confirms only the dialog. Ctrl+C still quits: it
24
24
  * is taken above every layer, in src/tui.tsx.
25
25
  *
26
+ * It takes every PASTE for the same reason, and does nothing with it: a search
27
+ * box on the screen behind still has focus, and without this its query would
28
+ * grow from a paste aimed at the dialog in front of it. The event itself is not
29
+ * stopped, so the input modal's focused `<input>` still receives it — the
30
+ * arrangement the `input` case below already relies on for keys.
31
+ *
26
32
  * - confirm: y / Enter confirm; n / Esc / q cancel.
27
33
  * - select: ↑↓ (j/k) move, Enter picks, Esc / q cancel (a no-op for a
28
34
  * question that must be answered).
@@ -79,6 +85,8 @@ export function ModalContainer() {
79
85
  }
80
86
  };
81
87
 
88
+ usePasteLayer("modal", modal !== null, () => true);
89
+
82
90
  useKeyLayer("modal", modal !== null, (key) => {
83
91
  if (!modal) return false;
84
92
 
@@ -1,29 +1,43 @@
1
1
  /**
2
- * The one keyboard subscriber in the React tree.
2
+ * The one keyboard subscriber in the React tree — and the one paste subscriber.
3
3
  *
4
- * Components do not call `useKeyboard`. They register a LAYER — modal, input,
5
- * screen, scroll or global — and this router offers each key to the live layers
6
- * through `dispatchKey`, top first, so exactly one of them acts on it. The
7
- * system layer (Ctrl+C) is not here: `src/tui.tsx` takes that key on the
8
- * renderer before this subscriber runs and stops it there.
4
+ * Components do not call `useKeyboard`, and no screen listens for a paste of
5
+ * its own. They register a LAYER — modal, input, screen, scroll or global — and
6
+ * this router offers each key to the live layers through `dispatchKey`, top
7
+ * first, so exactly one of them acts on it. The system layer (Ctrl+C) is not
8
+ * here: `src/tui.tsx` takes that key on the renderer before this subscriber
9
+ * runs and stops it there.
9
10
  *
10
11
  * Each key is dispatched inside `flushSync`, so everything a key changes is
11
12
  * committed — and every layer's `active` flag and handler refreshed — before the
12
- * next key is looked at. A paste or a fast typist delivers several keys in one
13
- * stdin read; without this, `/abc` would open the search box and then send `a`,
14
- * `b` and `c` to the screen underneath, whose handlers still believed the box
15
- * was closed.
13
+ * next key is looked at. A fast typist delivers several keys in one stdin read;
14
+ * without this, `/abc` would open the search box and then send `a`, `b` and `c`
15
+ * to the screen underneath, whose handlers still believed the box was closed.
16
+ *
17
+ * A PASTE is NOT that burst of keys. Bracketed paste is on (OpenTUI's native
18
+ * core writes `ESC[?2004h`), so the terminal wraps the clipboard in
19
+ * `ESC[200~ … ESC[201~` and the renderer emits ONE `paste` event carrying the
20
+ * whole text — and no `keypress` at all. That is why a search term pasted into
21
+ * a filter box used to vanish without a trace: every layer here listened for
22
+ * keys that were never sent. `dispatchPaste` runs the same layer order over the
23
+ * layers that registered a paste handler.
24
+ *
25
+ * The paste event is deliberately NOT stopped: OpenTUI hands it to the focused
26
+ * renderable afterwards, which is how the input modal's `<input>` still takes a
27
+ * paste of its own — exactly the arrangement the modal layer relies on for keys.
16
28
  */
17
29
 
18
30
  import { appendFileSync } from "node:fs";
19
31
  import os from "node:os";
20
32
  import path from "node:path";
21
- import type { KeyEvent } from "@opentui/core";
22
- import { flushSync, useKeyboard } from "@opentui/react";
33
+ import type { KeyEvent, PasteEvent } from "@opentui/core";
34
+ import { decodePasteBytes, stripAnsiSequences } from "@opentui/core";
35
+ import { flushSync, useKeyboard, useRenderer } from "@opentui/react";
23
36
  import {
24
37
  type ReactNode,
25
38
  createContext,
26
39
  useContext,
40
+ useEffect,
27
41
  useLayoutEffect,
28
42
  useState,
29
43
  } from "react";
@@ -37,12 +51,20 @@ import {
37
51
  type KeyLayer,
38
52
  type KeyLayerId,
39
53
  type KeyPress,
54
+ type PasteLayer,
40
55
  dispatchKey,
56
+ dispatchPaste,
41
57
  inputLayerConsumes,
42
58
  typedCharacter,
43
59
  } from "./key-dispatch.js";
44
60
 
45
- const LayersContext = createContext<Set<KeyLayer> | null>(null);
61
+ /** The two registries the router dispatches over. */
62
+ interface LayerRegistry {
63
+ keys: Set<KeyLayer>;
64
+ pastes: Set<PasteLayer>;
65
+ }
66
+
67
+ const LayersContext = createContext<LayerRegistry | null>(null);
46
68
 
47
69
  /** OpenTUI's key event, reduced to the fields the dispatcher reads. */
48
70
  export function toKeyPress(event: KeyEvent): KeyPress {
@@ -55,35 +77,96 @@ export function toKeyPress(event: KeyEvent): KeyPress {
55
77
  };
56
78
  }
57
79
 
80
+ /**
81
+ * The text a paste event carries, as a single-line search term.
82
+ *
83
+ * Three things are stripped, in this order. The bytes are decoded as UTF-8
84
+ * (`decodePasteBytes`). Any ANSI escape the clipboard carried is removed — a
85
+ * terminal forwards the clipboard verbatim inside the brackets, so copying
86
+ * coloured output pastes its escape sequences too, and they are not a search
87
+ * term. Then every remaining control character goes: a newline or a tab becomes
88
+ * one space, so a multi-line clipboard reads as words rather than running the
89
+ * lines together, and the rest are dropped outright.
90
+ *
91
+ * The ends are trimmed last, because a copied line almost always carries its
92
+ * trailing newline and a query ending in a space matches nothing.
93
+ */
94
+ export function pastedText(event: PasteEvent): string {
95
+ const decoded = stripAnsiSequences(decodePasteBytes(event.bytes));
96
+ let out = "";
97
+ for (const ch of decoded) {
98
+ const cp = ch.codePointAt(0) ?? 0;
99
+ // Newline, carriage return, tab: one space — and a RUN of them one space
100
+ // too, so a CRLF clipboard does not double-space every line break.
101
+ if (cp === 0x0a || cp === 0x0d || cp === 0x09) {
102
+ if (!out.endsWith(" ")) out += " ";
103
+ continue;
104
+ }
105
+ // The rest of C0 and C1, dropped. Written as codepoint bounds rather
106
+ // than a regex character class because the formatter rewrites a
107
+ // `\u0000` escape inside a regex literal into the raw byte it stands
108
+ // for — which puts literal control characters in this source file.
109
+ if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) continue;
110
+ out += ch;
111
+ }
112
+ return out.trim();
113
+ }
114
+
58
115
  /**
59
116
  * `MAGUS_DEBUG_KEYS=1` appends every key and the layer that took it to
60
117
  * `magus-keys.log` in the temp directory — the first thing to look at when a
61
- * key "does nothing".
118
+ * key "does nothing". A paste is logged the same way, as `paste`.
62
119
  */
63
- function logKey(key: KeyPress, consumer: KeyLayerId | null): void {
120
+ function logKey(what: string, consumer: KeyLayerId | null): void {
64
121
  if (process.env.MAGUS_DEBUG_KEYS !== "1") return;
65
122
  try {
66
123
  appendFileSync(
67
124
  path.join(os.tmpdir(), "magus-keys.log"),
68
- `${new Date().toISOString()} name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift} -> ${consumer ?? "nobody"}\n`,
125
+ `${new Date().toISOString()} ${what} -> ${consumer ?? "nobody"}\n`,
69
126
  );
70
127
  } catch {
71
128
  // Best-effort logging: never let it break a keypress.
72
129
  }
73
130
  }
74
131
 
132
+ function describeKey(key: KeyPress): string {
133
+ return `name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift}`;
134
+ }
135
+
75
136
  export function KeyRouter({ children }: { children: ReactNode }) {
76
- const [layers] = useState(() => new Set<KeyLayer>());
137
+ const [layers] = useState<LayerRegistry>(() => ({
138
+ keys: new Set<KeyLayer>(),
139
+ pastes: new Set<PasteLayer>(),
140
+ }));
141
+ const renderer = useRenderer();
77
142
 
78
143
  useKeyboard((event) => {
79
144
  const key = toKeyPress(event);
80
145
  let consumer: KeyLayerId | null = null;
81
146
  flushSync(() => {
82
- consumer = dispatchKey([...layers], key);
147
+ consumer = dispatchKey([...layers.keys], key);
83
148
  });
84
- logKey(key, consumer);
149
+ logKey(describeKey(key), consumer);
85
150
  });
86
151
 
152
+ // The ONE paste subscriber. `useKeyboard` has no paste counterpart in
153
+ // @opentui/react, so this listens on the renderer's key input directly —
154
+ // once, here, rather than once per screen.
155
+ useEffect(() => {
156
+ const onPaste = (event: PasteEvent): void => {
157
+ const text = pastedText(event);
158
+ let consumer: KeyLayerId | null = null;
159
+ flushSync(() => {
160
+ consumer = dispatchPaste([...layers.pastes], text);
161
+ });
162
+ logKey(`paste=${JSON.stringify(text)}`, consumer);
163
+ };
164
+ renderer.keyInput.on("paste", onPaste);
165
+ return () => {
166
+ renderer.keyInput.off("paste", onPaste);
167
+ };
168
+ }, [renderer, layers]);
169
+
87
170
  return (
88
171
  <LayersContext.Provider value={layers}>{children}</LayersContext.Provider>
89
172
  );
@@ -111,9 +194,40 @@ export function useKeyLayer(
111
194
  });
112
195
 
113
196
  useLayoutEffect(() => {
114
- layers.add(entry);
197
+ layers.keys.add(entry);
115
198
  return () => {
116
- layers.delete(entry);
199
+ layers.keys.delete(entry);
200
+ };
201
+ }, [layers, entry]);
202
+ }
203
+
204
+ /**
205
+ * Register a PASTE layer for as long as the calling component is mounted.
206
+ *
207
+ * The counterpart of {@link useKeyLayer}, and refreshed on every commit the
208
+ * same way. A component registers this only when a paste means something to it:
209
+ * a layer that does not is simply absent from the order, and the paste falls
210
+ * through it.
211
+ */
212
+ export function usePasteLayer(
213
+ id: KeyLayerId,
214
+ active: boolean,
215
+ handlePaste: (text: string) => boolean,
216
+ ): void {
217
+ const layers = useContext(LayersContext);
218
+ if (!layers) throw new Error("usePasteLayer needs a <KeyRouter> above it");
219
+ const [entry] = useState<PasteLayer>(() => ({ id, active, handlePaste }));
220
+
221
+ useLayoutEffect(() => {
222
+ entry.id = id;
223
+ entry.active = active;
224
+ entry.handlePaste = handlePaste;
225
+ });
226
+
227
+ useLayoutEffect(() => {
228
+ layers.pastes.add(entry);
229
+ return () => {
230
+ layers.pastes.delete(entry);
117
231
  };
118
232
  }, [layers, entry]);
119
233
  }
@@ -137,8 +251,8 @@ export interface SearchBox {
137
251
  /** The box has focus: what the user types goes into it. */
138
252
  focused: boolean;
139
253
  query: string;
140
- /** Add one typed character. */
141
- append(char: string): void;
254
+ /** Add text: one typed character, or a whole pasted term. */
255
+ append(text: string): void;
142
256
  /** Delete the last character. */
143
257
  erase(): void;
144
258
  /** Empty the box and stay in it. */
@@ -155,8 +269,18 @@ export interface SearchBox {
155
269
  * the box) and Esc (clear the text; on an empty box, leave). Up/Down, Tab and the
156
270
  * scroll keys pass through: the list still moves, tabs still cycle, the panel
157
271
  * still scrolls.
272
+ *
273
+ * It is the input layer for a PASTE too, and the only layer that takes one:
274
+ * pasted text is appended to the query exactly as typing it would be. With no
275
+ * box focused nothing consumes a paste — it must never reach the screen or the
276
+ * global keys, where a clipboard holding `q` would quit the app.
158
277
  */
159
278
  export function useSearchInput(box: SearchBox): void {
279
+ usePasteLayer("input", box.focused, (text) => {
280
+ box.append(text);
281
+ return true;
282
+ });
283
+
160
284
  useKeyLayer("input", box.focused, (key) => {
161
285
  if (!inputLayerConsumes(key)) return false;
162
286
  switch (key.name) {
@@ -24,6 +24,10 @@
24
24
  * - scroll: PgUp / PgDn / Ctrl+U / Ctrl+D scroll the detail panel.
25
25
  * - global: tab digits, Tab, `q`, `?`, Esc.
26
26
  *
27
+ * A PASTE follows the same order through `dispatchPaste`, over the layers that
28
+ * registered a paste handler. It is a separate entry point because a paste is a
29
+ * separate terminal event, not a burst of keys — see {@link PasteLayer}.
30
+ *
27
31
  * Pure: no React, no OpenTUI. `KeyRouter.tsx` feeds it the live layers.
28
32
  */
29
33
 
@@ -88,6 +92,50 @@ export function dispatchKey(
88
92
  return null;
89
93
  }
90
94
 
95
+ /**
96
+ * A layer that can take a PASTE.
97
+ *
98
+ * A terminal does not deliver a paste as keys. With bracketed paste on — and
99
+ * OpenTUI's native core turns it on — the whole clipboard arrives wrapped in
100
+ * `ESC[200~ … ESC[201~` and reaches the renderer as ONE `paste` event; no
101
+ * `keypress` is emitted for any of its characters. So a layer that only
102
+ * implements `handle()` never sees a pasted search term at all.
103
+ *
104
+ * Separate from {@link KeyLayer} because most layers have nothing to do with a
105
+ * paste: a tab digit, `q` or a scroll key is a keystroke, and pasting "q" must
106
+ * not quit.
107
+ */
108
+ export interface PasteLayer {
109
+ id: KeyLayerId;
110
+ /** An inactive layer is skipped without its handler being called. */
111
+ active: boolean;
112
+ /** True when the layer consumed the text; nothing below it will see it. */
113
+ handlePaste(text: string): boolean;
114
+ }
115
+
116
+ /**
117
+ * Offer pasted `text` to the active layers in LAYER_ORDER and stop at the first
118
+ * that consumes it — the same rule {@link dispatchKey} follows, so a paste
119
+ * lands where a keystroke would: a focused search box takes it, a modal above
120
+ * it takes it first, and with neither open nothing consumes it.
121
+ *
122
+ * Empty text is offered to nobody: there is nothing to insert, and a layer that
123
+ * consumed it would only report a phantom edit.
124
+ */
125
+ export function dispatchPaste(
126
+ layers: readonly PasteLayer[],
127
+ text: string,
128
+ ): KeyLayerId | null {
129
+ if (text.length === 0) return null;
130
+ for (const id of LAYER_ORDER) {
131
+ for (const layer of layers) {
132
+ if (layer.id !== id || !layer.active) continue;
133
+ if (layer.handlePaste(text)) return id;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
91
139
  /** PgUp, PgDn, Ctrl+U, Ctrl+D — the keys that scroll the detail panel. */
92
140
  export function isScrollKey(key: KeyPress): boolean {
93
141
  if (key.meta) return false;
package/src/ui/keymap.ts CHANGED
@@ -826,7 +826,14 @@ const LEGENDS: Partial<Record<Screen, readonly LegendEntry[]>> = {
826
826
  symbol: "?",
827
827
  meaning: "installed; magus cannot tell whether it is current",
828
828
  },
829
- { symbol: "!", meaning: "installed more than one way; c fixes it" },
829
+ {
830
+ symbol: "!",
831
+ meaning: "installed more than one way; c removes what it can",
832
+ },
833
+ {
834
+ symbol: "⊘",
835
+ meaning: "an install magus cannot remove; take it out by hand",
836
+ },
830
837
  ],
831
838
  };
832
839
 
@@ -3,6 +3,7 @@ import type { CliTool } from "../../data/cli-tools.js";
3
3
  import {
4
4
  type InstallMethod,
5
5
  cliToolUninstall,
6
+ uninstallBlocker,
6
7
  } from "../../services/cli-tool-commands.js";
7
8
  import { formatCommand } from "../../utils/command-utils.js";
8
9
  import {
@@ -47,7 +48,8 @@ function wrapValue(text: string, width: number): string {
47
48
  }
48
49
 
49
50
  /**
50
- * The uninstall command as a line the reader can copy.
51
+ * One line of the conflict list: what happens to this install, and the command
52
+ * that would do it.
51
53
  *
52
54
  * This used to be a second, hand-written copy of the screen's own uninstall
53
55
  * switch — same shape, and already drifted (`pip uninstall` here,
@@ -55,16 +57,26 @@ function wrapValue(text: string, width: number): string {
55
57
  * is not an executable. Now the renderer and the screen ask the same function,
56
58
  * so what is shown is what would run.
57
59
  *
58
- * Empty string when there is nothing to offer, which is what the caller
59
- * interpolates into the conflict list.
60
+ * An install magus cannot remove says so, rather than reading "remove" beside
61
+ * an empty command: the conflict fix will not offer to remove it either
62
+ * (R9.10), and a pane that promises what the dialog refuses is the same lie one
63
+ * step earlier.
60
64
  */
61
- function uninstallHint(
65
+ function removalLine(
62
66
  tool: CliTool,
63
67
  method: InstallMethod,
64
68
  brewFormula?: string,
65
- ): string {
69
+ ): { mark: string; hint: string; removable: boolean } {
70
+ const blocker = uninstallBlocker(tool, method, brewFormula);
71
+ if (blocker !== null) {
72
+ return { mark: " ⊘ stays ", hint: ` ${blocker}`, removable: false };
73
+ }
66
74
  const cmd = cliToolUninstall(tool, method, brewFormula);
67
- return cmd ? formatCommand(cmd) : "";
75
+ return {
76
+ mark: " ○ remove ",
77
+ hint: cmd ? ` ${formatCommand(cmd)}` : "",
78
+ removable: true,
79
+ };
68
80
  }
69
81
 
70
82
  function toneColor(tone: StatusTone): UiColor {
@@ -324,30 +336,56 @@ export function renderCliToolDetail(
324
336
  {"\n"}Keep one, remove the rest:
325
337
  </text>
326
338
  </box>
327
- {allMethods.map((method, i) => (
328
- <box key={method}>
329
- <text fg={theme.colors.text}>
330
- <span fg={i === 0 ? theme.colors.success : theme.colors.danger}>
331
- {i === 0 ? " ● keep " : " ○ remove "}
332
- </span>
333
- <span fg={theme.colors.warning}>{method}</span>
334
- {i > 0 ? (
339
+ {allMethods.map((method, i) => {
340
+ if (i === 0) {
341
+ return (
342
+ <box key={method}>
343
+ <text fg={theme.colors.text}>
344
+ <span fg={theme.colors.success}>{" ● keep "}</span>
345
+ <span fg={theme.colors.warning}>{method}</span>
346
+ <span fg={theme.colors.dim}> (active in PATH)</span>
347
+ </text>
348
+ </box>
349
+ );
350
+ }
351
+ const line = removalLine(tool, method, brewFormula);
352
+ return (
353
+ <box key={method}>
354
+ <text fg={theme.colors.text}>
335
355
  <span
336
- fg={theme.colors.dim}
337
- >{` ${uninstallHint(tool, method, brewFormula)}`}</span>
338
- ) : (
339
- <span fg={theme.colors.dim}> (active in PATH)</span>
340
- )}
356
+ fg={
357
+ line.removable ? theme.colors.danger : theme.colors.muted
358
+ }
359
+ >
360
+ {line.mark}
361
+ </span>
362
+ <span fg={theme.colors.warning}>{method}</span>
363
+ <span fg={theme.colors.dim}>{line.hint}</span>
364
+ </text>
365
+ </box>
366
+ );
367
+ })}
368
+ {/* `c` is advertised only while it can remove something. With
369
+ every install stuck it opens a dialog with no choices in
370
+ it, and a key hint for a key that does nothing reads as a
371
+ hung app. */}
372
+ {allMethods.some(
373
+ (m) => uninstallBlocker(tool, m, brewFormula) === null,
374
+ ) ? (
375
+ <box flexDirection="row" marginTop={1}>
376
+ <text bg={theme.colors.danger} fg={theme.hints.fg}>
377
+ {" "}
378
+ c{" "}
341
379
  </text>
380
+ <text fg={theme.colors.muted}> Resolve — pick which to keep</text>
342
381
  </box>
343
- ))}
344
- <box flexDirection="row" marginTop={1}>
345
- <text bg={theme.colors.danger} fg={theme.hints.fg}>
346
- {" "}
347
- c{" "}
348
- </text>
349
- <text fg={theme.colors.muted}> Resolve — pick which to keep</text>
350
- </box>
382
+ ) : (
383
+ <box marginTop={1}>
384
+ <text fg={theme.colors.muted}>
385
+ magus can remove none of these. Take one out by hand, then r.
386
+ </text>
387
+ </box>
388
+ )}
351
389
  </box>
352
390
  ) : null}
353
391