@north-light/crouter 0.3.170 → 0.3.171
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/dist/build-root.d.ts +1 -16
- package/dist/build-root.js +1 -25
- package/dist/builtin-memory/insights/capture.md +66 -0
- package/dist/builtin-memory/insights/init.md +45 -0
- package/dist/builtin-memory/internal/plugins.md +12 -11
- package/dist/cli.js +2 -6
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -1
- package/dist/clients/attach/__tests__/context-message.test.js +50 -3
- package/dist/clients/attach/__tests__/diagram.test.js +1 -1
- package/dist/clients/attach/chrome/header.d.ts +10 -0
- package/dist/clients/attach/chrome/header.js +35 -0
- package/dist/clients/attach/chrome/status-line.d.ts +3 -0
- package/dist/clients/attach/chrome/status-line.js +5 -0
- package/dist/clients/attach/chrome/widgets.js +7 -18
- package/dist/clients/attach/input/controller.d.ts +10 -0
- package/dist/clients/attach/input/controller.js +3 -0
- package/dist/clients/attach/overlays/help.d.ts +33 -0
- package/dist/clients/attach/overlays/help.js +204 -0
- package/dist/clients/attach/render/chat-view.d.ts +81 -9
- package/dist/clients/attach/render/chat-view.js +434 -90
- package/dist/clients/attach/render/context-message.js +3 -3
- package/dist/clients/attach/render/diagram.js +2 -2
- package/dist/clients/attach/render/measured-container.d.ts +94 -0
- package/dist/clients/attach/render/measured-container.js +307 -0
- package/dist/clients/attach/render/transcript-copy.d.ts +9 -0
- package/dist/clients/attach/render/transcript-copy.js +81 -0
- package/dist/clients/attach/render/viewport.d.ts +29 -0
- package/dist/clients/attach/render/viewport.js +160 -0
- package/dist/clients/attach/session/bindings.d.ts +5 -1
- package/dist/clients/attach/session/bindings.js +16 -0
- package/dist/clients/attach/session/context.d.ts +5 -3
- package/dist/clients/attach/session/frame.d.ts +34 -0
- package/dist/clients/attach/session/frame.js +173 -0
- package/dist/clients/attach/session/frames.d.ts +2 -0
- package/dist/clients/attach/session/frames.js +3 -0
- package/dist/clients/attach/session/input-wiring.d.ts +10 -4
- package/dist/clients/attach/session/input-wiring.js +13 -13
- package/dist/clients/attach/session/keys.d.ts +4 -1
- package/dist/clients/attach/session/keys.js +9 -4
- package/dist/clients/attach/session/layout.d.ts +10 -8
- package/dist/clients/attach/session/layout.js +32 -31
- package/dist/clients/attach/session/mouse.d.ts +15 -0
- package/dist/clients/attach/session/mouse.js +61 -0
- package/dist/clients/attach/slash/dispatch.d.ts +10 -0
- package/dist/clients/attach/slash/dispatch.js +35 -2
- package/dist/clients/attach/viewer.js +605 -581
- package/dist/clients/web/dev-server.d.ts +4 -1
- package/dist/clients/web/dev-server.js +8 -19
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/human/prompts.js +2 -1
- package/dist/commands/pkg/plugin-manage.js +143 -68
- package/dist/commands/sys/__tests__/setup-core.test.js +4 -2
- package/dist/commands/sys/setup-core.js +21 -16
- package/dist/core/clipboard-text.d.ts +8 -0
- package/dist/core/clipboard-text.js +18 -0
- package/dist/core/command-manifests/registry.d.ts +1 -3
- package/dist/core/command-plugins/bundle.d.ts +29 -0
- package/dist/core/command-plugins/bundle.js +198 -0
- package/dist/core/command-plugins/discovery.d.ts +0 -8
- package/dist/core/command-plugins/discovery.js +2 -39
- package/dist/core/command-plugins/transport/http-fetch.d.ts +4 -30
- package/dist/core/command-plugins/transport/http-fetch.js +17 -95
- package/dist/core/command.d.ts +1 -13
- package/dist/core/command.js +1 -16
- package/dist/core/config.js +2 -1
- package/dist/core/keybindings/__tests__/resolve.test.js +4 -0
- package/dist/core/keybindings/attach-control.d.ts +3 -0
- package/dist/core/keybindings/attach-control.js +1 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +8 -0
- package/dist/core/runtime/broker-protocol.d.ts +13 -1
- package/dist/core/runtime/broker.js +213 -5
- package/dist/core/runtime/canvas-extensions.d.ts +3 -0
- package/dist/core/runtime/canvas-extensions.js +3 -0
- package/dist/core/runtime/front-door.js +4 -4
- package/dist/core/runtime/kickoff.d.ts +1 -5
- package/dist/core/runtime/kickoff.js +1 -5
- package/dist/core/runtime/naming.d.ts +7 -0
- package/dist/core/runtime/naming.js +5 -2
- package/dist/core/runtime/node-read.js +15 -0
- package/dist/core/runtime/package-health.d.ts +6 -21
- package/dist/core/runtime/package-health.js +12 -123
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +3 -3
- package/dist/core/runtime/tool-group-summary.d.ts +19 -0
- package/dist/core/runtime/tool-group-summary.js +117 -0
- package/dist/core/termrender/code-doc.d.ts +12 -0
- package/dist/core/termrender/code-doc.js +91 -0
- package/dist/core/termrender/display.d.ts +12 -0
- package/dist/core/termrender/display.js +19 -0
- package/dist/core/termrender/termrender.d.ts +73 -0
- package/dist/core/termrender/termrender.js +795 -0
- package/dist/core/termrender/version.d.ts +1 -0
- package/dist/core/termrender/version.js +1 -0
- package/dist/core/user-settings.d.ts +14 -0
- package/dist/core/user-settings.js +20 -0
- package/dist/daemon/crtrd.js +5 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +63 -0
- package/dist/pi-extensions/canvas-stophook.js +2 -2
- package/dist/pi-extensions/canvas-structured-output.js +52 -8
- package/dist/pi-extensions/summary-tool.d.ts +33 -0
- package/dist/pi-extensions/summary-tool.js +62 -0
- package/dist/shared/generated-context.d.ts +19 -4
- package/dist/shared/generated-context.js +98 -7
- package/dist/shared/tool-groups.d.ts +23 -0
- package/dist/shared/tool-groups.js +60 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +1 -0
- package/dist/web-client/assets/{index-U_NZ66VE.js → index-D7I3gHCL.js} +26 -19
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +3 -1
- package/runtime.lock.json +62 -3
- package/scripts/postinstall.mjs +14 -1
- package/dist/core/command-plugins/store.d.ts +0 -16
- package/dist/core/command-plugins/store.js +0 -64
- package/dist/core/runtime/fault-recovery-nudge.d.ts +0 -3
- package/dist/core/runtime/fault-recovery-nudge.js +0 -3
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync, openSync, closeSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import stringWidth from 'string-width';
|
|
6
|
+
import { TERMRENDER_VERSION } from './version.js';
|
|
7
|
+
// ── The sole org-wide termrender binding ─────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// termrender is crouter's sole org-wide renderer binding: a pure-Python tool
|
|
10
|
+
// pinned to TERMRENDER_VERSION and installed into a venv crouter owns. The binary is
|
|
11
|
+
// resolved by ABSOLUTE PATH inside that venv — never `$PATH` — so a user's
|
|
12
|
+
// own `pip install termrender` can never shadow or break the pin.
|
|
13
|
+
const RENDERER_CACHE_DIR = join(resolve(process.env.XDG_CACHE_HOME || join(homedir(), '.cache')), 'crouter', 'termrender', TERMRENDER_VERSION);
|
|
14
|
+
const VENV_DIR = join(RENDERER_CACHE_DIR, 'venv');
|
|
15
|
+
const VENV_BIN = join(VENV_DIR, 'bin/termrender');
|
|
16
|
+
const VENV_PYTHON = join(VENV_DIR, 'bin/python');
|
|
17
|
+
// Readiness marker written by the single authoritative provisioning transition
|
|
18
|
+
// after a verified install. It fingerprints the ACTUAL verified environment —
|
|
19
|
+
// launcher + interpreter (mtime, size, mode) and the interpreter's realpath —
|
|
20
|
+
// so steady-state validation is a handful of cheap fs stats (no subprocess),
|
|
21
|
+
// yet a stripped exec bit, a swapped venv Python, or a rewritten launcher all
|
|
22
|
+
// invalidate it. Deeper corruption an fs stat can't see (e.g. mangled
|
|
23
|
+
// site-packages under an unchanged launcher) is caught by the other half of
|
|
24
|
+
// the contract: any `ready` invocation that fails to RUN invalidates this
|
|
25
|
+
// marker (see invalidateRenderer), so the next process repairs. Together these
|
|
26
|
+
// remove the ~149ms `termrender -h` + `importlib.metadata` spawn tax from the
|
|
27
|
+
// steady path without letting a stale marker trust a broken renderer forever.
|
|
28
|
+
const VENV_STAMP = join(VENV_DIR, '.crtr-termrender-stamp.json');
|
|
29
|
+
// Provisioning lock — lives OUTSIDE .venv (which `uv venv --clear` wipes) so it
|
|
30
|
+
// survives a reinstall. The user cache stays writable when crouter is installed
|
|
31
|
+
// in a read-only runtime image, and serializes venv mutation + stamp publication
|
|
32
|
+
// across processes: a stamp can never certify a concurrently-changing venv.
|
|
33
|
+
const VENV_LOCK = join(RENDERER_CACHE_DIR, '.crtr-termrender.lock');
|
|
34
|
+
// A lock older than this is from a crashed process and may be stolen. Set
|
|
35
|
+
// comfortably above the worst-case held path (uv probe 5s + venv 60s + install
|
|
36
|
+
// 120s + re-verify ~10s ≈ 195s) so a slow-but-alive holder is never judged
|
|
37
|
+
// stale while it still holds.
|
|
38
|
+
const LOCK_STALE_MS = 300_000;
|
|
39
|
+
// Absolute cap on how long a waiter spins before giving up to plaintext for
|
|
40
|
+
// this session (the next launch retries) — a safety valve so a wedged holder
|
|
41
|
+
// can never hang a process, WITHOUT ever stealing a lock we can't prove stale.
|
|
42
|
+
const LOCK_GIVE_UP_MS = LOCK_STALE_MS + 60_000;
|
|
43
|
+
let rendererState = 'unchecked';
|
|
44
|
+
function isFp(x) {
|
|
45
|
+
const e = x;
|
|
46
|
+
return !!e && typeof e.mtimeMs === 'number' && typeof e.size === 'number' && typeof e.mode === 'number';
|
|
47
|
+
}
|
|
48
|
+
function fingerprint(path) {
|
|
49
|
+
try {
|
|
50
|
+
const s = statSync(path);
|
|
51
|
+
return { mtimeMs: s.mtimeMs, size: s.size, mode: s.mode };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function fpMatch(a, b) {
|
|
58
|
+
return !!a && a.mtimeMs === b.mtimeMs && a.size === b.size && a.mode === b.mode;
|
|
59
|
+
}
|
|
60
|
+
function readStamp() {
|
|
61
|
+
try {
|
|
62
|
+
const p = JSON.parse(readFileSync(VENV_STAMP, 'utf8'));
|
|
63
|
+
if (p && typeof p.version === 'string' && isFp(p.bin) && isFp(p.python) && typeof p.pythonRealpath === 'string') {
|
|
64
|
+
return p;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Publish the readiness marker for the state we just verified. Failure to
|
|
73
|
+
// persist is surfaced explicitly (not swallowed): the renderer works for THIS
|
|
74
|
+
// process, but every future launch re-verifies the slow way until the marker
|
|
75
|
+
// can be written — the operator should know why launches stay slow.
|
|
76
|
+
function publishStamp() {
|
|
77
|
+
const bin = fingerprint(VENV_BIN);
|
|
78
|
+
const python = fingerprint(VENV_PYTHON);
|
|
79
|
+
if (!bin || !python) {
|
|
80
|
+
process.stderr.write('[crtr] termrender stamp skipped: venv files vanished immediately after verify\n');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let pythonRealpath;
|
|
84
|
+
try {
|
|
85
|
+
pythonRealpath = realpathSync(VENV_PYTHON);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
pythonRealpath = VENV_PYTHON;
|
|
89
|
+
}
|
|
90
|
+
const stamp = { version: TERMRENDER_VERSION, bin, python, pythonRealpath };
|
|
91
|
+
// Atomic publish: write a temp then rename, so a crash mid-write can't leave
|
|
92
|
+
// a half-written stamp and a concurrent reader never observes a torn file.
|
|
93
|
+
const tmp = `${VENV_STAMP}.${process.pid}.tmp`;
|
|
94
|
+
try {
|
|
95
|
+
writeFileSync(tmp, JSON.stringify(stamp));
|
|
96
|
+
renameSync(tmp, VENV_STAMP);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
try {
|
|
100
|
+
unlinkSync(tmp);
|
|
101
|
+
}
|
|
102
|
+
catch { /* nothing to clean up */ }
|
|
103
|
+
process.stderr.write(`[crtr] termrender ready but stamp not persisted (${err instanceof Error ? err.message : String(err)}); ` +
|
|
104
|
+
'future launches will re-verify the slow way\n');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Invalidate the readiness marker when a supposedly-ready renderer misbehaves
|
|
108
|
+
// in a way that implicates the environment (spawn fault, or a `doc render`
|
|
109
|
+
// failure — render is best-effort by contract, so ANY failure means the tool,
|
|
110
|
+
// not the input, is broken; this catches site-packages corruption an fs stat
|
|
111
|
+
// can't see). Removes the on-disk marker so the next process repairs, and
|
|
112
|
+
// downgrades THIS process to plaintext to avoid retry thrash within the session.
|
|
113
|
+
function invalidateRenderer(reason) {
|
|
114
|
+
let removed = true;
|
|
115
|
+
try {
|
|
116
|
+
unlinkSync(VENV_STAMP);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
// ENOENT means it's already gone (still invalidated); any other error means
|
|
120
|
+
// the marker SURVIVES and will keep certifying — say so honestly rather
|
|
121
|
+
// than promising a repair that can't happen until the file is removable.
|
|
122
|
+
if (err.code !== 'ENOENT')
|
|
123
|
+
removed = false;
|
|
124
|
+
}
|
|
125
|
+
rendererState = 'unavailable';
|
|
126
|
+
if (removed) {
|
|
127
|
+
process.stderr.write(`[crtr] termrender invocation failed (${reason}); invalidated readiness, future launches will repair\n`);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
process.stderr.write(`[crtr] termrender invocation failed (${reason}) but its readiness marker could not be removed; ` +
|
|
131
|
+
`future launches may keep trusting a broken renderer until ${VENV_STAMP} is deleted\n`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// True when a spawn was killed by its own timeout rather than failing to run —
|
|
135
|
+
// usually a slow/large document, not a broken environment. Excluded from
|
|
136
|
+
// invalidation so a reliably-slow render can't oscillate a healthy renderer
|
|
137
|
+
// (invalidate → slow re-verify → re-stamp) every session.
|
|
138
|
+
function isTimeout(err) {
|
|
139
|
+
const e = err;
|
|
140
|
+
return e?.code === 'ETIMEDOUT' || (!!e?.killed && !!e?.signal);
|
|
141
|
+
}
|
|
142
|
+
// Cheap steady-state trust — all fs stats, no subprocess. The pinned launcher
|
|
143
|
+
// is present, executable, and byte-for-byte the one the stamp verified; the
|
|
144
|
+
// interpreter it targets is the same file at the same realpath. Any of these
|
|
145
|
+
// drifting (version bump, stripped exec bit, rewritten launcher, swapped venv
|
|
146
|
+
// Python) forces the authoritative re-provision transition.
|
|
147
|
+
function stampValid() {
|
|
148
|
+
const stamp = readStamp();
|
|
149
|
+
if (!stamp || stamp.version !== TERMRENDER_VERSION)
|
|
150
|
+
return false;
|
|
151
|
+
try {
|
|
152
|
+
accessSync(VENV_BIN, constants.X_OK);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (!fpMatch(fingerprint(VENV_BIN), stamp.bin))
|
|
158
|
+
return false;
|
|
159
|
+
if (!fpMatch(fingerprint(VENV_PYTHON), stamp.python))
|
|
160
|
+
return false;
|
|
161
|
+
try {
|
|
162
|
+
if (realpathSync(VENV_PYTHON) !== stamp.pythonRealpath)
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
function sleepSync(ms) {
|
|
171
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
172
|
+
}
|
|
173
|
+
function lockIsStale() {
|
|
174
|
+
try {
|
|
175
|
+
const at = JSON.parse(readFileSync(VENV_LOCK, 'utf8')).at;
|
|
176
|
+
if (typeof at === 'number')
|
|
177
|
+
return Date.now() - at > LOCK_STALE_MS;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Unreadable/malformed lock — fall through to mtime.
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return Date.now() - statSync(VENV_LOCK).mtimeMs > LOCK_STALE_MS;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return true; // vanished — the retry loop re-acquires
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Run `provision` while holding the exclusive provisioning lock. If another
|
|
190
|
+
// live process holds it, wait for that process to publish (re-checking the
|
|
191
|
+
// stamp) rather than mutating the venv concurrently. Stale locks are stolen.
|
|
192
|
+
// Steal a lock judged stale by renaming it to a unique name first: rename is
|
|
193
|
+
// atomic, so if two waiters race only the one whose rename succeeds owns (and
|
|
194
|
+
// deletes) it — the loser gets ENOENT and re-loops. This can never delete a
|
|
195
|
+
// lock a peer has freshly re-acquired (that peer holds a DIFFERENT inode at the
|
|
196
|
+
// same path; our rename of the old name either already happened or fails).
|
|
197
|
+
function stealStaleLock() {
|
|
198
|
+
const tmp = `${VENV_LOCK}.steal.${process.pid}.${Date.now()}`;
|
|
199
|
+
try {
|
|
200
|
+
renameSync(VENV_LOCK, tmp);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return; // lost the steal race or already gone — caller re-loops
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
unlinkSync(tmp);
|
|
207
|
+
}
|
|
208
|
+
catch { /* best effort */ }
|
|
209
|
+
}
|
|
210
|
+
function withProvisionLock(provision) {
|
|
211
|
+
try {
|
|
212
|
+
mkdirSync(RENDERER_CACHE_DIR, { recursive: true });
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
rendererState = 'unavailable';
|
|
216
|
+
process.stderr.write(`[crtr] termrender unavailable — renderer cache cannot be created (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const giveUpAt = Date.now() + LOCK_GIVE_UP_MS;
|
|
220
|
+
for (;;) {
|
|
221
|
+
let fd;
|
|
222
|
+
try {
|
|
223
|
+
fd = openSync(VENV_LOCK, 'wx'); // O_CREAT | O_EXCL — atomic acquire
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
if (err.code !== 'EEXIST') {
|
|
227
|
+
rendererState = 'unavailable';
|
|
228
|
+
process.stderr.write(`[crtr] termrender unavailable — renderer cache cannot be locked (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (lockIsStale()) {
|
|
232
|
+
stealStaleLock();
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
// A live process is provisioning — give it a chance, then adopt its result.
|
|
236
|
+
// Never steal a lock we can't prove stale; if we wait too long, give up to
|
|
237
|
+
// plaintext this session rather than break mutual exclusion.
|
|
238
|
+
if (Date.now() > giveUpAt) {
|
|
239
|
+
rendererState = 'unavailable';
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
sleepSync(200);
|
|
243
|
+
if (stampValid()) {
|
|
244
|
+
rendererState = 'ready';
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
251
|
+
}
|
|
252
|
+
catch { /* lock held regardless of whether the marker body wrote */ }
|
|
253
|
+
try {
|
|
254
|
+
provision();
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
try {
|
|
258
|
+
closeSync(fd);
|
|
259
|
+
}
|
|
260
|
+
catch { /* already closed */ }
|
|
261
|
+
try {
|
|
262
|
+
unlinkSync(VENV_LOCK);
|
|
263
|
+
}
|
|
264
|
+
catch { /* stolen as stale by another process */ }
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function binaryOk() {
|
|
270
|
+
if (!existsSync(VENV_BIN))
|
|
271
|
+
return false;
|
|
272
|
+
try {
|
|
273
|
+
// v2 contract: no --version flag; use -h (exit 0) as a liveness check.
|
|
274
|
+
execFileSync(VENV_BIN, ['-h'], {
|
|
275
|
+
encoding: 'utf8',
|
|
276
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
277
|
+
timeout: 5000,
|
|
278
|
+
});
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Returns the termrender version installed in the managed venv (via
|
|
286
|
+
// importlib.metadata), or null if the venv is missing/broken or termrender is
|
|
287
|
+
// not installed. Used by ensureRenderer() to detect drift from the pin and
|
|
288
|
+
// trigger a reinstall — otherwise a venv provisioned at an older pin sticks
|
|
289
|
+
// forever (binaryOk passes for any working binary, regardless of version).
|
|
290
|
+
function installedVersion() {
|
|
291
|
+
if (!existsSync(VENV_PYTHON))
|
|
292
|
+
return null;
|
|
293
|
+
try {
|
|
294
|
+
const out = execFileSync(VENV_PYTHON, ['-c', 'import importlib.metadata as m; print(m.version("termrender"))'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
|
|
295
|
+
return out.trim() || null;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function uvAvailable() {
|
|
302
|
+
try {
|
|
303
|
+
execFileSync('uv', ['--version'], { stdio: 'pipe', timeout: 5000 });
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Memoized, self-healing. Guarantees at most one authoritative renderer
|
|
312
|
+
* lifecycle per process: trust a valid stamp outright (zero subprocess spawns),
|
|
313
|
+
* else run ONE provision/verify/publish transition under the exclusive lock.
|
|
314
|
+
* There is no permanent legacy-verifier fallback — the spawn-based verify
|
|
315
|
+
* (`binaryOk` + `installedVersion`) is a step INSIDE that single transition,
|
|
316
|
+
* always followed by publishing the stamp, never a lasting alternate path.
|
|
317
|
+
*
|
|
318
|
+
* Single degradation path: `uv` absent → one stderr remediation line + plaintext.
|
|
319
|
+
* win32 → plaintext (no renderer).
|
|
320
|
+
*
|
|
321
|
+
* Invoked at postinstall AND lazily on the first render/check/display call,
|
|
322
|
+
* so `npm ci --ignore-scripts` consumers still self-heal on first use.
|
|
323
|
+
*/
|
|
324
|
+
export function ensureRenderer() {
|
|
325
|
+
if (rendererState !== 'unchecked')
|
|
326
|
+
return;
|
|
327
|
+
if (process.platform === 'win32') {
|
|
328
|
+
rendererState = 'unavailable';
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Steady state: trust the stamp — zero subprocess spawns.
|
|
332
|
+
if (stampValid()) {
|
|
333
|
+
rendererState = 'ready';
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// No/invalid stamp → the single authoritative transition, serialized so no
|
|
337
|
+
// two processes mutate the venv (or certify it) concurrently.
|
|
338
|
+
provisionAndPublish();
|
|
339
|
+
}
|
|
340
|
+
// The one invalid-stamp transition. Under the exclusive lock: adopt a stamp a
|
|
341
|
+
// racing process just published; else verify the current venv (a healthy venv
|
|
342
|
+
// with no/stale stamp — an interrupted publish, or a peer
|
|
343
|
+
// that finished the venv but not the stamp — needs only re-verification, not a
|
|
344
|
+
// reinstall); reinstall via `uv` only when genuinely missing/drifted; then
|
|
345
|
+
// verify and publish. Every success path ends by publishing the stamp.
|
|
346
|
+
function provisionAndPublish() {
|
|
347
|
+
withProvisionLock(() => {
|
|
348
|
+
// A peer may have published between our unlocked stampValid() in
|
|
349
|
+
// ensureRenderer and our acquiring the lock here — adopt it, don't reinstall.
|
|
350
|
+
if (stampValid()) {
|
|
351
|
+
rendererState = 'ready';
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
let verified = binaryOk() && installedVersion() === TERMRENDER_VERSION;
|
|
355
|
+
if (!verified) {
|
|
356
|
+
if (!uvAvailable()) {
|
|
357
|
+
process.stderr.write('[crtr] termrender unavailable — install uv to enable rich rendering:\n' +
|
|
358
|
+
' curl -LsSf https://astral.sh/uv/install.sh | sh\n');
|
|
359
|
+
rendererState = 'unavailable';
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
// (Re)create the venv whenever the interpreter is missing — covers both
|
|
364
|
+
// "directory absent" and "directory present but bin/python stripped"
|
|
365
|
+
// (seen when pnpm rebuilds/dedupes node_modules or uv rotates its
|
|
366
|
+
// managed Python store). `--clear` wipes any partial state rather than
|
|
367
|
+
// refusing on the existing dir. Intact interpreter → reuse the venv.
|
|
368
|
+
if (!existsSync(VENV_PYTHON)) {
|
|
369
|
+
execFileSync('uv', ['venv', '--clear', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
|
|
370
|
+
}
|
|
371
|
+
// `--reinstall` forces uv to rebuild the package and rewrite its entry
|
|
372
|
+
// point even when the pinned version already appears satisfied — so this
|
|
373
|
+
// path actually REPAIRS a corrupt-but-present install (the case that
|
|
374
|
+
// drove us here via invalidation), not just a clean version drift.
|
|
375
|
+
execFileSync('uv', ['pip', 'install', '--reinstall', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
process.stderr.write(`[crtr] termrender install failed (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
379
|
+
rendererState = 'unavailable';
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
verified = binaryOk() && installedVersion() === TERMRENDER_VERSION;
|
|
383
|
+
}
|
|
384
|
+
if (!verified) {
|
|
385
|
+
rendererState = 'unavailable';
|
|
386
|
+
process.stderr.write('[crtr] termrender install completed but health check failed; using plaintext fallback\n');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
publishStamp();
|
|
390
|
+
rendererState = 'ready';
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
|
|
394
|
+
export function isRendererReady() {
|
|
395
|
+
if (rendererState === 'ready')
|
|
396
|
+
return true;
|
|
397
|
+
if (rendererState === 'unavailable')
|
|
398
|
+
return false;
|
|
399
|
+
return process.platform !== 'win32' && stampValid();
|
|
400
|
+
}
|
|
401
|
+
// ── Plaintext fallback helpers (kept here so this is the only termrender site) ─
|
|
402
|
+
const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
|
|
403
|
+
function sanitize(text) {
|
|
404
|
+
if (typeof text !== 'string')
|
|
405
|
+
return '';
|
|
406
|
+
return text.replace(CONTROL_CHARS_RE, '');
|
|
407
|
+
}
|
|
408
|
+
function sliceByWidth(s, maxWidth) {
|
|
409
|
+
let w = 0;
|
|
410
|
+
let out = '';
|
|
411
|
+
for (const ch of s) {
|
|
412
|
+
const cw = stringWidth(ch);
|
|
413
|
+
if (w + cw > maxWidth)
|
|
414
|
+
break;
|
|
415
|
+
out += ch;
|
|
416
|
+
w += cw;
|
|
417
|
+
}
|
|
418
|
+
if (out === '' && s.length > 0)
|
|
419
|
+
out = [...s][0];
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
function wrap(text, maxWidth) {
|
|
423
|
+
if (maxWidth < 1)
|
|
424
|
+
return [text];
|
|
425
|
+
const out = [];
|
|
426
|
+
const paragraphs = text.split('\n');
|
|
427
|
+
for (let p = 0; p < paragraphs.length; p++) {
|
|
428
|
+
const para = paragraphs[p];
|
|
429
|
+
if (para === '') {
|
|
430
|
+
out.push('');
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const words = para.split(/[ \t]+/).filter(Boolean);
|
|
434
|
+
let current = '';
|
|
435
|
+
for (let word of words) {
|
|
436
|
+
while (stringWidth(word) > maxWidth) {
|
|
437
|
+
if (current) {
|
|
438
|
+
out.push(current);
|
|
439
|
+
current = '';
|
|
440
|
+
}
|
|
441
|
+
const piece = sliceByWidth(word, maxWidth);
|
|
442
|
+
out.push(piece);
|
|
443
|
+
word = word.slice(piece.length);
|
|
444
|
+
}
|
|
445
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
446
|
+
if (stringWidth(candidate) <= maxWidth) {
|
|
447
|
+
current = candidate;
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
if (current)
|
|
451
|
+
out.push(current);
|
|
452
|
+
current = word;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (current)
|
|
456
|
+
out.push(current);
|
|
457
|
+
}
|
|
458
|
+
return out.length > 0 ? out : [''];
|
|
459
|
+
}
|
|
460
|
+
// ── Render surface ───────────────────────────────────────────────────────────
|
|
461
|
+
const _bodyCache = new Map();
|
|
462
|
+
/** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
|
|
463
|
+
export function renderMarkdown(md, width) {
|
|
464
|
+
const key = `${md}\0${width}`;
|
|
465
|
+
const cached = _bodyCache.get(key);
|
|
466
|
+
if (cached)
|
|
467
|
+
return cached;
|
|
468
|
+
ensureRenderer();
|
|
469
|
+
if (rendererState === 'ready') {
|
|
470
|
+
try {
|
|
471
|
+
const out = execFileSync(VENV_BIN, ['doc', 'render', '--width', String(width), '--color', 'on'], {
|
|
472
|
+
input: md,
|
|
473
|
+
encoding: 'utf-8',
|
|
474
|
+
timeout: 5000,
|
|
475
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
476
|
+
});
|
|
477
|
+
const lines = out.split('\n');
|
|
478
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
479
|
+
lines.pop();
|
|
480
|
+
_bodyCache.set(key, lines);
|
|
481
|
+
return lines;
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
// `doc render` is best-effort, so a non-timeout failure implicates the
|
|
485
|
+
// tool, not the markdown: invalidate so the next process repairs. A
|
|
486
|
+
// timeout is a slow/large doc, not corruption — just fall to plaintext.
|
|
487
|
+
if (!isTimeout(err))
|
|
488
|
+
invalidateRenderer('doc render');
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const fallback = wrap(sanitize(md), width);
|
|
492
|
+
_bodyCache.set(key, fallback);
|
|
493
|
+
return fallback;
|
|
494
|
+
}
|
|
495
|
+
/** Block types allowed to use the full pane width instead of the prose cap:
|
|
496
|
+
* a diagram is a picture, not prose, so a readability column limit only
|
|
497
|
+
* shrinks it below what the pane could show. */
|
|
498
|
+
const WIDE_BLOCK_TYPES = new Set(['mermaid']);
|
|
499
|
+
/** Validate + normalize `doc render --line-map` JSON into a RenderedDoc.
|
|
500
|
+
* Null block bounds (a scanner edge case) are filled from neighbors and
|
|
501
|
+
* clamped into the source range; an empty block list gets one whole-doc
|
|
502
|
+
* block so callers can always anchor. Malformed shape → null (tool fault). */
|
|
503
|
+
function parseRenderedDoc(out, source) {
|
|
504
|
+
let parsed;
|
|
505
|
+
try {
|
|
506
|
+
parsed = JSON.parse(out);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const p = parsed;
|
|
512
|
+
if (!Array.isArray(p.lines) || !Array.isArray(p.rows) || !Array.isArray(p.spans) || !Array.isArray(p.blocks))
|
|
513
|
+
return null;
|
|
514
|
+
if (p.rows.length !== p.lines.length || p.spans.length !== p.lines.length)
|
|
515
|
+
return null;
|
|
516
|
+
if (!p.lines.every((l) => typeof l === 'string'))
|
|
517
|
+
return null;
|
|
518
|
+
const blockCount = p.blocks.length;
|
|
519
|
+
if (!p.rows.every((r) => r === null || (typeof r === 'number' && Number.isInteger(r) && r >= 0 && r < blockCount)))
|
|
520
|
+
return null;
|
|
521
|
+
const totalSource = Math.max(1, source.split('\n').length);
|
|
522
|
+
const blocks = [];
|
|
523
|
+
let prevEnd = 0;
|
|
524
|
+
for (const raw of p.blocks) {
|
|
525
|
+
if (typeof raw !== 'object' || raw === null)
|
|
526
|
+
return null;
|
|
527
|
+
// The block type is what lets callers render diagrams differently from
|
|
528
|
+
// prose; a map without it cannot drive block-aware rendering at all.
|
|
529
|
+
if (typeof raw.type !== 'string' || raw.type === '')
|
|
530
|
+
return null;
|
|
531
|
+
// Bounds are integers or null — a fractional bound would otherwise flow
|
|
532
|
+
// into a recorded comment's line/endLine instead of tripping tool-fault.
|
|
533
|
+
if (raw.start != null && !Number.isInteger(raw.start))
|
|
534
|
+
return null;
|
|
535
|
+
if (raw.end != null && !Number.isInteger(raw.end))
|
|
536
|
+
return null;
|
|
537
|
+
const start = typeof raw.start === 'number' ? raw.start : prevEnd + 1;
|
|
538
|
+
const end = typeof raw.end === 'number' ? Math.max(raw.end, start) : start;
|
|
539
|
+
const s = Math.max(1, Math.min(start, totalSource));
|
|
540
|
+
const e = Math.max(s, Math.min(end, totalSource));
|
|
541
|
+
blocks.push({ type: raw.type, start: s, end: e });
|
|
542
|
+
prevEnd = e;
|
|
543
|
+
}
|
|
544
|
+
if (blocks.length === 0)
|
|
545
|
+
blocks.push({ type: 'paragraph', start: 1, end: totalSource });
|
|
546
|
+
const spans = [];
|
|
547
|
+
for (let r = 0; r < p.spans.length; r++) {
|
|
548
|
+
const raw = p.spans[r];
|
|
549
|
+
if (raw === null) {
|
|
550
|
+
spans.push(null);
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
// Same strictness as rows: a span is [start, end], 1-indexed inclusive
|
|
554
|
+
// integers in order, inside the source — these values flow straight into
|
|
555
|
+
// a recorded comment's line/endLine, so an out-of-range endpoint is tool
|
|
556
|
+
// fault to reject, never something to clamp into the wrong line.
|
|
557
|
+
if (!Array.isArray(raw) || raw.length !== 2)
|
|
558
|
+
return null;
|
|
559
|
+
const [s, e] = raw;
|
|
560
|
+
if (!Number.isInteger(s) || !Number.isInteger(e))
|
|
561
|
+
return null;
|
|
562
|
+
if (s < 1 || e < s || e > totalSource)
|
|
563
|
+
return null;
|
|
564
|
+
// Relational contract: a separator row (rows[r] === null) carries no
|
|
565
|
+
// span, and a leaf span stays inside its owning block's source range. A
|
|
566
|
+
// map that disagrees with its own rows/blocks would let a comment on a
|
|
567
|
+
// visible row record against unrelated source text.
|
|
568
|
+
const rowBlock = p.rows[r];
|
|
569
|
+
if (rowBlock === null)
|
|
570
|
+
return null;
|
|
571
|
+
const owner = blocks[rowBlock];
|
|
572
|
+
if (s < owner.start || e > owner.end)
|
|
573
|
+
return null;
|
|
574
|
+
spans.push([s, e]);
|
|
575
|
+
}
|
|
576
|
+
return { lines: p.lines, rows: p.rows, spans, blocks };
|
|
577
|
+
}
|
|
578
|
+
/** Renderer-free mapped render: group consecutive non-blank source lines into
|
|
579
|
+
* paragraph blocks and word-wrap each — the same shape as the termrender map,
|
|
580
|
+
* with degraded (plaintext) rendering. Each source line is wrapped on its own
|
|
581
|
+
* and spans exactly itself, so leaf anchoring degrades to true line-by-line. */
|
|
582
|
+
function fallbackDocWithMap(md, width) {
|
|
583
|
+
const src = sanitize(md).split('\n');
|
|
584
|
+
const blocks = [];
|
|
585
|
+
let openAt = null;
|
|
586
|
+
for (let i = 0; i < src.length; i++) {
|
|
587
|
+
if (src[i].trim() !== '') {
|
|
588
|
+
if (openAt === null)
|
|
589
|
+
openAt = i;
|
|
590
|
+
}
|
|
591
|
+
else if (openAt !== null) {
|
|
592
|
+
blocks.push({ type: 'paragraph', start: openAt + 1, end: i });
|
|
593
|
+
openAt = null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (openAt !== null)
|
|
597
|
+
blocks.push({ type: 'paragraph', start: openAt + 1, end: src.length });
|
|
598
|
+
if (blocks.length === 0)
|
|
599
|
+
blocks.push({ type: 'paragraph', start: 1, end: Math.max(1, src.length) });
|
|
600
|
+
const lines = [];
|
|
601
|
+
const rows = [];
|
|
602
|
+
const spans = [];
|
|
603
|
+
blocks.forEach((b, bi) => {
|
|
604
|
+
if (bi > 0) {
|
|
605
|
+
lines.push('');
|
|
606
|
+
rows.push(null);
|
|
607
|
+
spans.push(null);
|
|
608
|
+
}
|
|
609
|
+
// wrap() treats each '\n'-separated line as its own paragraph, so wrapping
|
|
610
|
+
// line-by-line emits the exact rows the joined-block wrap produced.
|
|
611
|
+
for (let ln = b.start; ln <= b.end; ln++) {
|
|
612
|
+
for (const l of wrap(src[ln - 1] ?? '', width)) {
|
|
613
|
+
lines.push(l);
|
|
614
|
+
rows.push(bi);
|
|
615
|
+
spans.push([ln, ln]);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
return { lines, rows, spans, blocks };
|
|
620
|
+
}
|
|
621
|
+
const _mapCache = new Map();
|
|
622
|
+
/** Render markdown with a row→source-line map via the pinned binary
|
|
623
|
+
* (`doc render --line-map`); plaintext paragraph-block fallback. */
|
|
624
|
+
export function renderMarkdownWithMap(md, width) {
|
|
625
|
+
const key = `${md}\0${width}`;
|
|
626
|
+
const cached = _mapCache.get(key);
|
|
627
|
+
if (cached)
|
|
628
|
+
return cached;
|
|
629
|
+
ensureRenderer();
|
|
630
|
+
if (rendererState === 'ready') {
|
|
631
|
+
try {
|
|
632
|
+
const out = execFileSync(VENV_BIN, ['doc', 'render', '--width', String(width), '--color', 'on', '--line-map'], {
|
|
633
|
+
input: md,
|
|
634
|
+
encoding: 'utf-8',
|
|
635
|
+
timeout: 5000,
|
|
636
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
637
|
+
// JSON-escaped ANSI inflates well past execFileSync's 1MB default.
|
|
638
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
639
|
+
});
|
|
640
|
+
const doc = parseRenderedDoc(out.trim(), md);
|
|
641
|
+
if (doc !== null) {
|
|
642
|
+
_mapCache.set(key, doc);
|
|
643
|
+
return doc;
|
|
644
|
+
}
|
|
645
|
+
// A working renderer never emits a malformed map — tool fault.
|
|
646
|
+
invalidateRenderer('doc render --line-map (malformed output)');
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
// Same contract as renderMarkdown: non-timeout failure implicates the
|
|
650
|
+
// tool; a timeout is a slow/large doc — just fall to the local map.
|
|
651
|
+
if (!isTimeout(err))
|
|
652
|
+
invalidateRenderer('doc render --line-map');
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// The fallback is cheap to recompute and deliberately NOT cached: a
|
|
656
|
+
// transient failure (e.g. a timeout) must not pin degraded block anchoring
|
|
657
|
+
// for the rest of the session when a later re-render could succeed.
|
|
658
|
+
return fallbackDocWithMap(md, width);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The shared block-aware mapped render: prose blocks keep the readability cap
|
|
662
|
+
* (`proseWidth`), while diagram blocks are re-rendered at the pane's full
|
|
663
|
+
* `paneWidth` and spliced back in by block index. Both surfaces (terminal
|
|
664
|
+
* review and the ask deck) go through this, so "which blocks may be wide" is
|
|
665
|
+
* decided once, from the renderer's own block types, never by heuristics on
|
|
666
|
+
* the emitted rows.
|
|
667
|
+
*
|
|
668
|
+
* Row order, block list and source spans are the narrow render's; only the
|
|
669
|
+
* rows OF a wide block are replaced, so anchoring stays source-based and
|
|
670
|
+
* unchanged.
|
|
671
|
+
*/
|
|
672
|
+
export function renderMarkdownBlockAware(md, proseWidth, paneWidth) {
|
|
673
|
+
const base = renderMarkdownWithMap(md, proseWidth);
|
|
674
|
+
if (paneWidth <= proseWidth)
|
|
675
|
+
return base;
|
|
676
|
+
if (!base.blocks.some((b) => WIDE_BLOCK_TYPES.has(b.type)))
|
|
677
|
+
return base;
|
|
678
|
+
const wide = renderMarkdownWithMap(md, paneWidth);
|
|
679
|
+
// Same source, so the two renders describe the same top-level blocks. A
|
|
680
|
+
// disagreement means one of the two maps is degraded (renderer failure mid
|
|
681
|
+
// session) — render the narrow one rather than splice mismatched rows.
|
|
682
|
+
if (wide.blocks.length !== base.blocks.length)
|
|
683
|
+
return base;
|
|
684
|
+
const lines = [];
|
|
685
|
+
const rows = [];
|
|
686
|
+
const spans = [];
|
|
687
|
+
for (let r = 0; r < base.lines.length; r++) {
|
|
688
|
+
const bi = base.rows[r] ?? null;
|
|
689
|
+
if (bi === null || !WIDE_BLOCK_TYPES.has(base.blocks[bi].type)) {
|
|
690
|
+
lines.push(base.lines[r]);
|
|
691
|
+
rows.push(bi);
|
|
692
|
+
spans.push(base.spans[r] ?? null);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
// First row of this block's run: emit the wide render's rows for the same
|
|
696
|
+
// block, then skip the narrow run (a block's rows are contiguous).
|
|
697
|
+
if (r === 0 || base.rows[r - 1] !== bi) {
|
|
698
|
+
for (let q = 0; q < wide.lines.length; q++) {
|
|
699
|
+
if (wide.rows[q] !== bi)
|
|
700
|
+
continue;
|
|
701
|
+
lines.push(wide.lines[q]);
|
|
702
|
+
rows.push(bi);
|
|
703
|
+
spans.push(wide.spans[q] ?? null);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return { lines, rows, spans, blocks: base.blocks };
|
|
708
|
+
}
|
|
709
|
+
/** Block-aware render as plain rows, for surfaces that do not anchor. */
|
|
710
|
+
export function renderMarkdownBlockAwareLines(md, proseWidth, paneWidth) {
|
|
711
|
+
return renderMarkdownBlockAware(md, proseWidth, paneWidth).lines;
|
|
712
|
+
}
|
|
713
|
+
/** Validate markdown via `termrender doc check`. */
|
|
714
|
+
export function checkMarkdown(md) {
|
|
715
|
+
ensureRenderer();
|
|
716
|
+
// Renderer unavailable → don't block validation; the body just renders as
|
|
717
|
+
// plaintext later. Bricking deck validation here would be the wrong default.
|
|
718
|
+
if (rendererState !== 'ready')
|
|
719
|
+
return { ok: true };
|
|
720
|
+
const result = spawnSync(VENV_BIN, ['doc', 'check'], {
|
|
721
|
+
input: md,
|
|
722
|
+
encoding: 'utf-8',
|
|
723
|
+
timeout: 5000,
|
|
724
|
+
});
|
|
725
|
+
if (result.error) {
|
|
726
|
+
// A timeout (slow doc) is not an environment fault; anything else is a
|
|
727
|
+
// spawn fault → invalidate so the next process repairs.
|
|
728
|
+
const timedOut = result.error.code === 'ETIMEDOUT' || result.signal === 'SIGTERM';
|
|
729
|
+
if (!timedOut)
|
|
730
|
+
invalidateRenderer('doc check');
|
|
731
|
+
return { ok: false, error: `termrender: invocation failed: ${result.error.message}` };
|
|
732
|
+
}
|
|
733
|
+
let parsed = null;
|
|
734
|
+
const rawStdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
735
|
+
if (rawStdout) {
|
|
736
|
+
try {
|
|
737
|
+
parsed = JSON.parse(rawStdout.trim());
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// stdout not parseable — fall through to exit-code handling
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (parsed !== null) {
|
|
744
|
+
if (parsed.ok)
|
|
745
|
+
return { ok: true };
|
|
746
|
+
const first = Array.isArray(parsed.errors) ? parsed.errors[0] : undefined;
|
|
747
|
+
const msg = (first && typeof first.message === 'string' && first.message) ? first.message : 'invalid markdown';
|
|
748
|
+
return { ok: false, error: `termrender: ${msg}` };
|
|
749
|
+
}
|
|
750
|
+
// exit code 2 = invalid per contract; any non-zero is an error
|
|
751
|
+
if (result.status !== 0) {
|
|
752
|
+
return { ok: false, error: `termrender: doc check exited ${result.status}` };
|
|
753
|
+
}
|
|
754
|
+
return { ok: true };
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Spawn termrender into a live tmux pane. The pane-budget policy (whether to
|
|
758
|
+
* split vs open a new window) is decided by the caller (`src/surfaces/
|
|
759
|
+
* display.ts`); this is the thin managed-binary spawn it delegates to.
|
|
760
|
+
*/
|
|
761
|
+
export function displayInPane(path, opts = {}) {
|
|
762
|
+
ensureRenderer();
|
|
763
|
+
if (rendererState !== 'ready')
|
|
764
|
+
return {};
|
|
765
|
+
// Always watch: a displayed pane is a live view of the file by definition.
|
|
766
|
+
const args = ['pane', 'open', path, '--watch'];
|
|
767
|
+
args.push('--window', opts.newWindow ? 'new' : 'split');
|
|
768
|
+
const result = spawnSync(VENV_BIN, args, {
|
|
769
|
+
encoding: 'utf-8',
|
|
770
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
771
|
+
});
|
|
772
|
+
// A spawn fault (binary broke) invalidates readiness; a non-zero exit (e.g.
|
|
773
|
+
// tmux refused, bad path) is not an environment fault and must not.
|
|
774
|
+
if (result.error) {
|
|
775
|
+
invalidateRenderer('pane open');
|
|
776
|
+
return {};
|
|
777
|
+
}
|
|
778
|
+
if (result.status !== 0)
|
|
779
|
+
return {};
|
|
780
|
+
// `encoding: 'utf-8'` makes spawnSync return stdout as a string.
|
|
781
|
+
const rawStdout = result.stdout;
|
|
782
|
+
if (!rawStdout)
|
|
783
|
+
return {};
|
|
784
|
+
let parsed = null;
|
|
785
|
+
try {
|
|
786
|
+
parsed = JSON.parse(rawStdout.trim());
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return {};
|
|
790
|
+
}
|
|
791
|
+
if (parsed && typeof parsed.pane_id === 'string' && parsed.pane_id) {
|
|
792
|
+
return { paneId: parsed.pane_id };
|
|
793
|
+
}
|
|
794
|
+
return {};
|
|
795
|
+
}
|