@hncode/hncode 0.4.1 → 0.4.2

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/README.md CHANGED
@@ -16,7 +16,8 @@ A cyan-blue terminal user interface (TUI) coding agent inspired by [Kimi Code](h
16
16
  - **Prompt Caching**: Explicit cache breakpoints for the stable prefix (`/cache`)
17
17
  - **MCP-style Toolbelt**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, etc.
18
18
  - **MCP Client**: Connect real Model Context Protocol servers (stdio + HTTP) and use their tools
19
- - **Skills**: Package reusable prompts as Markdown (`/import-skill`, `/skill:<name>` with tab-completion)
19
+ - **Skills**: Package reusable prompts as Markdown (`/import-skill`, `/skill:<name>` with tab-completion, plus a manager that installs them from the repo)
20
+ - **Skills & plugins manager**: `/skills` and `/plugins` browse what is installed *and* what the repo offers, with descriptions, and install/remove in place
20
21
  - **Shell Hooks**: Run your own commands at lifecycle events (`PreToolUse`, `PostToolUse`, …)
21
22
  - **Git Workflow**: `/git`, `/commit`, `/branch`, `/worktree`, `/pr` plus a `Git` tool for the agent
22
23
  - **Plan Review**: Review what a plan will touch, edit it, then approve before any file changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/plugin.js CHANGED
@@ -40,6 +40,49 @@ const registeredHooks = {};
40
40
  const registeredConfig = {};
41
41
  const loadedPlugins = [];
42
42
 
43
+ // ---------------------------------------------------------------------------
44
+ // Plugin notices — what loaded, what failed, and anything a plugin reports.
45
+ //
46
+ // A plugin author's instinct is console.log/error, and that used to be the only channel.
47
+ // The TUI owns the screen, so those messages went to a terminal the user cannot see while
48
+ // the app runs: a plugin that failed to load simply did not appear, with no explanation
49
+ // anywhere. Collecting every message and letting the TUI render it puts both the success
50
+ // and the failure where the user is already looking.
51
+ // ---------------------------------------------------------------------------
52
+ const pendingNotices = [];
53
+
54
+ /** Record one notice. `level` is 'info' | 'warn' | 'error'. */
55
+ function notice(level, text) {
56
+ pendingNotices.push({ level, text: String(text == null ? '' : text) });
57
+ }
58
+
59
+ /** Take and clear the pending notices — the TUI drains this after loading. */
60
+ export function takePluginNotices() {
61
+ const out = pendingNotices.slice();
62
+ pendingNotices.length = 0;
63
+ return out;
64
+ }
65
+
66
+ function fmtArgs(args) {
67
+ return args.map((a) => {
68
+ if (typeof a === 'string') return a;
69
+ if (a instanceof Error) return a.message;
70
+ try { return JSON.stringify(a); } catch { return String(a); }
71
+ }).join(' ');
72
+ }
73
+
74
+ /**
75
+ * A console-like object handed to `install(api)` as `api.log`, so a plugin can report
76
+ * without knowing anything about the UI. Everything reaches the chat AND mirrors to the
77
+ * terminal, so a plugin author watching stderr still sees it.
78
+ */
79
+ export const pluginLogger = {
80
+ log: (...a) => { notice('info', fmtArgs(a)); console.log('[hncode-plugin]', ...a); },
81
+ info: (...a) => { notice('info', fmtArgs(a)); console.log('[hncode-plugin]', ...a); },
82
+ warn: (...a) => { notice('warn', fmtArgs(a)); console.warn('[hncode-plugin]', ...a); },
83
+ error: (...a) => { notice('error', fmtArgs(a)); console.error('[hncode-plugin]', ...a); },
84
+ };
85
+
43
86
  const API = {
44
87
  // Register a new tool (same shape as built-in tool specs).
45
88
  // Returns an unregister function.
@@ -106,8 +149,15 @@ const API = {
106
149
  get hooks() { return { ...registeredHooks }; },
107
150
  get config() { return { ...registeredConfig }; },
108
151
  get plugins() { return [...loadedPlugins]; },
152
+
153
+ // Reporting. `api.log.info('…')` reaches the CHAT, which is the only channel a user
154
+ // can actually see while the TUI owns the screen; it also mirrors to the terminal for
155
+ // a developer watching it. See pluginLogger.
156
+ log: pluginLogger,
109
157
  };
110
158
 
159
+ // The agent context is injected lazily; plugins that need it read api.ctx
160
+
111
161
  // The agent context is injected lazily; plugins that need it read api.ctx
112
162
  // during their hook callbacks.
113
163
  let _ctx = null;
@@ -133,20 +183,47 @@ export async function loadPlugins(dir) {
133
183
  for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
134
184
  for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
135
185
  loadedPlugins.length = 0;
186
+ // Notices belong to THIS run too; a second load must not replay the first run's.
187
+ pendingNotices.length = 0;
136
188
 
137
- const files = fs.readdirSync(pluginDir)
138
- .filter((f) => f.endsWith('.js') || f.endsWith('.mjs'))
139
- .sort();
189
+ // Two layouts, because both are in the wild and the install path only produces one:
190
+ // plugins/foo.js — a single-file plugin
191
+ // plugins/foo/index.js — a plugin directory (this is what /plugins install
192
+ // writes, and what a plugin with helper files needs)
193
+ // Only the flat form used to be scanned, so every plugin installed through the
194
+ // manager was written to disk correctly and then silently never loaded.
195
+ const files = [];
196
+ for (const e of fs.readdirSync(pluginDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
197
+ if (e.name.startsWith('.')) continue;
198
+ if (e.isFile() && (e.name.endsWith('.js') || e.name.endsWith('.mjs'))) {
199
+ files.push({ id: e.name, full: path.join(pluginDir, e.name) });
200
+ continue;
201
+ }
202
+ if (!e.isDirectory()) continue;
203
+ const dir = path.join(pluginDir, e.name);
204
+ // A directory's entry point: index.js/.mjs, else the first .js/.mjs beside it (a
205
+ // directory may legitimately be named differently from its file).
206
+ let entries = [];
207
+ try { entries = fs.readdirSync(dir); } catch { continue; }
208
+ const entry = ['index.js', 'index.mjs'].find((n) => entries.includes(n))
209
+ || entries.filter((n) => n.endsWith('.js') || n.endsWith('.mjs')).sort()[0];
210
+ if (!entry) continue;
211
+ files.push({ id: `${e.name}/${entry}`, full: path.join(dir, entry) });
212
+ }
140
213
 
141
- for (const f of files) {
142
- const full = path.join(pluginDir, f);
214
+ for (const { id: f, full } of files) {
143
215
  try {
144
216
  const mod = await import(pathToFileURL(full).href);
217
+ // `install(api)` may be the named export or live on the default export.
145
218
  let installFn;
146
219
  if (mod && typeof mod.install === 'function') installFn = mod.install;
147
220
  else if (mod && mod.default && typeof mod.default.install === 'function') installFn = mod.default.install;
148
221
 
149
- if (typeof installFn !== 'function') {
222
+ if (typeof installFn !== 'function') {
223
+ // Report it to the chat as well: a file that is present but registers nothing is
224
+ // the most confusing failure of all — the user sees the file on disk and nothing
225
+ // in the UI, with no hint that the export is missing.
226
+ notice('error', `${f}: no install(api) function found — skipped`);
150
227
  // eslint-disable-next-line no-console
151
228
  console.error(`[hncode-plugin] ${f}: no install(api) function found, skipped.`);
152
229
  continue;
@@ -161,18 +238,35 @@ export async function loadPlugins(dir) {
161
238
  const def = (mod && mod.default) || {};
162
239
  const defMeta = typeof def === 'string' ? { name: def } : def;
163
240
  const namedMeta = (mod && mod.meta) || {};
241
+ // Fall back to the DIRECTORY name for a directory plugin, so a plugin at
242
+ // plugins/secret-guard/index.js with no metadata shows as `secret-guard`, not
243
+ // `secret-guard/index.js`.
244
+ const fallback = f.includes('/') ? f.split('/')[0] : f.replace(/\.m?js$/, '');
164
245
  const plugin = {
165
246
  id: f,
166
- name: defMeta.name || defMeta.meta?.name || namedMeta.name || namedMeta.meta?.name || f,
247
+ name: defMeta.name || defMeta.meta?.name || namedMeta.name || namedMeta.meta?.name || fallback,
167
248
  version: defMeta.version || defMeta.meta?.version || namedMeta.version || namedMeta.meta?.version || '0.0.0',
168
249
  };
169
- loadedPlugins.push(plugin);
250
+ // install(api) FIRST, then record it. Pushing before the call reported a plugin as
251
+ // loaded even when its install() threw — /plugins would list it under Loaded while
252
+ // none of its tools existed.
170
253
  installFn(API);
254
+ loadedPlugins.push(plugin);
255
+ // The notice goes to the chat; the console line stays for a developer watching a
256
+ // terminal, matching the failure path below.
257
+ notice('info', `loaded ${plugin.name}${plugin.version && plugin.version !== '0.0.0' ? ' v' + plugin.version : ''}`);
171
258
  // eslint-disable-next-line no-console
172
259
  console.log(`[hncode-plugin] loaded: ${plugin.name}`);
173
260
  } catch (e) {
261
+ // Report it BOTH ways. stderr is the developer's channel (a plugin author running
262
+ // hncode from a terminal), but the TUI owns the screen, so a user who installs a
263
+ // plugin through /plugins never sees it — their plugin simply does not appear and
264
+ // nothing says why. `notice` feeds the chat; the console line stays for a developer
265
+ // watching a terminal.
266
+ const msg = String((e && e.message) || e);
267
+ notice('error', `${f} failed to load: ${msg}`);
174
268
  // eslint-disable-next-line no-console
175
- console.error(`[hncode-plugin] ${f}: ${e.message}`);
269
+ console.error(`[hncode-plugin] ${f}: ${msg}`);
176
270
  }
177
271
  }
178
272
 
@@ -212,5 +306,6 @@ export function _resetPlugins() {
212
306
  for (const k of Object.keys(registeredHooks)) delete registeredHooks[k];
213
307
  for (const k of Object.keys(registeredConfig)) delete registeredConfig[k];
214
308
  loadedPlugins.length = 0;
309
+ pendingNotices.length = 0;
215
310
  _ctx = null;
216
311
  }
@@ -0,0 +1,299 @@
1
+ // RegistryBrowser — the full-screen /skills and /plugins manager.
2
+ //
3
+ // Modelled on kimi-code's PluginsPanelComponent (a tabbed list where every row has a
4
+ // DESCRIPTION under its label) and on this project's own /tasks browser: pure
5
+ // rendering plus key handling, returning ACTION STRINGS to the caller so all state
6
+ // stays in one place.
7
+ //
8
+ // ────────────────────────────────────────────────────────────────
9
+ // Skills
10
+ // Tab switch · ↑↓ select · Enter install/remove · Esc close
11
+ //
12
+ // [Installed (1)] Available (3)
13
+ //
14
+ // ❯ demo installed
15
+ // A demo skill
16
+ // review-pr install
17
+ // Review a pull request for bugs
18
+ // ────────────────────────────────────────────────────────────────
19
+ //
20
+ // The description line is the reason this exists instead of the plain list panel: a
21
+ // skill or plugin is meaningless without knowing what it does, and the picker's inline
22
+ // `sub` truncates on a narrow terminal.
23
+ //
24
+ // Tabs differ per kind, because the two halves mean different things:
25
+ // TABS_LOCAL = what is on disk here (installed skills / plugins on disk)
26
+ // TABS_REMOTE = what the repo offers, with an `installed` badge when present
27
+
28
+ import { C } from './colors.js';
29
+ import { visualWidth } from './term.js';
30
+
31
+ const ELLIPSIS = '\u2026';
32
+ const ESC = '\x1b';
33
+ const MIN_WIDTH = 44;
34
+ const MIN_HEIGHT = 8;
35
+
36
+ function visWidth(s) { return visualWidth(String(s).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')); }
37
+
38
+ function truncateToWidth(s, width) {
39
+ const str = String(s);
40
+ let out = '';
41
+ let n = 0;
42
+ let i = 0;
43
+ while (i < str.length) {
44
+ if (str[i] === ESC) {
45
+ const m = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(str.slice(i));
46
+ if (m) { out += m[0]; i += m[0].length; continue; }
47
+ }
48
+ const cp = str.codePointAt(i);
49
+ const ch = String.fromCodePoint(cp);
50
+ const cw = visWidth(ch);
51
+ if (n + cw > width - 1) return out + ELLIPSIS + ' '.repeat(Math.max(0, width - n - 1));
52
+ n += cw;
53
+ out += ch;
54
+ i += cp > 0xffff ? 2 : 1;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function fitExactly(line, width) {
60
+ const w = visWidth(line);
61
+ if (w === width) return line;
62
+ if (w > width) return truncateToWidth(line, width);
63
+ return line + ' '.repeat(width - w);
64
+ }
65
+
66
+ /** Plain-text wrap so a long description keeps its own indented lines. */
67
+ function wrapText(text, width) {
68
+ const words = String(text || '').trim().split(/\s+/).filter(Boolean);
69
+ const out = [];
70
+ let cur = '';
71
+ for (const w of words) {
72
+ const next = cur ? cur + ' ' + w : w;
73
+ if (visWidth(next) <= width) { cur = next; continue; }
74
+ if (cur) out.push(cur);
75
+ cur = visWidth(w) <= width ? w : w.slice(0, Math.max(1, width));
76
+ }
77
+ if (cur) out.push(cur);
78
+ return out.length ? out : [''];
79
+ }
80
+
81
+ // Tabs. `id` is what the key handler switches on; `label` is shown with the count.
82
+ export const SKILL_TABS = [
83
+ { id: 'installed', label: 'Installed' },
84
+ { id: 'available', label: 'Available' },
85
+ ];
86
+ export const PLUGIN_TABS = [
87
+ { id: 'loaded', label: 'Loaded' },
88
+ { id: 'ondisk', label: 'On disk' },
89
+ { id: 'available', label: 'Available' },
90
+ ];
91
+
92
+ /** The rows for one tab. Each row: { label, description, status, statusTone, name, action }. */
93
+ export function rowsForTab(state, tabId) {
94
+ const d = state.data || {};
95
+ if (state.kind === 'skills') {
96
+ if (tabId === 'installed') {
97
+ return (d.installed || []).map((s) => ({
98
+ name: s.name,
99
+ label: s.name,
100
+ description: s.description || '(no description)',
101
+ status: 'installed',
102
+ statusTone: 'dim',
103
+ action: 'remove-skill',
104
+ }));
105
+ }
106
+ const local = new Set((d.installed || []).map((s) => s.name));
107
+ return (d.remote || []).map((r) => ({
108
+ name: r.name,
109
+ label: r.name,
110
+ description: r.description || (r.readable === false ? '(no SKILL.md — cannot install)' : '(no description)'),
111
+ status: local.has(r.name) ? 'installed' : 'install',
112
+ statusTone: local.has(r.name) ? 'dim' : 'primary',
113
+ action: local.has(r.name) ? 'reinstall-skill' : 'install-skill',
114
+ disabled: r.readable === false,
115
+ }));
116
+ }
117
+ // plugins
118
+ if (tabId === 'loaded') {
119
+ return (d.loaded || []).map((p) => ({
120
+ name: p.name,
121
+ label: p.name,
122
+ description: `v${p.version || '0.0.0'} · ${p.id}`,
123
+ status: 'loaded',
124
+ statusTone: 'ok',
125
+ action: 'none',
126
+ }));
127
+ }
128
+ if (tabId === 'ondisk') {
129
+ const loadedNames = new Set((d.loaded || []).map((p) => p.name));
130
+ return (d.onDisk || []).map((p) => ({
131
+ name: p.name,
132
+ label: p.name + (p.kind === 'dir' ? '/' : ''),
133
+ description: loadedNames.has(p.name)
134
+ ? 'loaded in this session'
135
+ : 'not loaded — restart hncode to load it',
136
+ status: loadedNames.has(p.name) ? 'loaded' : 'restart',
137
+ statusTone: loadedNames.has(p.name) ? 'ok' : 'warn',
138
+ action: 'remove-plugin',
139
+ }));
140
+ }
141
+ const local = new Set((d.onDisk || []).map((p) => p.name));
142
+ return (d.remote || []).map((r) => ({
143
+ name: r.name,
144
+ label: r.name,
145
+ description: local.has(r.name) ? 'already installed locally' : 'available in the repository',
146
+ status: local.has(r.name) ? 'installed' : 'install',
147
+ statusTone: local.has(r.name) ? 'dim' : 'primary',
148
+ action: local.has(r.name) ? 'reinstall-plugin' : 'install-plugin',
149
+ }));
150
+ }
151
+
152
+ function statusColor(tone) {
153
+ if (tone === 'ok') return C.green;
154
+ if (tone === 'warn') return C.orange;
155
+ if (tone === 'dim') return C.gray;
156
+ return C.blue;
157
+ }
158
+
159
+ export function renderRegistryBrowser(state, cols, rows) {
160
+ if (cols < MIN_WIDTH || rows < MIN_HEIGHT) {
161
+ const lines = [fitExactly(C.red + 'Terminal too small (need >= ' + MIN_WIDTH + ' x ' + MIN_HEIGHT + ')' + C.reset, cols)];
162
+ for (let i = 1; i < rows; i++) lines.push(' '.repeat(cols));
163
+ return lines;
164
+ }
165
+ const title = state.kind === 'skills' ? ' Skills' : ' Plugins';
166
+ const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
167
+ const tabIdx = Math.max(0, tabs.findIndex((t) => t.id === state.tab));
168
+ const tab = tabs[tabIdx] || tabs[0];
169
+ const all = rowsForTab(state, tab.id);
170
+ const sel = Math.max(0, Math.min(all.length - 1, state.selectedIndex || 0));
171
+
172
+ const out = [];
173
+ const rule = C.border + '\u2500'.repeat(cols) + C.reset;
174
+ out.push(rule);
175
+ out.push(fitExactly(C.cyan + C.bold + title + C.reset, cols));
176
+ // The key hints stay visible while the fetch runs: the panel is usable during it
177
+ // (the local tabs are already loaded), so replacing the hints with a status message
178
+ // would hide the keys exactly when the user is most likely to press them.
179
+ const hint = ' Tab switch \u00b7 \u2191\u2193 select \u00b7 Enter install/remove \u00b7 Esc close';
180
+ out.push(fitExactly(C.gray + hint + (state.busy ? C.gray + ` ${state.busy}` : '') + C.reset, cols));
181
+ out.push('');
182
+
183
+ // Tab strip, with the count of each tab's rows.
184
+ // Tab strip, with the count of each tab's rows.
185
+ out.push(tabs.map((t) => {
186
+ const n = rowsForTab(state, t.id).length;
187
+ return t.id === tab.id
188
+ ? C.cyan + C.bold + `[${t.label} (${n})]` + C.reset
189
+ : C.gray + ` ${t.label} (${n}) ` + C.reset;
190
+ }).join(' '));
191
+ out.push('');
192
+
193
+ // Body: each row is a label line plus its wrapped description, so the footer must be
194
+ // sized against the ROWS, not the lines. A row is 2+ lines tall.
195
+ const listTop = out.length;
196
+ const bodyRows = Math.max(1, rows - listTop - 3); // leave room for the footer
197
+ if (!all.length) {
198
+ // The message must describe THIS tab. A failed remote fetch used to win over
199
+ // everything, so "Loaded" and "On disk" — which are read straight off the local
200
+ // filesystem and cannot fail from the network — reported "fetch failed" whenever
201
+ // the repo lookup did. That reads as "your local plugins are broken", which is
202
+ // both wrong and alarming. Only the Available tab can talk about the fetch.
203
+ const isRemoteTab = tab.id === 'available';
204
+ // "Loading" only applies to the tab that is actually waiting on the fetch; a local
205
+ // tab is empty because there is genuinely nothing there, and saying "Loading" for
206
+ // data that is already on screen reads as a stall.
207
+ const msg = (isRemoteTab && state.busy) ? ' Loading\u2026'
208
+ : (isRemoteTab && state.data && state.data.remoteError)
209
+ ? ' Could not fetch from the repo: ' + state.data.remoteError
210
+ : isRemoteTab ? ' Nothing published in the repo yet.'
211
+ : ' Nothing here.';
212
+ out.push(C.gray + msg + C.reset);
213
+ }
214
+ for (let i = 0; i < all.length; i++) {
215
+ const r = all[i];
216
+ const isSel = i === sel;
217
+ const ptr = isSel ? C.cyan + '\u276f ' + C.reset : ' ';
218
+ const nameStyle = isSel ? (C.cyan + C.bold) : C.white;
219
+ const badge = r.status ? ' ' + statusColor(r.statusTone) + r.status + C.reset : '';
220
+ const dis = r.disabled ? C.gray : '';
221
+ out.push(fitExactly(ptr + nameStyle + dis + r.label + C.reset + badge, cols));
222
+ for (const line of wrapText(r.description, Math.max(8, cols - 6))) {
223
+ out.push(C.gray + ' ' + line + C.reset);
224
+ }
225
+ }
226
+ // A fetch failure has to show even when the LOCAL half has rows, or the Available
227
+ // tab silently looks empty and the user cannot tell why.
228
+ if (all.length && state.data && state.data.remoteError && tab.id === 'available') {
229
+ out.push('');
230
+ out.push(C.orange + ' Could not fetch the list: ' + state.data.remoteError + C.reset);
231
+ }
232
+ // The last action's outcome (installed / removed / failed) sits above the hint line.
233
+ if (state.flash) {
234
+ out.push('');
235
+ out.push(C.cyan + ' ' + state.flash + C.reset);
236
+ }
237
+
238
+ // Footer: what the highlighted row would DO, so the effect of Enter is never a
239
+ // surprise. A row already installed says "reinstall", because that is what Enter
240
+ // does there (and it is the only way to pick up a newer version).
241
+ const cur = all[sel];
242
+ const action = !cur ? ''
243
+ : cur.disabled ? 'Enter does nothing \u2014 this entry has no SKILL.md'
244
+ : cur.action === 'reinstall-skill' || cur.action === 'reinstall-plugin'
245
+ ? 'Enter reinstalls ' + cur.name + ' (replaces the local copy)'
246
+ : cur.action === 'install-skill' || cur.action === 'install-plugin'
247
+ ? 'Enter installs ' + cur.name + ' from the repository'
248
+ : cur.action.startsWith('remove') ? 'Enter removes the local copy of ' + cur.name
249
+ : 'loaded plugin \u2014 switch to the On disk tab to remove it';
250
+ while (out.length < rows - 2) out.push('');
251
+ out.push(fitExactly(C.gray + ' ' + action + C.reset, cols));
252
+ out.push(rule);
253
+ return out.slice(0, rows);
254
+ }
255
+
256
+ /**
257
+ * One key. Returns an action string for the caller:
258
+ * 'close' | 'switchTab' | 'select' | 'install' | 'remove' | 'reload' | null
259
+ */
260
+ export function handleRegistryBrowserKey(state, t) {
261
+ const tabs = state.kind === 'skills' ? SKILL_TABS : PLUGIN_TABS;
262
+ const all = rowsForTab(state, state.tab);
263
+ const ch = t.ch;
264
+
265
+ if (t.key === 'escape' || ch === 'q' || ch === 'Q') return 'close';
266
+ if (t.key === 'tab') {
267
+ const i = Math.max(0, tabs.findIndex((x) => x.id === state.tab));
268
+ state.tab = tabs[(i + 1) % tabs.length].id;
269
+ state.selectedIndex = 0;
270
+ return 'switchTab';
271
+ }
272
+ if (t.key === 'up' || ch === 'k') {
273
+ state.selectedIndex = Math.max(0, (state.selectedIndex || 0) - 1);
274
+ return 'select';
275
+ }
276
+ if (t.key === 'down' || ch === 'j') {
277
+ state.selectedIndex = Math.min(Math.max(0, all.length - 1), (state.selectedIndex || 0) + 1);
278
+ return 'select';
279
+ }
280
+ if (t.key === 'home') { state.selectedIndex = 0; return 'select'; }
281
+ if (t.key === 'end') { state.selectedIndex = Math.max(0, all.length - 1); return 'select'; }
282
+ if (ch === 'r' || ch === 'R') return 'reload';
283
+ if (t.key === 'enter' || t.key === 'space' || ch === ' ') {
284
+ const row = all[state.selectedIndex || 0];
285
+ if (!row || row.disabled) return null;
286
+ // `installed` on the Available tab still reinstalls: that is how you pick up a
287
+ // newer version of something you already have, and refusing it would leave no way
288
+ // to update.
289
+ if (row.action.startsWith('install')) return 'install';
290
+ if (row.action.startsWith('remove')) return 'remove';
291
+ return null;
292
+ }
293
+ if (ch === 'd' || ch === 'D') {
294
+ const row = all[state.selectedIndex || 0];
295
+ if (row && row.action.startsWith('remove')) return 'remove';
296
+ return null;
297
+ }
298
+ return null;
299
+ }