@hncode/hncode 0.5.0 → 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/package.json +1 -1
- package/src/agent.js +4 -2
- package/src/colors.js +478 -387
- package/src/config.js +38 -11
- package/src/doctor.js +209 -11
- package/src/memory-profile.js +247 -0
- package/src/row-cache.js +147 -142
- package/src/session-hub.js +1 -0
- package/src/settings-schema.js +301 -0
- package/src/tool-result.js +264 -236
- package/src/tools/edit.js +1 -1
- package/src/tui.js +544 -80
- package/src/web.js +3 -1
package/src/row-cache.js
CHANGED
|
@@ -1,143 +1,148 @@
|
|
|
1
|
-
// A shared, bounded cache of rendered transcript rows.
|
|
2
|
-
//
|
|
3
|
-
// WHY THIS EXISTS
|
|
4
|
-
// ---------------
|
|
5
|
-
// Wrapping and markdown-rendering a message is the expensive half of every frame, and the
|
|
6
|
-
// result depends only on (message text, width, colours) — so it is cacheable. The TUI
|
|
7
|
-
// already cached it, but on the message object itself, which makes the cache a property of
|
|
8
|
-
// the TRANSCRIPT rather than of the renderer:
|
|
9
|
-
//
|
|
10
|
-
// * It was dropped by position, not by size. `CACHE_KEEP` counted messages from the
|
|
11
|
-
// tail, so a 4000-message session threw away the caches of the 3700 oldest messages
|
|
12
|
-
// while keeping the caches of 300 tiny ones — the window tracked message COUNT, not
|
|
13
|
-
// memory. Scrolling back into a long session re-rendered from scratch every time.
|
|
14
|
-
// * It could not survive a session switch, because it lived on messages that
|
|
15
|
-
// `reconstructChat` throws away and rebuilds from the log. Every `--continue` and
|
|
16
|
-
// every `/sessions` resume started with a cold cache, re-wrapping the whole
|
|
17
|
-
// transcript before the first frame.
|
|
18
|
-
// * It had two independent mechanisms — `m._cache` for the row list, `m._mdCache` for
|
|
19
|
-
// the markdown blocks — with separate lifetimes and no bound between them.
|
|
20
|
-
//
|
|
21
|
-
// A `Map` keyed by a string, with entries ordered by use, fixes all three: the bound is
|
|
22
|
-
// real, the key survives any rebuild of the transcript, and both caches share one budget.
|
|
23
|
-
//
|
|
24
|
-
// THE KEY IS THE WHOLE POINT
|
|
25
|
-
// --------------------------
|
|
26
|
-
// A key that omitted a component of the layout would serve a row rendered for a different
|
|
27
|
-
// width or colour — which is not a stale cache, it is a MISDRAWN frame. So the key spells
|
|
28
|
-
// out everything the render depends on, and `makeRowKey` is the single place that decides
|
|
29
|
-
// what "the same" means. `evictIfStale` is the second half of the same contract: a streamed
|
|
30
|
-
// message's text grows every frame, so an entry whose text no longer matches is not a
|
|
31
|
-
// reuse, and the cache has to notice rather than serve it.
|
|
32
|
-
|
|
33
|
-
/** How many messages keep their rows. Chosen by measurement, not by feel: the rows for a
|
|
34
|
-
* typical assistant message are a few hundred bytes each, so 1500 covers a long session's
|
|
35
|
-
* visible history while staying in the low megabytes. */
|
|
36
|
-
export const ROW_CACHE_LIMIT = 1500;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* A cache key that identifies a rendered message.
|
|
40
|
-
*
|
|
41
|
-
* Every input the render reads goes in, so "the same key" means "the same rows":
|
|
42
|
-
* `id` a stable identity for the message. Index-based ids break the moment a message
|
|
43
|
-
* is inserted or removed, which is exactly what compaction and /undo do.
|
|
44
|
-
* `text` the content. Compared by VALUE, not by reference: a streamed message's
|
|
45
|
-
* `text` is replaced on every delta, and a reference check would miss every
|
|
46
|
-
* reuse after the first frame.
|
|
47
|
-
* `w` the wrap width, `fg`/`codeFg` the colours, `expanded` the plugin-log toggle,
|
|
48
|
-
* `raw` the raw-mode flag, `pending`/`failed` the live-state flags, and `spin` the
|
|
49
|
-
* spinner key.
|
|
50
|
-
*/
|
|
51
|
-
export function makeRowKey(parts) {
|
|
52
|
-
return [
|
|
53
|
-
parts.id,
|
|
54
|
-
parts.text == null ? '' : parts.text,
|
|
55
|
-
parts.w,
|
|
56
|
-
parts.fg,
|
|
57
|
-
parts.codeFg,
|
|
58
|
-
parts.expanded ? 1 : 0,
|
|
59
|
-
parts.raw ? 1 : 0,
|
|
60
|
-
parts.pending ? 1 : 0,
|
|
61
|
-
parts.failed ? 1 : 0,
|
|
62
|
-
parts.spin,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
export function
|
|
87
|
-
store.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
stats.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
1
|
+
// A shared, bounded cache of rendered transcript rows.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// Wrapping and markdown-rendering a message is the expensive half of every frame, and the
|
|
6
|
+
// result depends only on (message text, width, colours) — so it is cacheable. The TUI
|
|
7
|
+
// already cached it, but on the message object itself, which makes the cache a property of
|
|
8
|
+
// the TRANSCRIPT rather than of the renderer:
|
|
9
|
+
//
|
|
10
|
+
// * It was dropped by position, not by size. `CACHE_KEEP` counted messages from the
|
|
11
|
+
// tail, so a 4000-message session threw away the caches of the 3700 oldest messages
|
|
12
|
+
// while keeping the caches of 300 tiny ones — the window tracked message COUNT, not
|
|
13
|
+
// memory. Scrolling back into a long session re-rendered from scratch every time.
|
|
14
|
+
// * It could not survive a session switch, because it lived on messages that
|
|
15
|
+
// `reconstructChat` throws away and rebuilds from the log. Every `--continue` and
|
|
16
|
+
// every `/sessions` resume started with a cold cache, re-wrapping the whole
|
|
17
|
+
// transcript before the first frame.
|
|
18
|
+
// * It had two independent mechanisms — `m._cache` for the row list, `m._mdCache` for
|
|
19
|
+
// the markdown blocks — with separate lifetimes and no bound between them.
|
|
20
|
+
//
|
|
21
|
+
// A `Map` keyed by a string, with entries ordered by use, fixes all three: the bound is
|
|
22
|
+
// real, the key survives any rebuild of the transcript, and both caches share one budget.
|
|
23
|
+
//
|
|
24
|
+
// THE KEY IS THE WHOLE POINT
|
|
25
|
+
// --------------------------
|
|
26
|
+
// A key that omitted a component of the layout would serve a row rendered for a different
|
|
27
|
+
// width or colour — which is not a stale cache, it is a MISDRAWN frame. So the key spells
|
|
28
|
+
// out everything the render depends on, and `makeRowKey` is the single place that decides
|
|
29
|
+
// what "the same" means. `evictIfStale` is the second half of the same contract: a streamed
|
|
30
|
+
// message's text grows every frame, so an entry whose text no longer matches is not a
|
|
31
|
+
// reuse, and the cache has to notice rather than serve it.
|
|
32
|
+
|
|
33
|
+
/** How many messages keep their rows. Chosen by measurement, not by feel: the rows for a
|
|
34
|
+
* typical assistant message are a few hundred bytes each, so 1500 covers a long session's
|
|
35
|
+
* visible history while staying in the low megabytes. */
|
|
36
|
+
export const ROW_CACHE_LIMIT = 1500;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A cache key that identifies a rendered message.
|
|
40
|
+
*
|
|
41
|
+
* Every input the render reads goes in, so "the same key" means "the same rows":
|
|
42
|
+
* `id` a stable identity for the message. Index-based ids break the moment a message
|
|
43
|
+
* is inserted or removed, which is exactly what compaction and /undo do.
|
|
44
|
+
* `text` the content. Compared by VALUE, not by reference: a streamed message's
|
|
45
|
+
* `text` is replaced on every delta, and a reference check would miss every
|
|
46
|
+
* reuse after the first frame.
|
|
47
|
+
* `w` the wrap width, `fg`/`codeFg` the colours, `expanded` the plugin-log toggle,
|
|
48
|
+
* `raw` the raw-mode flag, `pending`/`failed` the live-state flags, and `spin` the
|
|
49
|
+
* spinner key.
|
|
50
|
+
*/
|
|
51
|
+
export function makeRowKey(parts) {
|
|
52
|
+
return [
|
|
53
|
+
parts.id,
|
|
54
|
+
parts.text == null ? '' : parts.text,
|
|
55
|
+
parts.w,
|
|
56
|
+
parts.fg,
|
|
57
|
+
parts.codeFg,
|
|
58
|
+
parts.expanded ? 1 : 0,
|
|
59
|
+
parts.raw ? 1 : 0,
|
|
60
|
+
parts.pending ? 1 : 0,
|
|
61
|
+
parts.failed ? 1 : 0,
|
|
62
|
+
parts.spin,
|
|
63
|
+
// The palette. Cached rows are finished strings with their escapes already applied, so
|
|
64
|
+
// a theme switch has to miss every entry or the old colours come back. Without this a
|
|
65
|
+
// switched theme repainted the chrome (composed per frame) and left the transcript's
|
|
66
|
+
// own text in the previous palette — white words sitting in a gruvbox.
|
|
67
|
+
parts.theme || '',
|
|
68
|
+
].join('\x1f');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The shared store.
|
|
73
|
+
*
|
|
74
|
+
* A single `Map` with insertion order: `Map` iterates in insertion order and `set` on an
|
|
75
|
+
* existing key does NOT move it, so "touch on every read" has to be an explicit
|
|
76
|
+
* delete-then-set. That is what makes it an LRU rather than a FIFO — and the difference
|
|
77
|
+
* matters here, because the entries worth keeping are the ones being read, not the ones
|
|
78
|
+
* written most recently.
|
|
79
|
+
*/
|
|
80
|
+
const store = new Map();
|
|
81
|
+
|
|
82
|
+
/** Hits and misses, so a caller can tell "the cache is not helping" from "there is
|
|
83
|
+
* nothing to cache" without instrumenting the renderer. */
|
|
84
|
+
const stats = { hits: 0, misses: 0, evictions: 0 };
|
|
85
|
+
|
|
86
|
+
export function cacheStats() {
|
|
87
|
+
return { ...stats, size: store.size };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** For tests: a clean slate. */
|
|
91
|
+
export function resetRowCache() {
|
|
92
|
+
store.clear();
|
|
93
|
+
stats.hits = 0;
|
|
94
|
+
stats.misses = 0;
|
|
95
|
+
stats.evictions = 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Look up `key`, marking it as recently used.
|
|
100
|
+
*
|
|
101
|
+
* @returns the cached value, or undefined on a miss. Callers treat `undefined` as "render
|
|
102
|
+
* it" — a cache that cannot say "no" is not a cache.
|
|
103
|
+
*/
|
|
104
|
+
export function rowCacheGet(key) {
|
|
105
|
+
if (!store.has(key)) { stats.misses++; return undefined; }
|
|
106
|
+
const v = store.get(key);
|
|
107
|
+
// Touch: re-insert so this key moves to the young end of the Map's iteration order.
|
|
108
|
+
store.delete(key);
|
|
109
|
+
store.set(key, v);
|
|
110
|
+
stats.hits++;
|
|
111
|
+
return v;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Store `value` under `key` and enforce the bound.
|
|
116
|
+
*
|
|
117
|
+
* Eviction is oldest-first, and it is done HERE rather than by the caller counting
|
|
118
|
+
* messages: the caller cannot know what the entries cost, and a message-count window
|
|
119
|
+
* keeps a lot of large messages while discarding a lot of small ones.
|
|
120
|
+
*/
|
|
121
|
+
export function rowCacheSet(key, value) {
|
|
122
|
+
// Re-inserting an existing key would leave the old entry in place, so drop it first —
|
|
123
|
+
// otherwise the bound could be evictions short of what it looks like.
|
|
124
|
+
store.delete(key);
|
|
125
|
+
store.set(key, value);
|
|
126
|
+
while (store.size > ROW_CACHE_LIMIT) {
|
|
127
|
+
// Map iteration is insertion order, so the first key is the least recently used.
|
|
128
|
+
const oldest = store.keys().next();
|
|
129
|
+
if (oldest.done) break;
|
|
130
|
+
store.delete(oldest.value);
|
|
131
|
+
stats.evictions++;
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Is `cached` still valid for `key`'s message?
|
|
138
|
+
*
|
|
139
|
+
* A separate question from `rowCacheGet`, because a message can change without its key
|
|
140
|
+
* changing: a streamed reply's text is the key's own `text` component, so its key DOES
|
|
141
|
+
* change — but the row COUNT memo does not carry the text, and that one has to be asked
|
|
142
|
+
* whether the content moved. Kept as a function so the check is named at the call site
|
|
143
|
+
* rather than being an open-coded `===` someone later widens by accident.
|
|
144
|
+
*/
|
|
145
|
+
export function evictIfStale(entry, key) {
|
|
146
|
+
if (!entry) return true;
|
|
147
|
+
return entry.key !== key;
|
|
143
148
|
}
|
package/src/session-hub.js
CHANGED
|
@@ -168,6 +168,7 @@ export class SessionHub {
|
|
|
168
168
|
dispatch: null, // async (cmd, arg) => void — slash commands
|
|
169
169
|
interrupt: null, // async () => void
|
|
170
170
|
approve: null, // async (id, ok) => void — answer an approval
|
|
171
|
+
answerPlan: null, // async (id, outcome) => void — answer a Plan-mode review
|
|
171
172
|
answerQuestion: null,// async (id, answers) => void — AskUserQuestion
|
|
172
173
|
stopTask: null, // async (taskId) => void
|
|
173
174
|
setMode: null, // async (mode) => void
|
|
@@ -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
|
+
}
|