@hncode/hncode 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -1
@@ -0,0 +1,240 @@
1
+ // Side conversations (/btw) and recaps.
2
+ //
3
+ // A side conversation is a FORK of the main thread: it carries the SAME conversation
4
+ // history, so a question like "what does this error mean" has the context to be
5
+ // answered rather than bounced back as "which file?". What it must not do is affect
6
+ // the main thread — the main agent keeps running, and nothing said in the side thread
7
+ // is written back to the session or to disk.
8
+ //
9
+ // That is how the two references do it, and the split is deliberate:
10
+ // * kimi's `startBtw` calls `agentLifecycle.fork(main)`, and `fork` copies the
11
+ // whole message list into the child agent; its system reminder tells the side
12
+ // model to "prefer answering from what you already know from the conversation".
13
+ // * codex forks the thread with `ephemeral = true`, and its snapshot comment says
14
+ // it outright: "The forked history remains available to the model through core
15
+ // state, but side conversations should visually start at the side boundary."
16
+ //
17
+ // So history is a FEATURE of the side thread, not baggage. What the user sees is a
18
+ // clean question and answer; what the model has is the full context. Those two are
19
+ // separable, and this module keeps them separate:
20
+ //
21
+ // forkMessages(session) -> the history handed to the side model
22
+ // thread.transcript() -> only what happened since the fork, for the panel
23
+ //
24
+ // Tools are cut to the read-only three (Read / Grep / Glob): a side question may
25
+ // inspect a file to answer, and must not edit one. The model still sees the other
26
+ // tool DEFINITIONS in the request — that is what keeps the prompt cache shared with
27
+ // the main thread — so a call to anything else is refused rather than hidden. See
28
+ // SIDE_TOOL_REFUSAL: kimi does exactly this, and for the same reason.
29
+ //
30
+ // A RECAP is the opposite kind of read: it is a summary OF the main conversation, so
31
+ // it takes a digest rather than the history. The two live together because they share
32
+ // the "read-only, secondary model, must not disturb the session" shape.
33
+
34
+ /** The only tools a side question may actually use. */
35
+ export const SIDE_RO_TOOLS = ['Read', 'Grep', 'Glob', 'FileLines'];
36
+
37
+
38
+
39
+ /**
40
+ * The system prompt for a side thread: the session's prompt plus the side-thread
41
+ * framing.
42
+ *
43
+ * The framing has to do three jobs, and each line below exists because dropping it
44
+ * causes a specific failure:
45
+ * * say it is a side channel, so the model does not treat the user's question as a
46
+ * new task and start a multi-step effort;
47
+ * * say the main agent is still RUNNING, so it does not say "let me stop what I was
48
+ * doing" or otherwise narrate an interruption that did not happen;
49
+ * * say which tools actually work, so the model does not waste a step on one that is
50
+ * absent from the request.
51
+ */
52
+ export function sideSystemPrompt(basePrompt, opts = {}) {
53
+ const tools = Array.isArray(opts.tools) && opts.tools.length ? opts.tools : SIDE_RO_TOOLS;
54
+ const lines = [String(basePrompt || '')];
55
+ lines.push(
56
+ '',
57
+ '## This is a side question',
58
+ 'The user asked you something off to the side of the main conversation. Answer it',
59
+ 'directly and briefly.',
60
+ '',
61
+ 'You are a separate, lightweight instance. You have the conversation so far, so',
62
+ 'answer from what you already know; the main agent continues independently, so do',
63
+ 'not refer to being interrupted or to pausing anything.',
64
+ '',
65
+ `Only these tools work here: ${tools.join(', ')}. Use them when the answer depends`,
66
+ 'on the current contents of a file. Do not attempt to edit, write or run anything —',
67
+ 'those tools are not available in a side question.',
68
+ '',
69
+ 'Your reply is shown to the user but is NOT added to their conversation, so do not',
70
+ 'assume the main model will ever see it. If the answer calls for a code change, say',
71
+ 'what should be done and let the user decide to bring it up in the main thread.',
72
+ );
73
+ return lines.join('\n');
74
+ }
75
+
76
+ /** The refusal a side thread gets for a tool outside its set (kimi's wording, ours). */
77
+ export const SIDE_TOOL_REFUSAL =
78
+ 'Only reading tools are available in a side question (Read, Grep, Glob, FileLines). '
79
+ + 'Ask in the main thread if this needs to change something.';
80
+
81
+ /**
82
+ * The history a side thread forks from a session.
83
+ *
84
+ * A COPY, and only the roles a model can be given: `system` is rebuilt by the caller
85
+ * from the current prompt, and a `tool` row without its matching call would be an
86
+ * orphan the protocol rejects. Tool CALLS are kept — dropping them would break the
87
+ * pairing with their results and lose the record of what was already done, which is
88
+ * exactly the context a side question needs.
89
+ */
90
+ export function forkMessages(session, opts = {}) {
91
+ const limit = Number.isFinite(opts.limit) ? opts.limit : 0; // 0 = no limit
92
+ const list = Array.isArray(session && session.messages) ? session.messages : [];
93
+ const out = [];
94
+ for (const m of list) {
95
+ if (!m || typeof m !== 'object') continue;
96
+ if (m.role === 'system') continue;
97
+ if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
98
+ const text = typeof m.content === 'string' ? m.content : '';
99
+ const calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
100
+ if (!text.trim() && !calls.length) continue;
101
+ out.push({ ...m });
102
+ }
103
+ // `limit` keeps the most RECENT entries when set: a very long session does not need
104
+ // its opening messages to answer a question about the work in progress.
105
+ return limit > 0 && out.length > limit ? out.slice(-limit) : out;
106
+ }
107
+
108
+ /**
109
+ * One side thread: a fork of the main conversation.
110
+ *
111
+ * Two message lists, and the split is the whole design:
112
+ * `history` — the main conversation, handed to the model as context
113
+ * `messages` — ONLY what was said in the side thread
114
+ *
115
+ * `payload()` sends history + messages, so the side model answers with full context.
116
+ * `transcript()` returns ONLY `messages`, so the panel shows a clean question and
117
+ * answer rather than the entire session again. That is codex's "history remains
118
+ * available to the model, but side conversations visually start at the side boundary".
119
+ */
120
+ export class SideThread {
121
+ /**
122
+ * @param {object} opts
123
+ * basePrompt the system prompt the main session uses
124
+ * history the messages forked from the session (see forkMessages)
125
+ * tools the tool names that actually work here (defaults to the read-only set)
126
+ * model label of the model the side answers run on (for display)
127
+ */
128
+ constructor(opts = {}) {
129
+ this.basePrompt = String(opts.basePrompt || '');
130
+ this.model = String(opts.model || '');
131
+ this.tools = Array.isArray(opts.tools) && opts.tools.length ? [...opts.tools] : [...SIDE_RO_TOOLS];
132
+ // A COPY, never the session's array: appending here must not reach the session.
133
+ this.history = (Array.isArray(opts.history) ? opts.history : []).map((m) => ({ ...m }));
134
+ this.messages = [];
135
+ this.error = null;
136
+ }
137
+
138
+ /** How many entries were forked in — for the panel, so the context is visible. */
139
+ get forked() {
140
+ return this.history.length;
141
+ }
142
+
143
+ /** The request payload. Exposed so a test can assert exactly what would be sent. */
144
+ payload(text) {
145
+ return [
146
+ { role: 'system', content: sideSystemPrompt(this.basePrompt, { tools: this.tools }) },
147
+ ...this.history,
148
+ ...this.messages,
149
+ { role: 'user', content: String(text || '') },
150
+ ];
151
+ }
152
+
153
+ /** Record an exchange. Kept separate from the request so a failed ask is not kept. */
154
+ record(text, reply) {
155
+ this.messages.push({ role: 'user', content: String(text || '') });
156
+ this.messages.push({ role: 'assistant', content: String(reply == null ? '' : reply) });
157
+ }
158
+
159
+ /** How many exchanges have happened, for the header and the tests. */
160
+ get turns() {
161
+ return Math.floor(this.messages.length / 2);
162
+ }
163
+
164
+ /**
165
+ * ONLY the side thread's own exchanges — not the forked history.
166
+ *
167
+ * The reason is the one codex states: the history is context for the MODEL, and
168
+ * repeating it in the panel would bury the two lines the user actually asked for.
169
+ */
170
+ transcript() {
171
+ const rows = [];
172
+ for (const m of this.messages) {
173
+ rows.push({ role: m.role === 'user' ? 'user' : 'assistant', text: m.content });
174
+ }
175
+ return rows;
176
+ }
177
+ }
178
+
179
+ /** Is `name` a tool a side question may use? */
180
+ export function sideToolAllowed(name) {
181
+ return SIDE_RO_TOOLS.includes(String(name || ''));
182
+ }
183
+
184
+ /**
185
+ * The recap prompt. A recap answers "where was I", so the instruction pins the
186
+ * three things that answer it and forbids the two that do not.
187
+ *
188
+ * Deliberately not a summary of the transcript: /compact already does that, and its
189
+ * output REPLACES the history. This one is read by a human coming back to the
190
+ * terminal and changes nothing.
191
+ */
192
+ export const RECAP_PROMPT = `You are writing a short recap of a coding session the user is returning to.
193
+
194
+ State, in at most six short lines:
195
+ 1. What the user asked for (the goal, not the wording).
196
+ 2. What has been done so far, naming the files that changed.
197
+ 3. What is NOT done yet, or what is uncertain.
198
+
199
+ Rules: no greeting, no praise, no restating the rules you were given, no
200
+ speculation about what the user might want next. Plain sentences, no Markdown
201
+ headings. If the work is finished, say so in one line.`;
202
+
203
+ /**
204
+ * Build the messages for a recap request.
205
+ *
206
+ * The RECAP gets the forked history as real messages rather than a digest: the model
207
+ * is being asked what happened, and handing it the actual conversation beats handing
208
+ * it a lossy summary of one. `limit` bounds it so a very long session stays cheap.
209
+ */
210
+ export function recapMessages(history, opts = {}) {
211
+ const list = Array.isArray(history) ? history : [];
212
+ const limit = Number.isFinite(opts.limit) ? opts.limit : 0;
213
+ const tail = limit > 0 && list.length > limit ? list.slice(-limit) : list;
214
+ if (!tail.length) {
215
+ return [
216
+ { role: 'system', content: RECAP_PROMPT },
217
+ { role: 'user', content: 'The conversation is empty. Say so in one line.' },
218
+ ];
219
+ }
220
+ return [
221
+ { role: 'system', content: RECAP_PROMPT },
222
+ ...tail,
223
+ // The instruction comes LAST, as the final user turn, so it is the most recent
224
+ // thing the model reads — a recap is an instruction about the history above it.
225
+ { role: 'user', content: 'Write the recap of the session above, per the rules.' },
226
+ ];
227
+ }
228
+
229
+ /** Counts for a panel header: how much conversation is being summarised. */
230
+ export function digestStats(messages) {
231
+ const list = Array.isArray(messages) ? messages : [];
232
+ const users = list.filter((m) => m && m.role === 'user').length;
233
+ const assistants = list.filter((m) => m && m.role === 'assistant').length;
234
+ return { users, assistants, total: list.length };
235
+ }
236
+
237
+ export default {
238
+ sideSystemPrompt, SideThread, SIDE_RO_TOOLS, SIDE_TOOL_REFUSAL,
239
+ forkMessages, sideToolAllowed, recapMessages, digestStats, RECAP_PROMPT,
240
+ };
package/src/skills.js CHANGED
@@ -127,6 +127,33 @@ export function importSkill(file, name) {
127
127
  return { ok: true, name: skillName, path: dest, replaced };
128
128
  }
129
129
 
130
+ // Register (write or overwrite) a skill from an in-memory Markdown string rather
131
+ // than a file on disk. Used by plugins that want to ship a skill as code instead of
132
+ // requiring the user to /import-skill a file. The string must contain a leading
133
+ // `--- … ---` front-matter block with at least a `name` (or pass `name` explicitly);
134
+ // otherwise the name falls back to `name`/`fileName`. Returns the same shape as
135
+ // importSkill: { ok: true, name, path, replaced } or { ok: false, error }.
136
+ export function addSkillFromText(text, name, fileName = 'skill.md') {
137
+ const content = String(text == null ? '' : text);
138
+ if (!content.trim()) return { ok: false, error: 'Skill content is empty.' };
139
+ const destDir = skillsDir();
140
+ // Derive the name the way importSkill does, so the two paths agree.
141
+ const { meta } = parseFrontMatter(content);
142
+ const derived = name || meta.name || String(fileName || 'skill').replace(/\.md$/i, '');
143
+ const skillName = normalizeSkillName(derived);
144
+ if (!skillName) return { ok: false, error: 'Could not derive a skill name (pass one explicitly).' };
145
+ if (/[\\/]/.test(skillName)) return { ok: false, error: `Invalid skill name: ${skillName}` };
146
+ const dest = path.join(destDir, `${skillName}.md`);
147
+ const replaced = fs.existsSync(dest);
148
+ try {
149
+ fs.mkdirSync(destDir, { recursive: true });
150
+ fs.writeFileSync(dest, content, 'utf8');
151
+ } catch (e) {
152
+ return { ok: false, error: `Cannot write ${dest}: ${e.message}` };
153
+ }
154
+ return { ok: true, name: skillName, path: dest, replaced };
155
+ }
156
+
130
157
  export function deleteSkill(name) {
131
158
  const n = normalizeSkillName(name);
132
159
  if (!n) return false;
@@ -0,0 +1,350 @@
1
+ // Semantic stream buffer — paces the reveal of streaming text so every provider
2
+ // looks the same.
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ---------------
6
+ // Providers feed text deltas with wildly different cadences. OpenAI emits many tiny
7
+ // token-level deltas (a few characters every ~10-15ms), which already looks smooth.
8
+ // Anthropic coalesces its `content_block_delta` events into larger chunks that arrive
9
+ // in bursts with gaps (say 40 characters every ~100ms). Revealing each burst the
10
+ // instant it arrives makes the UI stair-step: a clump of text pops in, nothing for
11
+ // several frames, then another clump. The same interface therefore looks smooth on one
12
+ // provider and choppy on another, for no reason the user can see.
13
+ //
14
+ // To make every provider look the same, this buffer decouples ARRIVAL from REVEAL.
15
+ // Incoming content accumulates in an ordered backlog and a time-paced proportional
16
+ // controller drips it out: the rate rises with the backlog so a fast model is never
17
+ // left behind, yet a lone burst is spread over several frames instead of dumped in one.
18
+ //
19
+ // This is a port of jcode's `crates/jcode-tui-core/src/stream_buffer.rs`, kept to the
20
+ // same constants and the same shape so the two behave identically. Zero dependencies,
21
+ // like the rest of hncode.
22
+ //
23
+ // THE BACKLOG IS SEGMENT-AWARE
24
+ // ----------------------------
25
+ // Reasoning text and answer text are queued as ordered segments of ONE stream, plus
26
+ // zero-width "close reasoning region" markers. Both kinds therefore share the same
27
+ // smoothing controller and reveal strictly in arrival order. Pacing only the answer
28
+ // text would let reasoning pop in provider-sized clumps, and the ordering flushes
29
+ // needed to fix that would defeat the answer pacing too.
30
+
31
+ /** Steady-state reveal rate (chars/sec) with an empty backlog. This sets the floor
32
+ * cadence and how the trailing characters of a burst drain out. */
33
+ export const BASE_REVEAL_CPS = 180;
34
+
35
+ /** Additional reveal rate per buffered character. The controller speeds up as the
36
+ * backlog grows so a fast model is tracked with bounded latency: at a steady incoming
37
+ * rate `R`, the backlog settles near `(R - BASE_REVEAL_CPS) / REVEAL_BACKLOG_GAIN`. */
38
+ export const REVEAL_BACKLOG_GAIN = 3;
39
+
40
+ /** Hard ceiling for paced output, in characters per second. The proportional
41
+ * controller above may ask to catch up much faster when a provider delivers a whole
42
+ * response in one burst. Without a ceiling, a 3k-character backlog at a 50ms redraw
43
+ * cadence reveals more than 500 characters in one frame, which is several terminal
44
+ * rows appearing at once. Capping by ELAPSED TIME rather than by a fixed
45
+ * chars-per-frame value keeps a 16ms and a 50ms redraw loop at the same visual rate,
46
+ * while still draining a large burst in a few seconds. */
47
+ export const MAX_REVEAL_CPS = 960;
48
+
49
+ /** Maximum elapsed time credited to a single reveal step. Without this, a long idle
50
+ * gap before the next burst would bank a huge budget and dump the whole burst at
51
+ * once, reintroducing the choppiness this exists to remove. */
52
+ export const MAX_REVEAL_STEP_MS = 50;
53
+
54
+ /** Kinds of streamed content moving through the buffer. */
55
+ export const StreamKind = { Text: 'text', Reasoning: 'reasoning' };
56
+
57
+ /** Revealed operations, in arrival order. Callers apply these to the UI: `text`
58
+ * appends answer text, `reasoning` appends reasoning text, and `closeReasoning` ends
59
+ * the live reasoning region — exactly after the final buffered reasoning character it
60
+ * followed. */
61
+ export const StreamOp = {
62
+ Text: 'text',
63
+ Reasoning: 'reasoning',
64
+ CloseReasoning: 'closeReasoning',
65
+ };
66
+
67
+ /** Maximum jitter-recorder events retained per series (arrivals / reveals). */
68
+ const JITTER_EVENT_CAP = 4096;
69
+
70
+ /**
71
+ * Take at most `n` CODE POINTS off the front of `s`.
72
+ *
73
+ * The budget is counted in code points, not UTF-16 units: a surrogate pair is one
74
+ * character to the model and one column-pair on screen, so counting units would drain
75
+ * emoji at half rate and could split a pair mid-character.
76
+ */
77
+ function takeCodePoints(s, n) {
78
+ if (n <= 0) return ['', s];
79
+ let count = 0;
80
+ let i = 0;
81
+ while (i < s.length && count < n) {
82
+ const cp = s.codePointAt(i);
83
+ i += cp > 0xffff ? 2 : 1;
84
+ count++;
85
+ }
86
+ return [s.slice(0, i), s.slice(i)];
87
+ }
88
+
89
+ function countCodePoints(s) {
90
+ let n = 0;
91
+ for (let i = 0; i < s.length;) {
92
+ const cp = s.codePointAt(i);
93
+ i += cp > 0xffff ? 2 : 1;
94
+ n++;
95
+ }
96
+ return n;
97
+ }
98
+
99
+ /**
100
+ * Records arrival (provider burst) and reveal (paced UI) events so choppiness can be
101
+ * quantified: a smooth reveal stream has low variance in characters-per-time
102
+ * regardless of how bursty the arrivals were. This is what lets the smoothing be
103
+ * verified with a number instead of by eye.
104
+ */
105
+ class JitterRecorder {
106
+ constructor() {
107
+ this.arrivals = [];
108
+ this.reveals = [];
109
+ }
110
+
111
+ recordArrival(kind, chars, at) { this._record(this.arrivals, kind, chars, at); }
112
+ recordReveal(kind, chars, at) { this._record(this.reveals, kind, chars, at); }
113
+
114
+ _record(series, kind, chars, at) {
115
+ if (chars === 0) return;
116
+ if (series.length >= JITTER_EVENT_CAP) series.shift();
117
+ series.push({ at, chars, kind });
118
+ }
119
+
120
+ /** Characters-per-second samples across a series, plus their spread. */
121
+ static seriesStats(series) {
122
+ if (series.length < 2) return { samples: 0, mean: 0, stdev: 0 };
123
+ const rates = [];
124
+ for (let i = 1; i < series.length; i++) {
125
+ const dt = (series[i].at - series[i - 1].at) / 1000;
126
+ // A zero-length gap would divide by zero; sub-millisecond gaps are noise.
127
+ if (dt > 0) rates.push(series[i].chars / dt);
128
+ }
129
+ if (!rates.length) return { samples: 0, mean: 0, stdev: 0 };
130
+ const mean = rates.reduce((a, b) => a + b, 0) / rates.length;
131
+ const variance = rates.reduce((a, b) => a + (b - mean) ** 2, 0) / rates.length;
132
+ return { samples: rates.length, mean, stdev: Math.sqrt(variance) };
133
+ }
134
+
135
+ profile() {
136
+ const arrivals = JitterRecorder.seriesStats(this.arrivals);
137
+ const reveals = JitterRecorder.seriesStats(this.reveals);
138
+ return {
139
+ arrivals,
140
+ reveals,
141
+ // The headline number: how much steadier the reveal is than the arrivals that
142
+ // fed it. 1 means "no smoothing", >1 means the UI is smoother than the wire.
143
+ smoothing: arrivals.stdev > 0 ? arrivals.stdev / Math.max(1e-9, reveals.stdev) : 0,
144
+ };
145
+ }
146
+ }
147
+
148
+ export class StreamBuffer {
149
+ /**
150
+ * @param {object} [opts]
151
+ * @param {() => number} [opts.now] Injectable clock in ms. Tests pin it to make the
152
+ * pacing deterministic; production leaves it as `Date.now`.
153
+ */
154
+ constructor(opts = {}) {
155
+ this.now = opts.now || Date.now;
156
+ this.baseCps = opts.baseCps != null ? opts.baseCps : BASE_REVEAL_CPS;
157
+ this.backlogGain = opts.backlogGain != null ? opts.backlogGain : REVEAL_BACKLOG_GAIN;
158
+ this.maxCps = opts.maxCps != null ? opts.maxCps : MAX_REVEAL_CPS;
159
+ this.maxStepMs = opts.maxStepMs != null ? opts.maxStepMs : MAX_REVEAL_STEP_MS;
160
+ this.queue = [];
161
+ this.backlogChars = 0;
162
+ this.lastReveal = this.now();
163
+ // Fractional reveal budget carried between steps so slow rates still make progress
164
+ // instead of rounding down to zero forever.
165
+ this.carry = 0;
166
+ // Independent hard-ceiling budget. Kept separate from `carry` so a large
167
+ // proportional-controller backlog cannot bypass the wall-clock cap when many
168
+ // provider deltas arrive between redraw ticks.
169
+ this.ceilingCarry = 0;
170
+ // Whether reasoning pushed through this buffer is still "open" (no close marker
171
+ // queued since the last reasoning chunk).
172
+ this.reasoningOpen = false;
173
+ this.jitter = new JitterRecorder();
174
+ }
175
+
176
+ /** Push answer text, returning the paced ops ready to apply now. If a reasoning
177
+ * region is open in the backlog and this text contains non-whitespace, a
178
+ * `closeReasoning` marker is queued first so the region closes (in order) before
179
+ * the answer text reveals. */
180
+ pushText(text) {
181
+ const s = String(text == null ? '' : text);
182
+ if (!s) return this._revealNow();
183
+ if (this.reasoningOpen && s.trim() !== '') {
184
+ this.queue.push({ marker: true });
185
+ this.reasoningOpen = false;
186
+ }
187
+ this._pushChunk(StreamKind.Text, s);
188
+ return this._revealNow();
189
+ }
190
+
191
+ /** Push reasoning text, returning the paced ops ready to apply now. Marks the
192
+ * reasoning region open until a close marker is queued. */
193
+ pushReasoning(text) {
194
+ const s = String(text == null ? '' : text);
195
+ if (!s) return this._revealNow();
196
+ this.reasoningOpen = true;
197
+ this._pushChunk(StreamKind.Reasoning, s);
198
+ return this._revealNow();
199
+ }
200
+
201
+ /** Queue a reasoning-region close marker (a no-op when no reasoning is open in the
202
+ * backlog), returning the paced ops ready to apply now. The marker reveals exactly
203
+ * after the final buffered reasoning character. */
204
+ pushCloseReasoning() {
205
+ if (this.reasoningOpen) {
206
+ this.queue.push({ marker: true });
207
+ this.reasoningOpen = false;
208
+ }
209
+ return this._revealNow();
210
+ }
211
+
212
+ /** Force flush the entire backlog. Call this on message end, commit, or interrupt —
213
+ * anything that ends the turn must not leave text trickling out afterwards. */
214
+ flush() {
215
+ this.carry = 0;
216
+ this.ceilingCarry = 0;
217
+ this.lastReveal = this.now();
218
+ const ops = this._drainOps(this.backlogChars, true);
219
+ this.backlogChars = 0;
220
+ this.reasoningOpen = false;
221
+ return ops;
222
+ }
223
+
224
+ /** Reveal one paced frame worth of buffered content. Called from the periodic redraw
225
+ * tick so the backlog drains smoothly even when no new delta arrived this frame.
226
+ * Finalization paths should still call `flush()`. */
227
+ flushSmoothFrame() {
228
+ return this._revealNow();
229
+ }
230
+
231
+ /** True when nothing is buffered — no chunks and no markers. A caller driving a
232
+ * fast tick can stop the tick on this. */
233
+ get isEmpty() {
234
+ return this.queue.length === 0;
235
+ }
236
+
237
+ /** Characters still waiting to be revealed. */
238
+ get backlog() {
239
+ return this.backlogChars;
240
+ }
241
+
242
+ /** Drop the backlog without returning content. */
243
+ clear() {
244
+ this.queue.length = 0;
245
+ this.backlogChars = 0;
246
+ this.carry = 0;
247
+ this.ceilingCarry = 0;
248
+ this.reasoningOpen = false;
249
+ this.lastReveal = this.now();
250
+ }
251
+
252
+ /** Arrival-vs-reveal smoothness statistics. */
253
+ jitterProfile() {
254
+ return this.jitter.profile();
255
+ }
256
+
257
+ resetJitter() {
258
+ this.jitter = new JitterRecorder();
259
+ }
260
+
261
+ /** Append a chunk, coalescing with the previous queue entry of the same kind so the
262
+ * queue stays short under token-level feeds. */
263
+ _pushChunk(kind, text) {
264
+ const n = countCodePoints(text);
265
+ this.backlogChars += n;
266
+ this.jitter.recordArrival(kind, n, this.now());
267
+ const back = this.queue[this.queue.length - 1];
268
+ if (back && !back.marker && back.kind === kind) {
269
+ back.text += text;
270
+ return;
271
+ }
272
+ this.queue.push({ kind, text });
273
+ }
274
+
275
+ /** Proportional, time-paced reveal: advance the budget by the (clamped) elapsed time
276
+ * times a backlog-scaled rate, then drain that many characters plus any zero-cost
277
+ * markers reached along the way. */
278
+ _revealNow() {
279
+ const now = this.now();
280
+ if (this.backlogChars === 0) {
281
+ // No chunk backlog: reset so an idle gap cannot bank reveal budget. Any queued
282
+ // entries are markers only, and they emit immediately.
283
+ this.carry = 0;
284
+ this.ceilingCarry = 0;
285
+ this.lastReveal = now;
286
+ return this._drainOps(0, true);
287
+ }
288
+
289
+ const dt = Math.min(Math.max(0, now - this.lastReveal), this.maxStepMs) / 1000;
290
+ this.lastReveal = now;
291
+
292
+ const cps = this.baseCps + this.backlogChars * this.backlogGain;
293
+ this.carry += dt * cps;
294
+ this.ceilingCarry += dt * this.maxCps;
295
+
296
+ let reveal = Math.min(Math.floor(this.carry), Math.floor(this.ceilingCarry));
297
+ if (reveal === 0) {
298
+ // The budget has not reached a whole character yet; keep accumulating. Leading
299
+ // markers still emit so region boundaries are not delayed.
300
+ return this._drainOps(0, false);
301
+ }
302
+
303
+ // The backlog-scaled controller is intentionally aggressive about catching up, but
304
+ // it must never turn one provider burst into a visible wall of text. Bounding each
305
+ // step by the same wall-clock rate keeps 16ms and 50ms redraw loops visually
306
+ // identical, and the independent token bucket means repeated pushes with near-zero
307
+ // elapsed time cannot mint one free character each.
308
+ reveal = Math.min(reveal, this.backlogChars);
309
+ this.carry -= reveal;
310
+ this.ceilingCarry -= reveal;
311
+ return this._drainOps(reveal, false);
312
+ }
313
+
314
+ /** Drain up to `charCount` characters from the front of the queue, on code-point
315
+ * boundaries, emitting markers whenever they reach the front. A marker costs no
316
+ * budget, so a queued region boundary is never delayed behind text. */
317
+ _drainOps(charCount) {
318
+ const ops = [];
319
+ let budget = charCount;
320
+ const at = this.now();
321
+ for (;;) {
322
+ const front = this.queue[0];
323
+ if (!front) break;
324
+ if (front.marker) {
325
+ this.queue.shift();
326
+ ops.push({ op: StreamOp.CloseReasoning });
327
+ continue;
328
+ }
329
+ if (budget === 0) break;
330
+ // Read the kind BEFORE any shift: the pop below clears the reference.
331
+ const kind = front.kind;
332
+ const available = countCodePoints(front.text);
333
+ const take = Math.min(budget, available);
334
+ let chunk;
335
+ if (take === available) {
336
+ this.queue.shift();
337
+ chunk = front.text;
338
+ } else {
339
+ const [head, rest] = takeCodePoints(front.text, take);
340
+ front.text = rest;
341
+ chunk = head;
342
+ }
343
+ budget -= take;
344
+ this.backlogChars = Math.max(0, this.backlogChars - take);
345
+ this.jitter.recordReveal(kind, take, at);
346
+ ops.push({ op: kind === StreamKind.Text ? StreamOp.Text : StreamOp.Reasoning, text: chunk });
347
+ }
348
+ return ops;
349
+ }
350
+ }