leetcode-capture-helper 0.0.0-stage → 0.1.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 (48) hide show
  1. package/cli.js +92 -0
  2. package/config.js +25 -0
  3. package/framing.js +57 -0
  4. package/host.js +420 -0
  5. package/install.js +290 -0
  6. package/package.json +31 -4
  7. package/runtime/bundle.json +42 -0
  8. package/runtime/companion/capture-log.js +83 -0
  9. package/runtime/companion/codex-backend.js +337 -0
  10. package/runtime/companion/companion.js +2190 -0
  11. package/runtime/companion/mouse-input.js +154 -0
  12. package/runtime/companion/package.json +4 -0
  13. package/runtime/companion/panel-bridge.js +273 -0
  14. package/runtime/companion/settings-rpc.js +228 -0
  15. package/runtime/companion/settings.js +464 -0
  16. package/runtime/companion/terminal-format.js +330 -0
  17. package/runtime/companion/vault-summary.js +168 -0
  18. package/runtime/companion/virtual-terminal.js +138 -0
  19. package/runtime/extension/shared/anthropic-backend.js +193 -0
  20. package/runtime/extension/shared/catalog.js +415 -0
  21. package/runtime/extension/shared/code-ledger.js +333 -0
  22. package/runtime/extension/shared/context-budget.js +259 -0
  23. package/runtime/extension/shared/context-summary.js +271 -0
  24. package/runtime/extension/shared/conversation.js +81 -0
  25. package/runtime/extension/shared/event-hub.js +126 -0
  26. package/runtime/extension/shared/extension-settings.js +445 -0
  27. package/runtime/extension/shared/helper-launcher.js +248 -0
  28. package/runtime/extension/shared/local-backend.js +706 -0
  29. package/runtime/extension/shared/local-server.js +149 -0
  30. package/runtime/extension/shared/package.json +4 -0
  31. package/runtime/extension/shared/practice.js +228 -0
  32. package/runtime/extension/shared/problem-index.js +4128 -0
  33. package/runtime/extension/shared/profiles.js +106 -0
  34. package/runtime/extension/shared/providers.js +58 -0
  35. package/runtime/extension/shared/reply-guard.js +864 -0
  36. package/runtime/extension/shared/roadmap.js +1337 -0
  37. package/runtime/extension/shared/sanitize.js +68 -0
  38. package/runtime/extension/shared/solved.js +316 -0
  39. package/runtime/extension/shared/tutor-engine.js +369 -0
  40. package/runtime/extension/shared/tutor-prompt.js +108 -0
  41. package/runtime/extension/shared/tutor-service.js +520 -0
  42. package/runtime/relay-server/package.json +4 -0
  43. package/runtime/relay-server/panel-broker.js +797 -0
  44. package/runtime/relay-server/server.js +241 -0
  45. package/runtime/vault-tool/log-session.js +230 -0
  46. package/runtime/vault-tool/package.json +4 -0
  47. package/runtime/vault-tool/vault-notes.js +206 -0
  48. package/README.md +0 -3
@@ -0,0 +1,2190 @@
1
+ #!/usr/bin/env node
2
+ // companion/companion.js - single-process companion that owns a live chat
3
+ // conversation directly (no tmux, no CLI-pane scraping).
4
+ //
5
+ // Replaces the old start.sh + watch.js pair. This program:
6
+ // - Tails relay-server/data/captures.jsonl for new Run/Submit captures,
7
+ // the same way watch.js used to (byte-offset tracking, tail-not-backfill
8
+ // on first run).
9
+ // - Runs a normal readline chat loop on stdin/stdout so Thomson can type
10
+ // into it directly at any time.
11
+ // - Sends a changed Submit (never a Run) and every typed message into the
12
+ // same ongoing conversation through a swappable backend, the code as a
13
+ // diff against what the tutor already holds (see code-ledger.js), then
14
+ // prints the real response - no pane, nothing to scrape.
15
+ //
16
+ // Providers (chosen on the extension's options page, saved by settings.js, or
17
+ // with COMPANION_BACKEND, which wins):
18
+ // - "claude" (default): the Claude Agent SDK (@anthropic-ai/claude-agent-sdk)
19
+ // on the Claude Code sign-in, a real programmatic session - not the
20
+ // `claude` CLI driven by keystrokes. "anthropic" is the same backend with
21
+ // an API key.
22
+ // - "local": plain HTTP against an OpenAI-compatible chat-completions
23
+ // endpoint (e.g. a local Ollama install). No extra dependency - Node's
24
+ // built-in fetch. "openai" is the same backend against OpenAI's API.
25
+ // - "chatgpt": the Codex CLI on a ChatGPT sign-in (codex-backend.js).
26
+ //
27
+ // Setup: the environment variables, and exactly what credentials each backend needs.
28
+
29
+ import fs from 'node:fs';
30
+ import os from 'node:os';
31
+ import path from 'node:path';
32
+ import readline from 'node:readline';
33
+ import { PassThrough } from 'node:stream';
34
+ import { spawn, spawnSync } from 'node:child_process';
35
+ import { fileURLToPath } from 'node:url';
36
+ import {
37
+ prepareVaultContext,
38
+ extractVaultBlock,
39
+ writeVaultAutoSummary,
40
+ resolveVaultConfig,
41
+ validateVaultPath,
42
+ } from './vault-summary.js';
43
+ import {
44
+ stylingEnabled,
45
+ dim,
46
+ promptString,
47
+ turnMarker,
48
+ boxRule,
49
+ renderMarkdown,
50
+ spinnerFrame,
51
+ createMarkdownStreamer,
52
+ refreshWidth,
53
+ indentContinuation,
54
+ } from './terminal-format.js';
55
+ import { ENABLE_MOUSE_TRACKING, DISABLE_MOUSE_TRACKING, createMouseFilter } from './mouse-input.js';
56
+ import { LocalBackend } from '../extension/shared/local-backend.js';
57
+ import {
58
+ TUTOR_SYSTEM_MESSAGE,
59
+ SUBMIT_ADDENDUM,
60
+ triggerLabel,
61
+ captureHeaderLine,
62
+ captureProblemId,
63
+ captureProblemContext,
64
+ } from '../extension/shared/tutor-prompt.js';
65
+ import { createPanelBridge } from './panel-bridge.js';
66
+ import {
67
+ loadSettings,
68
+ resolveEffective,
69
+ settingsPath,
70
+ statusSummary,
71
+ claudeBackendEnv,
72
+ OLLAMA_BASE_URL,
73
+ PROVIDERS,
74
+ } from './settings.js';
75
+ import { createSettingsRpc } from './settings-rpc.js';
76
+ import { CodexBackend } from './codex-backend.js';
77
+ import { transferConversation } from '../extension/shared/conversation.js';
78
+ import { createReplyGuard, guardedSend } from '../extension/shared/reply-guard.js';
79
+ import { requestCaptureTrim, pendingTrimFor, pendingTrimHappened, isPendingTrim } from './capture-log.js';
80
+ import { stripControlChars, oneLine, sanitizeCapture } from '../extension/shared/sanitize.js';
81
+ import {
82
+ createCodeLedger,
83
+ sendsToTutor,
84
+ chatPreamble,
85
+ prepareChatTurn,
86
+ prepareSubmitTurn,
87
+ DEFAULT_MAX_PROBLEMS,
88
+ } from '../extension/shared/code-ledger.js';
89
+
90
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
91
+ const REPO_ROOT = path.join(__dirname, '..');
92
+
93
+ // --- alternate screen buffer -------------------------------------------
94
+ //
95
+ // The same mechanism vim/less/htop use: swap to a separate screen buffer
96
+ // for the whole session, so nothing companion.js prints ever mixes into
97
+ // the terminal's normal scrollback, and exiting - any path: /exit, Ctrl+C,
98
+ // a crash - restores the terminal to exactly what was on screen before
99
+ // companion.js ever ran. TTY-only: makes no sense (and would corrupt
100
+ // output) when piped. Entered and the matching cleanup registered
101
+ // together, synchronously, so there's never a window where the terminal
102
+ // could be left stuck in the alternate buffer with nothing to restore it.
103
+ //
104
+ // Most terminals (xterm and its many descendants, including kitty)
105
+ // automatically turn on "alternate scroll mode" (DECSET 1007) the moment
106
+ // the alternate screen buffer is active: with no native scrollback to show
107
+ // while in the alternate buffer, a mouse wheel notch gets translated into
108
+ // an Up/Down arrow keypress instead, on the theory that a full-screen
109
+ // alt-screen app (a pager, an editor) will handle its own scrolling with
110
+ // the arrow keys it already reads. Readline has no idea any of this is
111
+ // happening - it just sees an arrow key, which is *also* its own built-in
112
+ // binding for cycling through previously-typed input lines. The result:
113
+ // scrolling the wheel to read back through the conversation instead
114
+ // silently replaces whatever's in the prompt with an old typed message.
115
+ // Explicitly disabling 1007 stops the wheel-to-arrow-key translation, so
116
+ // scrolling no longer collides with readline's input history - though
117
+ // what the wheel does *instead* (the terminal's own alt-screen scrollback,
118
+ // if it has one, or nothing at all) is then up to the terminal, not
119
+ // something this program can fully control from here. Not restored on
120
+ // exit: 1007 only has any effect while also inside the alternate screen
121
+ // buffer (1049), so once that's left, this can't affect normal terminal
122
+ // use afterward either way.
123
+ // Mouse-wheel scrolling *inside* the alternate screen (see mouse-input.js
124
+ // and the "scrollback viewport" section further down) needs the terminal's
125
+ // own SGR mouse-reporting mode (1000+1006) turned on for the duration too -
126
+ // entered/exited in the same breath as the alternate screen itself, for
127
+ // the same reason: never leave the shell in a mode it didn't ask for once
128
+ // this program exits. process.stdin.setRawMode(true) is required for
129
+ // this - normally readline manages that on its own for a TTY input, but
130
+ // stdin here gets wired through a filtering PassThrough instead (see
131
+ // "raw input / mouse-wheel scrollback" below), which isn't a TTY, so readline
132
+ // would never touch stdin's raw mode at all left to its own devices.
133
+ if (process.stdout.isTTY) {
134
+ process.stdout.write('\x1b[?1049h');
135
+ process.stdout.write('\x1b[?1007l');
136
+ process.stdout.write(ENABLE_MOUSE_TRACKING);
137
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
138
+ process.on('exit', () => {
139
+ process.stdout.write(DISABLE_MOUSE_TRACKING);
140
+ process.stdout.write('\x1b[?1049l');
141
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
142
+ });
143
+ }
144
+
145
+ // --- config ---------------------------------------------------------------
146
+
147
+ // Which LLM the tutor talks to and how comes from settings.js: per field, an
148
+ // environment variable wins over the saved settings file (written by the
149
+ // extension's options page through settings-rpc.js), which wins over the
150
+ // default. A set environment variable locks its field in the options page.
151
+ // `effective` is replaced as a whole after each save from the options page
152
+ // (applySettings, below), so everything that reads it sees the new values
153
+ // from the next turn on. It carries the API key, so it is never published.
154
+ const SETTINGS_FILE = settingsPath(process.env);
155
+ const loadedSettings = loadSettings(SETTINGS_FILE);
156
+ let storedSettings = loadedSettings.settings;
157
+ let effective = resolveEffective(storedSettings, process.env);
158
+
159
+ // Ollama's own default OpenAI-compat address, used when nothing else is set.
160
+ // Any OpenAI-compatible server works (LM Studio, llama.cpp server, vLLM,
161
+ // text-generation-webui, LocalAI, ...).
162
+ const DEFAULT_BASE_URL = OLLAMA_BASE_URL;
163
+
164
+ // Which HTTP API LocalBackend actually speaks: 'openai' (the standard
165
+ // /v1/chat/completions shape every OpenAI-compatible server understands) or
166
+ // 'native' (Ollama's own /api/chat, which, unlike any OpenAI-compatible
167
+ // endpoint, honors a requested context window, `num_ctx`; see LOCAL_NUM_CTX
168
+ // and LocalBackend's class comment). 'auto' (the default, null here) probes
169
+ // the server once (local-server.js: Ollama answers GET /api/version) and uses
170
+ // 'native' only for a server that answers like Ollama, whatever its address
171
+ // or port; anything else, or a failed probe, is 'openai'. Explicit
172
+ // "openai"/"native" (case-insensitive) always wins.
173
+ const LOCAL_API_RAW = (process.env.COMPANION_LOCAL_API || 'auto').toLowerCase();
174
+ const LOCAL_API = LOCAL_API_RAW === 'openai' || LOCAL_API_RAW === 'native' ? LOCAL_API_RAW : null;
175
+
176
+ // The configured context window in tokens. It is what Ollama's native API is
177
+ // asked for (`options.num_ctx`), and the most LocalBackend ever budgets for
178
+ // on any server: the window actually used is the smallest of this, what the
179
+ // server reports when probed, and what a reply shows the server really kept
180
+ // (see resolveContextWindow in context-budget.js). 8192 is double the 4096
181
+ // this project's configured Ollama model (gemma4:26b) runs with by default,
182
+ // and was confirmed live to turn a real single-capture failure (a normal
183
+ // ~150-line solution plus its problem description, no prior history) into a
184
+ // normal completed reply with the exact same input.
185
+ // From settings.js: the "Context window (tokens)" field of the local
186
+ // provider, or COMPANION_LOCAL_NUM_CTX, which wins over it.
187
+ const DEFAULT_LOCAL_NUM_CTX = 8192;
188
+
189
+ // How many tokens of LOCAL_NUM_CTX to hold back for the model's own
190
+ // thinking + answer when pre-flight checking a request's estimated size
191
+ // (see estimateTokens/LocalBackend.sendMessage below) - the request itself
192
+ // only gets the remainder as budget. Chosen from live investigation data
193
+ // (data/companion-context-capacity-test in the firstmate repo): eval_count
194
+ // (thinking+answer combined) ranged 1,141-2,607 tokens across a wide sweep
195
+ // of real prompt sizes against this project's configured model, largely
196
+ // independent of prompt length. 2600 sits just above that observed max, so
197
+ // an unusually long "thinking" pass still has room without needing a
198
+ // per-request guess.
199
+ const LOCAL_RESERVE_TOKENS = Number(process.env.COMPANION_LOCAL_RESERVE_TOKENS || 2600);
200
+
201
+ // The other options also come from settings.js (`effective.options`):
202
+ // - vaultAutoSummary (VAULT_AUTO_SUMMARY), off by default: a portable
203
+ // checkout won't have this vault's Study/Algorithms structure, or even
204
+ // use Obsidian.
205
+ // - autoClearContext (COMPANION_AUTO_CLEAR_CONTEXT), on by default: see the
206
+ // "automatic context reset on problem switch" section below for why
207
+ // leaving it on is the safer default (a stale problem's code and
208
+ // discussion would otherwise keep being resent long after the
209
+ // conversation moved on).
210
+ // - localMaxHistoryTurns (COMPANION_LOCAL_MAX_HISTORY_TURNS), 6 by default:
211
+ // how many user+assistant pairs LocalBackend and CodexBackend keep in
212
+ // full; older ones are condensed into one short summary
213
+ // (context-summary.js). Only a cap on the count: every local request is
214
+ // also fitted to the token budget before it is sent (fitWithSummary).
215
+ // 0 turns the count cap off.
216
+ // In-page tutor panel (the extension's "Tutor" tab on leetcode.com): mirrors
217
+ // this conversation to the browser through the relay server and accepts
218
+ // messages typed there. On by default; COMPANION_PANEL=0 turns the bridge off
219
+ // and leaves the terminal chat exactly as it was. See panel-bridge.js.
220
+ const PANEL_ENABLED = !/^(0|false|no)$/i.test(process.env.COMPANION_PANEL || '');
221
+ // Run by the Native Messaging helper (helper/host.js) instead of from a
222
+ // terminal: stdin is the helper's pipe (it writes commands such as /reset,
223
+ // and closing it is the signal to quit), output goes to the helper's log
224
+ // file, and the helper owns the relay. A closed terminal's SIGHUP does not
225
+ // apply; the helper ends this process with SIGTERM or by closing stdin.
226
+ const HEADLESS = /^(1|true|yes)$/i.test(process.env.COMPANION_HEADLESS || '');
227
+ // With nothing heard from the relay (so no panel and no extension behind
228
+ // it) for this long, quit on our own, so a browser or helper that vanished
229
+ // cannot leave this process behind. Headless only by default; 0 turns it off.
230
+ const IDLE_EXIT_MS = Number(process.env.COMPANION_IDLE_EXIT_MS ?? (HEADLESS ? 3 * 60 * 1000 : 0)) || 0;
231
+
232
+ const { vaultPath: VAULT_PATH, algorithmsSubfolder: VAULT_ALGORITHMS_SUBFOLDER } = resolveVaultConfig();
233
+ // Fail loudly at startup, not on the first Submit - a wrong or unset vault
234
+ // path should never silently fabricate a new folder tree (see
235
+ // vault-notes.js's validateVaultPath). Only checked when the feature is
236
+ // actually on, so a portable checkout with it off never needs a real vault.
237
+ // A save from the options page is checked the same way (settings-rpc.js's
238
+ // validateVault) and refused instead.
239
+ if (effective.options.vaultAutoSummary) validateVaultPath(VAULT_PATH);
240
+
241
+ const SCRATCH_DIR =
242
+ process.env.LEETCODE_COMPANION_SCRATCH ||
243
+ path.join(os.homedir(), '.local', 'state', 'leetcode-companion', 'scratch');
244
+ const CAPTURES_PATH =
245
+ process.env.LEETCODE_CAPTURES_FILE ||
246
+ path.join(REPO_ROOT, 'relay-server', 'data', 'captures.jsonl');
247
+ const STATE_PATH =
248
+ process.env.LEETCODE_COMPANION_STATE_FILE || path.join(__dirname, '.companion-state.json');
249
+ const POLL_MS = Number(process.env.LEETCODE_COMPANION_POLL_MS || 1000);
250
+ // The size backstop for clearing the capture log (see "capture tailing"
251
+ // below): once the handled part passes this, it is dropped even without a
252
+ // problem switch.
253
+ const CAPTURE_LOG_TRIM_BYTES = Number(process.env.LEETCODE_CAPTURES_TRIM_BYTES || 4 * 1024 * 1024);
254
+
255
+ // Mirrors server.js's own HOST/PORT resolution exactly (127.0.0.1, and
256
+ // CAPTURE_PORT if set) so the health check below asks the same address the
257
+ // relay server actually binds to.
258
+ const RELAY_HOST = '127.0.0.1';
259
+ const RELAY_PORT = process.env.CAPTURE_PORT ? Number(process.env.CAPTURE_PORT) : 8135;
260
+ const RELAY_SERVER_DIR = path.join(REPO_ROOT, 'relay-server');
261
+
262
+ // --- backend adapters -------------------------------------------------------
263
+ //
264
+ // Shared shape: async sendMessage(text) -> Promise<string>. Each backend
265
+ // manages its own conversation continuity however is natural for it (the
266
+ // Claude backend resumes its own SDK session by id; the local backend keeps
267
+ // an explicit messages array and resends it in full, OpenAI-chat style).
268
+
269
+ class ClaudeBackend {
270
+ constructor({ model, apiKey = null }) {
271
+ this.model = model;
272
+ this.apiKey = apiKey;
273
+ this.sessionId = undefined;
274
+ this.queryFn = null;
275
+ }
276
+
277
+ async ensureLoaded() {
278
+ if (this.queryFn) return;
279
+ let mod;
280
+ try {
281
+ mod = await import('@anthropic-ai/claude-agent-sdk');
282
+ } catch (err) {
283
+ throw new Error(
284
+ `could not load @anthropic-ai/claude-agent-sdk (run "npm install" in companion/ first): ${err.message}`
285
+ );
286
+ }
287
+ this.queryFn = mod.query;
288
+ }
289
+
290
+ // `problemContext` (see companion.js's captureProblemContext) is supplied
291
+ // whenever a problem is known - only actually used below on a fresh SDK
292
+ // session's very first message (see promptText). `currentCode`/`compactText`
293
+ // (the stateless backends' code pinning) are not needed here: a resumed
294
+ // session keeps the first full code and every later diff, and a reset starts a
295
+ // new session. See docs/context-measurement.md for the measured case for
296
+ // keeping the session and what it still lacks.
297
+ async sendMessage(text, { onChunk, problemContext } = {}) {
298
+ await this.ensureLoaded();
299
+ fs.mkdirSync(SCRATCH_DIR, { recursive: true });
300
+
301
+ // A plain string here fully replaces Claude Code's own default system
302
+ // prompt rather than appending to it - the SDK only preserves the
303
+ // default when systemPrompt is `{ type: 'preset', preset: 'claude_code' }`
304
+ // (see node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts). That's the
305
+ // right call here: this companion has no tools configured and isn't
306
+ // acting as a coding agent over this repo, so the Claude Code framing
307
+ // (tool-use conventions, CLI-oriented tone) would only get in the way of
308
+ // the tutor persona.
309
+ //
310
+ // systemPrompt only controls the system-prompt *text* though - it says
311
+ // nothing about which tools get defined in the model's context or which
312
+ // filesystem settings (project/user/local CLAUDE.md, .mcp.json, plugins)
313
+ // get loaded, both of which default to "everything available" when left
314
+ // unset (see sdk.d.ts's `tools`/`mcpServers`/`settingSources`). Measured
315
+ // live via a direct SDK call matching this shape (custom cwd, this same
316
+ // plain-string systemPrompt, nothing else set): the resulting turn
317
+ // carried ~12,500 tokens of context that had nothing to do with the
318
+ // tutor conversation at all - the SDK's ~25 built-in tools (Task, Bash,
319
+ // Edit, Write, Read, and the rest) plus, in an environment with any MCP
320
+ // connectors configured (this one had Gmail/Calendar/Drive), every one
321
+ // of *their* tool definitions too - none of which this companion ever
322
+ // calls. `tools: []` is the specific fix for the built-in set - note
323
+ // `allowedTools: []` looked like the obvious option but does NOT do
324
+ // this: per sdk.d.ts it only skips the permission prompt for listed
325
+ // tools, it doesn't remove anything from context, confirmed live (still
326
+ // ~10,800 tokens with `allowedTools: []` alone, tool list unchanged).
327
+ // `mcpServers: {}` plus `strictMcpConfig: true` is the equivalent fix for
328
+ // MCP-server tool definitions - `strictMcpConfig` is required, not just
329
+ // `mcpServers: {}` alone, since without it the SDK still merges in
330
+ // whatever a user's own project/user config declares. `settingSources:
331
+ // []` additionally opts out of loading any CLAUDE.md/AGENTS.md at all
332
+ // (filesystem settings default to "load everything" too, independent of
333
+ // the systemPrompt override above) - defense in depth on top of `cwd`
334
+ // already pointing at SCRATCH_DIR, a dedicated non-project directory, so
335
+ // this repo's own 53KB CLAUDE.md was never actually reachable from here
336
+ // in practice (confirmed: no CLAUDE.md exists anywhere in SCRATCH_DIR's
337
+ // own directory-ancestor chain either) - but making the isolation
338
+ // explicit here means that stays true regardless of where SCRATCH_DIR
339
+ // ever points. With all four set, the same measured call dropped to 197
340
+ // tokens total, zero cache overhead - a ~98% reduction - confirming the
341
+ // ~12,500 tokens above really was unused tool/settings scaffolding, not
342
+ // anything the tutor persona needed.
343
+ const options = {
344
+ cwd: SCRATCH_DIR,
345
+ systemPrompt: TUTOR_SYSTEM_MESSAGE,
346
+ tools: [],
347
+ mcpServers: {},
348
+ strictMcpConfig: true,
349
+ settingSources: [],
350
+ env: claudeBackendEnv(this.apiKey),
351
+ };
352
+ if (this.model) options.model = this.model;
353
+ if (this.sessionId) options.resume = this.sessionId;
354
+ // Only opt into partial-message events when a caller actually wants
355
+ // them - onReply's vault-block-stripping path (see sendAndPrint) calls
356
+ // this with no onChunk at all, and this stays a plain, unchanged
357
+ // request in that case.
358
+ if (onChunk) options.includePartialMessages = true;
359
+
360
+ // A fresh SDK session (no resumed session id yet) needs the problem
361
+ // description inline, once - unlike LocalBackend, this backend's own
362
+ // continuity is a resumed server-side session with no trimming (see
363
+ // resetContext below), so the description only needs supplying once per
364
+ // session, right at its start, and stays available for the rest of this
365
+ // problem's captures until AUTO_CLEAR_CONTEXT resets sessionId on a
366
+ // genuine problem switch - no separate pinning/reconstruction logic
367
+ // needed the way LocalBackend.buildMessages requires.
368
+ let promptText = text;
369
+ if (!this.sessionId && problemContext?.description) {
370
+ promptText =
371
+ `Problem currently being discussed: ${problemContext.title} (${problemContext.slug})\n\n` +
372
+ `${problemContext.description}\n\n${text}`;
373
+ }
374
+
375
+ let resultText = null;
376
+ let errorNote = null;
377
+ for await (const message of this.queryFn({ prompt: promptText, options })) {
378
+ if (message.session_id) this.sessionId = message.session_id;
379
+ // A `stream_event` message wraps a raw Anthropic API stream event
380
+ // (see SDKPartialAssistantMessage in sdk.d.ts); only its text deltas
381
+ // are relevant here - tool-use/thinking/citation deltas never appear
382
+ // for this backend, since it's configured with no tools.
383
+ if (onChunk && message.type === 'stream_event') {
384
+ const event = message.event;
385
+ if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
386
+ onChunk(event.delta.text);
387
+ }
388
+ }
389
+ if (message.type === 'result') {
390
+ if (message.subtype === 'success') {
391
+ resultText = message.result;
392
+ } else {
393
+ errorNote = `query ended with ${message.subtype}${
394
+ message.errors && message.errors.length ? `: ${message.errors.join('; ')}` : ''
395
+ }`;
396
+ }
397
+ }
398
+ }
399
+ if (resultText !== null && resultText.trim()) return resultText;
400
+ if (resultText !== null) {
401
+ throw new Error('Agent SDK query succeeded but returned an empty result');
402
+ }
403
+ throw new Error(errorNote || 'no result message received from the Agent SDK');
404
+ }
405
+
406
+ // Drops the resumed session id so the next sendMessage starts a fresh SDK
407
+ // session (options.resume is only set at all when this.sessionId is
408
+ // truthy - see sendMessage above) rather than continuing the old one.
409
+ resetContext() {
410
+ this.sessionId = undefined;
411
+ }
412
+ }
413
+
414
+ // LocalBackend (any OpenAI-compatible server, or Ollama's native API) lives
415
+ // in extension/shared/local-backend.js, shared with the extension's own
416
+ // engine.
417
+
418
+ // Builds the backend for an effective configuration (settings.js's
419
+ // resolveEffective). Throws, with a message fit for the options page, when
420
+ // one cannot be built from it. Used at startup and by every options-page
421
+ // save or connection test (settings-rpc.js), so all of them agree.
422
+ function makeBackend(config) {
423
+ const { provider } = config;
424
+ if (provider === 'claude') return new ClaudeBackend({ model: config.model });
425
+ if (provider === 'anthropic') {
426
+ if (!config.apiKey) throw new Error('Anthropic API key needs an API key (or ANTHROPIC_API_KEY)');
427
+ return new ClaudeBackend({ model: config.model, apiKey: config.apiKey });
428
+ }
429
+ if (provider === 'local' || provider === 'openai') {
430
+ return new LocalBackend({
431
+ baseUrl: config.baseUrl || DEFAULT_BASE_URL,
432
+ model: config.model,
433
+ apiKey: config.apiKey,
434
+ maxHistoryTurns: config.options.localMaxHistoryTurns,
435
+ // A hosted OpenAI API always speaks the OpenAI shape; never probe it
436
+ // for Ollama's native API.
437
+ apiStyle: provider === 'openai' ? 'openai' : LOCAL_API,
438
+ numCtx: config.contextTokens || DEFAULT_LOCAL_NUM_CTX,
439
+ reserveTokens: LOCAL_RESERVE_TOKENS,
440
+ });
441
+ }
442
+ if (provider === 'chatgpt') {
443
+ return new CodexBackend({
444
+ model: config.model,
445
+ cwd: path.join(SCRATCH_DIR, 'codex'),
446
+ systemPrompt: TUTOR_SYSTEM_MESSAGE,
447
+ maxHistoryTurns: config.options.localMaxHistoryTurns,
448
+ });
449
+ }
450
+ throw new Error(`unknown provider "${provider}" (expected one of ${Object.keys(PROVIDERS).join(', ')})`);
451
+ }
452
+
453
+ // Swappable: an options-page save replaces it through the turn queue (see
454
+ // applySettings), so a swap never lands in the middle of a turn.
455
+ let backend = makeBackend(effective);
456
+
457
+ // The label used in "error talking to backend (...)" lines.
458
+ function backendLabel() {
459
+ return effective.provider;
460
+ }
461
+
462
+ // --- relay server lifecycle -------------------------------------------------
463
+ //
464
+ // companion.js now owns the relay server's lifecycle: it starts one on
465
+ // launch if nothing is already listening, and stops the one it started when
466
+ // it exits - one command instead of two. If a server was already running
467
+ // (started separately, or left over from another companion instance), this
468
+ // instance leaves it alone on both ends: it doesn't spawn a second one, and
469
+ // it doesn't stop it on exit. The tradeoff: captures during a gap
470
+ // where companion.js isn't running are no longer durably logged.
471
+
472
+ // Set only if *this* process spawned the relay server; stays null if an
473
+ // already-running server was found instead, so the exit handler below knows
474
+ // whether it's allowed to stop it.
475
+ let relayServerChild = null;
476
+
477
+ async function isRelayServerUp() {
478
+ const controller = new AbortController();
479
+ const timeout = setTimeout(() => controller.abort(), 500);
480
+ try {
481
+ // OPTIONS /capture is answered unconditionally by server.js (see its
482
+ // CORS_HEADERS comment) regardless of body or other headers, making it
483
+ // the cheapest real request to probe with - a plain TCP connect would
484
+ // also tell us *something* is listening on the port, but not that it's
485
+ // actually this relay server.
486
+ await fetch(`http://${RELAY_HOST}:${RELAY_PORT}/capture`, {
487
+ method: 'OPTIONS',
488
+ signal: controller.signal,
489
+ });
490
+ return true;
491
+ } catch {
492
+ return false;
493
+ } finally {
494
+ clearTimeout(timeout);
495
+ }
496
+ }
497
+
498
+ async function ensureRelayServer() {
499
+ if (await isRelayServerUp()) return;
500
+ // The helper starts the relay before this process and stops it after, so a
501
+ // relay started from here would outlive both.
502
+ if (HEADLESS) {
503
+ printBanner(dim(`companion: warning: the relay is not answering on http://${RELAY_HOST}:${RELAY_PORT}`), console.error);
504
+ return;
505
+ }
506
+
507
+ // Pass the environment through unchanged: if Thomson set CAPTURE_PORT or
508
+ // CAPTURE_LOG_PATH for this companion invocation, server.js reads those
509
+ // same variables itself, so the spawned server naturally stays in sync
510
+ // with whatever this companion process is configured for.
511
+ const child = spawn(process.execPath, ['server.js'], {
512
+ cwd: RELAY_SERVER_DIR,
513
+ env: process.env,
514
+ detached: true,
515
+ stdio: 'ignore',
516
+ });
517
+ child.unref();
518
+ relayServerChild = child;
519
+ printBanner(dim('companion: relay server not detected, starting it now'));
520
+
521
+ // Wait (bounded) for it to actually come up, so the very first capture
522
+ // isn't lost to a race between spawning it and its listen() callback.
523
+ const deadline = Date.now() + 5000;
524
+ while (Date.now() < deadline) {
525
+ // eslint-disable-next-line no-await-in-loop
526
+ if (await isRelayServerUp()) return;
527
+ // eslint-disable-next-line no-await-in-loop
528
+ await new Promise((resolve) => setTimeout(resolve, 150));
529
+ }
530
+ printBanner(
531
+ dim(
532
+ `companion: warning: started the relay server but it hasn't answered on http://${RELAY_HOST}:${RELAY_PORT} yet - continuing anyway`
533
+ ),
534
+ console.error
535
+ );
536
+ }
537
+
538
+ function stopRelayServerIfOwned() {
539
+ if (!relayServerChild) return;
540
+ try {
541
+ process.kill(relayServerChild.pid, 'SIGTERM');
542
+ } catch {
543
+ // Already gone (e.g. it crashed on its own) - nothing to do.
544
+ }
545
+ relayServerChild = null;
546
+ }
547
+
548
+ // A synchronous 'exit' handler is the one place guaranteed to run
549
+ // regardless of which path led there - rl.close() -> process.exit(0) after
550
+ // /exit, /quit, or Ctrl+C; the pollLoop fatal-error process.exit(1); or a
551
+ // plain fall-through exit - so it's the single point of truth for "did this
552
+ // instance start the server, and if so, stop it."
553
+ process.on('exit', stopRelayServerIfOwned);
554
+
555
+ // --- in-page panel bridge ----------------------------------------------------
556
+ //
557
+ // Everything printed to the terminal for the tutor conversation is also
558
+ // published as small structured events (user message, capture, turn
559
+ // start/chunk/end, notice) so the panel can render the same conversation in
560
+ // the browser. The terminal path stays the source of truth: every publish is
561
+ // fire-and-forget through panelPublish, and a missing/slow relay can never
562
+ // block or alter what the terminal does.
563
+ let panelBridge = null;
564
+ // The problem the conversation is currently about, as the panel's own
565
+ // per-problem transcript key. Updated by captures and by panel messages.
566
+ let panelProblemSlug = null;
567
+ let panelTurnCounter = 0;
568
+ // What code the tutor has been shown, the newest code per problem, and which
569
+ // problem is active (see code-ledger.js). In memory only: a restart starts
570
+ // the backends from nothing too.
571
+ const ledger = createCodeLedger({ maxProblems: DEFAULT_MAX_PROBLEMS });
572
+ // Hint count and what the student named or saw, for the post-check guard
573
+ // (extension/shared/reply-guard.js); reset with the tutor context.
574
+ const replyGuard = createReplyGuard();
575
+
576
+ // The panel's "Processing" light: how many tutor turns are running or
577
+ // queued. A typed message counts from the moment it is queued, a Submit from
578
+ // when it turns out to need the tutor (a Run, or an unchanged Submit, never
579
+ // does). Going idle is told a moment late, so the gap between two queued
580
+ // turns does not flash "Connected" in between.
581
+ let busyTurns = 0;
582
+ let busyIdleTimer = null;
583
+ const BUSY_IDLE_DELAY_MS = 100;
584
+ function holdBusy() {
585
+ busyTurns += 1;
586
+ if (busyIdleTimer) {
587
+ clearTimeout(busyIdleTimer);
588
+ busyIdleTimer = null;
589
+ }
590
+ if (panelBridge) panelBridge.setBusy(true);
591
+ let released = false;
592
+ return function releaseBusy() {
593
+ if (released) return;
594
+ released = true;
595
+ busyTurns -= 1;
596
+ if (busyTurns > 0 || busyIdleTimer) return;
597
+ busyIdleTimer = setTimeout(() => {
598
+ busyIdleTimer = null;
599
+ if (busyTurns === 0 && panelBridge) panelBridge.setBusy(false);
600
+ }, BUSY_IDLE_DELAY_MS);
601
+ busyIdleTimer.unref();
602
+ };
603
+ }
604
+
605
+ function panelPublish(event) {
606
+ if (!panelBridge) return;
607
+ // The panel keeps one transcript per problem and only shows the one for the
608
+ // page it is on, so an event that belongs to no problem (a message typed in
609
+ // the terminal before any capture, say) would be stored under an empty key
610
+ // that no page ever displays: dead weight in the relay's replay buffer and
611
+ // in every panel. Not sending it is the honest outcome. Chunks carry no slug
612
+ // of their own; sendAndPrintTurn skips those for such a turn.
613
+ if ('problemSlug' in event && !event.problemSlug) return;
614
+ panelBridge.publish(event);
615
+ }
616
+
617
+ function newPanelTurnId() {
618
+ panelTurnCounter += 1;
619
+ return `t${Date.now().toString(36)}-${panelTurnCounter}`;
620
+ }
621
+
622
+ // Capture formatting (triggerLabel, captureHeaderLine, SUBMIT_ADDENDUM,
623
+ // captureProblemId, captureProblemContext) lives in
624
+ // extension/shared/tutor-prompt.js, shared with the extension's own engine.
625
+
626
+ // --- automatic context reset on problem switch (AUTO_CLEAR_CONTEXT) --------
627
+ //
628
+ // The active problem is whichever one the student last sent a message, Run
629
+ // or Submit from. Activity on a different problem hard-resets the backend's
630
+ // own continuity (see each backend's resetContext, above) instead of leaving
631
+ // it to the model to notice the switch, drops what the ledger knew about the
632
+ // old problem (code-ledger.js's activate), and asks the relay to drop the old
633
+ // problem's buffered chat. The part of captures.jsonl already handled is
634
+ // dropped too (see "capture tailing" below); nothing about an old problem is
635
+ // kept.
636
+ //
637
+ // Identifier: capture.problemSlug, falling back to capture.problemTitle when
638
+ // slug is absent. The very first problem this process sees resets nothing
639
+ // (there is no prior context to clear), so a restart needs no persisted
640
+ // state either.
641
+
642
+ // Makes problemId the active problem, resetting everything about the old one
643
+ // when it changed (and AUTO_CLEAR_CONTEXT is on). Runs inside the turn queue,
644
+ // so a reset never lands in the middle of a backend call.
645
+ function activateProblem(problemId, slug) {
646
+ const result = ledger.activate(problemId, { slug, resetOnSwitch: effective.options.autoClearContext });
647
+ if (!result.reset) return result;
648
+ backend.resetContext();
649
+ replyGuard.reset();
650
+ if (result.dropped && result.dropped.slug && panelBridge) panelBridge.dropProblem(result.dropped.slug);
651
+ // The old problem's captures are no longer needed either: the handled
652
+ // part of the log goes at the end of the current poll (see pollLoop).
653
+ captureTrimWanted = true;
654
+ printAboveInput(
655
+ consumeTurnBoundary() + dim(`companion: new problem detected (${oneLine(problemId) || 'unknown'}) - clearing tutor context`)
656
+ );
657
+ panelPublish({ type: 'notice', problemSlug: slug || null, text: 'New problem detected. Started a fresh tutor session.', switch: true });
658
+ return result;
659
+ }
660
+
661
+ // `/reset` (typed, or written by the helper when the active problem's tab
662
+ // stayed closed for its grace period): the tutor forgets the conversation and
663
+ // the code it held, so the next message starts fresh with the full code. The
664
+ // relay, the panel feed and the process itself stay as they are.
665
+ function resetTutorContext() {
666
+ backend.resetContext();
667
+ replyGuard.reset();
668
+ ledger.forgetHeld();
669
+ printAboveInput(consumeTurnBoundary() + dim('companion: tutor context cleared'));
670
+ }
671
+
672
+ // --- vault auto-summary (VAULT_AUTO_SUMMARY) --------------------------------
673
+ //
674
+ // Turns the companion's own tutoring response to a Submit into a durable,
675
+ // structured note in Thomson's Obsidian vault, updated in place as the
676
+ // session progresses. The actual logic (building the addendum, parsing the
677
+ // model's reply, writing the notes) lives in vault-summary.js, split out so
678
+ // it can be unit-tested without booting this file's readline loop / relay
679
+ // server lifecycle - see that file's header for the full design.
680
+
681
+ // --- terminal chat loop ------------------------------------------------------
682
+ //
683
+ // A capture can arrive from the file tailer at any moment, including while
684
+ // Thomson is mid-way through typing a line. The input area is a small
685
+ // pinned box - a thin rule, then the "> " prompt - that stays the last
686
+ // thing on screen: printAboveInput clears the box, prints the new text
687
+ // above where it was, then redraws the box fresh (see drawBox - readline's
688
+ // own rl.prompt(true) already renders the current line/cursor correctly on
689
+ // its own, so there's nothing to save and reinsert). Both captures and
690
+ // normal replies to typed input go through this same path, including each
691
+ // individually-streamed piece of a reply, so the box stays visible (pinned
692
+ // at the bottom) throughout a reply rather than only reappearing once it's
693
+ // fully done.
694
+
695
+ // --- raw input / mouse-wheel scrollback -------------------------------
696
+ //
697
+ // In a real terminal, readline reads from `readlineInput` - a filtered
698
+ // copy of stdin - rather than process.stdin directly. Mouse-wheel SGR
699
+ // reports (enabled above, alongside the alternate screen) arrive on stdin
700
+ // interleaved with ordinary keystrokes, and readline's own keypress
701
+ // decoder has no understanding of that protocol at all: fed one directly,
702
+ // it doesn't ignore it, it shreds it into a run of spurious single-
703
+ // character keypresses that get inserted straight into whatever's being
704
+ // typed (confirmed live - see mouse-input.js's header). createMouseFilter
705
+ // sits in front of readline instead: every byte that isn't part of a
706
+ // wheel report passes through to `filteredStdin` unchanged and in order,
707
+ // so readline's own line editing, arrow-key history, Ctrl+C handling, and
708
+ // Page Up detection all keep working exactly as before against a stream
709
+ // that's identical to stdin minus wheel reports. handleWheel (defined
710
+ // further down, alongside the scrollback viewport it drives) is only ever
711
+ // invoked once a real wheel event arrives, well after the rest of this
712
+ // file's top-level code has finished running, so referencing it here
713
+ // ahead of its own definition is safe. Non-TTY mode is untouched -
714
+ // readline reads process.stdin directly there, same as before this
715
+ // feature existed, and none of this wiring runs.
716
+ const filteredStdin = process.stdout.isTTY ? new PassThrough() : null;
717
+ if (filteredStdin) {
718
+ const mouseFilter = createMouseFilter({ output: filteredStdin, onWheel: handleWheel });
719
+ process.stdin.on('data', (chunk) => mouseFilter.push(chunk));
720
+ process.stdin.on('end', () => filteredStdin.end());
721
+ }
722
+
723
+ const rl = readline.createInterface({
724
+ input: filteredStdin || process.stdin,
725
+ output: process.stdout,
726
+ prompt: promptString(),
727
+ });
728
+ let stopping = false;
729
+
730
+ // How many terminal rows, ending at the cursor's current row and extending
731
+ // upward, are occupied by whatever's reserved at the bottom right now that
732
+ // *isn't* the box itself - i.e. the 1-row spinner, or 0 when nothing's
733
+ // reserved (mid content-write, or right after readline's own native Enter
734
+ // handling already claimed the row). 0 until anything is first drawn. See
735
+ // boxShown below for why the box's own row count is never stored here.
736
+ // Only meaningful in styled/TTY mode; non-TTY output never reserves any
737
+ // rows at all.
738
+ let bottomRowsShown = 0;
739
+
740
+ // True exactly when the box (the blank breathing-room line, the rule, and
741
+ // the prompt - see drawBoxRaw) is currently the thing occupying the bottom
742
+ // reserved rows, as opposed to the spinner or nothing. Unlike
743
+ // bottomRowsShown, the box's own row count is never cached in a variable -
744
+ // it's always computed fresh, on demand, via promptRowCount() (see
745
+ // reservedBottomRows below). That distinction matters: readline redraws its
746
+ // own prompt - correctly reflecting how many physical rows a long typed/
747
+ // pasted line currently wraps to - on every keystroke, entirely on its own,
748
+ // with no call into any of this file's own drawing code. A row count cached
749
+ // at the box's last *drawBoxRaw* call goes stale the instant the user types
750
+ // past (or back under) a wrap boundary afterward; confirmed live: typing a
751
+ // line long enough to wrap to a second terminal row, then triggering an
752
+ // ordinary redraw (a capture arriving), cleared only the row count cached
753
+ // from before typing started, leaving part of the actual on-screen wrapped
754
+ // prompt behind as leftover garbage that the new content got written on top
755
+ // of - corrupting everything drawn afterward, including the box's own next
756
+ // redraw. Computing promptRowCount() fresh every time avoids this entirely,
757
+ // since rl.line (and the terminal's actual on-screen rendering of it) is
758
+ // always already current by the time anything here runs.
759
+ let boxShown = false;
760
+
761
+ // The number of rows clearBottomRows needs to clear right now: the box's
762
+ // own current height (2 fixed rows - the blank line and the rule - plus
763
+ // however many rows its prompt currently wraps to, computed fresh; see
764
+ // boxShown above) when the box is what's on screen, or bottomRowsShown
765
+ // (the spinner's fixed 1 row, or 0) otherwise. Deliberately does NOT count
766
+ // the extra blank breathing-room row currentVisibleRows() reserves below
767
+ // the prompt (see that function) - this is "rows at and above the cursor's
768
+ // own row," and that reserved row sits below the cursor, never touched by
769
+ // clearBottomRows's own upward-clearing loop.
770
+ function reservedBottomRows() {
771
+ return boxShown ? 2 + promptRowCount() : bottomRowsShown;
772
+ }
773
+
774
+ // True for the duration of one sendAndPrint call (a typed message's reply,
775
+ // or a capture's whole exchange) - two independent things check this before
776
+ // touching the screen outside the normal print flow: the resize handler
777
+ // (near the bottom of this file), and Page Up/showHistory (below). Mid-turn,
778
+ // bottomRowsShown might mean "a 1-row spinner" rather than "a 2-row box" at
779
+ // any given moment, so forcing a redraw or spawning a pager there would
780
+ // corrupt whatever's actually on screen - the resize handler falls back to
781
+ // only updating the width for future draws, and Page Up is simply ignored,
782
+ // until the turn's own writes catch up naturally. Idle (turnActive false),
783
+ // the box's state is simple and known, so both are safe to act on
784
+ // immediately.
785
+ let turnActive = false;
786
+
787
+ // Every line of actual conversation content (captures, replies, warnings,
788
+ // errors - not the box's own rule/prompt, not the transient spinner) is
789
+ // also recorded here, verbatim, ANSI codes included, in addition to being
790
+ // printed live - this is the only durable record of anything that's
791
+ // scrolled off screen, since the alternate screen buffer this program runs
792
+ // in (see the top of this file) has no native scrollback of its own in
793
+ // most terminals once content exceeds one screen's height. Page Up opens
794
+ // it in a real pager (`less`) rather than a hand-rolled scroll view -
795
+ // less already gets wrapping, search, and its own Page Up/Down exactly
796
+ // right. Capped by character count, not line count, so one enormous
797
+ // unbroken piece of content can't make the trim loop below do
798
+ // disproportionate work for how much it actually frees.
799
+ const HISTORY_MAX_CHARS = 2_000_000;
800
+ let historyChars = 0;
801
+ const historyBuffer = [];
802
+ function recordHistory(text) {
803
+ if (!text) return;
804
+ historyBuffer.push(text);
805
+ historyChars += text.length;
806
+ while (historyChars > HISTORY_MAX_CHARS && historyBuffer.length > 1) {
807
+ historyChars -= historyBuffer.shift().length;
808
+ }
809
+ }
810
+
811
+ // Startup/relay-server-lifecycle notices (below and near ensureRelayServer)
812
+ // print via this instead of a bare console.log/console.error - they're real
813
+ // on-screen rows just like anything else, so they need to be recorded (for
814
+ // Page Up, and so redrawViewport's own padding math below counts them) the
815
+ // same as every other line this program ever prints. logFn defaults to
816
+ // console.log;
817
+ // ensureRelayServer's own warning passes console.error to keep that line's
818
+ // existing stream, since both still land on the same physical terminal
819
+ // either way.
820
+ function printBanner(text, logFn = console.log) {
821
+ logFn(text);
822
+ if (process.stdout.isTTY) recordHistory(`${text}\n`);
823
+ }
824
+
825
+ // historyBuffer's own chunks are always newline-terminated (every caller of
826
+ // recordHistory ends its text with exactly one trailing '\n' - see each call
827
+ // site) - so joining and naively splitting on '\n' leaves one trailing empty
828
+ // string that isn't a real displayed row. Dropping it keeps this an exact
829
+ // count of on-screen lines, not off by one - which matters below, since an
830
+ // off-by-one here would just reproduce a smaller version of the bug this is
831
+ // fixing.
832
+ function historyLines() {
833
+ const joined = historyBuffer.join('');
834
+ if (!joined) return [];
835
+ const lines = joined.split('\n');
836
+ if (lines[lines.length - 1] === '') lines.pop();
837
+ return lines;
838
+ }
839
+
840
+ // True once the box has been pinned flush to the terminal's actual last row
841
+ // with the screen genuinely full of real content behind it (contentRows >=
842
+ // visibleRows) - see drawBox/redrawViewport, both of which set this only
843
+ // under that real condition now, not merely "a draw happened."
844
+ let screenFilledToBox = false;
845
+
846
+ // clearBottomRows clears exactly whatever's currently reserved at the
847
+ // bottom right now (reservedBottomRows - the box, freshly measured, or the
848
+ // spinner) and nothing more - it does not know or care whether the screen
849
+ // has genuinely filled with real content yet. That distinction belongs
850
+ // entirely to drawBox, right below.
851
+ function clearBottomRows() {
852
+ if (!stylingEnabled()) return;
853
+ const total = reservedBottomRows();
854
+ for (let i = 0; i < total; i += 1) {
855
+ readline.clearLine(process.stdout, 0);
856
+ readline.cursorTo(process.stdout, 0);
857
+ if (i < total - 1) readline.moveCursor(process.stdout, 0, -1);
858
+ }
859
+ bottomRowsShown = 0;
860
+ boxShown = false;
861
+ }
862
+
863
+ // How many physical terminal rows readline's own current prompt line
864
+ // (promptString() plus whatever's typed so far) actually occupies once the
865
+ // terminal's own soft-wrap kicks in - normally 1, but more once a
866
+ // typed/pasted line is long enough to wrap on its own. Every piece of this
867
+ // file's box-pinning math (bottomRowsShown, clearBottomRows, drawBoxRaw,
868
+ // currentVisibleRows) used to assume this was always exactly 1 - confirmed
869
+ // live: pasting a line longer than one terminal row, then triggering a
870
+ // redraw (a capture arriving, or the reply to a normal typed message),
871
+ // cleared only the single row the old code assumed the prompt occupied,
872
+ // leaving the wrapped-over remainder of the old prompt line sitting as
873
+ // leftover garbage above the freshly redrawn box. Node's readline has no
874
+ // public API for this, so it's derived the same way readline derives it
875
+ // internally: total character count (the plain prompt marker - ANSI codes
876
+ // in it don't occupy a column, so they're stripped first - plus the raw
877
+ // typed text) divided by the terminal's current column count. An
878
+ // approximation, not a byte-exact match for every terminal's own wrapping
879
+ // quirks (e.g. deferred/"pending" wrap at an exact multiple of the column
880
+ // count), but Math.ceil errs on the side of clearing one row too many
881
+ // rather than too few, which self-corrects on the very next redraw rather
882
+ // than leaving stray content behind.
883
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
884
+ function promptRowCount() {
885
+ const cols = process.stdout.columns || 80;
886
+ const plainPromptLength = promptString().replace(ANSI_RE, '').length;
887
+ const totalLength = plainPromptLength + rl.line.length;
888
+ return Math.max(1, Math.ceil(totalLength / cols));
889
+ }
890
+
891
+ // Draws just the box itself (a blank breathing-room line, the rule, then the
892
+ // prompt - see promptRowCount above for why the prompt can be more than one
893
+ // physical row) at the cursor's current position, with no
894
+ // padding logic of its own - callers are responsible for the cursor already
895
+ // being at the correct row. Both of drawBox's two branches below end here:
896
+ // the fast incremental path (cursor left wherever clearBottomRows +
897
+ // newly-written content put it) and redrawViewport's full repaint (cursor at
898
+ // the end of its freshly-painted history slice). rl.prompt(true) alone
899
+ // already renders readline's own current line and cursor position correctly
900
+ // on its own (verified live: it reflects whatever was typed, including
901
+ // mid-line edits and a wrapped multi-row line, with no help needed) - no
902
+ // manual re-insertion of a "saved" line, which matters here specifically
903
+ // because a turn no longer pauses readline for its duration (see
904
+ // sendAndPrint): a keystroke can land between two box redraws, and manually
905
+ // re-inserting a previously-saved line back into a buffer readline's own
906
+ // keypress handling has since updated would duplicate it. A no-op in
907
+ // non-styled mode: the plain '> ' prompt there is just readline's own,
908
+ // undecorated, and never needs a rule or an explicit redraw of its own.
909
+ function drawBoxRaw() {
910
+ if (!stylingEnabled()) return;
911
+ process.stdout.write(`\n${boxRule()}\n`);
912
+ // Reserve the blank breathing-room row below the prompt (symmetric with
913
+ // the blank line above the rule, just written) *before* rendering the
914
+ // prompt itself, while the cursor sits at a known, unambiguous position:
915
+ // column 0 of the prompt's own first row. Writing promptRowCount() plain
916
+ // newlines forces the terminal to scroll if it's already at its bottom
917
+ // margin - the only way to genuinely create a new row there; a relative
918
+ // cursor-down move (readline.moveCursor with a positive dy, i.e. CSI
919
+ // "...B") does *not* scroll, it just clamps uselessly at the last row,
920
+ // which would silently skip the reservation exactly when it's needed
921
+ // most (the steady-state, screen-already-full case this function mostly
922
+ // runs in). clearLine then guards against stale content left over from a
923
+ // taller previous draw landing at this same spot (e.g. right after a
924
+ // resize). moveCursor back up the same count is a pure relative vertical
925
+ // move (unlike another plain '\n', which most terminals' own output
926
+ // processing also treats as an implicit carriage return) - paired with
927
+ // the explicit cursorTo(0) after it, this returns the cursor to exactly
928
+ // where it started, so rl.prompt(true) below renders into unchanged,
929
+ // correctly-positioned space, unaware any of this happened. Doing this
930
+ // *before* rl.prompt(true) - rather than after, relative to wherever it
931
+ // leaves the cursor - sidesteps having to know precisely where within a
932
+ // multi-row wrapped prompt the cursor ends up (e.g. mid-line editing
933
+ // after arrowing left): that position is genuinely ambiguous from here,
934
+ // while "column 0 of the prompt's first row" is not.
935
+ const promptRows = promptRowCount();
936
+ process.stdout.write('\n'.repeat(promptRows));
937
+ readline.clearLine(process.stdout, 0);
938
+ readline.moveCursor(process.stdout, 0, -promptRows);
939
+ readline.cursorTo(process.stdout, 0);
940
+ // Reset readline's own internal "how many rows did my last render take"
941
+ // bookkeeping (rl.prevRows - an undocumented but stable, directly-
942
+ // readable property; see node's lib/internal/readline/interface.js
943
+ // _refreshLine) before letting it redraw. rl.prompt(true) doesn't just
944
+ // paint at the cursor's current position - internally it moves the
945
+ // cursor *up* by rl.prevRows first (to reach what it believes was the
946
+ // start of its own previous multi-row render), then clearScreenDown()s
947
+ // from there before writing the new prompt. That's fine as long as
948
+ // readline is the only thing that's touched the screen since its last
949
+ // render - but it isn't: clearBottomRows just repositioned the cursor
950
+ // using this file's own row math, to the correct spot for a *fresh*
951
+ // render. If rl.prevRows is left stale from readline's last render of
952
+ // this same long/wrapped line (e.g. 2, for a cursor sitting on the third
953
+ // row of a 3-row wrapped prompt), rl.prompt(true) moves up 2 *more* rows
954
+ // than it should - straight into content clearBottomRows never touched
955
+ // (the ack/label lines this turn already printed above the box) - and
956
+ // then clearScreenDown() erases all of it before redrawing the box 2 rows
957
+ // too high. Confirmed live: this is the actual mechanism behind "a
958
+ // capture arriving while a long typed line sits in the prompt corrupts
959
+ // everything above the box," not a row-*counting* bug (promptRowCount
960
+ // above was already correct throughout) - forcing rl.prevRows to 0 here
961
+ // makes _refreshLine's own upward move a no-op, so it just clears
962
+ // (harmlessly - the box's own rows are already blank) from the cursor's
963
+ // actual current position downward and draws fresh there, matching what
964
+ // clearBottomRows already prepared.
965
+ rl.prevRows = 0;
966
+ rl.prompt(true);
967
+ boxShown = true;
968
+ }
969
+
970
+ // The box is pinned to the terminal's actual last row(s) - not wherever the
971
+ // cursor happens to be after whatever's been printed so far. Two phases:
972
+ //
973
+ // While the screen hasn't genuinely filled with real content yet
974
+ // (screenFilledToBox false), every redraw goes through a full repaint
975
+ // (redrawViewport) instead of the incremental clear/write path below. This
976
+ // is the real fix for "chat ends up clustered at the bottom with the box
977
+ // instead of starting from the top and growing down, with padding sitting
978
+ // between it and the box": the incremental path only ever cleared the box's
979
+ // own rows (clearBottomRows) and then wrote new content immediately adjacent
980
+ // to wherever the box already was - which, since the box is pinned to the
981
+ // terminal's actual last row, always means writing right at that last row.
982
+ // Doing that forces the *terminal itself* to scroll on every single
983
+ // redraw (there's nowhere left to print a trailing newline without
984
+ // overflowing the last row) - which silently drops exactly one row off the
985
+ // *top* of the screen each time, even while genuine blank padding still sits
986
+ // untouched in the middle, never being consumed by it. The old
987
+ // padToBottomIfNeeded compounded this by only ever padding once (an
988
+ // unconditional `screenFilledToBox = true` after its very first call,
989
+ // regardless of whether the screen had actually filled) - confirmed live:
990
+ // a short conversation in a tall window showed new content clustered
991
+ // directly above the box while the padding above stayed frozen at whatever
992
+ // it was after the very first startup draw, and the window's original
993
+ // startup banner silently scrolled off the top within the first couple of
994
+ // capture exchanges. redrawViewport, in contrast, was already correct - it
995
+ // repaints the whole visible window fresh from historyBuffer (the true,
996
+ // never-lost record of everything printed) on every call, which is exactly
997
+ // why Thomson's own live testing found that a single wheel-scroll tick
998
+ // "snapped" a broken-looking screen to the correct layout: it was the first
999
+ // full repaint since the bug's incremental drawBox calls had been silently
1000
+ // scrolling real content away. Using that same full-repaint path for every
1001
+ // redraw during the fill phase costs a full-screen clear per redraw, but
1002
+ // that phase is inherently bounded (at most visibleRows lines of content),
1003
+ // unlike the conversation itself - once the screen has genuinely filled
1004
+ // (contentRows >= visibleRows), natural terminal scrolling is exactly the
1005
+ // desired behavior forever after, so the cheap incremental path (clear only
1006
+ // the box's own rows, write, redraw in place) takes back over, unchanged
1007
+ // from before this fix and already verified correct for that case.
1008
+ function drawBox() {
1009
+ if (!stylingEnabled()) return;
1010
+ if (!screenFilledToBox) {
1011
+ redrawViewport();
1012
+ return;
1013
+ }
1014
+ drawBoxRaw();
1015
+ }
1016
+
1017
+ function printAboveInput(text) {
1018
+ const body = text.endsWith('\n') ? text : `${text}\n`;
1019
+ if (!process.stdout.isTTY) {
1020
+ process.stdout.write(body);
1021
+ return;
1022
+ }
1023
+ recordHistory(body);
1024
+ clearBottomRows();
1025
+ process.stdout.write(body);
1026
+ drawBox();
1027
+ }
1028
+
1029
+ // Marks the start of a new turn (a capture's whole exchange, or a typed
1030
+ // message's reply) so exactly one blank line separates it from whatever
1031
+ // came before - never more, never none. Set at the end of every turn;
1032
+ // consumed (and cleared) by whichever print happens first for the next one,
1033
+ // wherever that is - a capture's local ack, or a typed reply's own first
1034
+ // output - so it's correct regardless of which of those runs first, without
1035
+ // each needing to know about the other. Styled/TTY only, like every other
1036
+ // decoration in this file - piped output stays exactly the raw text stream
1037
+ // it's always been, with no separators inserted.
1038
+ let turnBoundaryPending = false;
1039
+ function consumeTurnBoundary() {
1040
+ if (!stylingEnabled() || !turnBoundaryPending) return '';
1041
+ turnBoundaryPending = false;
1042
+ return '\n';
1043
+ }
1044
+
1045
+ // Serializes every backend call (typed messages and injected captures alike)
1046
+ // through one queue, so a capture landing mid-response to a typed message -
1047
+ // or vice versa - never sends two overlapping requests to a backend that
1048
+ // keeps its own session/history state.
1049
+ let queueTail = Promise.resolve();
1050
+ function enqueue(task) {
1051
+ const result = queueTail.then(() => task());
1052
+ queueTail = result.then(
1053
+ () => undefined,
1054
+ () => undefined
1055
+ );
1056
+ return result;
1057
+ }
1058
+
1059
+ // The "waiting for the backend" indicator: temporarily replaces the box
1060
+ // with a single animated line until the first content is ready, redrawn in
1061
+ // place on that same line each tick. A plain no-op whenever styling is off,
1062
+ // so a piped/non-TTY invocation is never touched by it - see
1063
+ // terminal-format.js's stylingEnabled.
1064
+ function startSpinner() {
1065
+ if (!stylingEnabled()) return () => {};
1066
+ clearBottomRows();
1067
+ let i = 0;
1068
+ let stopped = false;
1069
+ process.stdout.write(spinnerFrame(i));
1070
+ bottomRowsShown = 1;
1071
+ const timer = setInterval(() => {
1072
+ i += 1;
1073
+ readline.cursorTo(process.stdout, 0);
1074
+ readline.clearLine(process.stdout, 0);
1075
+ process.stdout.write(spinnerFrame(i));
1076
+ }, 120);
1077
+ return function stopSpinner() {
1078
+ if (stopped) return;
1079
+ stopped = true;
1080
+ clearInterval(timer);
1081
+ // Leaves an empty, still-reserved row - clearBottomRows (bottomRowsShown
1082
+ // stays 1) picks it up cleanly on whatever prints next, same as if a
1083
+ // one-line box were there.
1084
+ readline.cursorTo(process.stdout, 0);
1085
+ readline.clearLine(process.stdout, 0);
1086
+ };
1087
+ }
1088
+
1089
+ // Runs one queued task as a turn: the view snaps back to the live bottom
1090
+ // first (new activity, even if the user was scrolled up reading history),
1091
+ // and turnActive is set while it runs. Every capture, panel message and typed
1092
+ // message goes through here, so the ledger's reads and writes, context
1093
+ // resets and backend calls all happen in arrival order, one at a time.
1094
+ function queueTurn(task) {
1095
+ resetScrollIfNeeded();
1096
+ return enqueue(async () => {
1097
+ turnActive = true;
1098
+ try {
1099
+ return await task();
1100
+ } finally {
1101
+ turnActive = false;
1102
+ }
1103
+ });
1104
+ }
1105
+
1106
+ // Sends one message and prints (and mirrors to the panel) the reply.
1107
+ // Resolves true once the backend answered with a real reply, false on an
1108
+ // error or an empty reply, so the caller only records the code as held by
1109
+ // the tutor when it really got it.
1110
+ //
1111
+ // Every turn goes through the post-check guard (extension/shared/
1112
+ // reply-guard.js, the same one the extension runtime uses): `guardText` is
1113
+ // the student's own words (null for a Submit) and `trigger` 'submit' or
1114
+ // null. What streams to the terminal and the panel is only what the guard
1115
+ // releases, and the turn-end text is exactly that.
1116
+ //
1117
+ // onReply, when given, runs on the raw reply before it's printed and may
1118
+ // return a transformed string - used by handleCaptureLine to peel the vault
1119
+ // auto-summary's JSON block back off a Submit reply (see extractVaultBlock)
1120
+ // before it ever reaches the terminal. That stripping can only happen once
1121
+ // the reply is complete, so a turn with onReply set never streams - see the
1122
+ // non-streaming branch below.
1123
+ async function sendAndPrintTurn(label, text, options = {}) {
1124
+ try {
1125
+ return await sendAndPrintTurnBody(label, text, options);
1126
+ } finally {
1127
+ // A backend's own warnings from this turn (LocalBackend's silent-
1128
+ // truncation guard), printed below the reply or error they are about.
1129
+ for (const warning of backend.takeWarnings?.() || []) {
1130
+ printAboveInput(dim(`companion: warning: ${warning}`));
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ async function sendAndPrintTurnBody(label, text, { onReply, problemContext, currentCode, compactText, panelMeta, guardText = null, trigger = null } = {}) {
1136
+ const guardTurn = replyGuard.begin({ text: guardText, trigger, code: currentCode && typeof currentCode.code === 'string' ? currentCode.code : null });
1137
+ const guardLog = (message) => printAboveInput(dim(`companion: ${message}`));
1138
+ const cutOffNote = 'The reply was cut off because the model ran out of room.';
1139
+ // Mirrors this turn to the in-page panel (see "in-page panel bridge"
1140
+ // above): a start marker, each streamed chunk, then one end event carrying
1141
+ // the authoritative full text (or the error), so a panel that connects
1142
+ // mid-turn or reloads still ends up with the right transcript.
1143
+ const panelTurnId = newPanelTurnId();
1144
+ const panelSlug = (panelMeta && panelMeta.problemSlug) || panelProblemSlug;
1145
+ // No problem, no panel transcript to put this turn in (see panelPublish).
1146
+ const mirrorChunks = Boolean(panelSlug);
1147
+ panelPublish({ type: 'turn-start', turnId: panelTurnId, problemSlug: panelSlug });
1148
+ const panelEnd = (fields) => panelPublish({ type: 'turn-end', turnId: panelTurnId, problemSlug: panelSlug, ...fields });
1149
+ const separator = consumeTurnBoundary();
1150
+ if (label) printAboveInput(separator + label);
1151
+ else if (separator) printAboveInput(''); // separator alone - printAboveInput('') is exactly one blank line
1152
+
1153
+ const streaming = !onReply;
1154
+ const stopSpinner = startSpinner();
1155
+
1156
+ if (streaming) {
1157
+ let wroteMarker = false;
1158
+ let anyChunk = false;
1159
+ const streamer = createMarkdownStreamer();
1160
+
1161
+ // Writes one flushed piece of the reply above the box, then redraws
1162
+ // the box fresh below it - each call is its own printAboveInput-style
1163
+ // clear/print/redraw cycle, throttled by the streamer to real
1164
+ // markdown block boundaries (not raw network chunks), so the box
1165
+ // stays visibly pinned through a whole reply without flickering on
1166
+ // every tiny piece. The turn marker (•) opens the very first piece;
1167
+ // consecutive blocks are separated by a blank line, since each is
1168
+ // rendered independently and trimmed at its own boundary - without
1169
+ // this the paragraph spacing between them would be lost. In
1170
+ // non-styled mode a piece is raw, possibly mid-word text - no marker,
1171
+ // no separator, straight pass-through.
1172
+ function writeReplyPiece(piece) {
1173
+ if (!piece) return;
1174
+ if (!stylingEnabled()) {
1175
+ // Raw, possibly mid-word pass-through - no marker, no separator,
1176
+ // and critically no newline added: a chunk boundary can fall
1177
+ // anywhere, and injecting one here would corrupt piped output.
1178
+ process.stdout.write(piece);
1179
+ return;
1180
+ }
1181
+ stopSpinner();
1182
+ clearBottomRows();
1183
+ // Each piece always ends with its own trailing newline (added
1184
+ // below, so the box's rule has a fresh line to start on) - that
1185
+ // newline already accounts for the first half of a blank-line
1186
+ // separator, so only one more newline (not two) is needed here to
1187
+ // leave exactly one blank row before a second-or-later piece.
1188
+ const separatorOrMarker = wroteMarker ? '\n' : turnMarker();
1189
+ // The very first piece's own first line sits right after turnMarker()
1190
+ // itself, so only that one line stays unindented; every later piece
1191
+ // starts on a fresh line of its own (following the blank-line
1192
+ // separator above) and needs the same hanging indent as any other
1193
+ // continuation line - see indentContinuation.
1194
+ const indentedPiece = indentContinuation(piece, { indentFirstLine: wroteMarker });
1195
+ const fullPiece = indentedPiece.endsWith('\n') ? indentedPiece : `${indentedPiece}\n`;
1196
+ recordHistory(separatorOrMarker + fullPiece);
1197
+ process.stdout.write(separatorOrMarker);
1198
+ wroteMarker = true;
1199
+ process.stdout.write(fullPiece);
1200
+ drawBox();
1201
+ }
1202
+
1203
+ let reply;
1204
+ try {
1205
+ reply = await guardedSend({
1206
+ backend,
1207
+ text,
1208
+ options: { problemContext, currentCode, compactText },
1209
+ turn: guardTurn,
1210
+ onText: (chunk) => {
1211
+ anyChunk = true;
1212
+ if (mirrorChunks) panelPublish({ type: 'chunk', turnId: panelTurnId, text: chunk });
1213
+ writeReplyPiece(streamer.push(chunk));
1214
+ },
1215
+ log: guardLog,
1216
+ });
1217
+ } catch (err) {
1218
+ if (err.cutOff) {
1219
+ if (!anyChunk && err.partialReply) writeReplyPiece(streamer.push(err.partialReply));
1220
+ writeReplyPiece(streamer.finish());
1221
+ }
1222
+ stopSpinner();
1223
+ clearBottomRows();
1224
+ const errorLine = `${err.cutOff && !stylingEnabled() && anyChunk ? '\n' : ''}` +
1225
+ `${dim(`companion: error talking to backend (${backendLabel()}): ${err.message}`)}\n` +
1226
+ (err.cutOff ? `${dim(cutOffNote)}\n` : '');
1227
+ recordHistory(errorLine);
1228
+ process.stdout.write(errorLine);
1229
+ drawBox();
1230
+ turnBoundaryPending = true;
1231
+ panelEnd(err.cutOff
1232
+ ? { text: err.partialReply || undefined, error: cutOffNote + ' Open Settings and check Context window (tokens).' }
1233
+ : { error: `Error talking to the backend (${backendLabel()}): ${err.message}` });
1234
+ return false;
1235
+ }
1236
+
1237
+ // Defense in depth: even if the backend resolves with an
1238
+ // empty/whitespace-only reply without throwing, never let that render
1239
+ // as total silence - indistinguishable from "the reply never arrived"
1240
+ // (see LocalBackend.sendMessage's own empty-reply guard).
1241
+ const isEmpty = !(typeof reply === 'string' && reply.trim());
1242
+ // A backend/model that ignored the streaming request entirely (no
1243
+ // onChunk call ever fired) still has a real reply to show - feed it
1244
+ // to the streamer as one final push, exactly like a real chunk would
1245
+ // be, so it renders instead of being lost.
1246
+ if (!anyChunk && !isEmpty) writeReplyPiece(streamer.push(reply));
1247
+ writeReplyPiece(streamer.finish());
1248
+ if (isEmpty) {
1249
+ stopSpinner();
1250
+ clearBottomRows();
1251
+ const warningLine = `${dim('companion: warning: backend returned an empty reply')}\n`;
1252
+ recordHistory(warningLine);
1253
+ process.stdout.write(warningLine);
1254
+ drawBox();
1255
+ panelEnd({ error: 'The backend returned an empty reply.' });
1256
+ } else {
1257
+ panelEnd({ text: reply });
1258
+ }
1259
+ turnBoundaryPending = true;
1260
+ return !isEmpty;
1261
+ }
1262
+
1263
+ // Non-streaming path (vault auto-summary's Submit turns): the whole
1264
+ // transformed reply prints as one block once it's fully ready, same
1265
+ // shape as before streaming existed.
1266
+ // The guard sees the reply with the vault block already peeled off (by
1267
+ // onReply, called exactly once); it does not stream here, and a Submit
1268
+ // review is never re-asked, so a reply with nothing left shows the
1269
+ // fallback.
1270
+ let reply;
1271
+ let displayText;
1272
+ try {
1273
+ displayText = await guardedSend({
1274
+ backend: {
1275
+ // Not streamed: only the turn's own options, no onChunk.
1276
+ sendMessage: async (message) => {
1277
+ reply = await backend.sendMessage(message, { problemContext, currentCode, compactText });
1278
+ return onReply(reply);
1279
+ },
1280
+ replaceLastReply: (shown) => backend.replaceLastReply?.(shown),
1281
+ },
1282
+ text,
1283
+ options: { problemContext, currentCode, compactText },
1284
+ turn: guardTurn,
1285
+ log: guardLog,
1286
+ });
1287
+ } catch (err) {
1288
+ stopSpinner();
1289
+ clearBottomRows();
1290
+ const partial = err.cutOff ? (err.partialReply || '').split('<<<VAULT_JSON>>>')[0].trim() : '';
1291
+ if (partial) {
1292
+ const body = indentContinuation(renderMarkdown(partial));
1293
+ const shown = `${stylingEnabled() ? turnMarker() : ''}${body.endsWith('\n') ? body : `${body}\n`}`;
1294
+ recordHistory(shown);
1295
+ process.stdout.write(shown);
1296
+ }
1297
+ const errorLine = `${dim(`companion: error talking to backend (${backendLabel()}): ${err.message}`)}\n` +
1298
+ (err.cutOff ? `${dim(cutOffNote)}\n` : '');
1299
+ recordHistory(errorLine);
1300
+ process.stdout.write(errorLine);
1301
+ drawBox();
1302
+ turnBoundaryPending = true;
1303
+ panelEnd(err.cutOff
1304
+ ? { text: partial || undefined, error: cutOffNote + ' Open Settings and check Context window (tokens).' }
1305
+ : { error: `Error talking to the backend (${backendLabel()}): ${err.message}` });
1306
+ return false;
1307
+ }
1308
+ stopSpinner();
1309
+ clearBottomRows();
1310
+ const isEmpty = !(typeof displayText === 'string' && displayText.trim());
1311
+ const marker = stylingEnabled() && !isEmpty ? turnMarker() : '';
1312
+ // Same hanging-indent treatment as the streaming path's first piece
1313
+ // (writeReplyPiece) - the whole reply prints as one block here, so its
1314
+ // own first line sits right after the marker and only that one line
1315
+ // stays unindented; see indentContinuation.
1316
+ const body = isEmpty
1317
+ ? dim('companion: warning: backend returned an empty reply')
1318
+ : indentContinuation(renderMarkdown(displayText));
1319
+ const fullBody = `${marker}${body.endsWith('\n') ? body : `${body}\n`}`;
1320
+ recordHistory(fullBody);
1321
+ process.stdout.write(fullBody);
1322
+ drawBox();
1323
+ turnBoundaryPending = true;
1324
+ if (isEmpty) panelEnd({ error: 'The backend returned an empty reply.' });
1325
+ else panelEnd({ text: displayText });
1326
+ // The tutor got the code even if the vault block was all it wrote back.
1327
+ return typeof reply === 'string' && reply.trim() !== '';
1328
+ }
1329
+
1330
+ rl.on('line', (line) => {
1331
+ const text = line.trim();
1332
+ if (!text) {
1333
+ // Ignore an empty submission entirely. Pressing Enter is readline's
1334
+ // own native handling, not ours - it already committed the (empty)
1335
+ // prompt row as permanent scrollback and moved the cursor to a fresh
1336
+ // row before this listener ever ran. A naive drawBox() here would draw
1337
+ // a whole new rule+prompt under that leftover row instead of reusing
1338
+ // it - meaning every empty Enter would permanently grow the screen by
1339
+ // two lines, and the rule (meant to be the single, unique break
1340
+ // between chat history and the live input) would duplicate itself
1341
+ // once per press. Reclaim that row instead (move up, clear) and redraw
1342
+ // only the prompt, still empty - the rule and blank-breathing-room
1343
+ // rows above it were never touched by readline's own handling and are
1344
+ // already correct, so the box ends up looking exactly as it did before
1345
+ // Enter was pressed, not just similar to it.
1346
+ //
1347
+ // While the screen hasn't genuinely filled yet, though, that reclaim
1348
+ // isn't enough on its own: readline's own commit-and-move-to-a-fresh-row
1349
+ // handling for Enter still emits a raw newline at whatever the terminal's
1350
+ // current last row is (the prompt's own row, since the box is always
1351
+ // pinned there) - the exact same last-row-overflow mechanism drawBox's
1352
+ // own fill-phase branch above exists to work around, so it forces a real
1353
+ // terminal scroll here too, silently dropping a row off the top, even
1354
+ // though an empty submission adds nothing to historyBuffer to justify
1355
+ // it. A full repaint recovers correctly, the same as drawBox's own
1356
+ // fill-phase branch - there's no typed content to preserve either way,
1357
+ // since the submission was empty.
1358
+ if (stylingEnabled()) {
1359
+ if (!screenFilledToBox) {
1360
+ redrawViewport();
1361
+ } else {
1362
+ readline.moveCursor(process.stdout, 0, -1);
1363
+ readline.clearLine(process.stdout, 0);
1364
+ readline.cursorTo(process.stdout, 0);
1365
+ rl.prompt(true);
1366
+ }
1367
+ } else {
1368
+ rl.prompt();
1369
+ }
1370
+ return;
1371
+ }
1372
+ // A non-empty submission, in contrast, must keep the row(s) readline just
1373
+ // committed - that's Thomson's own message, and it's meant to become part
1374
+ // of permanent chat history. Whatever the box was (boxShown/
1375
+ // reservedBottomRows) is now stale: readline's own native Enter handling
1376
+ // already committed and cleared all of it on its own. Reset both to
1377
+ // "nothing reserved" before the reply's own printAboveInput/box calls run.
1378
+ bottomRowsShown = 0;
1379
+ boxShown = false;
1380
+ if (text === '/exit' || text === '/quit') {
1381
+ rl.close();
1382
+ return;
1383
+ }
1384
+ if (text === '/reset') {
1385
+ queueTurn(async () => resetTutorContext()).catch(() => {});
1386
+ return;
1387
+ }
1388
+ // Readline's own echo already put this line on screen (that's what was
1389
+ // just committed to permanent scrollback, above) - this is purely for
1390
+ // history/showHistory's benefit, since readline's echo never goes
1391
+ // through printAboveInput or anything else that records it.
1392
+ if (process.stdout.isTTY) recordHistory(`${promptString()}${text}\n`);
1393
+ panelPublish({ type: 'user', text, problemSlug: panelProblemSlug });
1394
+ // The terminal cannot read the live editor, so the code reference is the
1395
+ // newest Run or Submit recorded for the active problem.
1396
+ const releaseBusy = holdBusy();
1397
+ queueTurn(() => sendChatTurn({ label: null, text, problemId: ledger.activeProblemId })).finally(releaseBusy);
1398
+ });
1399
+
1400
+ // A typed message (terminal or panel), inside the turn queue: prefixed with
1401
+ // the code update when the code changed since the tutor last saw it (see
1402
+ // code-ledger.js), and the code counted as held once the reply came back.
1403
+ // codeUnavailable: a panel message whose editor code could not be read. It
1404
+ // goes with no code update at all (the newest Run or Submit does not stand
1405
+ // in for the live code), and the tutor is told why.
1406
+ async function sendChatTurn({ label, text, problemId, preamble = null, codeUnavailable = false }) {
1407
+ const turn = prepareChatTurn(ledger, { text, problemId, preamble, codeUnavailable });
1408
+ const ok = await sendAndPrintTurn(label, turn.message.text, {
1409
+ guardText: text,
1410
+ problemContext: turn.problemContext,
1411
+ currentCode: turn.currentCode,
1412
+ compactText: turn.message.compactText,
1413
+ panelMeta: { problemSlug: panelProblemSlug },
1414
+ });
1415
+ if (ok && (turn.plan.kind === 'full' || turn.plan.kind === 'diff')) ledger.commit(problemId, turn.plan);
1416
+ }
1417
+
1418
+ rl.on('close', () => {
1419
+ stopping = true;
1420
+ // No "goodbye" line here - the alternate-screen restore (see the top of
1421
+ // this file) fires immediately after process.exit() below and erases
1422
+ // whatever's on screen anyway, so printing one would only ever flash
1423
+ // briefly, if it rendered at all, working against "exiting restores
1424
+ // exactly what was there before" rather than serving any purpose.
1425
+ process.exit(0);
1426
+ });
1427
+
1428
+ // --- capture tailing (offset tracking adapted from the old watch.js) -------
1429
+ //
1430
+ // The log is a short-lived delivery queue from the relay, and it is cleared
1431
+ // as it is consumed (see capture-log.js): the handled prefix is dropped at
1432
+ // start, after a problem switch, and once it passes CAPTURE_LOG_TRIM_BYTES.
1433
+ // Always between polls (pollLoop), so `offset` then marks exactly the lines
1434
+ // already handled, and never while a poll is reading the file.
1435
+
1436
+ // The log's bytes at [position, position + length), fewer at its end.
1437
+ function readCaptureBytes(position, length) {
1438
+ let fd;
1439
+ try {
1440
+ fd = fs.openSync(CAPTURES_PATH, 'r');
1441
+ } catch {
1442
+ return Buffer.alloc(0);
1443
+ }
1444
+ try {
1445
+ const buf = Buffer.alloc(length);
1446
+ const read = fs.readSync(fd, buf, 0, length, Math.max(0, position));
1447
+ return buf.subarray(0, read);
1448
+ } finally {
1449
+ fs.closeSync(fd);
1450
+ }
1451
+ }
1452
+
1453
+ function loadOffset() {
1454
+ try {
1455
+ const raw = fs.readFileSync(STATE_PATH, 'utf8');
1456
+ const parsed = JSON.parse(raw);
1457
+ if (typeof parsed.offset === 'number' && parsed.offset >= 0) {
1458
+ // A trim this companion asked for before it last stopped, with no
1459
+ // record of how it ended: the bytes before the cut tell.
1460
+ if (isPendingTrim(parsed.pendingTrim)) {
1461
+ const happened = pendingTrimHappened(parsed.pendingTrim, readCaptureBytes);
1462
+ const resolved = happened ? Math.max(0, parsed.offset - parsed.pendingTrim.before) : parsed.offset;
1463
+ saveOffset(resolved);
1464
+ return resolved;
1465
+ }
1466
+ return parsed.offset;
1467
+ }
1468
+ } catch (err) {
1469
+ if (err.code !== 'ENOENT') {
1470
+ console.error(dim(`companion: warning: could not read state file (${err.message}); starting fresh`));
1471
+ }
1472
+ }
1473
+ // No usable state: this is effectively a first run. Start at the current
1474
+ // end of the log (if it exists) so we tail forward, not replay history.
1475
+ try {
1476
+ return fs.statSync(CAPTURES_PATH).size;
1477
+ } catch {
1478
+ return 0;
1479
+ }
1480
+ }
1481
+
1482
+ function saveOffset(offset, pendingTrim) {
1483
+ const tmp = `${STATE_PATH}.tmp`;
1484
+ fs.writeFileSync(tmp, JSON.stringify(pendingTrim ? { offset, pendingTrim } : { offset }), 'utf8');
1485
+ fs.renameSync(tmp, STATE_PATH);
1486
+ }
1487
+
1488
+ let offset = loadOffset();
1489
+ let captureTrimWanted = false;
1490
+ // Set once the relay turns out not to support trimming (or refuses), so the
1491
+ // warning is printed once and the request not repeated every poll.
1492
+ let captureTrimDisabled = false;
1493
+
1494
+ // Asks the relay to drop the handled prefix of the log (everything before
1495
+ // `offset`), and moves `offset` back by what it removed once it confirms.
1496
+ // Only called from pollLoop, between polls.
1497
+ async function trimHandledCaptures() {
1498
+ captureTrimWanted = false;
1499
+ if (captureTrimDisabled || offset === 0) return;
1500
+ const before = offset;
1501
+ const pending = pendingTrimFor(before, readCaptureBytes);
1502
+ saveOffset(offset, pending);
1503
+ const result = await requestCaptureTrim({
1504
+ baseUrl: `http://${RELAY_HOST}:${RELAY_PORT}`,
1505
+ before,
1506
+ logPath: path.resolve(CAPTURES_PATH),
1507
+ });
1508
+ if (result.outcome === 'trimmed') {
1509
+ offset -= result.removed;
1510
+ } else if (result.outcome === 'unknown') {
1511
+ // No answer (a timeout, the relay restarting): whether it cut the file
1512
+ // is read off the file itself, the same way a restart would.
1513
+ if (pendingTrimHappened(pending, readCaptureBytes)) offset = Math.max(0, offset - before);
1514
+ } else {
1515
+ captureTrimDisabled = true;
1516
+ printAboveInput(
1517
+ dim(
1518
+ result.outcome === 'unsupported'
1519
+ ? 'companion: warning: the running relay server cannot clear the capture log; restart it to enable that'
1520
+ : `companion: warning: the relay would not clear the capture log (${oneLine(result.error)}); is CAPTURE_LOG_PATH the same file as LEETCODE_CAPTURES_FILE?`
1521
+ )
1522
+ );
1523
+ }
1524
+ saveOffset(offset);
1525
+ }
1526
+
1527
+ async function handleCaptureLine(line) {
1528
+ // A capture is new activity, not something the user asked for - if
1529
+ // they're mid-scroll reading back through history (see the scrollback
1530
+ // viewport section below), snap back to the live view first so every
1531
+ // write this function and the turn it triggers make below lands where
1532
+ // the existing rendering code has always assumed it would: at the live
1533
+ // bottom. Keeps that rendering code (printAboveInput, the streaming
1534
+ // writer, the spinner) entirely unaware scrolling exists at all.
1535
+ resetScrollIfNeeded();
1536
+ const trimmed = line.trim();
1537
+ if (!trimmed) return;
1538
+ let capture;
1539
+ try {
1540
+ // Every capture field that gets printed, published to the panel or put
1541
+ // in the model's prompt label is attacker-controlled: /capture is CORS
1542
+ // `*`, so any website can post one. Sanitized once, here (see
1543
+ // sanitize.js).
1544
+ capture = sanitizeCapture(JSON.parse(trimmed));
1545
+ } catch (err) {
1546
+ // V8's JSON.parse message quotes part of the input, escapes included.
1547
+ printAboveInput(dim(`companion: warning: skipping malformed capture JSON: ${oneLine(err.message)}`));
1548
+ return;
1549
+ }
1550
+ const label = dim(
1551
+ `[capture] ${triggerLabel(capture.trigger)} - ${capture.problemTitle || capture.problemSlug || 'unknown'} (attempt ${capture.attemptSeq ?? '?'})`
1552
+ );
1553
+ const problemId = captureProblemId(capture);
1554
+ const sends = sendsToTutor(capture.trigger);
1555
+ if (capture.problemSlug) panelProblemSlug = capture.problemSlug;
1556
+ const shownTitle = capture.problemTitle || capture.problemSlug || 'this problem';
1557
+
1558
+ // Everything else happens in the turn queue, in arrival order: the reset
1559
+ // (if this capture is for another problem) first, then recording the code,
1560
+ // then (a changed Submit only) the backend call.
1561
+ await queueTurn(async () => {
1562
+ if (problemId) {
1563
+ activateProblem(problemId, capture.problemSlug || null);
1564
+ ledger.record(problemId, {
1565
+ code: typeof capture.code === 'string' ? capture.code : undefined,
1566
+ language: capture.language || null,
1567
+ slug: capture.problemSlug || null,
1568
+ context: captureProblemContext(capture),
1569
+ });
1570
+ }
1571
+
1572
+ // A Run (or anything else that is not a Submit) is only recorded: the
1573
+ // tutor sees what changed with the student's next message.
1574
+ if (!sends) {
1575
+ printAboveInput(
1576
+ consumeTurnBoundary() +
1577
+ dim(
1578
+ `companion: recorded your ${triggerLabel(capture.trigger)} for ${shownTitle} (attempt ${capture.attemptSeq ?? '?'}) - not sent to the tutor; it sees the changes with your next message`
1579
+ )
1580
+ );
1581
+ turnBoundaryPending = true;
1582
+ return;
1583
+ }
1584
+
1585
+ const plan = problemId ? ledger.planUpdate(problemId) : { kind: 'none' };
1586
+ if (plan.kind === 'unchanged' || plan.kind === 'none') {
1587
+ const why = plan.kind === 'unchanged' ? 'is unchanged since the tutor last saw it' : 'carried no code';
1588
+ printAboveInput(
1589
+ consumeTurnBoundary() +
1590
+ dim(`companion: your Submit for ${shownTitle} (attempt ${capture.attemptSeq ?? '?'}) ${why} - not sent`)
1591
+ );
1592
+ turnBoundaryPending = true;
1593
+ return;
1594
+ }
1595
+
1596
+ // Only a capture the tutor is about to see shows its chip in the panel:
1597
+ // a Run or an unchanged Submit shows nothing there.
1598
+ panelPublish({
1599
+ type: 'capture',
1600
+ problemSlug: capture.problemSlug || null,
1601
+ problemTitle: capture.problemTitle || null,
1602
+ trigger: capture.trigger,
1603
+ attemptSeq: Number.isInteger(capture.attemptSeq) ? capture.attemptSeq : null,
1604
+ });
1605
+
1606
+ // From here on the tutor is working on this Submit (the panel's
1607
+ // "Processing" light, see holdBusy).
1608
+ const releaseBusy = holdBusy();
1609
+ try {
1610
+ // An instant, local, non-LLM acknowledgement before the backend call
1611
+ // starts: the tutor's own acknowledgement is baked into its reply and can
1612
+ // lag several seconds behind.
1613
+ printAboveInput(consumeTurnBoundary() + dim(`companion: got your Submit for ${shownTitle} - taking a look...`));
1614
+
1615
+ // Only Submits drive the vault auto-summary, and only when the toggle is
1616
+ // on - see the "vault auto-summary" section above. One extra instruction
1617
+ // rides on the same message; no second LLM call.
1618
+ let vaultContext = null;
1619
+ if (effective.options.vaultAutoSummary) {
1620
+ try {
1621
+ vaultContext = prepareVaultContext({
1622
+ capture,
1623
+ vaultPath: VAULT_PATH,
1624
+ algorithmsSubfolder: VAULT_ALGORITHMS_SUBFOLDER,
1625
+ });
1626
+ } catch (err) {
1627
+ printAboveInput(dim(`companion: warning: could not prepare vault auto-summary context (${err.message})`));
1628
+ }
1629
+ }
1630
+ const turn = prepareSubmitTurn(ledger, {
1631
+ problemId,
1632
+ plan,
1633
+ headerLine: captureHeaderLine(capture),
1634
+ addendum: SUBMIT_ADDENDUM + (vaultContext ? vaultContext.addendum : ''),
1635
+ fallbackContext: captureProblemContext(capture),
1636
+ });
1637
+ const ok = await sendAndPrintTurn(label, turn.message.text, {
1638
+ onReply: vaultContext
1639
+ ? (reply) => {
1640
+ const { displayText, vaultData } = extractVaultBlock(reply, {
1641
+ warn: (msg) => printAboveInput(dim(`companion: warning: ${msg}`)),
1642
+ });
1643
+ if (vaultData) {
1644
+ try {
1645
+ writeVaultAutoSummary({ capture, vaultContext, vaultData });
1646
+ } catch (err) {
1647
+ printAboveInput(dim(`companion: warning: vault auto-summary write failed (${err.message})`));
1648
+ }
1649
+ } else {
1650
+ printAboveInput(
1651
+ dim('companion: warning: vault auto-summary was on but the reply had no vault data block; skipping this write')
1652
+ );
1653
+ }
1654
+ return displayText;
1655
+ }
1656
+ : undefined,
1657
+ problemContext: turn.problemContext,
1658
+ currentCode: turn.currentCode,
1659
+ compactText: turn.message.compactText,
1660
+ panelMeta: { problemSlug: capture.problemSlug || null },
1661
+ trigger: 'submit',
1662
+ });
1663
+ if (ok) ledger.commit(problemId, plan);
1664
+ } finally {
1665
+ releaseBusy();
1666
+ }
1667
+ });
1668
+ }
1669
+
1670
+ // The live editor code the panel read at send time (see extension/panel.js
1671
+ // and content.js), or null. Only the fields the tutor needs, made safe the
1672
+ // same way a capture is; the code itself is left exactly as typed.
1673
+ function panelSnapshot(raw) {
1674
+ if (!raw || typeof raw !== 'object' || typeof raw.code !== 'string') return null;
1675
+ const clean = sanitizeCapture({
1676
+ code: raw.code,
1677
+ language: typeof raw.language === 'string' ? raw.language : null,
1678
+ problemTitle: typeof raw.problemTitle === 'string' ? raw.problemTitle : null,
1679
+ problemDescription: typeof raw.problemDescription === 'string' ? raw.problemDescription : null,
1680
+ });
1681
+ return clean;
1682
+ }
1683
+
1684
+ // A message typed into the in-page panel (delivered by panel-bridge.js). It
1685
+ // joins the same queue as terminal-typed messages and captures, and is echoed
1686
+ // in the terminal under a [panel] label so the two views never diverge. The
1687
+ // panel sends the live editor code with it when it could read it; without
1688
+ // that the message goes with no code at all (a terminal message, which has no
1689
+ // editor to read, uses the newest Run or Submit instead).
1690
+ function handlePanelMessage(message) {
1691
+ resetScrollIfNeeded();
1692
+ // The relay already strips control characters from panel messages, but
1693
+ // this process is the one writing to the terminal, so it does not rely on
1694
+ // that (see sanitize.js).
1695
+ const text = stripControlChars(message.text).trim();
1696
+ const problemSlug = oneLine(message.problemSlug) || null;
1697
+ if (!text) return;
1698
+ const snapshot = panelSnapshot(message.snapshot);
1699
+ if (problemSlug) panelProblemSlug = problemSlug;
1700
+ const messageProblemSlug = panelProblemSlug;
1701
+ const label = dim(`[panel] ${text}`);
1702
+ const releaseBusy = holdBusy();
1703
+ queueTurn(async () => {
1704
+ if (!problemSlug) {
1705
+ panelPublish({ type: 'user', text, problemSlug: messageProblemSlug });
1706
+ await sendChatTurn({ label, text, problemId: ledger.activeProblemId });
1707
+ return;
1708
+ }
1709
+ // A message from another problem than the active one resets the tutor
1710
+ // (see activateProblem), and the tutor is told which problem is on screen.
1711
+ const { previousId } = activateProblem(problemSlug, problemSlug);
1712
+ panelPublish({ type: 'user', text, problemSlug });
1713
+ if (snapshot) {
1714
+ ledger.record(problemSlug, {
1715
+ code: snapshot.code,
1716
+ language: snapshot.language,
1717
+ slug: problemSlug,
1718
+ context: {
1719
+ problemId: problemSlug,
1720
+ title: snapshot.problemTitle || problemSlug,
1721
+ slug: problemSlug,
1722
+ description:
1723
+ snapshot.problemDescription && snapshot.problemDescription.trim() ? snapshot.problemDescription : null,
1724
+ },
1725
+ });
1726
+ }
1727
+ const codeUnavailable = !snapshot;
1728
+ const preamble = ({ hasHeld }) => chatPreamble({ previousId, problemId: problemSlug, codeUnavailable, hasHeld });
1729
+ await sendChatTurn({ label, text, problemId: problemSlug, preamble, codeUnavailable });
1730
+ }).finally(releaseBusy);
1731
+ }
1732
+
1733
+ async function poll() {
1734
+ let stats;
1735
+ try {
1736
+ stats = fs.statSync(CAPTURES_PATH);
1737
+ } catch {
1738
+ return; // log file doesn't exist yet
1739
+ }
1740
+
1741
+ if (stats.size < offset) {
1742
+ printAboveInput(dim('companion: capture log shrank; resetting offset to 0'));
1743
+ offset = 0;
1744
+ }
1745
+ if (stats.size <= offset) return;
1746
+
1747
+ const fd = fs.openSync(CAPTURES_PATH, 'r');
1748
+ let buf;
1749
+ try {
1750
+ const length = stats.size - offset;
1751
+ buf = Buffer.alloc(length);
1752
+ fs.readSync(fd, buf, 0, length, offset);
1753
+ } finally {
1754
+ fs.closeSync(fd);
1755
+ }
1756
+
1757
+ const text = buf.toString('utf8');
1758
+ const lastNewline = text.lastIndexOf('\n');
1759
+ if (lastNewline === -1) return; // no complete line yet
1760
+
1761
+ const complete = text.slice(0, lastNewline);
1762
+ const consumedBytes = Buffer.byteLength(complete, 'utf8') + 1;
1763
+ const lines = complete.split('\n');
1764
+
1765
+ for (const line of lines) {
1766
+ // eslint-disable-next-line no-await-in-loop
1767
+ await handleCaptureLine(line);
1768
+ }
1769
+
1770
+ offset += consumedBytes;
1771
+ saveOffset(offset);
1772
+ }
1773
+
1774
+ async function pollLoop() {
1775
+ // Captures from before this start are not needed (an unread tail after the
1776
+ // saved offset still is, and stays).
1777
+ captureTrimWanted = true;
1778
+ while (!stopping) {
1779
+ try {
1780
+ if (captureTrimWanted || offset > CAPTURE_LOG_TRIM_BYTES) {
1781
+ // eslint-disable-next-line no-await-in-loop
1782
+ await trimHandledCaptures();
1783
+ }
1784
+ // eslint-disable-next-line no-await-in-loop
1785
+ await poll();
1786
+ } catch (err) {
1787
+ printAboveInput(dim(`companion: error during capture poll: ${err.stack || err.message}`));
1788
+ }
1789
+ if (stopping) break;
1790
+ // eslint-disable-next-line no-await-in-loop
1791
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
1792
+ }
1793
+ }
1794
+
1795
+ // SIGHUP is what a terminal sends when its window is closed. Node's default
1796
+ // for it ends the process without running 'exit' handlers, which left the
1797
+ // relay server this companion started running (and holding its port) after
1798
+ // the window was gone.
1799
+ // Run by the helper, there is no terminal to close: SIGHUP is ignored, and
1800
+ // the helper's SIGTERM or closed stdin ends this process.
1801
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
1802
+ process.on(sig, () => {
1803
+ if (sig === 'SIGHUP' && HEADLESS) return;
1804
+ rl.close();
1805
+ });
1806
+ }
1807
+
1808
+ // --- mouse-wheel scrollback viewport ------------------------------------
1809
+ //
1810
+ // The alternate screen buffer has no native scrollback (see the top of
1811
+ // this file), so this program keeps its own: historyBuffer already records
1812
+ // every line of real conversation content verbatim (for Page Up/less
1813
+ // below); scrollOffset is how many lines up from the live bottom the
1814
+ // visible window currently sits, and redrawViewport paints exactly that
1815
+ // window - a real, application-level scrollback, not reliance on anything
1816
+ // the terminal provides. 0 means "the live tail" - the normal, default
1817
+ // view, following new content exactly like before this feature existed.
1818
+ // Only ever changed while idle (turnActive false) - see handleWheel and
1819
+ // resetScrollIfNeeded below for why a turn in progress and a nonzero
1820
+ // scrollOffset can never coincide, which is what lets the rest of this
1821
+ // file's rendering code (printAboveInput, the streaming writer, the
1822
+ // spinner) stay entirely unaware scrolling exists at all.
1823
+ let scrollOffset = 0;
1824
+
1825
+ // How many lines a single wheel notch moves the viewport - most terminals
1826
+ // send one SGR wheel report per physical notch (not per pixel), so this is
1827
+ // "lines per notch," matching the common OS-level default of a few lines
1828
+ // per click rather than a full screen.
1829
+ const WHEEL_SCROLL_LINES = 3;
1830
+
1831
+ // Leaves room for the box's own rows: a blank breathing-room line, the
1832
+ // rule, however many physical rows the prompt itself currently needs (see
1833
+ // promptRowCount - normally 1, more once a long typed/pasted line has
1834
+ // wrapped), and one more blank breathing-room row below the prompt,
1835
+ // symmetric with the one above the rule (see drawBoxRaw's own reservation
1836
+ // of that below-row, right before it renders the prompt). Reserving it here
1837
+ // too, so redrawViewport's own padding math leaves the box's bottom pinned
1838
+ // one row higher than the terminal's actual last row, is what makes the
1839
+ // *fill-phase* draws (before the screen has genuinely filled with real
1840
+ // content) agree with drawBoxRaw's own steady-state reservation, rather
1841
+ // than the two disagreeing about where the box's bottom belongs.
1842
+ // clearBottomRows/reservedBottomRows deliberately keep counting only "the
1843
+ // rule plus the prompt" rows, since those are the rows at-and-above the
1844
+ // cursor's own row - the reserved row below the cursor is never part of
1845
+ // that upward count. Recomputed on every call rather than assumed fixed, so
1846
+ // a long typed line correctly shrinks the space left for chat content
1847
+ // instead of the box's extra wrapped row silently overlapping it.
1848
+ function currentVisibleRows() {
1849
+ const rows = process.stdout.rows || 24;
1850
+ return Math.max(1, rows - 3 - promptRowCount());
1851
+ }
1852
+
1853
+ // Repaints the screen from historyBuffer at the current scrollOffset, sized
1854
+ // to the terminal's current height, then the box - the one place that
1855
+ // actually turns "recorded history + an offset" into pixels. Used to
1856
+ // return to the live view after the pager (see showHistory) closes, to
1857
+ // respond to a wheel event, to reflow the current window after a resize,
1858
+ // and (see drawBox above) as the fill-phase repaint for every ordinary
1859
+ // redraw until the screen has genuinely filled with real content. Because
1860
+ // it always repaints the *entire* visible window fresh from historyBuffer -
1861
+ // the true, never-lost record of everything ever printed - rather than
1862
+ // incrementally editing whatever the physical terminal screen currently
1863
+ // shows, it can't inherit whatever an unrelated stray scroll (this
1864
+ // program's own, or readline's own newline-at-the-last-row on a plain
1865
+ // Enter) already did to the physical screen; it always recomputes the
1866
+ // correct layout from scratch. scrollOffset is clamped here (not at the
1867
+ // call sites) so it can never point past either end of the buffer
1868
+ // regardless of how it got there - a shrunk terminal window, or history
1869
+ // that's grown shorter than a previously-valid offset (e.g. after the
1870
+ // capture log's own trim).
1871
+ function redrawViewport() {
1872
+ const visibleRows = currentVisibleRows();
1873
+ const allLines = historyLines();
1874
+ const total = allLines.length;
1875
+ scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, total - visibleRows)));
1876
+ const end = total - scrollOffset;
1877
+ const start = Math.max(0, end - visibleRows);
1878
+ const slice = allLines.slice(start, end);
1879
+ process.stdout.write('\x1b[2J\x1b[H'); // clear screen, cursor to top-left
1880
+ if (slice.length) process.stdout.write(`${slice.join('\n')}\n`);
1881
+ if (slice.length < visibleRows) {
1882
+ process.stdout.write('\n'.repeat(visibleRows - slice.length));
1883
+ }
1884
+ // Only latch "the screen has genuinely filled" when the *real* content
1885
+ // (not padding) actually reaches visibleRows - unlike the old code, which
1886
+ // set this unconditionally after any repaint. Latching unconditionally is
1887
+ // exactly what made drawBox's old incremental path silently corrupt a
1888
+ // short conversation: after the very first draw, drawBox would never pad
1889
+ // again regardless of how little real content there actually was, so
1890
+ // every later redraw's own newline-at-the-last-row forced a real terminal
1891
+ // scroll that dropped a row off the top instead. total >= visibleRows is
1892
+ // the one condition under which that natural scrolling is actually
1893
+ // correct and desired (see drawBox's own comment) - so that's the only
1894
+ // condition allowed to turn the incremental path back on.
1895
+ screenFilledToBox = total >= visibleRows;
1896
+ bottomRowsShown = 0;
1897
+ // drawBoxRaw directly, not drawBox - this *is* the fill-phase repaint
1898
+ // drawBox would otherwise delegate back to, so calling drawBox here would
1899
+ // either recurse into this same function again (screenFilledToBox still
1900
+ // false) or silently skip a row of padding this call already accounted
1901
+ // for (screenFilledToBox just turned true) by drawing the box a second,
1902
+ // redundant time.
1903
+ drawBoxRaw();
1904
+ }
1905
+
1906
+ // Called by anything that represents new activity (a capture, a typed
1907
+ // message) before it writes a single byte - if the user was scrolled up
1908
+ // reading history, this always returns them to the live view first, so
1909
+ // nothing else in this file ever has to reason about "what if we're
1910
+ // mid-scroll right now." A no-op when already at the live bottom.
1911
+ function resetScrollIfNeeded() {
1912
+ if (scrollOffset === 0) return;
1913
+ scrollOffset = 0;
1914
+ redrawViewport();
1915
+ }
1916
+
1917
+ // The mouse-filter's onWheel callback (wired up where readlineInput is
1918
+ // built, near the top of this file) - ignored entirely while a turn is
1919
+ // active, same as Page Up already is below: mid-turn, bottomRowsShown
1920
+ // might mean "a 1-row spinner" or a still-streaming piece rather than a
1921
+ // settled 2-row box, and redrawViewport's full-screen clear would corrupt
1922
+ // whatever that turn's own writes are tracking. Idle, scrolling is always
1923
+ // safe: resetScrollIfNeeded (above) guarantees scrollOffset is already 0
1924
+ // by the time any turn begins, so there's no state to reconcile once one
1925
+ // does.
1926
+ function handleWheel(direction) {
1927
+ if (!process.stdout.isTTY || turnActive) return;
1928
+ scrollOffset += direction === 'up' ? WHEEL_SCROLL_LINES : -WHEEL_SCROLL_LINES;
1929
+ scrollOffset = Math.max(0, scrollOffset);
1930
+ redrawViewport();
1931
+ }
1932
+
1933
+ // Page Up opens the full conversation history in `less` rather than a
1934
+ // hand-rolled scroll view - a real pager already gets wrapping, search,
1935
+ // and its own Page Up/Down exactly right, which a from-scratch
1936
+ // implementation would have to earn the hard way. `-X` specifically
1937
+ // disables less's own terminal init/deinit (its own alternate-screen
1938
+ // enter/exit) - without it, less entering *its own* alternate screen while
1939
+ // this program is already in one, then leaving it, would drop the
1940
+ // terminal straight back to the normal buffer under it instead of back to
1941
+ // this program's own alternate screen, since 1049 isn't a stack (see the
1942
+ // comment at the top of this file on why 1049 is used at all). With -X,
1943
+ // less paints directly into the screen this program already owns, and
1944
+ // handing it real terminal-attached stdio (not a pipe) is what makes its
1945
+ // own scrolling/search interactive rather than just dumping the content.
1946
+ // Still worth keeping now that the wheel scrolls the box's own viewport
1947
+ // natively: `less` adds real search (`/`), unbounded scroll speed, and
1948
+ // works even on a terminal that doesn't support SGR mouse reporting at
1949
+ // all, none of which the lightweight windowed viewport above attempts to
1950
+ // replace - both draw from the same historyBuffer regardless, so Page Up
1951
+ // is now best thought of as "open the exact same history in a real pager"
1952
+ // rather than the wheel's only fallback.
1953
+ // Ignored while a turn is active (a spinner or a streamed piece is still
1954
+ // mid-write to the same terminal - spawning a pager on top of that would
1955
+ // corrupt both) and while there's nothing to show yet.
1956
+ function showHistory() {
1957
+ if (!process.stdout.isTTY || turnActive || historyBuffer.length === 0) return;
1958
+ const content = historyBuffer.join('');
1959
+ rl.pause();
1960
+ // Mouse tracking is a terminal-wide mode, not scoped to any one file
1961
+ // descriptor - `less` reads its own keyboard input directly from
1962
+ // /dev/tty (the standard pager trick for staying interactive even when
1963
+ // its content comes in over a pipe, as it does here), completely
1964
+ // bypassing this process's stdin. Left enabled, a wheel scroll while
1965
+ // `less` is open would still generate SGR reports, just delivered to
1966
+ // `less` instead - which doesn't understand them any better than
1967
+ // readline does - so they're turned off for the duration and restored
1968
+ // once `less` returns.
1969
+ process.stdout.write(DISABLE_MOUSE_TRACKING);
1970
+ // spawnSync doesn't throw for a missing binary (ENOENT) or any other
1971
+ // spawn-time failure - it returns normally with .error set instead, so
1972
+ // that's what needs checking here, not a try/catch.
1973
+ const result = spawnSync('less', ['-RX'], { input: content, stdio: ['pipe', 'inherit', 'inherit'] });
1974
+ process.stdout.write(ENABLE_MOUSE_TRACKING);
1975
+ rl.resume();
1976
+ if (result.error) {
1977
+ printAboveInput(dim(`companion: warning: could not open history in "less" (${result.error.message})`));
1978
+ return;
1979
+ }
1980
+ scrollOffset = 0;
1981
+ redrawViewport();
1982
+ }
1983
+
1984
+ if (process.stdout.isTTY) {
1985
+ // Attached to filteredStdin, not process.stdin directly - see the "raw
1986
+ // input / mouse-wheel scrollback" section near the top of this file for
1987
+ // why: readline's own keypress decoder (and this listener, sharing the
1988
+ // same decoder) only ever sees the filtered stream in a real terminal.
1989
+ filteredStdin.on('keypress', (str, key) => {
1990
+ if (key && key.name === 'pageup') showHistory();
1991
+ });
1992
+ }
1993
+
1994
+ // --- settings from the extension's options page ----------------------------
1995
+ //
1996
+ // The options page calls settings-rpc.js through the relay (POST
1997
+ // /settings/rpc, forwarded down the inbox stream; see panel-bridge.js). A
1998
+ // save is validated and a backend is built from it before anything is
1999
+ // written, then applySettings swaps it in.
2000
+
2001
+ // The non-secret summary of what the tutor runs on, for the panel's header.
2002
+ function publishConfig() {
2003
+ if (panelBridge) panelBridge.setConfig(statusSummary(effective));
2004
+ }
2005
+
2006
+ // What a backend is built from; when none of it changed (only an option such
2007
+ // as the vault auto-summary did), the running backend and its conversation
2008
+ // are kept.
2009
+ function backendKey(config) {
2010
+ return JSON.stringify([
2011
+ config.provider,
2012
+ config.model,
2013
+ config.baseUrl,
2014
+ config.apiKey,
2015
+ config.contextTokens,
2016
+ config.options.localMaxHistoryTurns,
2017
+ ]);
2018
+ }
2019
+
2020
+ // Puts `nextBackend` in place of the running one between turns, moving the
2021
+ // conversation over (extension/shared/conversation.js) so the new model sees
2022
+ // the same chat and statement (the code comes from the ledger every turn).
2023
+ // Only a backend that cannot take it over (ClaudeBackend's session) starts
2024
+ // fresh, and then the ledger forgets what the tutor saw, so the next message
2025
+ // carries the full code. Returns whether the conversation moved. This is
2026
+ // also what a model switcher calls.
2027
+ function swapBackend(nextBackend) {
2028
+ const moved = transferConversation(backend, nextBackend);
2029
+ backend = nextBackend;
2030
+ if (!moved) ledger.forgetHeld();
2031
+ return moved;
2032
+ }
2033
+
2034
+ // Swaps in a saved configuration through the turn queue, so it lands between
2035
+ // turns and never in the middle of one (a message queued after the save goes
2036
+ // to the new backend). Returns the queued swap: a save does not wait for it
2037
+ // (the options page hears "saved" at once even while a long reply is still
2038
+ // running), the model switcher does (`why` is 'switch'), so the panel shows
2039
+ // "Switching" until the new model is really in place.
2040
+ function applySettings(nextEffective, nextBackend, why = 'save') {
2041
+ return queueTurn(async () => {
2042
+ const swap = backendKey(nextEffective) !== backendKey(effective);
2043
+ effective = nextEffective;
2044
+ if (swap) swapBackend(nextBackend);
2045
+ const now = statusSummary(effective);
2046
+ const model = now.model ? ` (${oneLine(now.model)})` : '';
2047
+ printAboveInput(
2048
+ dim(
2049
+ why === 'switch'
2050
+ ? `companion: switched models - now using ${now.label}${model}, same conversation`
2051
+ : `companion: settings saved - ${swap ? 'now using' : 'still using'} ${now.label}${model}`
2052
+ )
2053
+ );
2054
+ publishConfig();
2055
+ }).catch((err) => {
2056
+ printAboveInput(dim(`companion: error applying settings: ${err.message}`));
2057
+ throw err;
2058
+ });
2059
+ }
2060
+
2061
+ const settingsRpc = createSettingsRpc({
2062
+ file: SETTINGS_FILE,
2063
+ env: process.env,
2064
+ getStored: () => storedSettings,
2065
+ setStored: (next) => {
2066
+ storedSettings = next;
2067
+ },
2068
+ buildBackend: (config) => makeBackend(config),
2069
+ apply: applySettings,
2070
+ validateVault: () => validateVaultPath(VAULT_PATH),
2071
+ });
2072
+
2073
+ // For the idle exit: whether the panel bridge's inbox stream to the relay is
2074
+ // up, and when it last was.
2075
+ let relayConnected = false;
2076
+ let relayLastSeen = Date.now();
2077
+ function noteRelayState(state) {
2078
+ if (relayConnected) relayLastSeen = Date.now();
2079
+ relayConnected = state === 'connected';
2080
+ if (relayConnected) relayLastSeen = Date.now();
2081
+ }
2082
+
2083
+ await ensureRelayServer();
2084
+
2085
+ if (PANEL_ENABLED) {
2086
+ let announced = false;
2087
+ panelBridge = createPanelBridge({
2088
+ baseUrl: `http://${RELAY_HOST}:${RELAY_PORT}`,
2089
+ onMessage: handlePanelMessage,
2090
+ onRpc: (method, params) => settingsRpc.handle(method, params),
2091
+ onState: (state) => {
2092
+ noteRelayState(state);
2093
+ if (state === 'connected' && !announced) {
2094
+ announced = true;
2095
+ printAboveInput(dim('companion: in-page panel connected (the Tutor tab on leetcode.com)'));
2096
+ } else if (state === 'unsupported') {
2097
+ printAboveInput(
2098
+ dim('companion: warning: the running relay server predates the in-page panel; restart it to enable the Tutor tab')
2099
+ );
2100
+ }
2101
+ },
2102
+ });
2103
+ panelBridge.start();
2104
+ publishConfig();
2105
+ if (IDLE_EXIT_MS > 0) {
2106
+ setInterval(() => {
2107
+ if (relayConnected || Date.now() - relayLastSeen < IDLE_EXIT_MS) return;
2108
+ printAboveInput(dim(`companion: nothing heard from the relay for ${Math.round(IDLE_EXIT_MS / 1000)} s, exiting`));
2109
+ rl.close();
2110
+ }, Math.min(IDLE_EXIT_MS, 10000)).unref();
2111
+ }
2112
+ process.on('exit', () => panelBridge.stop());
2113
+ }
2114
+
2115
+ {
2116
+ const active = statusSummary(effective);
2117
+ printBanner(
2118
+ dim(`companion: backend=${active.provider} (${active.label})${active.model ? ` model=${oneLine(active.model)}` : ''}`)
2119
+ );
2120
+ if (loadedSettings.warning) printBanner(dim(`companion: warning: settings: ${oneLine(loadedSettings.warning)}`));
2121
+ }
2122
+ printBanner(dim(`companion: watching ${CAPTURES_PATH} (starting offset=${offset})`));
2123
+ printBanner(
2124
+ dim(
2125
+ effective.options.vaultAutoSummary
2126
+ ? `companion: vault auto-summary ON - writing to ${VAULT_PATH}`
2127
+ : 'companion: vault auto-summary off (turn it on in the Tutor settings, or set VAULT_AUTO_SUMMARY=1)'
2128
+ )
2129
+ );
2130
+ printBanner(
2131
+ dim(
2132
+ effective.options.autoClearContext
2133
+ ? 'companion: auto-clear-context ON - a message, Run or Submit on a different problem resets the tutor session (set COMPANION_AUTO_CLEAR_CONTEXT=0 to disable)'
2134
+ : 'companion: auto-clear-context off (COMPANION_AUTO_CLEAR_CONTEXT=0)'
2135
+ )
2136
+ );
2137
+ printBanner(
2138
+ dim('companion: type to chat directly; a changed Submit goes to the tutor, a Run waits for your next message. Page Up for history, /exit to quit.')
2139
+ );
2140
+ if (stylingEnabled()) drawBox();
2141
+ else rl.prompt();
2142
+
2143
+ // Node updates process.stdout.columns/rows (from the terminal's SIGWINCH)
2144
+ // and emits 'resize' before this fires, so refreshWidth() below - which
2145
+ // just re-reads process.stdout.columns - already picks up the new size.
2146
+ // Always safe to do regardless of what's on screen right now. Redrawing
2147
+ // the box itself to *show* the new width immediately is only safe when
2148
+ // idle (turnActive false): mid-turn, bottomRowsShown might mean "a 1-row
2149
+ // spinner" rather than "a 2-row box" at any given moment, and forcing a
2150
+ // 2-row box there would corrupt whatever the turn's own writes are
2151
+ // tracking. Idle, the box's state is simple and known, so it can just be
2152
+ // redrawn on the spot; mid-turn, the new width still takes effect - just
2153
+ // only visibly once the turn's own next write happens, rather than
2154
+ // instantly. Registered only after the box above is first drawn, so there's
2155
+ // no window where a resize could fire before there's a box to redraw.
2156
+ if (process.stdout.isTTY) {
2157
+ process.stdout.on('resize', () => {
2158
+ refreshWidth();
2159
+ // Rows may have changed too, not just columns. redrawViewport (below)
2160
+ // already recomputes its own padding fresh on every call regardless of
2161
+ // this latch's prior value, so this reset only actually matters for the
2162
+ // turnActive branch: mid-turn, redrawViewport doesn't run yet (see
2163
+ // below), so drop the latch now so the turn's own next incremental
2164
+ // drawBox call - once it does run - re-evaluates against the new size
2165
+ // instead of trusting a conclusion reached at the old one.
2166
+ screenFilledToBox = false;
2167
+ if (!turnActive) {
2168
+ // Always a full repaint (not just the box's own row), whether idle at
2169
+ // the live tail or scrolled - redrawViewport already handles both
2170
+ // (scrollOffset 0 is simply "show the live tail"), and it's what
2171
+ // makes the box actually move to the new last row rather than only
2172
+ // updating in place where it already was. It replays already-
2173
+ // rendered content from historyBuffer rather than re-wrapping it to
2174
+ // the new width (only markdown rendered *after* this point picks up
2175
+ // the new width - see refreshWidth's own comment), so this is safe
2176
+ // for a plain column-only resize too; the cost is a full-screen
2177
+ // clear on every resize rather than only when scrolled, which is an
2178
+ // acceptable trade for a resize actually pinning correctly.
2179
+ redrawViewport();
2180
+ }
2181
+ // Mid-turn, the new size still takes effect - just only visibly once
2182
+ // the turn's own next write calls drawBox again (screenFilledToBox was
2183
+ // just cleared above, so that draw will pad correctly too).
2184
+ });
2185
+ }
2186
+
2187
+ pollLoop().catch((err) => {
2188
+ console.error(dim(`companion: fatal: ${err.stack || err.message}`));
2189
+ process.exit(1);
2190
+ });