@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/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: '
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
4308
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
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)) {
|
|
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
|
|
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))
|
|
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
|
-
//
|
|
10852
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
12058
|
-
//
|
|
12059
|
-
//
|
|
12060
|
-
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
|
|
12066
|
-
|
|
12067
|
-
|
|
12068
|
-
|
|
12069
|
-
|
|
12070
|
-
|
|
12071
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
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)
|
|
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
|
-
//
|
|
14474
|
-
// row of
|
|
14475
|
-
|
|
14476
|
-
|
|
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
|
*
|