@hncode/hncode 0.4.0 → 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 +2 -1
- package/package.json +3 -3
- package/src/plugin.js +104 -9
- package/src/registry-browser.js +299 -0
- package/src/remote.js +271 -0
- package/src/tasks-browser.js +54 -11
- package/src/tools/bash.js +17 -3
- package/src/tools/edit.js +2 -2
- package/src/tools/shell.js +106 -0
- package/src/tui.js +12255 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +100 -54
- package/src/web-public/home.css +3 -3
- package/src/web-public/login.css +1 -1
- /package/bin/{install.js → install.cjs} +0 -0
- /package/bin/{preinstall.js → preinstall.cjs} +0 -0
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.
|
|
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",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"package.json"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"preinstall": "node bin/preinstall.
|
|
20
|
-
"install": "node bin/install.
|
|
19
|
+
"preinstall": "node bin/preinstall.cjs",
|
|
20
|
+
"install": "node bin/install.cjs",
|
|
21
21
|
"i18n": "node tools/sync-i18n.mjs",
|
|
22
22
|
"i18n:check": "node tools/sync-i18n.mjs --check"
|
|
23
23
|
},
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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}: ${
|
|
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
|
+
}
|