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.
- package/package.json +4 -4
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/cli/doctor.ts +20 -6
- package/src/cli/install.ts +34 -9
- package/src/services/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/settings-file.ts +9 -3
- package/src/services/tool-probe.ts +48 -3
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* system layer (Ctrl+C) is not
|
|
8
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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()}
|
|
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(() =>
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
*
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
<
|
|
331
|
-
{
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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={
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
</
|
|
349
|
-
|
|
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
|
|