@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/src/tui.js CHANGED
@@ -18,11 +18,12 @@ import fs from 'node:fs';
18
18
  import os from 'node:os';
19
19
  import cp from 'node:child_process';
20
20
  import {
21
- C, TRUECOLOR, blockCursor, showCursor, hideCursor, alternateScreen, clearScreen, clearAndSetBg, setTheme, THEME_NAMES, THEME_LABELS, isLightTheme, hasTheme, lerpColor, bgRgb,
21
+ C, TRUECOLOR, blockCursor, showCursor, hideCursor, alternateScreen, clearScreen, clearAndSetBg, setTheme, setLightBackground, currentTheme, THEME_NAMES, THEME_LABELS, isLightTheme, hasTheme, lerpColor, bgRgb,
22
22
  } from './colors.js';
23
23
  import { copyText, readText, warmClipboard, readClipboardContentAsync, cleanupPastedImages } from './clipboard.js';
24
24
  import { visualWidth, estimateTokens, estimateMessagesTokens, estimateMessageTokens, messageTokens, expandTabs } from './term.js';
25
25
  // The picker is composited onto the frame as a cell layer rather than assigned over
26
+ // The picker is composited onto the frame as a cell layer rather than assigned over
26
27
  // it (see src/cell-buffer.js), so the transcript BESIDE the card survives.
27
28
  import { parseCells, renderCells, drawFrameBuffer } from './cell-buffer.js';
28
29
  // Streaming text is paced through a backlog so a coalescing provider (Anthropic) looks
@@ -42,6 +43,7 @@ import { forkOf } from './fork.js';
42
43
  import { makeRowKey, rowCacheGet, rowCacheSet } from './row-cache.js';
43
44
  import { renderContextBar, segmentCaption } from './context-bar.js';
44
45
  import { runDoctor, formatDoctor } from './doctor.js';
46
+ import { profileMemory, renderMemoryProfile } from './memory-profile.js';
45
47
  import { loadKeybindings, resolveKey, describeKeybindings, readOrEmpty, writeKeybindings, normalizeKey } from './keybindings.js';
46
48
  import { describeRuns, listRuns, sendToRun, interruptRun, interruptAll, closeRun } from './subagent-control.js';
47
49
  import { buildTree, visibleRows, allDirs, renderTree, moveSel } from './file-tree.js';
@@ -73,6 +75,10 @@ import { FAMILIES, TASKS, buildPreset, presetLabel } from './prompt-presets.js';
73
75
  import * as upd from './update.js';
74
76
  import { renderTasksBrowser, handleTasksBrowserKey, visibleTasks } from './tasks-browser.js';
75
77
  import { renderRegistryBrowser, handleRegistryBrowserKey } from './registry-browser.js';
78
+ import {
79
+ SETTINGS_SCHEMA, SETTING_BY_KEY, populatedTabs, settingsForTab,
80
+ coerceSetting, effectiveValue, isSet, displayValue, changedSettings,
81
+ } from './settings-schema.js';
76
82
  import { renderTaskOutputViewer, handleViewerKey, makeViewerState } from './task-output-viewer.js';
77
83
  import { renderSwarmProgress } from './swarm-progress.js';
78
84
  import { sortedTasks, getTask, settleTask, STATUS_LABEL, backgroundTaskCard } from './agent-task.js';
@@ -158,7 +164,8 @@ let searchSeq = 0;
158
164
  export const COMMANDS = [
159
165
  { name: 'yolo', aliases: ['yes'], desc: 'Yolo mode: anything inside the workspace (edits, writes, commands) runs automatically; paths outside it, destructive commands, questions and plans still ask.', priority: 101 },
160
166
  { name: 'permission', desc: 'Select permission mode', priority: 100 },
161
- { name: 'settings', aliases: ['config'], desc: 'Open settings (model / permission / statusline)', priority: 100 },
167
+ { name: 'settings', aliases: ['config'], desc: 'Browse and change every setting, grouped by area', priority: 100, argumentHint: '[tab|filter]' },
168
+ { name: 'set', desc: 'Set one config value by key, or list what differs from the default', priority: 100, argumentHint: '[<key> [value]]' },
162
169
  { name: 'plan', desc: 'Toggle plan mode', priority: 100, argumentHint: '[on|off|clear]' },
163
170
  { name: 'focus', desc: 'Toggle Focus mode (minimal tools first, full tools after)', priority: 98, argumentHint: '[on|off]' },
164
171
  { name: 'auto', desc: 'Auto mode: never interrupts you; everything runs and is decided automatically.', priority: 99 },
@@ -182,10 +189,11 @@ export const COMMANDS = [
182
189
  { name: 'fork', desc: 'Fork the current session into a copy without switching to it', priority: 80 },
183
190
  { name: 'undo', desc: 'Withdraw the last prompt: transcript + files it changed', priority: 80, argumentHint: '[count]' },
184
191
  { name: 'trim', desc: 'Trim old tool results now, down to the keep ratio (manual)', priority: 40, argumentHint: '[keep-ratio]' },
185
- { name: 'auto-trim', aliases: ['autotrim'], desc: 'Auto tool-result trimming: toggle, or set the trigger/keep ratios', priority: 40, argumentHint: '[on|off | threshold <n> | keep <n>]' },
192
+ { name: 'auto-trim', aliases: ['autotrim'], desc: 'Auto tool-result trimming: the threshold is a share of the WINDOW, `keep` a share of the tool-result TEXT', priority: 40, argumentHint: '[on|off | threshold <n> | keep <n>]' },
186
193
  { name: 'title', aliases: ['rename'], desc: 'Set or show session title (also sets window title)', priority: 60, argumentHint: '<title>' },
187
194
  { name: 'status', desc: 'Show current session and runtime status', priority: 60 },
188
- { name: 'doctor', desc: 'Diagnose the environment: config, store, MCP, hooks, terminal', priority: 60, argumentHint: '[--all]' },
195
+ { name: 'doctor', desc: 'Diagnose the environment: config, store, MCP, hooks, disk, git, terminal', priority: 60, argumentHint: '[--all] [--probe]' },
196
+ { name: 'memory-profile', aliases: ['memprof'], desc: 'Where this session’s memory goes: text vs tool results vs tool arguments', priority: 60, argumentHint: '[--verbose]' },
189
197
  { name: 'usage', desc: 'Show real token totals (provider-reported) + context window', priority: 60 },
190
198
  { name: 'cost', desc: 'Show what this session has spent, in USD or CNY', priority: 60, argumentHint: '[usd|cny]' },
191
199
  { name: 'context', aliases: ['ctx'], desc: 'Break down what fills the context window', priority: 60 },
@@ -2984,7 +2992,8 @@ function lruKeyFor(m, rowWidth, expanded, spinKey, state) {
2984
2992
  raw: !!state.rawMode,
2985
2993
  pending: !!m.pending,
2986
2994
  failed: !!m.failed,
2987
- spin: spinKey,
2995
+ theme: currentTheme(),
2996
+ spin: spinKey,
2988
2997
  });
2989
2998
  }
2990
2999
 
@@ -3063,7 +3072,13 @@ export function renderChatLines(state, w, viewport) {
3063
3072
  // /raw changes how an assistant row is built, so the cache must be invalidated
3064
3073
  // when it flips — otherwise the old Markdown rows are reused and the toggle
3065
3074
  // appears to do nothing.
3066
- && cached.raw === !!state.rawMode;
3075
+ && cached.raw === !!state.rawMode
3076
+ // THE PALETTE. Every cached row is finished text, escapes included, so a theme
3077
+ // change has to invalidate all of them. Without this the chrome repainted (it is
3078
+ // composed per frame) while the assistant's own text kept the OLD colours — white
3079
+ // words left sitting in a gruvbox palette after a switch. `auto` resolves to a
3080
+ // concrete name, so following the terminal is caught too.
3081
+ && cached.theme === currentTheme();
3067
3082
  // An evicted message keeps only `m._count`. When its layout key still matches,
3068
3083
  // the row count is known, so the message is counted WITHOUT re-rendering it —
3069
3084
  // which is the whole point of keeping the count separate from the rows (an idle
@@ -3108,6 +3123,10 @@ export function renderChatLines(state, w, viewport) {
3108
3123
  const wrapped = rows.map(rowToLine);
3109
3124
  const isPending2 = m.pending ? 1 : 0;
3110
3125
  const cc = {
3126
+ // The palette is part of the key: `rows` are finished strings with their escapes
3127
+ // already in them, so a cached row cannot be re-tinted later. See the `valid` test
3128
+ // above, which compares this field against `currentTheme()`.
3129
+ theme: currentTheme(),
3111
3130
  rowW: rowWidth, expanded, spinKey: spinKeyNow, isPending: isPending2,
3112
3131
  textRef: m.text, streamRef: m.streamContent, liveRef: m.liveOutput,
3113
3132
  failed: m.failed, diffRef: m.diff, countsRef: m._diffCounts,
@@ -3288,6 +3307,14 @@ export function metricsSignature(state, n, w, expanded, innerW) {
3288
3307
  let h = 2166136261 >>> 0;
3289
3308
  const mix = (v) => { h ^= (v | 0); h = Math.imul(h, 16777619) >>> 0; };
3290
3309
  mix(n); mix(w); mix(innerW); mix(expanded ? 1 : 0); mix(state.spin || 0);
3310
+ // THE PALETTE. This signature decides whether the WHOLE transcript is walked at all, so a
3311
+ // theme switch that left it alone skipped the per-message `valid` check too — every cached
3312
+ // row kept the colours it was rendered with, which is why the chrome repainted and the
3313
+ // assistant's text did not. Hashed rather than compared by name: this is a number.
3314
+ for (let ci = 0; ci < currentTheme().length; ci++) {
3315
+ h ^= currentTheme().charCodeAt(ci);
3316
+ h = Math.imul(h, 16777619) >>> 0;
3317
+ }
3291
3318
  // rawMode changes every assistant row's shape, so it is part of the layout
3292
3319
  // identity: without this, toggling /raw left the cached rows on screen.
3293
3320
  mix(state.rawMode ? 1 : 0);
@@ -4256,6 +4283,7 @@ export function composeFrame(state, cols, rows) {
4256
4283
  const lines = renderRegistryBrowser(state.registryBrowser, w, h);
4257
4284
  return takeoverFrame(lines, (state.registryBrowser._regHits || []).slice());
4258
4285
  }
4286
+
4259
4287
  // The file tree browser (/files) is another takeover. It re-renders from ITS OWN state
4260
4288
  // every frame, so a directory opened with the arrows is reflected immediately.
4261
4289
  if (state.fileTree) {
@@ -4304,8 +4332,11 @@ const takeover = state.editor || state.panel || null;
4304
4332
  : 0;
4305
4333
  // The notice no longer reserves a row: it is drawn ON the context row (see the
4306
4334
  // bottom of this function), so `noticeH` only selects the colour there.
4307
- const noticeH = state.notice ? 1 : 0;
4308
- const confirmH = state.confirmExit ? 1 : 0;
4335
+ const noticeH = state.notice ? 1 : 0;
4336
+ // The confirm prompt now works the way the notice above it does: drawn ON the context row,
4337
+ // in the tip row's left half. It took a row of its own before, which pushed the bar and
4338
+ // the composer down every time Ctrl+C was pressed — for a prompt that lasts three seconds.
4339
+ const confirmH = 0;
4309
4340
  const workingH = (!takeover && state.running) ? 1 : 0;
4310
4341
  // The todo and queue panels sit directly above the composer, so they stay visible
4311
4342
  // under a picker for the same reason the composer does — a popup covers the
@@ -4331,7 +4362,7 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
4331
4362
  const mentionItems = (state.mentionOpen && state.mentionList.length && !takeover)
4332
4363
  ? Math.min(state.mentionList.length, MAX_MENU) + 1
4333
4364
  : 0;
4334
- const chromeH = todoH + queueH + btwH + workingH + composerBoxH + menuItems + mentionItems + STATUS_H + confirmH + CTX_H;
4365
+ const chromeH = todoH + queueH + btwH + workingH + composerBoxH + menuItems + mentionItems + STATUS_H + CTX_H;
4335
4366
  // NOTE the extra `- 1`: `bodyH` below is clamped to a MINIMUM of 1 row, so that
4336
4367
  // row has to be reserved here too. Without it the total came out one over and
4337
4368
  // the top trim ate the box's `╭` border.
@@ -4462,7 +4493,18 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
4462
4493
  // overlay step turns it into the box's inner width once that is known.
4463
4494
  const RULE = { __rule: true };
4464
4495
  const fields = f.fields || [];
4465
- const body = [col(f.title || '', C.cyan + C.bold), RULE, ''];
4496
+ // An optional DESCRIPTION under the title, wrapped to the box. /settings uses it: the
4497
+ // list row carries only the key and its value, so this is where the setting says what it
4498
+ // actually does — and it is the moment the user is about to change it.
4499
+ const body = [col(f.title || '', C.cyan + C.bold)];
4500
+ if (f.note) {
4501
+ // `cols`, not `innerW`: the card's own width is decided later, in the overlay step, and
4502
+ // `innerW` does not exist in this scope — naming it killed the process the moment any
4503
+ // setting was opened. Wrapped to the terminal minus the card's margins; the overlay
4504
+ // step trims whatever still does not fit.
4505
+ for (const l of wrapWords(f.note, Math.max(20, cols - 8))) body.push(col(l, C.gray));
4506
+ }
4507
+ body.push(RULE, '');
4466
4508
  const fieldBodyRows = [];
4467
4509
  fields.forEach((field, i) => {
4468
4510
  const active = i === f.fieldIdx;
@@ -4590,8 +4632,32 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
4590
4632
  // The "current" marker sits right after the name (not right-aligned at the far
4591
4633
  // edge), with a wider gap so it does not crowd the label.
4592
4634
  const cur = item.current ? col(' ← current', C.green) : '';
4635
+ // `valueTag` is the setting's CURRENT VALUE. It goes after the description, not
4636
+ // right-aligned: the card sizes itself to its widest row, so padding a value out to
4637
+ // the terminal width made every row full-width and the card filled the screen.
4638
+ //
4639
+ // The value is capped at 40 columns INDEPENDENTLY of the room left on the row. A
4640
+ // value pasted into `system_prompt` can be a paragraph, and letting its length decide
4641
+ // meant one row set the width for all 50 — which is the same full-screen problem by
4642
+ // another route. `displayValue` already shortens long values for this reason; the cap
4643
+ // here is the picker's own, because it is what decides the card.
4644
+ let tag = '';
4645
+ if (item.valueTag != null && item.valueTag !== '') {
4646
+ const raw = String(item.valueTag);
4647
+ const room = Math.min(40, Math.max(8, cols - visualCol(ptr + label + sub + cur) - 6));
4648
+ const text = visualCol(raw) > room ? `${raw.slice(0, Math.max(1, room - 1))}…` : raw;
4649
+ tag = col(
4650
+ ` ${text}`,
4651
+ // Grey for the default, and colour ONLY for a value the user has actually changed.
4652
+ // A settings list is mostly defaults, so painting all of them drew the eye nowhere;
4653
+ // what stands out should be what is NOT the default. Orange rather than cyan:
4654
+ // cyan is the accent the rest of the chrome already uses (the pointer, the tab,
4655
+ // the selection), so a changed value in it read as "selected" rather than "edited".
4656
+ item.valueTagChanged ? C.orange : C.gray,
4657
+ );
4658
+ }
4593
4659
  itemBodyRows.push(body.length);
4594
- body.push(col(ptr + label + sub + cur, C.white));
4660
+ body.push(col(ptr + label + sub + cur + tag, C.white));
4595
4661
  });
4596
4662
  if (list.length > maxItems) body.push(col(`▼ ${list.length - (first + shown.length)} more`, C.gray));
4597
4663
  let footerBodyRow = -1;
@@ -5540,8 +5606,6 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5540
5606
  // generic hover tint (any hitbox is tinted unless it is a composerRow).
5541
5607
  for (const h of slHits) addHit(statusRow, h.col0, h.col1, { kind: h.kind });
5542
5608
 
5543
- if (confirmH) lines.push(col(fitAnsi('Press Ctrl+C again to exit the hncode', w), C.yellow));
5544
-
5545
5609
  // The context row is now a BAR: the window's composition as colour, with the same
5546
5610
  // `context: 12% (11.7k/97.7k)` text set into the free segment's right edge. The wording
5547
5611
  // is unchanged from the string this replaces — only its carrier changed from a line of
@@ -5582,9 +5646,15 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5582
5646
  ? segmentCaption(barHover.data, ctxSegs[barHover.data] || 0)
5583
5647
  : '';
5584
5648
  const tipText = (state.slTips !== false && state.tip) ? col(state.tip, C.gray) : '';
5585
- const leftText = segCaption
5586
- ? col(segCaption, C.white)
5587
- : (noticeH ? col(state.notice, state.noticeKind === 'error' ? C.red : C.gray) : '');
5649
+ // The confirm prompt takes this slot ahead of the hover caption and the notice. It is the
5650
+ // one thing on screen that must not be missed, and a slot already exists: the left of the
5651
+ // tip row. Giving it a row of its own pushed the bar up and moved the notice out of the
5652
+ // place the eye already looks for it.
5653
+ const leftText = state.confirmExit
5654
+ ? col(fitAnsi('Press Ctrl+C again to exit', w - visualCol(tipText) - 2), C.yellow)
5655
+ : (segCaption
5656
+ ? col(segCaption, C.white)
5657
+ : (noticeH ? col(state.notice, state.noticeKind === 'error' ? C.red : C.gray) : ''));
5588
5658
  lines.push(justify(leftText, tipText, w));
5589
5659
 
5590
5660
 
@@ -5676,7 +5746,9 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
5676
5746
  // layout budget above.
5677
5747
  const mentionH = (state.mentionOpen && state.mentionList.length && !dialog)
5678
5748
  ? Math.min(state.mentionList.length, MAX_MENU) + 1 : 0;
5679
- const bottomChrome = STATUS_H + (state.confirmExit ? 1 : 0) + CTX_H + menuH + mentionH;
5749
+ // The confirm prompt is NOT a term here: it shares the tip row, which is already inside
5750
+ // STATUS_H, so counting it made the caret jump up a line every time Ctrl+C was pressed.
5751
+ const bottomChrome = STATUS_H + CTX_H + menuH + mentionH;
5680
5752
  const caretScreenRow = h - bottomChrome - 2 - (composer.rows.length - 1 - composer.caretRow);
5681
5753
  cursor = {
5682
5754
  row: Math.max(0, Math.min(caretScreenRow, h - 1)),
@@ -6563,6 +6635,76 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
6563
6635
  });
6564
6636
  addChat({ role: 'rich', text: out.join('\n') });
6565
6637
  };
6638
+
6639
+ /**
6640
+ * Write one setting, through the single validation point in the schema.
6641
+ *
6642
+ * Every surface lands here — `/set`, the /settings browser, and the web's setConfig — so
6643
+ * they cannot disagree about what a value means or which keys exist.
6644
+ */
6645
+ function applySetting(key, value) {
6646
+ const def = SETTING_BY_KEY.get(key);
6647
+ if (!def) { appErr(`Unknown setting: ${key}. Try /settings.`); return false; }
6648
+ const coerced = coerceSetting(key, value);
6649
+ if (!coerced.ok) { appErr(coerced.error); return false; }
6650
+
6651
+ try {
6652
+ if (def.kind === 'bool') setConfigBool(key, coerced.value === 'true');
6653
+ else setConfigString(key, coerced.value);
6654
+ } catch (e) { appErr(`Could not write config.toml: ${e.message}`); return false; }
6655
+
6656
+ // Live-apply: mutate `cfg` AND `cfg.raw`, because some readers look at one and some at
6657
+ // the other, and a change that only reaches one of them appears to work until the next
6658
+ // frame reads the other.
6659
+ const raw = coerced.value;
6660
+ if (def.kind === 'bool') {
6661
+ const v = raw === 'true';
6662
+ cfg[key] = v; cfg.raw[key] = v;
6663
+ // The names the rest of the code actually reads, where they differ from the key.
6664
+ if (key === 'auto_compact') cfg.autoCompact = v;
6665
+ if (key === 'auto_trim') cfg.autoTrim = v;
6666
+ if (key === 'auto_update') cfg.autoUpdate = v;
6667
+ if (key === 'calm_mode') cfg.calmMode = v;
6668
+ if (key === 'swarm_mode') cfg.swarm = v;
6669
+ if (key === 'tool_allow_external_paths') cfg.allowExternal = v;
6670
+ } else {
6671
+ cfg[key] = raw; cfg.raw[key] = raw;
6672
+ if (key === 'compact_threshold') cfg.compactThreshold = Number(raw) / 100;
6673
+ if (key === 'compact_keep_ratio') cfg.compactKeepRatio = Number(raw) / 100;
6674
+ if (key === 'trim_threshold') cfg.trimThreshold = Number(raw) / 100;
6675
+ if (key === 'trim_keep_ratio') cfg.trimKeepRatio = Number(raw) / 100;
6676
+ if (key === 'max_context_tokens') cfg.maxContextTokens = Number(raw) || cfg.maxContextTokens;
6677
+ if (key === 'provider') cfg.provider = raw;
6678
+ if (key === 'model') { cfg.model = raw; state.modelLabel = raw; }
6679
+ if (key === 'effort') { cfg.effort = raw; state.effort = raw; }
6680
+ if (key === 'secondary_model') cfg.secondaryModel = raw;
6681
+ if (key === 'subagent_model') cfg.subagentModel = raw;
6682
+ if (key === 'tool_result_max_bytes') cfg.toolResultMaxBytes = Number(raw);
6683
+ if (key === 'tool_result_preview_bytes') cfg.toolResultPreviewBytes = Number(raw);
6684
+ if (key === 'probe_timeout_ms') cfg.probeTimeoutMs = Number(raw);
6685
+ if (key === 'log_level') cfg.logLevel = raw;
6686
+ if (key === 'shell') cfg.shell = raw;
6687
+ }
6688
+
6689
+ // Effects that are not a plain field: switching the theme has to repaint every cell,
6690
+ // and a system-prompt change has to reach the next request.
6691
+ //
6692
+ // The repaint is requested through `state`, not by touching `lastFrame`: this function
6693
+ // runs inside `dispatch`, which cannot see startTUI's painter. `lastFrame` is dropped by
6694
+ // the next `paintNow`, which is what forces every cell to be rewritten — a theme change
6695
+ // left to the differential painter emits nothing, because the frame it compares against
6696
+ // already matches.
6697
+ if (key === 'theme') { setTheme(raw); state._forceRepaint = true; }
6698
+ if (key === 'system_prompt') cfg.systemPrompt = raw;
6699
+ if (key === 'append_system_prompt') cfg.appendSystemPrompt = raw;
6700
+ if (key === 'plan_instructions') cfg.planInstructions = raw;
6701
+ if (key === 'focus_instructions') cfg.focusInstructions = raw;
6702
+ if (key === 'swarm_instructions') cfg.swarmInstructions = raw;
6703
+ if (typeof renderFrameArg === 'function') renderFrameArg();
6704
+ return true;
6705
+ return true;
6706
+ }
6707
+
6566
6708
  const raw = (arg || '').trim();
6567
6709
 
6568
6710
  switch (cmd) {
@@ -6621,26 +6763,134 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
6621
6763
  return;
6622
6764
  }
6623
6765
 
6624
- case 'settings':
6766
+ case 'settings': {
6767
+ // A PICKER, not a screen takeover. Every other chooser in hncode is a picker — /model,
6768
+ // /theme, /permissions — and they share a look, a key set and the mouse. A settings
6769
+ // screen that owned the whole terminal looked like a different program and, because
6770
+ // the takeover path has its own click handling, could not be clicked at all.
6771
+ //
6772
+ // The tabs are the picker's own `categories`, which already render as a clickable,
6773
+ // hoverable strip with per-tab counts. Nothing new had to be invented for them.
6774
+ //
6775
+ // The SETTINGS_SCHEMA order drives the rows, and every row edits exactly as it does
6776
+ // elsewhere: a bool flips, anything else opens the value prompt.
6777
+ const arg = raw.trim().toLowerCase();
6778
+ const tabs = populatedTabs();
6779
+ const argTab = tabs.find((t) => t.id === arg);
6780
+ const items = [];
6781
+ for (const t of tabs) {
6782
+ for (const d of settingsForTab(t.id)) {
6783
+ const value = displayValue(cfg, d.key);
6784
+ // The row carries the NAME and the VALUE, nothing else. A description here made
6785
+ // every row four times longer than the information it conveys, squeezed the
6786
+ // value into whatever space was left, and let the longest description set the
6787
+ // width of the whole card. The description is not lost — it is what the edit
6788
+ // form shows, which is where the user is about to change the value anyway.
6789
+ const changed = isSet(cfg, d.key) && value !== d.default;
6790
+ items.push({
6791
+ label: d.ui.label || d.key,
6792
+ valueTag: value,
6793
+ valueTagChanged: changed,
6794
+ // Which tab this row belongs to, for the category filter.
6795
+ category: t.id,
6796
+ // Carried through to onPick: the key, its kind, and the description the form
6797
+ // needs — none of it belongs on the row.
6798
+ setKey: d.key,
6799
+ setKind: d.kind,
6800
+ setHint: d.ui ? d.ui.hint : '',
6801
+ });
6802
+ }
6803
+ }
6804
+ const catLabels = ['All', ...tabs.map((t) => t.id)];
6625
6805
  openPicker({
6626
6806
  title: 'Settings',
6627
- items: [
6628
- { label: 'model', sub: 'switch LLM model' },
6629
- { label: 'effort', sub: 'switch thinking effort' },
6630
- { label: 'permission', sub: 'select permission mode' },
6631
- { label: 'provider', sub: 'manage AI providers' },
6632
- { label: 'statusline', sub: 'configure status line items' },
6633
- { label: 'add-dir', sub: 'add an additional workspace directory' },
6634
- { label: 'auto-update', sub: `check npm for updates at startup + every 30 min — currently ${cfg.autoUpdate ? 'on' : 'off'}` },
6635
- // The thresholds are CONFIGURABLE (/auto-compact threshold <n>), so show the
6636
- // live value rather than a hardcoded "85%"/"50%" that lied after an edit.
6637
- { label: 'auto-compact', sub: `summarize older history at ${Math.round((cfg.compactThreshold ?? 0.85) * 100)}% of the window — currently ${cfg.autoCompact === false ? 'off' : 'on'}` },
6638
- { label: 'auto-trim', sub: `elide old tool results at ${Math.round((cfg.trimThreshold ?? 0.5) * 100)}% of the window — currently ${cfg.autoTrim === false ? 'off' : 'on'}` },
6639
- { label: 'trim', sub: `elide old tool results now — /trim [${cfg.trimKeepRatio ?? 0.3}] sets how much to keep` },
6640
- ],
6641
- onPick: (it) => { dispatch(it.label, '', state, cfg, session, h, submit, stdout); return true; },
6807
+ items,
6808
+ categories: catLabels.length > 1 ? catLabels : null,
6809
+ category: argTab ? argTab.id : null,
6810
+ // Open on the tab named on the command line, else on the first row.
6811
+ sel: 0,
6812
+ hint: '↑↓ navigate · Enter change · Tab category · /search · Esc close',
6813
+ onPick: (it) => {
6814
+ if (!it || !it.setKey) return true;
6815
+ const key = it.setKey;
6816
+ const def = SETTING_BY_KEY.get(key);
6817
+ // A bool flips IN PLACE: a toggle with a confirm step is a worse toggle.
6818
+ if (def.kind === 'bool') {
6819
+ applySetting(key, effectiveValue(cfg, key) === 'true' ? 'false' : 'true');
6820
+ // Re-open so the list shows the new value — the picker closed on the pick, and
6821
+ // a settings screen that closes after every change is unusable for a second one.
6822
+ void dispatch('/settings', raw.trim(), state, cfg, session, h, submit, stdout);
6823
+ return true;
6824
+ }
6825
+ // Everything else opens a one-line prompt, pre-filled with the value in force.
6826
+ openForm({
6827
+ title: def.ui ? def.ui.label : key,
6828
+ fields: [{
6829
+ key: 'value',
6830
+ label: def.kind === 'text' ? 'Value (blank clears)' : 'Value',
6831
+ value: String(effectiveValue(cfg, key)),
6832
+ }],
6833
+ type: null,
6834
+ hideType: true,
6835
+ labelW: 18,
6836
+ // The description left the list row, so the form is where it appears.
6837
+ note: it.setHint || (def.ui ? def.ui.hint : ''),
6838
+ hint: def.values
6839
+ ? `one of: ${def.values.join(', ')} · Enter save · Esc cancel`
6840
+ : 'Enter save · Esc cancel',
6841
+ onSubmit: (values) => {
6842
+ applySetting(key, values && values.value);
6843
+ // Back to the list, so the next setting is one keystroke away.
6844
+ void dispatch('/settings', raw.trim(), state, cfg, session, h, submit, stdout);
6845
+ },
6846
+ });
6847
+ return true;
6848
+ },
6642
6849
  });
6643
6850
  return;
6851
+ }
6852
+
6853
+
6854
+ case 'set': {
6855
+ // `/set <key> <value>` — the direct path, so any setting is reachable without
6856
+ // remembering which command owns it. `/set` alone lists what is changed.
6857
+ const parts = raw.trim().split(/\s+/).filter(Boolean);
6858
+ if (!parts.length) {
6859
+ const changed = changedSettings(cfg);
6860
+ if (!changed.length) {
6861
+ sayPanel(['Nothing is set away from its default.', '', 'Browse everything with /settings,',
6862
+ 'or set one directly: /set <key> <value>'].join('\n'));
6863
+ return;
6864
+ }
6865
+ const lines = [`${changed.length} setting(s) differ from the default:`, ''];
6866
+ for (const d of changed) {
6867
+ lines.push(` ${d.key.padEnd(28)} ${displayValue(cfg, d.key)} (default ${d.default})`);
6868
+ }
6869
+ sayPanel(lines.join('\n'));
6870
+ return;
6871
+ }
6872
+ const key = parts[0];
6873
+ if (!SETTING_BY_KEY.has(key)) {
6874
+ appErr(`Unknown setting: ${key}. Run /settings to browse, or /set to list what is changed.`);
6875
+ return;
6876
+ }
6877
+ if (parts.length === 1) {
6878
+ const d = SETTING_BY_KEY.get(key);
6879
+ sayPanel([
6880
+ ` ${d.key}`,
6881
+ ` value ${displayValue(cfg, key)}`,
6882
+ ` default ${d.default}`,
6883
+ ` type ${d.kind}${d.values ? ` (${d.values.join(', ')})` : ''}`,
6884
+ d.ui ? ` where ${d.ui.tab} › ${d.ui.group}` : ' (no settings row; set by /set only)',
6885
+ '',
6886
+ d.ui ? ` ${d.ui.hint}` : '',
6887
+ ].join('\n'));
6888
+ return;
6889
+ }
6890
+ applySetting(key, parts.slice(1).join(' '));
6891
+ return;
6892
+ }
6893
+
6644
6894
 
6645
6895
  case 'model': {
6646
6896
  const models = (cfg.raw && cfg.raw.models) || {};
@@ -7039,7 +7289,7 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
7039
7289
  return true;
7040
7290
  },
7041
7291
  });
7042
- });
7292
+ });
7043
7293
  return true;
7044
7294
  },
7045
7295
  });
@@ -8844,6 +9094,9 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
8844
9094
  else { cfg.trimKeepRatio = v; cfg.raw.trim_keep_ratio = v; }
8845
9095
  app(sub === 'threshold'
8846
9096
  ? `Auto-trim now fires at ${Math.round(v * 100)}% of the context window`
9097
+ // Both knobs are shares of the SAME thing — what the model is sent — so the
9098
+ // denominator is named here. It was "of the tool-result text", which read as a
9099
+ // different scale from the line above and is no longer what the code does.
8847
9100
  : `Auto-trim now keeps ~${Math.round(v * 100)}% of the tool-result text after trimming`);
8848
9101
  return;
8849
9102
  }
@@ -8963,18 +9216,52 @@ case 'trim': {
8963
9216
  case 'doctor': {
8964
9217
  // Diagnose the environment. Read-only: nothing here repairs or writes, so it is
8965
9218
  // safe to run on a machine whose state matters.
9219
+ //
9220
+ // Async because one of the checks profiles the largest session, which loads and
9221
+ // parses it. A synchronous report could not answer "where did the memory go".
8966
9222
  const cfgMcp = loadMcpConfig(state.workspace || cfg.workspace);
8967
- const results = runDoctor({
9223
+ // Terminal size comes from the `stdout` PARAMETER, not from `dims()`. `dims` is a
9224
+ // closure inside startTUI and this switch runs in the module-level `dispatch`, which
9225
+ // cannot see it — referencing it threw `dims is not defined` for anyone who ran
9226
+ // /doctor. `stdout` is already a dispatch parameter, and the fallback matches dims'.
9227
+ let termDims = { cols: 80, rows: 24 };
9228
+ try {
9229
+ const [c, r] = stdout.getWindowSize();
9230
+ termDims = { cols: c || 80, rows: r || 24 };
9231
+ } catch { /* not a TTY: the default stands */ }
9232
+ const args = {
8968
9233
  cfg,
8969
9234
  workspace: state.workspace || cfg.workspace || state.cwd,
8970
9235
  sessionsDir: sess.sessionsDir(),
8971
9236
  mcpServers: cfgMcp.servers,
8972
9237
  mcpConnections: getLiveConnections(),
8973
- dims: dims(),
9238
+ dims: termDims,
9239
+ };
9240
+ const flags = String(raw || '').trim().split(/\s+/).filter(Boolean);
9241
+ const showAll = flags.includes('--all') || flags.includes('-a');
9242
+ // `--probe` makes a real request, so it is opt-in and announced: the user is about to
9243
+ // spend a few tokens, and a report that silently did that would be worse than one that
9244
+ // did not.
9245
+ const probe = flags.includes('--probe');
9246
+ if (probe) {
9247
+ args.probe = true;
9248
+ args.childProcess = cp;
9249
+ h.notice('Probing the provider with one real request…', 'info');
9250
+ }
9251
+ Promise.resolve(runDoctor(args)).then((results) => {
9252
+ h.openPanel('hncode — doctor', formatDoctor(results, { showOk: showAll, version: VERSION }));
9253
+ }).catch((e) => {
9254
+ appErr(`doctor failed: ${(e && e.message) || e}`);
8974
9255
  });
8975
- const showAll = ['--all', '-a'].includes(String(raw || '').trim());
8976
- const lines = formatDoctor(results, { showOk: showAll, version: VERSION });
8977
- h.openPanel('hncode — doctor', lines);
9256
+ return;
9257
+ }
9258
+ case 'memory-profile': {
9259
+ // Where this session's memory actually goes. A file size or a row count says nothing
9260
+ // actionable: on a real 2.6 MB session the tool ARGUMENTS were 0.66 MB — more than the
9261
+ // text and the tool results combined — and no count hints at that.
9262
+ const verbose = /--verbose|-v\b/.test(String(raw || '').trim());
9263
+ const profile = profileMemory({ session, state, convRowFor: sess.convRowFor });
9264
+ h.openPanel('hncode — memory', renderMemoryProfile(profile, { verbose }));
8978
9265
  return;
8979
9266
  }
8980
9267
  case 'mcp-config': {
@@ -9115,24 +9402,24 @@ case 'trim': {
9115
9402
  }
9116
9403
 
9117
9404
  case 'theme': {
9118
- // The theme system already existed (colors.js THEMES + setTheme) but nothing could
9119
- // REACH it: no command, and no config key, so `light` only ever applied when a light
9120
- // terminal happened to be detected. This is the missing entry point.
9121
9405
  const arg = raw.trim().toLowerCase();
9406
+ // ONE write path, shared with /settings and /set. Writing the config HERE by hand is
9407
+ // how the theme ended up half-applied: `setConfigString` reached the file but not
9408
+ // `cfg.raw`, so anything that re-read the setting - reopening this picker, a frame
9409
+ // composed later - saw the old value while the screen showed the new one.
9122
9410
  const apply = (name) => {
9123
- if (!hasTheme(name)) { appErr(`Unknown theme "${name}". Known: ${THEME_NAMES.join(', ')}`); return false; }
9411
+ if (!hasTheme(name)) {
9412
+ appErr(`Unknown theme "${name}". Known: ${THEME_NAMES.join(', ')}`);
9413
+ return false;
9414
+ }
9415
+ // `applySetting` writes config.toml, updates `cfg` AND `cfg.raw`, applies the
9416
+ // palette, and requests the full repaint a colour change needs.
9417
+ applySetting('theme', name);
9124
9418
  state.theme = name;
9125
- setTheme(name);
9126
- try { setConfigString('theme', name); } catch { /* not fatal: the session still uses it */ }
9127
9419
  return true;
9128
9420
  };
9129
9421
  if (THEME_NAMES.includes(arg)) {
9130
9422
  if (!apply(arg)) return;
9131
- // A FULL repaint, not a diff. `setTheme` mutates `C` in place, and the differential
9132
- // painter compares against the frame it believes it painted — so every row already
9133
- // on screen would keep its old escapes and a diff would emit nothing. Dropping the
9134
- // cached frame is what makes the next paint rewrite every cell.
9135
- lastFrame = null;
9136
9423
  app(`Theme: ${arg}`);
9137
9424
  return;
9138
9425
  }
@@ -9140,17 +9427,23 @@ case 'trim': {
9140
9427
  openPicker({
9141
9428
  title: 'Theme',
9142
9429
  items: THEME_NAMES.map((n) => ({
9143
- label: n + (state.theme === n ? ' ← current' : ''),
9430
+ label: n,
9144
9431
  sub: THEME_LABELS[n] || '',
9432
+ // `current` is the picker's own marker: a green `← current` AFTER the
9433
+ // description. Folding it into `label` put a grey arrow beside the name.
9434
+ current: state.theme === n,
9145
9435
  value: n,
9146
9436
  })),
9147
9437
  onPick: (it) => {
9148
- if (apply(it.value)) { lastFrame = null; app(`Theme: ${it.value}`); }
9438
+ if (apply(it.value)) app(`Theme: ${it.value}`);
9149
9439
  return true;
9150
9440
  },
9151
9441
  });
9152
9442
  return;
9153
9443
  }
9444
+
9445
+ case 'keybindings': {
9446
+ }
9154
9447
  case 'keybindings': {
9155
9448
  // Bindings live in ~/.hncode/keybindings.json; this is the editing entry point.
9156
9449
  // A binding names a target the key dispatcher ALREADY understands (another key) or
@@ -10848,8 +11141,10 @@ export async function startTUI(opts) {
10848
11141
  const fromEnv = enabledExperiments({ raw: {} }, process.env);
10849
11142
  if (fromEnv.length) app(`Experiments from environment: ${fromEnv.join(', ')}`);
10850
11143
  }
10851
- // Force dark theme (theme removed from state)
10852
- setTheme('dark');
11144
+ // The startup palette: what config says, else `auto` — the brand palette, and the theme
11145
+ // schema's own default. Hard-coding 'dark' here meant a saved theme was ignored on the
11146
+ // next start, so `/theme gruvbox` looked like it had not stuck.
11147
+ setTheme(cfg.raw && hasTheme(cfg.raw.theme) ? cfg.raw.theme : 'auto');
10853
11148
 
10854
11149
  // Live argument completions for the composer menu. /effort completes from the
10855
11150
  // CURRENT model's thinking levels (models.dev data), which the static hint
@@ -11084,7 +11379,7 @@ todos: [...(state.todos || [])],
11084
11379
  // candidates that can replace it. Every entry maps to a `dispatch` invocation,
11085
11380
  // so the browser never needs a code path of its own — it posts the same command
11086
11381
  // the terminal would run. That is what makes /model work in the browser.
11087
- function optionsForWeb() {
11382
+ function optionsForWeb() {
11088
11383
  const models = (cfg.raw && cfg.raw.models) || {};
11089
11384
  const modelItems = Object.keys(models).map((key) => {
11090
11385
  // Each model carries its OWN thinking levels. The browser draws them next
@@ -11176,7 +11471,7 @@ return {
11176
11471
  return toolsCache;
11177
11472
  }
11178
11473
 
11179
- function commandsForWeb() {
11474
+ function commandsForWeb() {
11180
11475
  if (commandsCache) return commandsCache;
11181
11476
  try {
11182
11477
  const cmds = allCommands().map((c) => ({
@@ -11233,7 +11528,45 @@ function commandsForWeb() {
11233
11528
  placeholder: '',
11234
11529
  };
11235
11530
  }
11531
+ // The PLAN under review in Plan mode. Same channel as the other two modals, so the
11532
+ // browser needs no new plumbing — only a third `kind` to render. Without it a plan
11533
+ // arrived in the web UI as a blocking turn with nothing on screen to answer it, and
11534
+ // the only way out was Esc in a terminal that might not be open.
11535
+ if (state.planPending) {
11536
+ return {
11537
+ kind: 'plan',
11538
+ id: 'plan',
11539
+ // The plan text as the model wrote it. `detail` is what the browser's modal body
11540
+ // already renders, so it is filled here too rather than only in `plan`.
11541
+ detail: String(state.planPending.plan || ''),
11542
+ plan: String(state.planPending.plan || ''),
11543
+ // The four outcomes the terminal's key handler can produce (see the planPending
11544
+ // branch in handleKey: enter -> approve, e -> edit, esc -> keep, and 'auto' which
11545
+ // skips the prompt entirely). Each is offered so the browser is not a dead end.
11546
+ outcomes: ['approve', 'edit', 'keep'],
11547
+ };
11548
+ }
11549
+ // The full-screen EDITOR (`openEditor`), reached by /memory, /personal, /permissions,
11550
+ // /output-style, /keybindings and /set-system-prompt. Without a web form for it, all
11551
+ // six commands run to the point of opening the editor and then do nothing visible —
11552
+ // the browser had no way to show one, so the command looked broken rather than
11553
+ // unsupported. It rides the same `pending` channel as the other modals.
11554
+ if (state.editor) {
11555
+ const ed = state.editor;
11556
+ return {
11557
+ kind: 'editor',
11558
+ id: 'editor',
11559
+ title: String(ed.title || 'Edit'),
11560
+ hint: String(ed.hint || ''),
11561
+ text: String(ed.text == null ? '' : ed.text),
11562
+ // Whether saving writes anything back. `onSave` is what the TUI calls; an editor
11563
+ // without one is read-only, and the browser should say so rather than offer a Save
11564
+ // that silently discards.
11565
+ savable: typeof ed.onSave === 'function',
11566
+ };
11567
+ }
11236
11568
  return null;
11569
+
11237
11570
  }
11238
11571
 
11239
11572
  // Roles that exist only on screen, so `session.messages` cannot carry them: that array
@@ -11316,7 +11649,14 @@ function commandsForWeb() {
11316
11649
  const { cols, rows } = dims();
11317
11650
  const frame = composeFrame(state, cols, rows);
11318
11651
  const now = Date.now();
11319
- const forceFull = (now - lastFullPaint) >= FULL_REPAINT_MS;
11652
+ // `state._forceRepaint` is set by anything that changed what EVERY cell should say
11653
+ // without changing the frame's text — a theme switch is the case that matters. The
11654
+ // differential painter compares against the frame it believes it painted, so a new
11655
+ // palette alone emits no bytes and the screen keeps the old colours. Set through the
11656
+ // state because the setter lives in `dispatch`, which cannot see this closure.
11657
+ const forcePaint = state._forceRepaint === true;
11658
+ if (forcePaint) state._forceRepaint = false;
11659
+ const forceFull = forcePaint || (now - lastFullPaint) >= FULL_REPAINT_MS;
11320
11660
  const out = diffFrame(forceFull ? null : lastFrame, frame);
11321
11661
  if (forceFull) lastFullPaint = now;
11322
11662
  lastFrame = frame;
@@ -11553,6 +11893,7 @@ function commandsForWeb() {
11553
11893
  types: Array.isArray(spec.types) && spec.types.length ? spec.types : PROTOCOL_TYPES,
11554
11894
  hideType: !!spec.hideType,
11555
11895
  labelW,
11896
+ note: spec.note || '',
11556
11897
  hint: spec.hint || 'Tab next field · ←/→ type · Enter submit · Esc cancel',
11557
11898
  // Opt-in: a CLICK on a Type option submits the form, the same as Enter. Off by
11558
11899
  // default so a form where the type is one of several decisions keeps the click
@@ -12054,23 +12395,24 @@ function commandsForWeb() {
12054
12395
  const probe = await probeTerminal({ stdin, stdout });
12055
12396
  const caps = terminalCaps();
12056
12397
  if (caps.bg) state.termBg = caps.bg;
12057
- // An EXPLICIT theme (config `theme =`, or /theme) wins over the probe: the probe is
12058
- // guessing from a background colour, and a user who named a theme has told us. Only
12059
- // when nothing is configured does a light terminal force the light theme — the frame
12060
- // pads its rows with plain spaces and paints no background, so the terminal's shows
12061
- // through, and the dark theme's normal text is WHITE, i.e. white on white.
12062
- const configuredTheme = cfg.raw && cfg.raw.theme;
12063
- if (configuredTheme && hasTheme(configuredTheme)) {
12064
- state.theme = configuredTheme;
12065
- setTheme(configuredTheme);
12066
- } else if (isLightBg(caps.bg)) {
12067
- state.theme = 'light';
12068
- setTheme('light');
12069
- } else {
12070
- state.theme = state.theme || 'dark';
12071
- }
12072
-
12073
- stdout.write(alternateScreen(true));
12398
+ // Tell the palette resolver which way the terminal goes. `auto` needs this and nothing
12399
+ // else: on a light background it becomes the light theme, otherwise the dark one. The
12400
+ // answer is handed in because colors.js is a leaf module with no probe of its own.
12401
+ const lightBg = isLightBg(caps.bg);
12402
+ setLightBackground(lightBg);
12403
+ state.lightBg = lightBg;
12404
+ // An EXPLICIT theme wins over the probe: a user who named one has told us. `auto` is the
12405
+ // default and asks the probe, which is how the setting named "follow the terminal" ends
12406
+ // up following it — the old code special-cased a light background here instead, so the
12407
+ // setting and the code disagreed about what auto meant.
12408
+ const configuredTheme = (cfg.raw && cfg.raw.theme) || 'auto';
12409
+ state.theme = hasTheme(configuredTheme) ? configuredTheme : 'auto';
12410
+ setTheme(state.theme);
12411
+
12412
+ // ONLY NOW may the alternate screen be entered. Everything above is a query whose reply
12413
+ // arrives on stdin, and the probe owns stdin while it waits — entering the alternate
12414
+ // screen first would paint a frame in the wrong palette before the answer came back, which
12415
+ // is the flash this ordering exists to prevent.
12074
12416
  stdout.write(alternateScreen(true));
12075
12417
  stdout.write(clearScreen());
12076
12418
  // Kitty keyboard protocol. Great when supported (finer key reporting), but a
@@ -12268,8 +12610,16 @@ function commandsForWeb() {
12268
12610
  const replyFilterUntil = probe.complete ? 0 : Date.now() + 2000;
12269
12611
 
12270
12612
  stdin.on('data', (chunk) => {
12613
+ // `HNCODE_TRACE_KEYS` makes every inbound chunk and every key it produced visible in
12614
+ // ~/.hncode/keys.log. It exists because a keystroke that is silently swallowed looks
12615
+ // identical from the outside to one that was never delivered — the terminal may send
12616
+ // raw 0x03, a kitty CSI-u sequence, or nothing at all, and only the bytes say which.
12617
+ // Off unless the variable is set; nothing here runs in the normal path beyond the test.
12618
+ const traceKeys = process.env.HNCODE_TRACE_KEYS === '1';
12619
+ if (traceKeys) traceKey('chunk', chunk);
12271
12620
  if (Date.now() < replyFilterUntil) {
12272
12621
  chunk = stripProbeReplies(chunk);
12622
+ if (traceKeys) traceKey('after-probe-filter', chunk);
12273
12623
  if (!chunk) return;
12274
12624
  }
12275
12625
  keyBuf += chunk;
@@ -12277,7 +12627,10 @@ function commandsForWeb() {
12277
12627
  if (escTimer) clearTimeout(escTimer);
12278
12628
  const { tokens, rest } = tokenize(keyBuf);
12279
12629
  keyBuf = rest;
12280
- for (const t of tokens) handleKey(t);
12630
+ for (const t of tokens) {
12631
+ if (traceKeys) traceKey('key', t);
12632
+ handleKey(t);
12633
+ }
12281
12634
  if (keyBuf) {
12282
12635
  escTimer = setTimeout(flushEsc, 40);
12283
12636
  }
@@ -13226,6 +13579,19 @@ function commandsForWeb() {
13226
13579
  if (t.key === 'escape') { settle('keep'); return; }
13227
13580
  if (t.ch === 'e' || t.ch === 'E') { settle('edit'); return; }
13228
13581
  if (t.key === 'c-c') return; // do not exit while a plan is under review
13582
+ // The wheel and the page keys SCROLL. They do not change the review's state, so
13583
+ // swallowing them bought nothing and cost the one thing a long plan needs: a way to
13584
+ // read past the fold. Everything else still belongs to the review.
13585
+ if (t.key === 'wheelup' || t.key === 'wheeldown') {
13586
+ scrollChat(state, t.key === 'wheelup' ? 3 : -3);
13587
+ renderFrame();
13588
+ return;
13589
+ }
13590
+ if (t.key === 'pageup' || t.key === 'pagedown') {
13591
+ scrollChat(state, t.key === 'pageup' ? 10 : -10);
13592
+ renderFrame();
13593
+ return;
13594
+ }
13229
13595
  return; // swallow other keys while reviewing
13230
13596
  }
13231
13597
  if (t.key === 'c-c') {
@@ -13398,11 +13764,25 @@ function commandsForWeb() {
13398
13764
  advance();
13399
13765
  return;
13400
13766
  }
13767
+ // The wheel and the page keys SCROLL the transcript behind the dialog. They carry no
13768
+ // answer, so "own ALL input" does not need them — and without this a long question
13769
+ // with an option list taller than the screen could not be read past at all.
13770
+ if (t.key === 'wheelup' || t.key === 'wheeldown') {
13771
+ scrollChat(state, t.key === 'wheelup' ? 3 : -3);
13772
+ renderFrame();
13773
+ return;
13774
+ }
13775
+ if (t.key === 'pageup' || t.key === 'pagedown') {
13776
+ scrollChat(state, t.key === 'pageup' ? 10 : -10);
13777
+ renderFrame();
13778
+ return;
13779
+ }
13401
13780
  return;
13402
13781
  }
13403
13782
 
13404
13783
  if (state.confirmExit) { state.confirmExit = false; if (confirmTimer) clearTimeout(confirmTimer); }
13405
13784
 
13785
+
13406
13786
  // Ctrl+Up / Ctrl+Down — scroll the AI output area, ALWAYS (independent of the
13407
13787
  // composer, unlike the plain arrows which first move the caret / walk history).
13408
13788
  // This is the dedicated "read back the transcript" key the user asked for.
@@ -14470,10 +14850,10 @@ function commandsForWeb() {
14470
14850
  }
14471
14851
 
14472
14852
  function statusExtra(state) {
14473
- // No `notice` term: the notice shares the context row instead of taking a
14474
- // row of its own, so it must not shrink the calculated chat viewport either.
14475
- return (state.confirmExit ? 1 : 0)
14476
- + (state.running ? 1 : 0)
14853
+ // Neither the notice nor the confirm prompt appears here: both are drawn ON the
14854
+ // context row rather than taking one of their own, so neither may shrink the
14855
+ // calculated chat viewport either.
14856
+ return (state.running ? 1 : 0)
14477
14857
  + ((state.menuOpen && state.menuList.length) ? Math.min(state.menuList.length, MAX_MENU) + 1 : 0);
14478
14858
  }
14479
14859
 
@@ -14730,17 +15110,36 @@ function commandsForWeb() {
14730
15110
  const basePrompt = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || SYSTEM_PROMPT;
14731
15111
  let sysText = basePrompt;
14732
15112
 
15113
+ // APPEND rather than replace — the safer of the two prompt knobs, and the one to reach
15114
+ // for when the built-in prompt already does most of what you want.
15115
+ if (cfg.appendSystemPrompt && String(cfg.appendSystemPrompt).trim()) {
15116
+ sysText += '\n\n' + String(cfg.appendSystemPrompt).trim();
15117
+ }
15118
+
14733
15119
  if (cfg.calmMode) {
14734
15120
  sysText += '\n\n' + CALM_MODE_INSTRUCTION;
14735
15121
  }
14736
15122
  // PLAN MODE (/plan): tell the model what to PRODUCE. The tool filter alone
14737
- // only removed its write tools — this is what makes it emit a plan block.
15123
+ // only removed its write tools — this is what makes it emit a plan block. A user's own
15124
+ // `plan_instructions` is APPENDED, never substituted: replacing this would silently
15125
+ // switch Plan mode back to the behaviour the instruction exists to fix.
14738
15126
  if (state.plan) {
14739
15127
  sysText += '\n\n' + PLAN_MODE_INSTRUCTION;
15128
+ if (cfg.planInstructions && String(cfg.planInstructions).trim()) {
15129
+ sysText += '\n\n' + String(cfg.planInstructions).trim();
15130
+ }
14740
15131
  }
14741
15132
  // SWARM MODE (/swarm): push the model toward parallel decomposition.
14742
15133
  if (state.swarm) {
14743
15134
  sysText += '\n\n' + SWARM_MODE_INSTRUCTION;
15135
+ if (cfg.swarmInstructions && String(cfg.swarmInstructions).trim()) {
15136
+ sysText += '\n\n' + String(cfg.swarmInstructions).trim();
15137
+ }
15138
+ }
15139
+ // FOCUS MODE has no built-in instruction — the tool filter IS its behaviour — so this
15140
+ // is the only place to say anything about it.
15141
+ if (state.focus && cfg.focusInstructions && String(cfg.focusInstructions).trim()) {
15142
+ sysText += '\n\n' + String(cfg.focusInstructions).trim();
14744
15143
  }
14745
15144
  const personal = readPersonalPrompt(state.workspace || cfg.workspace);
14746
15145
  if (personal) {
@@ -15699,6 +16098,47 @@ else if (e.type === 'todos') { state.todos = e.todos || []; if (session) session
15699
16098
  ap.resolve(!!ok);
15700
16099
  renderFrame();
15701
16100
  };
16101
+ // The plan under review in Plan mode. Same idea as `approve`: the turn is blocked on a
16102
+ // promise, and a browser has to be able to settle it. Without this, a plan raised in
16103
+ // Plan mode left the web UI stuck on a spinning turn with no way forward except finding
16104
+ // the terminal that raised it.
16105
+ hub.actions.answerPlan = async (id, outcome) => {
16106
+ const pp = state.planPending;
16107
+ if (!pp) throw new Error('no plan is pending');
16108
+ const want = String(outcome || '').toLowerCase();
16109
+ // The terminal produces exactly these three (handleKey: enter -> approve, e -> edit,
16110
+ // esc -> keep). An unknown one is refused rather than coerced, so a stale browser tab
16111
+ // cannot settle a review with a meaning nobody chose.
16112
+ if (!['approve', 'edit', 'keep'].includes(want)) {
16113
+ throw new Error(`unknown plan outcome: ${outcome} (expected approve, edit or keep)`);
16114
+ }
16115
+ state.planPending = null;
16116
+ pp.resolve(want);
16117
+ renderFrame();
16118
+ };
16119
+ // The full-screen editor's Save and Cancel, so the six commands that open one
16120
+ // (/memory, /personal, /permissions, /output-style, /keybindings, /set-system-prompt)
16121
+ // are usable from a browser. They call the SAME `onSave` the terminal's Ctrl+S calls,
16122
+ // so a value written from the web takes the identical path — including the validation
16123
+ // the command puts in its own onSave.
16124
+ hub.actions.saveEditor = async (id, text) => {
16125
+ const ed = state.editor;
16126
+ if (!ed) throw new Error('no editor is open');
16127
+ if (typeof ed.onSave !== 'function') {
16128
+ throw new Error('this editor is read-only: no onSave handler');
16129
+ }
16130
+ const value = String(text == null ? '' : text);
16131
+ // Close first, then save: onSave may itself open a picker or write a notice, and it
16132
+ // must not be overwritten by a stale editor still being on screen.
16133
+ state.editor = null;
16134
+ renderFrame();
16135
+ ed.onSave(value);
16136
+ };
16137
+ hub.actions.cancelEditor = async (id) => {
16138
+ if (!state.editor) throw new Error('no editor is open');
16139
+ state.editor = null;
16140
+ renderFrame();
16141
+ };
15702
16142
  hub.actions.answerQuestion = async (id, answers, extra, note, advance) => {
15703
16143
  const q = state.question;
15704
16144
  if (!q) throw new Error('no question is pending');
@@ -16531,6 +16971,30 @@ function quit() {
16531
16971
  }
16532
16972
  process.on('SIGWINCH', () => renderFrame());
16533
16973
  process.on('SIGINT', () => quit());
16974
+
16975
+ /**
16976
+ * Append one line to ~/.hncode/keys.log. Used only when HNCODE_TRACE_KEYS=1.
16977
+ *
16978
+ * A swallowed keystroke and an undelivered one look identical from the outside: the
16979
+ * screen simply does not react. A terminal may send Ctrl+C as raw 0x03, as a kitty CSI-u
16980
+ * sequence, or as SIGINT with no bytes at all — and each of those needs a different fix.
16981
+ * This records which one actually arrived, so the question is answered by bytes rather
16982
+ * than by guessing. Append-only and best-effort: a failed write must never break input.
16983
+ */
16984
+ function traceKey(kind, value) {
16985
+ try {
16986
+ const dir = process.env.HNCODE_HOME || path.join(os.homedir(), '.hncode');
16987
+ fs.mkdirSync(dir, { recursive: true });
16988
+ const shown = typeof value === 'string'
16989
+ ? [...value].map((c) => {
16990
+ const n = c.charCodeAt(0);
16991
+ return n < 0x20 ? `\\x${n.toString(16).padStart(2, '0')}` : c;
16992
+ }).join('')
16993
+ : JSON.stringify(value);
16994
+ fs.appendFileSync(path.join(dir, 'keys.log'),
16995
+ `${new Date().toISOString()} ${kind} ${shown}\n`);
16996
+ } catch { /* diagnostics must never break the keyboard */ }
16997
+ }
16534
16998
  /**
16535
16999
  * Record WHY the process ended, before it does.
16536
17000
  *