@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.
Files changed (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. 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] Validate a config.toml (default: merged config).
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
- await agent.run();
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) { printHelp(); return 0; }
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
- // Load plugins (if enabled) before anything else so their tools and commands
391
- // are available in both headless and TUI modes.
392
- if (cfg.pluginDir) {
393
- const { loadPlugins } = await import('./plugin.js');
394
- await loadPlugins(cfg.pluginDir);
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
- return runPrompt({ cfg, prompt: args.prompt, format, session, modelArg: args.model });
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
+ }