@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
@@ -0,0 +1,301 @@
1
+ // The settings schema — one declaration per setting, projected onto every surface.
2
+ //
3
+ // WHY A TABLE
4
+ // -----------
5
+ // Settings were spread across two dozen `/xxx` commands, each with its own argument
6
+ // parsing, validation and persistence. Adding one meant writing a command; finding one
7
+ // meant knowing what it was called; and the surfaces disagreed — the TUI had a
8
+ // `/calm` toggle, the web's `setConfig` action accepted eight keys, and `config.toml`
9
+ // accepted everything. CodeWhale's `settings_schema.rs` solves this with a single
10
+ // declarative table that the UI renders from, the setter validates against, and the
11
+ // defaults come from, so this mirrors that shape:
12
+ //
13
+ // SETTINGS_SCHEMA = [ { key, kind, default, ui: { tab, group, label, hint } | null } ]
14
+ //
15
+ // `ui: null` means DECLARED BUT NOT SHOWN — settable through `/set <key> <value>` and
16
+ // persisted, but with no row. That is how a value stays reachable without cluttering the
17
+ // screen (CodeWhale hides 28 of its 87 this way).
18
+ //
19
+ // WHAT `kind` BUYS
20
+ // ----------------
21
+ // `bool` renders a toggle, `enum` a picker over `values`, `int` a number prompt, `string`
22
+ // and `text` an editor. The surface never guesses, and a new setting needs no new UI code.
23
+ //
24
+ // THE DEFAULTS LIVE HERE TOO. A default written in the schema and nowhere else means
25
+ // `/settings` can always show what a value WOULD be, even before it is set — which is the
26
+ // difference between a settings screen and a list of the points you have already changed.
27
+
28
+ import { THEME_NAMES } from './colors.js';
29
+
30
+ /** Where a group appears in the rail, in display order. */
31
+ export const SETTINGS_TABS = [
32
+ { id: 'appearance', label: 'Appearance', hint: 'Theme, density, motion, transcript' },
33
+ { id: 'prompt', label: 'Prompt', hint: 'System prompt and per-mode instructions' },
34
+ { id: 'model', label: 'Model', hint: 'Provider, model, thinking effort' },
35
+ { id: 'context', label: 'Context', hint: 'Compaction and tool-result trimming' },
36
+ { id: 'tools', label: 'Tools', hint: 'Permissions, delegation, shell' },
37
+ { id: 'advanced', label: 'Advanced', hint: 'Update, telemetry, experiments' },
38
+ ];
39
+
40
+ /** Kind -> a one-word name for the value, used by the value prompt. */
41
+ export const KIND_LABEL = {
42
+ bool: 'on/off',
43
+ int: 'number',
44
+ enum: 'choice',
45
+ string: 'text',
46
+ text: 'multi-line text',
47
+ };
48
+
49
+ // Defaults are STRINGS, matching what config.toml holds and what `setConfigString`
50
+ // writes. `bool(false)` used to expand to `default: false` — a JS boolean — and every
51
+ // comparison against `'true'` then failed, so every toggle rendered as "off" regardless of
52
+ // its default. The kind says how a value is EDITED; the default says how it is STORED, and
53
+ // storing is a string.
54
+ const bool = (d) => ({ kind: 'bool', default: d ? 'true' : 'false' });
55
+ const int = (d, min, max) => ({ kind: 'int', default: String(d), min, max });
56
+ const en = (d, values) => ({ kind: 'enum', default: d, values });
57
+ const str = (d) => ({ kind: 'string', default: d });
58
+ const text = (d) => ({ kind: 'text', default: d });
59
+
60
+ /**
61
+ * Every setting the program reads or can persist.
62
+ *
63
+ * `key` is the config.toml key verbatim — there is no rename layer, so what is written to
64
+ * the file is what the code reads and what `/set` accepts.
65
+ */
66
+ export const SETTINGS_SCHEMA = [
67
+ // ---- appearance ---------------------------------------------------------
68
+ // The default is `dark`, NOT `auto`. Setting it to `auto` silently switched every existing
69
+ // setup to whatever the terminal background implies — on a light terminal, a completely
70
+ // different palette. A default that rearranges the user's screen is not a default.
71
+ // `auto` stays in the list for anyone who wants to follow the terminal.
72
+ //
73
+ // The accepted values come from `THEME_NAMES` rather than being written out here: a
74
+ // hardcoded list meant adding a theme needed a second edit in a different file, and
75
+ // forgetting it left /settings offering fewer themes than /theme did.
76
+ { key: 'theme', ...en('dark', THEME_NAMES),
77
+ ui: { tab: 'appearance', group: 'Display', label: 'Theme', hint: 'Colour scheme. `auto` follows the terminal background' } },
78
+ { key: 'calm_mode', ...bool(false),
79
+ ui: { tab: 'appearance', group: 'Display', label: 'Calm mode', hint: 'Fewer decorative flourishes; keeps the information, drops the animation' } },
80
+ { key: 'reduced_motion', ...bool(false),
81
+ ui: { tab: 'appearance', group: 'Motion', label: 'Reduced motion', hint: 'No sweeps or pulses — for a terminal over a slow link, or a preference' } },
82
+ { key: 'shimmer_edge', ...bool(true),
83
+ ui: { tab: 'appearance', group: 'Motion', label: 'Shimmer edge', hint: 'The travelling highlight on a running tool name' } },
84
+ { key: 'show_tool_details', ...bool(true),
85
+ ui: { tab: 'appearance', group: 'Transcript', label: 'Tool details', hint: 'Show arguments and output previews on tool rows' } },
86
+ { key: 'save_history', ...bool(true),
87
+ ui: { tab: 'appearance', group: 'Transcript', label: 'Save display transcript', hint: 'Keep notices, warnings, plan cards and reasoning in the saved session' } },
88
+ { key: 'save_thinking', ...bool(true),
89
+ ui: { tab: 'appearance', group: 'Transcript', label: 'Save reasoning', hint: 'Persist thinking blocks, not only the visible answer' } },
90
+ { key: 'cost_currency', ...en('usd', ['usd', 'cny']),
91
+ ui: { tab: 'appearance', group: 'Display', label: 'Cost currency', hint: 'Which unit /cost reports in' } },
92
+ { key: 'cny_per_usd', ...int(0, 0, 100000),
93
+ ui: { tab: 'appearance', group: 'Display', label: 'CNY per USD', hint: 'Exchange rate used when reporting cost in CNY; 0 means fetch it' } },
94
+
95
+ // ---- prompt -------------------------------------------------------------
96
+ // A text setting rather than a command: the system prompt is the largest thing a user
97
+ // can meaningfully tune, and it needed an editor, not a one-line argument.
98
+ { key: 'system_prompt', ...text(''),
99
+ ui: { tab: 'prompt', group: 'System', label: 'System prompt', hint: 'Replaces the built-in prompt entirely when set' } },
100
+ { key: 'append_system_prompt', ...text(''),
101
+ ui: { tab: 'prompt', group: 'System', label: 'Append to system prompt', hint: 'Added after the built-in prompt — the safer of the two' } },
102
+ { key: 'plan_instructions', ...text(''),
103
+ ui: { tab: 'prompt', group: 'Modes', label: 'Plan mode instructions', hint: 'Extra guidance while Plan mode is on' } },
104
+ { key: 'focus_instructions', ...text(''),
105
+ ui: { tab: 'prompt', group: 'Modes', label: 'Focus mode instructions', hint: 'Extra guidance while Focus mode is on' } },
106
+ { key: 'swarm_instructions', ...text(''),
107
+ ui: { tab: 'prompt', group: 'Modes', label: 'Swarm mode instructions', hint: 'Extra guidance while swarm mode is on' } },
108
+
109
+ // ---- model --------------------------------------------------------------
110
+ { key: 'provider', ...str(''),
111
+ ui: { tab: 'model', group: 'Provider', label: 'Provider', hint: 'Which provider serves requests' } },
112
+ { key: 'model', ...str(''),
113
+ ui: { tab: 'model', group: 'Provider', label: 'Model', hint: 'provider/model, as written in config.toml' } },
114
+ { key: 'default_model', ...str(''),
115
+ ui: { tab: 'model', group: 'Provider', label: 'Default model', hint: 'Used when a session does not name one' } },
116
+ { key: 'secondary_model', ...str(''),
117
+ ui: { tab: 'model', group: 'Delegation', label: 'Secondary model', hint: 'For side tasks: titles, compaction summaries' } },
118
+ { key: 'subagent_model', ...str(''),
119
+ ui: { tab: 'model', group: 'Delegation', label: 'Subagent model', hint: 'What the Agent tool uses; empty means the main model' } },
120
+ { key: 'effort', ...en('off', ['off', 'on', 'low', 'medium', 'high', 'max']),
121
+ ui: { tab: 'model', group: 'Thinking', label: 'Thinking effort', hint: 'Only the levels the current model declares are offered' } },
122
+ { key: 'max_context_tokens', ...int(0, 0, 10000000),
123
+ ui: { tab: 'model', group: 'Thinking', label: 'Context window', hint: 'Override the model window when the catalog is wrong; 0 means use it' } },
124
+ { key: 'prompt_cache', ...bool(true),
125
+ ui: { tab: 'model', group: 'Provider', label: 'Prompt caching', hint: 'Ask the provider to cache the request prefix' } },
126
+
127
+ // ---- context ------------------------------------------------------------
128
+ { key: 'auto_compact', ...bool(true),
129
+ ui: { tab: 'context', group: 'Compaction', label: 'Auto-compact', hint: 'Summarize older history when the window fills' } },
130
+ { key: 'compact_threshold', ...int(85, 1, 99),
131
+ ui: { tab: 'context', group: 'Compaction', label: 'Compact at %', hint: 'Share of the window at which compaction fires' } },
132
+ { key: 'compact_keep_ratio', ...int(20, 1, 99),
133
+ ui: { tab: 'context', group: 'Compaction', label: 'Keep %', hint: 'Share of the CURRENT usage kept after compaction' } },
134
+ { key: 'auto_trim', ...bool(true),
135
+ ui: { tab: 'context', group: 'Tool results', label: 'Auto-trim', hint: 'Elide old tool output from the request' } },
136
+ { key: 'trim_threshold', ...int(50, 1, 99),
137
+ ui: { tab: 'context', group: 'Tool results', label: 'Trim at %', hint: 'Share of the WINDOW that triggers a trim' } },
138
+ { key: 'trim_keep_ratio', ...int(30, 1, 99),
139
+ ui: { tab: 'context', group: 'Tool results', label: 'Keep %', hint: 'Share of the TOOL-RESULT TEXT kept after a trim — a different scale from the trigger' } },
140
+ { key: 'tool_result_max_bytes', ...int(51200, 1024, 10485760),
141
+ ui: { tab: 'context', group: 'Tool results', label: 'Max result bytes', hint: 'Larger output is written to disk and replaced with a pointer' } },
142
+ { key: 'tool_result_preview_bytes', ...int(2048, 128, 1048576),
143
+ ui: { tab: 'context', group: 'Tool results', label: 'Preview bytes', hint: 'How much of a spilled result stays inline' } },
144
+
145
+ // ---- tools --------------------------------------------------------------
146
+ { key: 'tool_allow_external_paths', ...bool(false),
147
+ ui: { tab: 'tools', group: 'Files', label: 'Allow external paths', hint: 'Let file tools read and write outside the workspace' } },
148
+ { key: 'swarm_mode', ...bool(false),
149
+ ui: { tab: 'tools', group: 'Delegation', label: 'Swarm mode', hint: 'Expose AgentSwarm for fanning work across subagents' } },
150
+ { key: 'output_style', ...str(''),
151
+ ui: { tab: 'tools', group: 'Output', label: 'Output style', hint: 'Named style overriding how replies are written' } },
152
+ { key: 'auto_trim_aggressive', ...bool(false), ui: null },
153
+ { key: 'no_delegation', ...bool(false), ui: null },
154
+
155
+ // ---- advanced -----------------------------------------------------------
156
+ { key: 'auto_update', ...bool(true),
157
+ ui: { tab: 'advanced', group: 'Updates', label: 'Auto-update', hint: 'Check npm at startup and every 30 minutes' } },
158
+ { key: 'experiments', ...str(''),
159
+ ui: { tab: 'advanced', group: 'Features', label: 'Experiments', hint: 'Comma-separated feature flags to enable' } },
160
+ { key: 'telemetry', ...bool(false),
161
+ ui: { tab: 'advanced', group: 'Features', label: 'Telemetry', hint: 'Send anonymous usage counts' } },
162
+ { key: 'vim_mode', ...bool(false),
163
+ ui: { tab: 'advanced', group: 'Editor', label: 'Vim mode', hint: 'Modal keys in the composer' } },
164
+ { key: 'shell', ...str(''),
165
+ ui: { tab: 'advanced', group: 'Editor', label: 'Shell', hint: 'Program used by the Bash tool; empty means auto-detect' } },
166
+ { key: 'mcp_config_path', ...str(''),
167
+ ui: { tab: 'advanced', group: 'MCP', label: 'MCP config path', hint: 'Defaults to ~/.hncode/mcp.json' } },
168
+ { key: 'checkpoints_max', ...int(500, 1, 100000),
169
+ ui: { tab: 'advanced', group: 'Editor', label: 'Max checkpoints', hint: 'Per-session cap on file-history entries' } },
170
+ { key: 'log_level', ...en('warn', ['error', 'warn', 'info', 'debug']),
171
+ ui: { tab: 'advanced', group: 'Diagnostics', label: 'Log level', hint: 'How much reaches session-errors.log' } },
172
+ { key: 'probe_timeout_ms', ...int(20000, 1000, 600000),
173
+ ui: { tab: 'advanced', group: 'Diagnostics', label: 'Probe timeout (ms)', hint: 'How long /doctor --probe waits for a reply' } },
174
+
175
+ // ---- the startup defaults, which had no row anywhere --------------------
176
+ // These already worked from config.toml but were reachable only by hand-editing it: a
177
+ // user could change the model from /model but not the base URL those models are reached
178
+ // at, and the protocol had no surface at all.
179
+ { key: 'base_url', ...str(''),
180
+ ui: { tab: 'model', group: 'Provider', label: 'Base URL', hint: 'Endpoint used when a provider does not name one' } },
181
+ { key: 'protocol', ...en('', ['', 'openai', 'anthropic', 'responses']),
182
+ ui: { tab: 'model', group: 'Provider', label: 'Protocol', hint: 'Wire format; empty detects from the provider' } },
183
+ { key: 'max_context_size', ...int(0, 0, 10000000),
184
+ ui: { tab: 'model', group: 'Thinking', label: 'Max context size', hint: 'Startup default when the catalog has no window; 0 means fall back again' } },
185
+ { key: 'max_output_size', ...int(0, 0, 10000000),
186
+ ui: { tab: 'model', group: 'Thinking', label: 'Max output size', hint: 'Startup default for the reply length; 0 means fall back' } },
187
+ { key: 'reasoning', ...bool(false),
188
+ ui: { tab: 'model', group: 'Thinking', label: 'Reasoning by default', hint: 'Whether new sessions start with thinking on' } },
189
+ { key: 'plugins_dir', ...str(''),
190
+ ui: { tab: 'advanced', group: 'MCP', label: 'Plugins directory', hint: 'Where plugins load from; defaults to ~/.hncode/plugins' } },
191
+ { key: 'web_host', ...str('127.0.0.1'),
192
+ ui: { tab: 'advanced', group: 'Web', label: 'Web host', hint: 'Address the /web daemon binds to' } },
193
+ { key: 'web_port', ...int(8765, 1, 65535),
194
+ ui: { tab: 'advanced', group: 'Web', label: 'Web port', hint: 'Fixed port for the single global daemon' } },
195
+ ];
196
+
197
+ /** key -> definition, for the setter and for validation. */
198
+ export const SETTING_BY_KEY = new Map(SETTINGS_SCHEMA.map((d) => [d.key, d]));
199
+
200
+ /** The settings that get a row, grouped by tab, in schema order. */
201
+ export function settingsForTab(tab) {
202
+ return SETTINGS_SCHEMA.filter((d) => d.ui && d.ui.tab === tab);
203
+ }
204
+
205
+ /** Tabs that actually have at least one row, so the rail never shows an empty section. */
206
+ export function populatedTabs() {
207
+ return SETTINGS_TABS.filter((t) => settingsForTab(t.id).length > 0);
208
+ }
209
+
210
+ /** Every key `/set` accepts — including the ones with no row. */
211
+ export function settableKeys() {
212
+ return SETTINGS_SCHEMA.map((d) => d.key);
213
+ }
214
+
215
+ /**
216
+ * Coerce a typed value to what the setting stores, or report why it cannot.
217
+ *
218
+ * The single validation point: `/settings`, `/set` and the web's `setConfig` all come
219
+ * through here, so they cannot disagree about what a value means.
220
+ *
221
+ * @returns {{ok: true, value: string} | {ok: false, error: string}}
222
+ */
223
+ export function coerceSetting(key, input) {
224
+ const def = SETTING_BY_KEY.get(key);
225
+ if (!def) return { ok: false, error: `unknown setting: ${key}` };
226
+ const raw = String(input == null ? '' : input).trim();
227
+
228
+ if (def.kind === 'bool') {
229
+ const v = raw.toLowerCase();
230
+ if (['1', 'true', 'on', 'yes'].includes(v)) return { ok: true, value: 'true' };
231
+ if (['0', 'false', 'off', 'no', ''].includes(v)) return { ok: true, value: 'false' };
232
+ return { ok: false, error: `${key} is on/off; got ${JSON.stringify(input)}` };
233
+ }
234
+ if (def.kind === 'int') {
235
+ // Accept `85` and `85%` — the percentage settings read naturally with a sign, and
236
+ // the schema stores a plain number.
237
+ const n = Number(String(raw).replace(/%$/, ''));
238
+ if (!Number.isFinite(n) || !Number.isInteger(n)) return { ok: false, error: `${key} needs a whole number; got ${JSON.stringify(input)}` };
239
+ if (def.min != null && n < def.min) return { ok: false, error: `${key} must be at least ${def.min}` };
240
+ if (def.max != null && n > def.max) return { ok: false, error: `${key} must be at most ${def.max}` };
241
+ return { ok: true, value: String(n) };
242
+ }
243
+ if (def.kind === 'enum') {
244
+ if (def.values.includes(raw)) return { ok: true, value: raw };
245
+ // A case-insensitive hit is accepted; anything else is refused rather than coerced,
246
+ // because silently picking a near-miss is how a setting ends up not meaning what it
247
+ // says.
248
+ const lower = def.values.find((v) => v.toLowerCase() === raw.toLowerCase());
249
+ if (lower) return { ok: true, value: lower };
250
+ return { ok: false, error: `${key} must be one of ${def.values.join(', ')}; got ${JSON.stringify(input)}` };
251
+ }
252
+ // string / text: anything goes, including empty (which means "unset").
253
+ return { ok: true, value: raw };
254
+ }
255
+
256
+ /**
257
+ * The value in force: what is configured, else the schema default.
258
+ *
259
+ * `cfg.raw` holds what config.toml said. A value there wins even when it equals the
260
+ * default, so `/settings` can tell "set to X" from "unset, so X applies".
261
+ */
262
+ export function effectiveValue(cfg, key) {
263
+ const def = SETTING_BY_KEY.get(key);
264
+ const raw = cfg && cfg.raw ? cfg.raw[key] : undefined;
265
+ if (raw === undefined || raw === null || raw === '') return def ? def.default : '';
266
+ return String(raw);
267
+ }
268
+
269
+ /** True when the value came from config rather than from the schema default. */
270
+ export function isSet(cfg, key) {
271
+ const raw = cfg && cfg.raw ? cfg.raw[key] : undefined;
272
+ return raw !== undefined && raw !== null && raw !== '';
273
+ }
274
+
275
+ /** How a value reads on a row: `on`, `dark`, `85%`, or `(unset)`. */
276
+ export function displayValue(cfg, key) {
277
+ const def = SETTING_BY_KEY.get(key);
278
+ if (!def) return '';
279
+ const v = effectiveValue(cfg, key);
280
+ if (def.kind === 'bool') return v === 'true' ? 'on' : 'off';
281
+ if (def.kind === 'text') {
282
+ if (!v) return '(unset)';
283
+ const first = v.split('\n')[0];
284
+ return v.includes('\n') || first.length > 40 ? `${first.slice(0, 40)}…` : first;
285
+ }
286
+ // An empty value reads as `(unset)` for EVERY kind, not only `string`. `protocol` is an
287
+ // enum whose default is the empty string, so its row rendered with a blank value column —
288
+ // indistinguishable from a row whose value failed to render.
289
+ if (!v) return '(unset)';
290
+ return v;
291
+ }
292
+
293
+
294
+ /**
295
+ * Which settings differ from their default, newest-first by schema order.
296
+ * `/settings` marks these; it is how a user sees what they have actually changed.
297
+ */
298
+ export function changedSettings(cfg) {
299
+ return SETTINGS_SCHEMA.filter((d) => isSet(cfg, d.key)
300
+ && effectiveValue(cfg, d.key) !== d.default);
301
+ }