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