@hncode/hncode 0.4.2 → 0.5.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/README.md +1 -15
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +149 -35
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +478 -242
- package/src/config.js +409 -40
- package/src/context-bar.js +237 -0
- package/src/doctor.js +518 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/memory-profile.js +247 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +611 -58
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +31 -13
- package/src/remote.js +20 -2
- package/src/rollout.js +151 -0
- package/src/row-cache.js +148 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session-hub.js +1 -0
- package/src/session.js +178 -8
- package/src/settings-schema.js +301 -0
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tool-result.js +264 -236
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +34 -6
- package/src/tui.js +5637 -746
- package/src/web-public/app.js +7 -0
- package/src/web.js +3 -1
package/src/index.js
CHANGED
|
@@ -34,6 +34,8 @@ Options:
|
|
|
34
34
|
json prints one result object; stream-json streams events then a result.
|
|
35
35
|
Exit code reflects the outcome: 0 ok, 1 failure, 2 config, 3 no answer, 4 interrupted.
|
|
36
36
|
--control Expose a local control socket (prompt/status/interrupt) while the TUI runs.
|
|
37
|
+
--timeout <seconds> Prompt mode: abort the run after this many seconds (default: no limit).
|
|
38
|
+
--retries <n> Prompt mode: HTTP retries per request before failing (default: 3).
|
|
37
39
|
--control <path> Same, at an explicit socket path.
|
|
38
40
|
--web Serve this session to a browser on 127.0.0.1 (random port).
|
|
39
41
|
--web [ip][:port] Same, on a chosen address/port (e.g. --web 0.0.0.0:8080).
|
|
@@ -45,7 +47,7 @@ Options:
|
|
|
45
47
|
Commands:
|
|
46
48
|
session list List sessions, most recent first.
|
|
47
49
|
provider list Show configured providers.
|
|
48
|
-
doctor config [path]
|
|
50
|
+
doctor [config] [path] Environment self-check (default), or validate a config.toml.
|
|
49
51
|
init Create ~/.hncode/config.toml with defaults.
|
|
50
52
|
export [id] Export a session to ~/.hncode/export/<id>.json.
|
|
51
53
|
control status Ask a running session (started with --control) for its status.
|
|
@@ -70,7 +72,7 @@ export function parseArgs(argv) {
|
|
|
70
72
|
else { key = a.slice(2); val = null; }
|
|
71
73
|
if (key === 'resume') key = 'session'; // `--resume <id>` is an alias for `--session <id>`
|
|
72
74
|
if (['session', 'control', 'web'].includes(key) && val === null) { const nx = argv[i + 1]; out[key] = nx !== undefined && !nx.startsWith('-') ? (i++, nx) : true; }
|
|
73
|
-
else if (key === 'model' || key === 'prompt' || key === 'output-format' || key === 'add-dir') {
|
|
75
|
+
else if (key === 'model' || key === 'prompt' || key === 'output-format' || key === 'add-dir' || key === 'timeout' || key === 'retries') {
|
|
74
76
|
if (val === null) {
|
|
75
77
|
const nx = argv[i + 1];
|
|
76
78
|
// Only consume the next token if it is a real value, not another flag
|
|
@@ -151,6 +153,75 @@ async function cmdDoctorConfig(p) {
|
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// `hncode doctor` — environment self-check. Answers "will hncode actually work in
|
|
157
|
+
// THIS terminal?" for the things that silently break it: too-old Node, a non-TTY
|
|
158
|
+
// or odd terminal, Windows consoles that do not forward mouse sequences, a missing
|
|
159
|
+
// git, an unreadable config. Every line is OK / WARN / FAIL so one paste answers a
|
|
160
|
+
// support question.
|
|
161
|
+
async function cmdDoctorEnv() {
|
|
162
|
+
const rows = [];
|
|
163
|
+
const add = (level, label, detail) => rows.push({ level, label, detail });
|
|
164
|
+
|
|
165
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
166
|
+
if (major >= 22) add('OK', 'node', process.version);
|
|
167
|
+
else if (major >= 20) add('WARN', 'node', process.version + ' — 20/21 work, but 22+ has TTY fixes');
|
|
168
|
+
else add('FAIL', 'node', process.version + ' — hncode needs Node >= 20');
|
|
169
|
+
|
|
170
|
+
add('OK', 'platform', process.platform + ' ' + os.release());
|
|
171
|
+
const isWin = process.platform === 'win32';
|
|
172
|
+
const term = process.env.TERM || '';
|
|
173
|
+
const wt = process.env.WT_SESSION ? 'windows-terminal' : (process.env.TERM_PROGRAM || (isWin ? 'conhost/other' : 'unknown'));
|
|
174
|
+
add(process.stdout.isTTY ? 'OK' : 'WARN', 'stdout isatty', process.stdout.isTTY ? 'yes' : 'no — colors/mouse assume a real terminal');
|
|
175
|
+
add('OK', 'terminal', wt + (term ? ' (TERM=' + term + ')' : ''));
|
|
176
|
+
if (isWin && !process.env.WT_SESSION) {
|
|
177
|
+
add('WARN', 'mouse', 'not inside Windows Terminal (WT_SESSION unset); some consoles do not forward mouse input');
|
|
178
|
+
}
|
|
179
|
+
add('OK', 'truecolor', (process.env.COLORTERM === 'truecolor' || /256color|truecolor/.test(term)) ? 'yes' : 'no — 256-color fallback in use');
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const { spawnSync } = await import('node:child_process');
|
|
183
|
+
const r = spawnSync('git', ['--version'], { encoding: 'utf8' });
|
|
184
|
+
add(r.status === 0 ? 'OK' : 'WARN', 'git', r.status === 0 ? String(r.stdout).trim() : 'not found — git tools limited');
|
|
185
|
+
} catch { add('WARN', 'git', 'not found on PATH'); }
|
|
186
|
+
|
|
187
|
+
const cfgFile = process.env.HNCODE_CONFIG || path.join(os.homedir(), '.hncode', 'config.toml');
|
|
188
|
+
if (!fs.existsSync(cfgFile)) {
|
|
189
|
+
add('WARN', 'config', 'no file at ' + cfgFile + ' (run hncode init)');
|
|
190
|
+
} else {
|
|
191
|
+
try {
|
|
192
|
+
const { parse } = await import('./toml.js');
|
|
193
|
+
parse(fs.readFileSync(cfgFile, 'utf8'));
|
|
194
|
+
add('OK', 'config', cfgFile);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
add('FAIL', 'config', cfgFile + ': ' + e.message);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const cfg = resolveConfig();
|
|
202
|
+
const model = cfg.innerModel || cfg.model;
|
|
203
|
+
add(model ? 'OK' : 'WARN', 'model', model || 'none configured (run /model or set default_model)');
|
|
204
|
+
add(cfg.apiKey ? 'OK' : 'WARN', 'api key', cfg.apiKey ? 'set' : 'missing — requests will 401');
|
|
205
|
+
} catch (e) {
|
|
206
|
+
add('WARN', 'config resolve', e.message);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const dir = path.join(os.homedir(), '.hncode', 'plugins');
|
|
211
|
+
const n = fs.existsSync(dir) ? fs.readdirSync(dir).filter((x) => fs.statSync(path.join(dir, x)).isDirectory()).length : 0;
|
|
212
|
+
add('OK', 'plugins', n + ' installed (' + dir + ')');
|
|
213
|
+
} catch { /* ignore */ }
|
|
214
|
+
|
|
215
|
+
const order = { FAIL: 0, WARN: 1, OK: 2 };
|
|
216
|
+
rows.sort((a, b) => order[a.level] - order[b.level]);
|
|
217
|
+
console.log('hncode doctor — environment check\n');
|
|
218
|
+
for (const r of rows) console.log(' [' + r.level.padEnd(4) + '] ' + r.label.padEnd(16) + ' ' + r.detail);
|
|
219
|
+
const fails = rows.filter((r) => r.level === 'FAIL').length;
|
|
220
|
+
console.log('\n' + (fails ? fails + ' problem(s) found' : 'No blocking problems found.'));
|
|
221
|
+
return fails ? 1 : 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
154
225
|
function cmdInit() {
|
|
155
226
|
const cfg = resolveConfig();
|
|
156
227
|
const target = path.join(os.homedir(), '.hncode', 'config.toml');
|
|
@@ -222,8 +293,9 @@ async function cmdControl(sub, arg) {
|
|
|
222
293
|
}
|
|
223
294
|
|
|
224
295
|
// ---- headless prompt mode ----
|
|
225
|
-
async function runPrompt({ cfg, prompt, format, session, modelArg }) {
|
|
296
|
+
async function runPrompt({ cfg, prompt, format, session, modelArg, timeoutSeconds, maxRetries }) {
|
|
226
297
|
cfg = resolveModelArg(cfg, modelArg);
|
|
298
|
+
if (maxRetries !== undefined) cfg.maxRetries = maxRetries;
|
|
227
299
|
if (!cfg.apiKey) {
|
|
228
300
|
console.error('hncode: no api_key configured. Set HNCODE_API_KEY or configure a provider first. Run `hncode doctor config` to check.');
|
|
229
301
|
return EXIT.CONFIG;
|
|
@@ -265,7 +337,25 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
|
|
|
265
337
|
},
|
|
266
338
|
});
|
|
267
339
|
saved.messages = messages;
|
|
268
|
-
|
|
340
|
+
// `--timeout <seconds>`: hard wall-clock cap on the whole run, so an unattended
|
|
341
|
+
// scripted prompt cannot hang forever on a stuck model or a wedged tool. The
|
|
342
|
+
// timer calls the SAME interrupt() the TUI's Esc uses — it aborts the in-flight
|
|
343
|
+
// request and kills the tool's AbortSignal — rather than process.exit, so the
|
|
344
|
+
// session is still saved and any final output is emitted.
|
|
345
|
+
let timedOut = false;
|
|
346
|
+
let timer = null;
|
|
347
|
+
if (timeoutSeconds > 0) {
|
|
348
|
+
timer = setTimeout(() => {
|
|
349
|
+
timedOut = true;
|
|
350
|
+
try { agent.interrupt(); } catch { /* best-effort */ }
|
|
351
|
+
}, timeoutSeconds * 1000);
|
|
352
|
+
if (timer.unref) timer.unref();
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await agent.run();
|
|
356
|
+
} finally {
|
|
357
|
+
if (timer) clearTimeout(timer);
|
|
358
|
+
}
|
|
269
359
|
sess.saveSession(saved);
|
|
270
360
|
|
|
271
361
|
const reply = finalReply(messages, tracker.text);
|
|
@@ -278,6 +368,7 @@ async function runPrompt({ cfg, prompt, format, session, modelArg }) {
|
|
|
278
368
|
durationMs: Date.now() - startedAt,
|
|
279
369
|
model: cfg.model,
|
|
280
370
|
sessionId: saved.id,
|
|
371
|
+
timedOut,
|
|
281
372
|
});
|
|
282
373
|
|
|
283
374
|
if (format === 'json') {
|
|
@@ -369,7 +460,7 @@ export async function main(argv) {
|
|
|
369
460
|
// subcommands
|
|
370
461
|
const [cmd, sub] = args.positional;
|
|
371
462
|
if (cmd) {
|
|
372
|
-
if (cmd === 'doctor' && !sub) {
|
|
463
|
+
if (cmd === 'doctor' && !sub) { return await cmdDoctorEnv(); }
|
|
373
464
|
const handler = SUBCOMMANDS[cmd];
|
|
374
465
|
if (handler) {
|
|
375
466
|
if (sub !== undefined) {
|
|
@@ -387,11 +478,20 @@ export async function main(argv) {
|
|
|
387
478
|
const baseCfg = resolveConfig();
|
|
388
479
|
const cfg = resolveModelArg(baseCfg, args.model);
|
|
389
480
|
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
481
|
+
// Plugins are normally loaded by bootstrap (bin/hncode) BEFORE this module is
|
|
482
|
+
// imported, because symbol patching has to register targets before the patched
|
|
483
|
+
// modules load. Only load them here when bootstrap did NOT already do it (e.g.
|
|
484
|
+
// index.js imported directly, or a test), so tools/commands still appear in
|
|
485
|
+
// both modes without a second load tripping "tool already registered".
|
|
486
|
+
try {
|
|
487
|
+
const { pluginsAlreadyLoaded } = await import('./bootstrap.js');
|
|
488
|
+
if (cfg.pluginDir && !pluginsAlreadyLoaded() && !globalThis.__hncodePluginsLoaded) {
|
|
489
|
+
const { loadPlugins } = await import('./plugin.js');
|
|
490
|
+
await loadPlugins(cfg.pluginDir);
|
|
491
|
+
globalThis.__hncodePluginsLoaded = true;
|
|
492
|
+
}
|
|
493
|
+
} catch (e) {
|
|
494
|
+
console.error('[hncode] plugin load step failed, continuing without plugins:', e && e.message);
|
|
395
495
|
}
|
|
396
496
|
|
|
397
497
|
// Connect configured MCP servers and register their tools.
|
|
@@ -437,7 +537,19 @@ export async function main(argv) {
|
|
|
437
537
|
let session = null;
|
|
438
538
|
if (args.session) session = typeof args.session === 'string' ? sess.loadSession(args.session) : sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
439
539
|
else if (args.continue) session = sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
440
|
-
|
|
540
|
+
// --timeout / --retries are validated here so a typo fails loudly instead of
|
|
541
|
+
// being parsed as NaN and silently ignored.
|
|
542
|
+
const timeoutSeconds = args.timeout !== undefined ? Number.parseInt(String(args.timeout), 10) : 0;
|
|
543
|
+
if (args.timeout !== undefined && (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1)) {
|
|
544
|
+
console.error(`hncode: --timeout needs a positive integer (seconds), got "${args.timeout}".`);
|
|
545
|
+
return EXIT.CONFIG;
|
|
546
|
+
}
|
|
547
|
+
const maxRetries = args.retries !== undefined ? Number.parseInt(String(args.retries), 10) : undefined;
|
|
548
|
+
if (args.retries !== undefined && (!Number.isInteger(maxRetries) || maxRetries < 0)) {
|
|
549
|
+
console.error(`hncode: --retries needs a non-negative integer, got "${args.retries}".`);
|
|
550
|
+
return EXIT.CONFIG;
|
|
551
|
+
}
|
|
552
|
+
return runPrompt({ cfg, prompt: args.prompt, format, session, modelArg: args.model, timeoutSeconds, maxRetries });
|
|
441
553
|
}
|
|
442
554
|
|
|
443
555
|
if (!process.stdout.isTTY) {
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// User keybindings — remap a key to another key, or to a slash command.
|
|
2
|
+
//
|
|
3
|
+
// WHY AN ALIAS TABLE AND NOT A PLUGIN-STYLE HANDLER LIST
|
|
4
|
+
// -----------------------------------------------------
|
|
5
|
+
// The TUI dispatches keys in one large switch whose cases are also the ONLY place the
|
|
6
|
+
// key's meaning is defined. Rewriting that switch into a registry would be a much larger
|
|
7
|
+
// change than the feature is worth, and it would put a layer between every keystroke and
|
|
8
|
+
// the handler — on the one path where latency is visible.
|
|
9
|
+
//
|
|
10
|
+
// So a binding is an ALIAS: it names a target the switcher already understands.
|
|
11
|
+
//
|
|
12
|
+
// key -> the key to send instead ("ctrl-j" = "enter")
|
|
13
|
+
// command -> the slash command to run ("ctrl-e" = "/theme")
|
|
14
|
+
//
|
|
15
|
+
// One key, one target, applied before dispatch. That covers what people actually rebind
|
|
16
|
+
// for — a key their terminal eats, a chord they prefer, a command they run constantly —
|
|
17
|
+
// with no change to the dispatch path and no way for a binding to leave the UI in a
|
|
18
|
+
// state the switcher does not know how to handle. A key with no binding passes through
|
|
19
|
+
// untouched, which is what keeps the default experience identical.
|
|
20
|
+
//
|
|
21
|
+
// WHY THE FILE IS THE SOURCE OF TRUTH
|
|
22
|
+
// -----------------------------------
|
|
23
|
+
// Bindings live in ~/.hncode/keybindings.json, read per keypress rather than cached: it is
|
|
24
|
+
// a small file, the read costs microseconds, and it means an edit takes effect immediately
|
|
25
|
+
// instead of after a restart. A malformed file is reported ONCE and then ignored — a typo
|
|
26
|
+
// must not silently swallow every key the user presses.
|
|
27
|
+
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
|
|
32
|
+
/** Where user bindings live. `HNCODE_HOME` moves it, like the rest of the config. */
|
|
33
|
+
export function keybindingsFile(home) {
|
|
34
|
+
const base = home || process.env.HNCODE_HOME || path.join(os.homedir(), '.hncode');
|
|
35
|
+
return path.join(base, 'keybindings.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Names people will reasonably write, mapped to the tokens the tokenizer emits. Without
|
|
39
|
+
// this, `ctrl+o` (how every other tool spells it) would not match `c-o`, and the binding
|
|
40
|
+
// would silently do nothing — the worst outcome for a config feature.
|
|
41
|
+
const KEY_ALIASES = {
|
|
42
|
+
return: 'enter', cr: 'enter',
|
|
43
|
+
esc: 'escape',
|
|
44
|
+
del: 'delete',
|
|
45
|
+
pageup: 'pgup', pagedown: 'pgdn',
|
|
46
|
+
space: ' ',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Modifier names, recognised on their own so they can be joined with a base key.
|
|
50
|
+
const MODIFIERS = {
|
|
51
|
+
ctrl: 'c', control: 'c', ctl: 'c',
|
|
52
|
+
shift: 's',
|
|
53
|
+
alt: 'm', meta: 'm', option: 'm', opt: 'm', cmd: 'm', command: 'm', super: 'm', win: 'm',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalise a key spelling to this TUI's token vocabulary.
|
|
58
|
+
* `ctrl+o` -> `c-o`, `Return` -> `enter`, `C-T` -> `c-t`.
|
|
59
|
+
*
|
|
60
|
+
* A spec that names only modifiers, or ends on the separator (`ctrl+`), is malformed and
|
|
61
|
+
* returns '' — the caller treats that as an error. Returning a made-up token instead would
|
|
62
|
+
* create a binding the tokenizer can never emit, which is a key that silently does nothing.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeKey(spec) {
|
|
65
|
+
const s = String(spec == null ? '' : spec).trim().toLowerCase();
|
|
66
|
+
if (!s) return '';
|
|
67
|
+
// A leading/trailing `+`, or a trailing `-`, means a half-written chord. `ctrl+` would
|
|
68
|
+
// otherwise split into one part and be returned verbatim as the literal string "ctrl+".
|
|
69
|
+
if (/[+\s]$/.test(s) && !/^-+$/.test(s)) return '';
|
|
70
|
+
if (/^[+\s]/.test(s)) return '';
|
|
71
|
+
// `ctrl-o` is the other common spelling of a chord: split it on `-` when the first
|
|
72
|
+
// segment is a modifier name, so it is not returned whole as an unknown key.
|
|
73
|
+
let parts = s.split(/[+\s]+/).filter(Boolean);
|
|
74
|
+
if (parts.length === 1 && parts[0].includes('-')) {
|
|
75
|
+
const seg = parts[0].split('-').filter(Boolean);
|
|
76
|
+
if (seg.length > 1 && seg.slice(0, -1).every((p) => MODIFIERS[p])) parts = seg;
|
|
77
|
+
}
|
|
78
|
+
if (!parts.length) return '';
|
|
79
|
+
if (parts.length === 1) {
|
|
80
|
+
// A bare modifier name is not a key: nothing emits `ctrl` on its own.
|
|
81
|
+
if (MODIFIERS[parts[0]]) return '';
|
|
82
|
+
return KEY_ALIASES[parts[0]] || parts[0];
|
|
83
|
+
}
|
|
84
|
+
const mods = [];
|
|
85
|
+
let base = '';
|
|
86
|
+
for (const p of parts) {
|
|
87
|
+
const m = MODIFIERS[p];
|
|
88
|
+
if (m) { if (!mods.includes(m)) mods.push(m); continue; }
|
|
89
|
+
// A second non-modifier means the spec is not a chord at all (`ctrl+a+b`); the last one
|
|
90
|
+
// wins, matching how the single-part path treats an unknown name.
|
|
91
|
+
base = KEY_ALIASES[p] || p;
|
|
92
|
+
}
|
|
93
|
+
if (!base) return ''; // `ctrl+shift` names no key
|
|
94
|
+
if (!mods.length) return base;
|
|
95
|
+
// Ctrl is emitted as `c-<letter>`; a modified non-letter (ctrl+up) is `c-up`; and a
|
|
96
|
+
// shift-modified control is the `c-s-<x>` form the CSI-u path produces.
|
|
97
|
+
if (mods.length === 1 && mods[0] === 'c') return `c-${base}`;
|
|
98
|
+
// Shift alone is emitted as `shift-<base>` for the named keys the tokenizer handles
|
|
99
|
+
// specially (shift-tab, shift-up, shift-home, …).
|
|
100
|
+
if (mods.length === 1 && mods[0] === 's') return `shift-${base}`;
|
|
101
|
+
if (mods.includes('c') && mods.includes('s')) return `c-s-${base}`;
|
|
102
|
+
// Any other combination resolves to the base key rather than to a token nothing emits,
|
|
103
|
+
// so `alt+x` still does something sensible instead of becoming a dead key.
|
|
104
|
+
return base;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isCommand = (s) => typeof s === 'string' && s.trim().startsWith('/');
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read and validate the bindings file.
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read and validate the bindings file.
|
|
114
|
+
*
|
|
115
|
+
* @returns {{bindings: Map<string, {key?:string, command?:string}>, errors: string[], file: string}}
|
|
116
|
+
* `bindings` maps a NORMALISED source key to exactly one target.
|
|
117
|
+
*/
|
|
118
|
+
export function loadKeybindings(home) {
|
|
119
|
+
const file = keybindingsFile(home);
|
|
120
|
+
const out = { bindings: new Map(), errors: [], file };
|
|
121
|
+
let raw;
|
|
122
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
123
|
+
catch (e) {
|
|
124
|
+
// Absent is the normal case and deserves no complaint. Anything else is worth saying.
|
|
125
|
+
if (e && e.code !== 'ENOENT') out.errors.push(`could not read ${file}: ${e.message}`);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
let doc;
|
|
129
|
+
try { doc = JSON.parse(raw); }
|
|
130
|
+
catch (e) { out.errors.push(`${file} is not valid JSON: ${e.message}`); return out; }
|
|
131
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
132
|
+
out.errors.push(`${file} must contain a JSON object of "key": "target" pairs`);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
// Accept both a flat map and a `{ bindings: { … } }` wrapper, because both spellings are
|
|
136
|
+
// natural and refusing one would be a pointless papercut.
|
|
137
|
+
const table = (doc.bindings && typeof doc.bindings === 'object' && !Array.isArray(doc.bindings))
|
|
138
|
+
? doc.bindings : doc;
|
|
139
|
+
for (const [from, to] of Object.entries(table)) {
|
|
140
|
+
if (from === 'bindings') continue;
|
|
141
|
+
const src = normalizeKey(from);
|
|
142
|
+
if (!src) { out.errors.push(`"${from}": not a usable key name`); continue; }
|
|
143
|
+
let target;
|
|
144
|
+
if (typeof to === 'string' && isCommand(to)) {
|
|
145
|
+
target = { command: to.trim() };
|
|
146
|
+
} else if (typeof to === 'string') {
|
|
147
|
+
const dst = normalizeKey(to);
|
|
148
|
+
if (!dst) { out.errors.push(`"${from}": "${to}" is not a usable key name or a /command`); continue; }
|
|
149
|
+
if (dst === src) { out.errors.push(`"${from}": a key cannot be bound to itself`); continue; }
|
|
150
|
+
target = { key: dst };
|
|
151
|
+
} else if (to && typeof to === 'object' && typeof to.command === 'string' && isCommand(to.command)) {
|
|
152
|
+
target = { command: to.command.trim() };
|
|
153
|
+
} else if (to && typeof to === 'object' && typeof to.key === 'string') {
|
|
154
|
+
const dst = normalizeKey(to.key);
|
|
155
|
+
if (!dst || dst === src) { out.errors.push(`"${from}": bad target key`); continue; }
|
|
156
|
+
target = { key: dst };
|
|
157
|
+
} else {
|
|
158
|
+
out.errors.push(`"${from}": the target must be a key name (e.g. "enter") or a command (e.g. "/theme")`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
out.bindings.set(src, target);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve one incoming key through the bindings.
|
|
168
|
+
*
|
|
169
|
+
* A command binding is CAPPED: resolving a command binding that points at another command
|
|
170
|
+
* binding, or a chain of key aliases, must not loop. One hop for a command, and a bounded
|
|
171
|
+
* walk for keys, is enough to be useful and impossible to hang on.
|
|
172
|
+
*
|
|
173
|
+
* @returns {{key: string, command: string|null}} the key to dispatch, plus a command to run.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveKey(key, bindings, maxHops = 8) {
|
|
176
|
+
const k = String(key == null ? '' : key);
|
|
177
|
+
if (!bindings || typeof bindings.get !== 'function') return { key: k, command: null };
|
|
178
|
+
let cur = k;
|
|
179
|
+
let command = null;
|
|
180
|
+
for (let hop = 0; hop < maxHops; hop++) {
|
|
181
|
+
const hit = bindings.get(cur);
|
|
182
|
+
if (!hit) break;
|
|
183
|
+
if (hit.command) {
|
|
184
|
+
// The command replaces the key entirely: running it and ALSO dispatching the
|
|
185
|
+
// original key would fire two unrelated actions from one press.
|
|
186
|
+
if (command) break; // already found one; do not chain commands
|
|
187
|
+
command = hit.command;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
if (!hit.key || hit.key === cur) break;
|
|
191
|
+
cur = hit.key;
|
|
192
|
+
}
|
|
193
|
+
return { key: cur, command };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A JSON body for the bindings file, used by `/keybindings add` on first write. */
|
|
197
|
+
export function readOrEmpty(home) {
|
|
198
|
+
const file = keybindingsFile(home);
|
|
199
|
+
try { return { file, doc: JSON.parse(fs.readFileSync(file, 'utf8')) || {} }; }
|
|
200
|
+
catch { return { file, doc: {} }; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Write the bindings file, creating the directory if needed. */
|
|
204
|
+
export function writeKeybindings(home, doc) {
|
|
205
|
+
const file = keybindingsFile(home);
|
|
206
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
207
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
208
|
+
return file;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A human-readable table of the settings, for `/keybindings`.
|
|
213
|
+
* Sorted so the output is stable across runs.
|
|
214
|
+
*/
|
|
215
|
+
export function describeKeybindings(loaded) {
|
|
216
|
+
const out = [];
|
|
217
|
+
if (!loaded.bindings.size) {
|
|
218
|
+
out.push('No keybindings set.', '', `File: ${loaded.file}`, 'Add one with /keybindings add <key> <key|/command>');
|
|
219
|
+
} else {
|
|
220
|
+
out.push(`Keybindings (${loaded.bindings.size}) — ${loaded.file}`, '');
|
|
221
|
+
for (const [from, to] of [...loaded.bindings.entries()].sort()) {
|
|
222
|
+
out.push(` ${from.padEnd(12)} -> ${to.command ? to.command : to.key}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (loaded.errors.length) {
|
|
226
|
+
out.push('', 'Problems:', ...loaded.errors.map((e) => ` ! ${e}`));
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|