@hncode/hncode 0.4.2 → 0.5.0
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/README.md +1 -15
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +611 -58
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +31 -13
- package/src/remote.js +20 -2
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +34 -6
- package/src/tui.js +5219 -792
- package/src/web-public/app.js +7 -0
package/src/prompt-presets.js
CHANGED
|
@@ -61,7 +61,7 @@ const RULES = {
|
|
|
61
61
|
],
|
|
62
62
|
// Only for models with no dedicated reasoning channel.
|
|
63
63
|
tagFallback: [
|
|
64
|
-
'Put all internal reasoning inside
|
|
64
|
+
'Put all internal reasoning inside <|thinking|>...<|/thinking|> and close the block before your answer. Reasoning must never appear outside it.',
|
|
65
65
|
],
|
|
66
66
|
},
|
|
67
67
|
scope: [
|
package/src/registry-browser.js
CHANGED
|
@@ -108,8 +108,8 @@ export function rowsForTab(state, tabId) {
|
|
|
108
108
|
name: r.name,
|
|
109
109
|
label: r.name,
|
|
110
110
|
description: r.description || (r.readable === false ? '(no SKILL.md — cannot install)' : '(no description)'),
|
|
111
|
-
status: local.has(r.name) ? '
|
|
112
|
-
statusTone: local.has(r.name) ? '
|
|
111
|
+
status: local.has(r.name) ? 'Installed' : 'Not Install',
|
|
112
|
+
statusTone: local.has(r.name) ? 'ok' : 'warn',
|
|
113
113
|
action: local.has(r.name) ? 'reinstall-skill' : 'install-skill',
|
|
114
114
|
disabled: r.readable === false,
|
|
115
115
|
}));
|
|
@@ -132,8 +132,8 @@ export function rowsForTab(state, tabId) {
|
|
|
132
132
|
label: p.name + (p.kind === 'dir' ? '/' : ''),
|
|
133
133
|
description: loadedNames.has(p.name)
|
|
134
134
|
? 'loaded in this session'
|
|
135
|
-
: 'not loaded —
|
|
136
|
-
status: loadedNames.has(p.name) ? 'loaded' : '
|
|
135
|
+
: 'not loaded — Need Restart',
|
|
136
|
+
status: loadedNames.has(p.name) ? 'loaded' : 'Need Restart',
|
|
137
137
|
statusTone: loadedNames.has(p.name) ? 'ok' : 'warn',
|
|
138
138
|
action: 'remove-plugin',
|
|
139
139
|
}));
|
|
@@ -143,8 +143,8 @@ export function rowsForTab(state, tabId) {
|
|
|
143
143
|
name: r.name,
|
|
144
144
|
label: r.name,
|
|
145
145
|
description: local.has(r.name) ? 'already installed locally' : 'available in the repository',
|
|
146
|
-
status: local.has(r.name) ? '
|
|
147
|
-
statusTone: local.has(r.name) ? '
|
|
146
|
+
status: local.has(r.name) ? 'Installed' : 'Not Install',
|
|
147
|
+
statusTone: local.has(r.name) ? 'ok' : 'warn',
|
|
148
148
|
action: local.has(r.name) ? 'reinstall-plugin' : 'install-plugin',
|
|
149
149
|
}));
|
|
150
150
|
}
|
|
@@ -171,6 +171,12 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
171
171
|
|
|
172
172
|
const out = [];
|
|
173
173
|
const rule = C.border + '\u2500'.repeat(cols) + C.reset;
|
|
174
|
+
// Mouse targets for this frame. The browser is pure render + key, so it records
|
|
175
|
+
// its own hitboxes here and the caller (tui.js) maps mouse events onto them —
|
|
176
|
+
// exactly like the tasks browser does with `_taskHits`. Row is a FRAME row
|
|
177
|
+
// (0 = the top rule); the caller converts the 1-based screen row.
|
|
178
|
+
const hits = [];
|
|
179
|
+
state._regHits = hits;
|
|
174
180
|
out.push(rule);
|
|
175
181
|
out.push(fitExactly(C.cyan + C.bold + title + C.reset, cols));
|
|
176
182
|
// The key hints stay visible while the fetch runs: the panel is usable during it
|
|
@@ -181,13 +187,21 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
181
187
|
out.push('');
|
|
182
188
|
|
|
183
189
|
// Tab strip, with the count of each tab's rows.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
{
|
|
191
|
+
const stripRow = out.length;
|
|
192
|
+
let col = 0;
|
|
193
|
+
const segs = tabs.map((t) => {
|
|
194
|
+
const n = rowsForTab(state, t.id).length;
|
|
195
|
+
const text = t.id === tab.id ? `[${t.label} (${n})]` : ` ${t.label} (${n}) `;
|
|
196
|
+
const col0 = col;
|
|
197
|
+
col += visWidth(text) + 1; // +1 for the joining space
|
|
198
|
+
return { id: t.id, text, col0, col1: col0 + visWidth(text) - 1 };
|
|
199
|
+
});
|
|
200
|
+
out.push(segs.map((s) => (s.id === tab.id
|
|
201
|
+
? C.cyan + C.bold + s.text + C.reset
|
|
202
|
+
: C.gray + s.text + C.reset)).join(' '));
|
|
203
|
+
for (const s of segs) hits.push({ row: stripRow, col0: s.col0, col1: s.col1, kind: 'regTab', tab: s.id });
|
|
204
|
+
}
|
|
191
205
|
out.push('');
|
|
192
206
|
|
|
193
207
|
// Body: each row is a label line plus its wrapped description, so the footer must be
|
|
@@ -218,7 +232,11 @@ export function renderRegistryBrowser(state, cols, rows) {
|
|
|
218
232
|
const nameStyle = isSel ? (C.cyan + C.bold) : C.white;
|
|
219
233
|
const badge = r.status ? ' ' + statusColor(r.statusTone) + r.status + C.reset : '';
|
|
220
234
|
const dis = r.disabled ? C.gray : '';
|
|
235
|
+
const labelRow = out.length; // the clickable row for this item
|
|
221
236
|
out.push(fitExactly(ptr + nameStyle + dis + r.label + C.reset + badge, cols));
|
|
237
|
+
// The whole label row is a click target: clicking selects the item (and a
|
|
238
|
+
// second click / the footer activates it), mirroring the tasks browser.
|
|
239
|
+
hits.push({ row: labelRow, col0: 0, col1: cols - 1, kind: 'regRow', index: i });
|
|
222
240
|
for (const line of wrapText(r.description, Math.max(8, cols - 6))) {
|
|
223
241
|
out.push(C.gray + ' ' + line + C.reset);
|
|
224
242
|
}
|
package/src/remote.js
CHANGED
|
@@ -23,6 +23,24 @@ import os from 'node:os';
|
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
import { skillsDir, normalizeSkillName } from './skills.js';
|
|
25
25
|
|
|
26
|
+
// TLS verification is ON by default. If a local proxy / AV breaks the cert
|
|
27
|
+
// chain, set HNCODE_DISABLE_TLS_VERIFY to skip verification for GitHub fetches.
|
|
28
|
+
if (process.env.HNCODE_DISABLE_TLS_VERIFY) {
|
|
29
|
+
// Node prints an "insecure TLS" warning for this intentional opt-out. Replace the
|
|
30
|
+
// default `warning` handler with one that swallows only that message, so the rest
|
|
31
|
+
// of Node's warnings still come through.
|
|
32
|
+
process.removeAllListeners('warning');
|
|
33
|
+
process.on('warning', (warning) => {
|
|
34
|
+
if (warning && /NODE_TLS_REJECT_UNAUTHORIZED/i.test(warning.message || '')) return;
|
|
35
|
+
const pid = process.pid;
|
|
36
|
+
const name = warning && warning.name ? `${warning.name}: ` : '';
|
|
37
|
+
let line = `(node:${pid}) ${name}${warning && warning.message ? warning.message : String(warning)}`;
|
|
38
|
+
if (warning && warning.detail) line += `\n${warning.detail}`;
|
|
39
|
+
process.stderr.write(line + '\n');
|
|
40
|
+
});
|
|
41
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
// The canonical source. Overridable so a fork (or a test) can point elsewhere.
|
|
27
45
|
export const DEFAULT_REPO = process.env.HNCODE_REPO || 'NiceHello666/hncode';
|
|
28
46
|
export const DEFAULT_REF = process.env.HNCODE_REPO_REF || 'main';
|
|
@@ -75,11 +93,11 @@ async function fetchText(url, { timeoutMs = 20000 } = {}) {
|
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
function apiContents(repo, ref, dir) {
|
|
78
|
-
return `
|
|
96
|
+
return `https://api.github.com/repos/${repo}/contents/${dir}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`;
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
function rawUrl(repo, ref, file) {
|
|
82
|
-
return `
|
|
100
|
+
return `https://raw.githubusercontent.com/${repo}/${ref}/${file}`;
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
// Each item is a DIRECTORY in the repo (skills/<name>/, plugins/<name>/). A loose file
|
package/src/rollout.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Session replay (/replay) — play a recorded conversation back, one step at a time.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: a session file records WHAT happened but not how long it took or
|
|
4
|
+
// in what ORDER relative to the tools, and reading the raw JSON to understand a run
|
|
5
|
+
// is unpleasant. More importantly, when something went wrong two hours ago, the
|
|
6
|
+
// question is "what did the model see when it made that bad call" — and the answer is
|
|
7
|
+
// the transcript AS IT STOOD at that step, which is not the same as the final
|
|
8
|
+
// transcript.
|
|
9
|
+
//
|
|
10
|
+
// So the replay is a step index over the session's messages, with the state at each
|
|
11
|
+
// step reconstructable. It is deliberately read-only: nothing here writes a file or
|
|
12
|
+
// resumes execution. A replay that could act would be a footgun.
|
|
13
|
+
//
|
|
14
|
+
// Steps are the unit the user cares about — a user message, an assistant reply, a
|
|
15
|
+
// tool call, a tool result — not raw JSON entries, because one assistant message can
|
|
16
|
+
// carry several tool calls and the interesting question is "which tool ran, in what
|
|
17
|
+
// order".
|
|
18
|
+
|
|
19
|
+
/** The kinds of step a replay can stop on, in the order they can occur. */
|
|
20
|
+
export const STEP_KINDS = ['user', 'assistant', 'thinking', 'tool', 'system'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Turn a session's messages into replay steps.
|
|
24
|
+
*
|
|
25
|
+
* Returns an array of
|
|
26
|
+
* `{ index, kind, role, label, text, toolName, args, at }`. `at` is only present
|
|
27
|
+
* when the recording kept a timestamp, which older sessions do not.
|
|
28
|
+
*/
|
|
29
|
+
export function buildSteps(messages) {
|
|
30
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
31
|
+
const steps = [];
|
|
32
|
+
for (const m of list) {
|
|
33
|
+
if (!m || typeof m !== 'object') continue;
|
|
34
|
+
const role = String(m.role || '');
|
|
35
|
+
const at = m.at || m.timestamp || null;
|
|
36
|
+
if (role === 'assistant') {
|
|
37
|
+
// Split one assistant message into its reasoning, its prose, and each tool
|
|
38
|
+
// call: the tool calls are the steps a debugging reader wants on their own.
|
|
39
|
+
const thinking = typeof m.reasoning === 'string' ? m.reasoning : '';
|
|
40
|
+
if (thinking.trim()) {
|
|
41
|
+
steps.push({ kind: 'thinking', role, label: 'thinking', text: thinking, at });
|
|
42
|
+
}
|
|
43
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
44
|
+
if (text.trim()) {
|
|
45
|
+
steps.push({ kind: 'assistant', role, label: 'reply', text, at });
|
|
46
|
+
}
|
|
47
|
+
const calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
|
|
48
|
+
for (const c of calls) {
|
|
49
|
+
const name = (c && (c.name || (c.function && c.function.name))) || 'tool';
|
|
50
|
+
steps.push({
|
|
51
|
+
kind: 'tool', role, label: name, toolName: name,
|
|
52
|
+
text: '', args: c && (c.args || c.arguments || (c.function && c.function.arguments)) || null,
|
|
53
|
+
at,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!thinking.trim() && !text.trim() && !calls.length) {
|
|
57
|
+
steps.push({ kind: 'assistant', role, label: 'reply', text: '', at });
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (role === 'tool') {
|
|
62
|
+
const name = m.toolName || m.name || 'tool';
|
|
63
|
+
steps.push({ kind: 'tool', role, label: `${name} result`, toolName: name, text: typeof m.content === 'string' ? m.content : '', at });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (role === 'user' || role === 'system') {
|
|
67
|
+
steps.push({ kind: role, role, label: role === 'user' ? 'prompt' : 'system', text: typeof m.content === 'string' ? m.content : '', at });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Anything else (a plugin-injected role, a future one) is shown as itself rather
|
|
71
|
+
// than dropped: a replay that silently omits steps is worse than an ugly label.
|
|
72
|
+
steps.push({ kind: 'other', role, label: role || 'entry', text: typeof m.content === 'string' ? m.content : '', at });
|
|
73
|
+
}
|
|
74
|
+
return steps.map((s, i) => ({ ...s, index: i }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The state at step `i`: which prompt is being answered and what has been seen.
|
|
79
|
+
*
|
|
80
|
+
* This is the "as it stood" view — the whole reason to replay rather than scroll.
|
|
81
|
+
*/
|
|
82
|
+
export function stateAtStep(steps, i) {
|
|
83
|
+
const list = Array.isArray(steps) ? steps : [];
|
|
84
|
+
const at = Math.max(0, Math.min(list.length - 1, i | 0));
|
|
85
|
+
let lastPrompt = '';
|
|
86
|
+
let tools = 0;
|
|
87
|
+
for (let n = 0; n <= at; n++) {
|
|
88
|
+
const s = list[n];
|
|
89
|
+
if (!s) continue;
|
|
90
|
+
if (s.kind === 'user') lastPrompt = s.text;
|
|
91
|
+
if (s.kind === 'tool') tools++;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
index: at,
|
|
95
|
+
lastPrompt,
|
|
96
|
+
tools,
|
|
97
|
+
// The turn number the step belongs to, counting prompts: 1-based for display.
|
|
98
|
+
turn: list.slice(0, at + 1).filter((s) => s && s.kind === 'user').length || 1,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A one-line summary per step, for the scrubber list. */
|
|
103
|
+
export function stepLine(step, width = 72) {
|
|
104
|
+
if (!step) return '';
|
|
105
|
+
const t = String(step.text || '').replace(/\s+/g, ' ').trim();
|
|
106
|
+
const arg = step.args == null ? ''
|
|
107
|
+
: (typeof step.args === 'string' ? step.args : JSON.stringify(step.args)).replace(/\s+/g, ' ');
|
|
108
|
+
const body = t || arg;
|
|
109
|
+
const cut = body.length > width ? body.slice(0, width - 1) + '…' : body;
|
|
110
|
+
return cut;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The frame for one step: a header plus the step's body, wrapped.
|
|
115
|
+
*
|
|
116
|
+
* Wrapping is done here rather than by the caller so a replay view and a test agree
|
|
117
|
+
* on the exact rows.
|
|
118
|
+
*/
|
|
119
|
+
export function renderStep(steps, i, opts = {}) {
|
|
120
|
+
const list = Array.isArray(steps) ? steps : [];
|
|
121
|
+
if (!list.length) return ['(this session has no steps to replay)'];
|
|
122
|
+
const at = Math.max(0, Math.min(list.length - 1, i | 0));
|
|
123
|
+
const s = list[at];
|
|
124
|
+
const st = stateAtStep(list, at);
|
|
125
|
+
const width = Math.max(20, opts.width || 76);
|
|
126
|
+
const out = [];
|
|
127
|
+
out.push(`step ${at + 1}/${list.length} turn ${st.turn} ${s.kind}${s.toolName ? ' ' + s.toolName : ''}`);
|
|
128
|
+
if (st.lastPrompt) out.push(` answering: ${st.lastPrompt.split('\n')[0].slice(0, width - 12)}`);
|
|
129
|
+
if (s.at) out.push(` at: ${s.at}`);
|
|
130
|
+
out.push('');
|
|
131
|
+
const body = [String(s.text || ''), s.args == null ? '' : (typeof s.args === 'string' ? s.args : JSON.stringify(s.args, null, 2))]
|
|
132
|
+
.filter((x) => x && String(x).trim()).join('\n');
|
|
133
|
+
const rows = body ? String(body).split('\n') : ['(no content)'];
|
|
134
|
+
for (const r of rows) {
|
|
135
|
+
if (r.length <= width) { out.push(r); continue; }
|
|
136
|
+
for (let k = 0; k < r.length; k += width) out.push(r.slice(k, k + width));
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Totals for the header: how many steps of each kind, and how many tools ran. */
|
|
142
|
+
export function replaySummary(steps) {
|
|
143
|
+
const list = Array.isArray(steps) ? steps : [];
|
|
144
|
+
const counts = {};
|
|
145
|
+
for (const s of list) counts[s.kind] = (counts[s.kind] || 0) + 1;
|
|
146
|
+
const calls = list.filter((s) => s.kind === 'tool' && s.role === 'assistant').length;
|
|
147
|
+
const results = list.filter((s) => s.kind === 'tool' && s.role === 'tool').length;
|
|
148
|
+
return { total: list.length, counts, calls, results };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default { buildSteps, stateAtStep, stepLine, renderStep, replaySummary, STEP_KINDS };
|
package/src/row-cache.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// A shared, bounded cache of rendered transcript rows.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// ---------------
|
|
5
|
+
// Wrapping and markdown-rendering a message is the expensive half of every frame, and the
|
|
6
|
+
// result depends only on (message text, width, colours) — so it is cacheable. The TUI
|
|
7
|
+
// already cached it, but on the message object itself, which makes the cache a property of
|
|
8
|
+
// the TRANSCRIPT rather than of the renderer:
|
|
9
|
+
//
|
|
10
|
+
// * It was dropped by position, not by size. `CACHE_KEEP` counted messages from the
|
|
11
|
+
// tail, so a 4000-message session threw away the caches of the 3700 oldest messages
|
|
12
|
+
// while keeping the caches of 300 tiny ones — the window tracked message COUNT, not
|
|
13
|
+
// memory. Scrolling back into a long session re-rendered from scratch every time.
|
|
14
|
+
// * It could not survive a session switch, because it lived on messages that
|
|
15
|
+
// `reconstructChat` throws away and rebuilds from the log. Every `--continue` and
|
|
16
|
+
// every `/sessions` resume started with a cold cache, re-wrapping the whole
|
|
17
|
+
// transcript before the first frame.
|
|
18
|
+
// * It had two independent mechanisms — `m._cache` for the row list, `m._mdCache` for
|
|
19
|
+
// the markdown blocks — with separate lifetimes and no bound between them.
|
|
20
|
+
//
|
|
21
|
+
// A `Map` keyed by a string, with entries ordered by use, fixes all three: the bound is
|
|
22
|
+
// real, the key survives any rebuild of the transcript, and both caches share one budget.
|
|
23
|
+
//
|
|
24
|
+
// THE KEY IS THE WHOLE POINT
|
|
25
|
+
// --------------------------
|
|
26
|
+
// A key that omitted a component of the layout would serve a row rendered for a different
|
|
27
|
+
// width or colour — which is not a stale cache, it is a MISDRAWN frame. So the key spells
|
|
28
|
+
// out everything the render depends on, and `makeRowKey` is the single place that decides
|
|
29
|
+
// what "the same" means. `evictIfStale` is the second half of the same contract: a streamed
|
|
30
|
+
// message's text grows every frame, so an entry whose text no longer matches is not a
|
|
31
|
+
// reuse, and the cache has to notice rather than serve it.
|
|
32
|
+
|
|
33
|
+
/** How many messages keep their rows. Chosen by measurement, not by feel: the rows for a
|
|
34
|
+
* typical assistant message are a few hundred bytes each, so 1500 covers a long session's
|
|
35
|
+
* visible history while staying in the low megabytes. */
|
|
36
|
+
export const ROW_CACHE_LIMIT = 1500;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A cache key that identifies a rendered message.
|
|
40
|
+
*
|
|
41
|
+
* Every input the render reads goes in, so "the same key" means "the same rows":
|
|
42
|
+
* `id` a stable identity for the message. Index-based ids break the moment a message
|
|
43
|
+
* is inserted or removed, which is exactly what compaction and /undo do.
|
|
44
|
+
* `text` the content. Compared by VALUE, not by reference: a streamed message's
|
|
45
|
+
* `text` is replaced on every delta, and a reference check would miss every
|
|
46
|
+
* reuse after the first frame.
|
|
47
|
+
* `w` the wrap width, `fg`/`codeFg` the colours, `expanded` the plugin-log toggle,
|
|
48
|
+
* `raw` the raw-mode flag, `pending`/`failed` the live-state flags, and `spin` the
|
|
49
|
+
* spinner key.
|
|
50
|
+
*/
|
|
51
|
+
export function makeRowKey(parts) {
|
|
52
|
+
return [
|
|
53
|
+
parts.id,
|
|
54
|
+
parts.text == null ? '' : parts.text,
|
|
55
|
+
parts.w,
|
|
56
|
+
parts.fg,
|
|
57
|
+
parts.codeFg,
|
|
58
|
+
parts.expanded ? 1 : 0,
|
|
59
|
+
parts.raw ? 1 : 0,
|
|
60
|
+
parts.pending ? 1 : 0,
|
|
61
|
+
parts.failed ? 1 : 0,
|
|
62
|
+
parts.spin,
|
|
63
|
+
].join('\x1f');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The shared store.
|
|
68
|
+
*
|
|
69
|
+
* A single `Map` with insertion order: `Map` iterates in insertion order and `set` on an
|
|
70
|
+
* existing key does NOT move it, so "touch on every read" has to be an explicit
|
|
71
|
+
* delete-then-set. That is what makes it an LRU rather than a FIFO — and the difference
|
|
72
|
+
* matters here, because the entries worth keeping are the ones being read, not the ones
|
|
73
|
+
* written most recently.
|
|
74
|
+
*/
|
|
75
|
+
const store = new Map();
|
|
76
|
+
|
|
77
|
+
/** Hits and misses, so a caller can tell "the cache is not helping" from "there is
|
|
78
|
+
* nothing to cache" without instrumenting the renderer. */
|
|
79
|
+
const stats = { hits: 0, misses: 0, evictions: 0 };
|
|
80
|
+
|
|
81
|
+
export function cacheStats() {
|
|
82
|
+
return { ...stats, size: store.size };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** For tests: a clean slate. */
|
|
86
|
+
export function resetRowCache() {
|
|
87
|
+
store.clear();
|
|
88
|
+
stats.hits = 0;
|
|
89
|
+
stats.misses = 0;
|
|
90
|
+
stats.evictions = 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Look up `key`, marking it as recently used.
|
|
95
|
+
*
|
|
96
|
+
* @returns the cached value, or undefined on a miss. Callers treat `undefined` as "render
|
|
97
|
+
* it" — a cache that cannot say "no" is not a cache.
|
|
98
|
+
*/
|
|
99
|
+
export function rowCacheGet(key) {
|
|
100
|
+
if (!store.has(key)) { stats.misses++; return undefined; }
|
|
101
|
+
const v = store.get(key);
|
|
102
|
+
// Touch: re-insert so this key moves to the young end of the Map's iteration order.
|
|
103
|
+
store.delete(key);
|
|
104
|
+
store.set(key, v);
|
|
105
|
+
stats.hits++;
|
|
106
|
+
return v;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Store `value` under `key` and enforce the bound.
|
|
111
|
+
*
|
|
112
|
+
* Eviction is oldest-first, and it is done HERE rather than by the caller counting
|
|
113
|
+
* messages: the caller cannot know what the entries cost, and a message-count window
|
|
114
|
+
* keeps a lot of large messages while discarding a lot of small ones.
|
|
115
|
+
*/
|
|
116
|
+
export function rowCacheSet(key, value) {
|
|
117
|
+
// Re-inserting an existing key would leave the old entry in place, so drop it first —
|
|
118
|
+
// otherwise the bound could be evictions short of what it looks like.
|
|
119
|
+
store.delete(key);
|
|
120
|
+
store.set(key, value);
|
|
121
|
+
while (store.size > ROW_CACHE_LIMIT) {
|
|
122
|
+
// Map iteration is insertion order, so the first key is the least recently used.
|
|
123
|
+
const oldest = store.keys().next();
|
|
124
|
+
if (oldest.done) break;
|
|
125
|
+
store.delete(oldest.value);
|
|
126
|
+
stats.evictions++;
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Is `cached` still valid for `key`'s message?
|
|
133
|
+
*
|
|
134
|
+
* A separate question from `rowCacheGet`, because a message can change without its key
|
|
135
|
+
* changing: a streamed reply's text is the key's own `text` component, so its key DOES
|
|
136
|
+
* change — but the row COUNT memo does not carry the text, and that one has to be asked
|
|
137
|
+
* whether the content moved. Kept as a function so the check is named at the call site
|
|
138
|
+
* rather than being an open-coded `===` someone later widens by accident.
|
|
139
|
+
*/
|
|
140
|
+
export function evictIfStale(entry, key) {
|
|
141
|
+
if (!entry) return true;
|
|
142
|
+
return entry.key !== key;
|
|
143
|
+
}
|