@zhuxixi/pi-agent-board 0.7.0 → 0.9.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/CHANGELOG.md +46 -0
- package/README.md +16 -12
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/RELEASE.md +148 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
- package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
- package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
- package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
- package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
- package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
- package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
- package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
- package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
- package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
- package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
- package/package.json +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +633 -31
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/release.mjs +446 -0
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +62 -0
- package/src/core/launch-options.mjs +37 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +809 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-query-sequences.mjs +132 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +61 -41
- package/src/ui/pty-attach.ts +318 -72
- package/src/core/pty-input.mjs +0 -47
package/src/ui/pty-attach.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
|
5
5
|
import { createConnection, type Socket } from "node:net";
|
|
6
6
|
import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
7
7
|
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
-
import { isProbablyEmptyPiInputLine, isProbablyPiInputLine, resolveEditorEmpty } from "../core/pty-input.mjs";
|
|
9
8
|
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
10
|
-
import { createAttachOutputRenderScheduler, detectCursorDesync, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
9
|
+
import { createAttachOutputRenderScheduler, detectCursorDesync, isPtyCursorHidden, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
11
10
|
import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
|
|
12
11
|
import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
|
|
13
12
|
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
13
|
+
import { createTerminalAttachClient } from "../core/terminal-attach-client.mjs";
|
|
14
|
+
import { extractOscQuerySequences, toColorSchemeReport } from "../core/terminal-query-sequences.mjs";
|
|
14
15
|
import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
|
|
15
16
|
|
|
16
17
|
export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };
|
|
@@ -40,6 +41,13 @@ const LOADING_TICK_MS = 120;
|
|
|
40
41
|
const ATTACH_SETTLE_MS = 250;
|
|
41
42
|
/** Hard cap on the attach transition so a silent session can't stall the banner. */
|
|
42
43
|
const ATTACH_HARD_TIMEOUT_MS = 2500;
|
|
44
|
+
/** Protocol-race guard for legacy jiggle arming (issue #91 phase 4): the
|
|
45
|
+
* undecided window must not pulse the child before the snapshot probe
|
|
46
|
+
* resolves — a resize-free attach is the protocol-mode contract. Invisible
|
|
47
|
+
* for legacy (the probe timeout decides at 1500ms ≫ 100ms) and the protocol
|
|
48
|
+
* answer (~ms) reliably beats it; a pathologically late snapshot merely costs
|
|
49
|
+
* one harmless shrink pulse that the protocol mode event unwinds. */
|
|
50
|
+
const LEGACY_JIGGLE_ARM_DELAY_MS = 100;
|
|
43
51
|
/** Give ordered detach packets time to flush before using destroy as a fallback. */
|
|
44
52
|
const GRACEFUL_SOCKET_CLOSE_MS = 1000;
|
|
45
53
|
/** Desync detection window: how long output must stay silent before a
|
|
@@ -66,6 +74,9 @@ const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
|
|
|
66
74
|
interface XtermLike {
|
|
67
75
|
write(data: string, cb?: () => void): void;
|
|
68
76
|
resize(cols: number, rows: number): void;
|
|
77
|
+
// Full buffer wipe (@xterm/headless Terminal.reset): the attach client's
|
|
78
|
+
// "empty/resnapshot → UI resets its buffer" contract (phase 4, F3).
|
|
79
|
+
reset(): void;
|
|
69
80
|
buffer: {
|
|
70
81
|
active: {
|
|
71
82
|
baseY: number;
|
|
@@ -77,6 +88,7 @@ interface XtermLike {
|
|
|
77
88
|
};
|
|
78
89
|
};
|
|
79
90
|
_core?: {
|
|
91
|
+
coreService?: { isCursorHidden?: boolean };
|
|
80
92
|
_oscLinkService?: {
|
|
81
93
|
getLinkData?: (id: number) => { uri?: string } | undefined;
|
|
82
94
|
_dataByLinkId?: Map<number, { data?: { uri?: string } }>;
|
|
@@ -142,8 +154,24 @@ export class PtyAttachComponent implements Component {
|
|
|
142
154
|
},
|
|
143
155
|
clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
|
|
144
156
|
});
|
|
157
|
+
// Snapshot+subscribe attach client (issue #91 phase 4, D2). Created ONCE —
|
|
158
|
+
// reconnects re-point send at the CURRENT socket (this.send routes through
|
|
159
|
+
// this.socket) and resume from the applied cursor; wiring shape per the
|
|
160
|
+
// verified reference in test/terminal-snapshot.integration.test.mjs
|
|
161
|
+
// (wireClient). AGENT_BOARD_TERMINAL_SNAPSHOT=0 forces the legacy path
|
|
162
|
+
// (escape hatch + deterministic legacy tests).
|
|
163
|
+
private readonly attachClient = createTerminalAttachClient({
|
|
164
|
+
send: (msg) => this.send(msg),
|
|
165
|
+
emit: (event, payload) => this.handleAttachEvent(event, payload),
|
|
166
|
+
});
|
|
167
|
+
/** Decided attach path: "undecided" until the probe resolves (snapshot
|
|
168
|
+
* begin → protocol; probe timeout / version mismatch → legacy). Gates
|
|
169
|
+
* jiggle arming and probe-vs-cursor reconnects. */
|
|
170
|
+
private attachMode: "undecided" | "protocol" | "legacy" = "undecided";
|
|
171
|
+
private jiggleStartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
145
172
|
private osc52Carry = "";
|
|
146
173
|
private passthroughCarry = "";
|
|
174
|
+
private oscQueryCarry = "";
|
|
147
175
|
private readonly connectStartedAt = Date.now();
|
|
148
176
|
// Set once the first socket connect succeeds; drives the reconnect give-up
|
|
149
177
|
// policy (short window after a live session dies, long window during cold
|
|
@@ -175,7 +203,8 @@ export class PtyAttachComponent implements Component {
|
|
|
175
203
|
private lastClickPoint: MousePoint | null = null;
|
|
176
204
|
private lastClickAt = 0;
|
|
177
205
|
/** Authoritative editor emptiness pushed by the child Pi extension via the
|
|
178
|
-
* control socket (issue #68). null = unknown —
|
|
206
|
+
* control socket (issue #68). null = unknown — forward conservatively
|
|
207
|
+
* (spec §D1); only true detaches. */
|
|
179
208
|
private editorEmpty: boolean | null = null;
|
|
180
209
|
// Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
|
|
181
210
|
// loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
|
|
@@ -187,6 +216,10 @@ export class PtyAttachComponent implements Component {
|
|
|
187
216
|
private attaching = true;
|
|
188
217
|
private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
189
218
|
private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
219
|
+
// Issue #128 D2: unsubscriber for the color-scheme bridge registered at
|
|
220
|
+
// attach settle; nulled on every terminal path so the TUI never keeps a
|
|
221
|
+
// listener pointing at a dead attach surface.
|
|
222
|
+
private colorSchemeBridgeUnsubscribe: (() => void) | null = null;
|
|
190
223
|
private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
191
224
|
// Force a single full-clear on the first paint so the prior session/dashboard can't
|
|
192
225
|
// ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
|
|
@@ -260,6 +293,18 @@ export class PtyAttachComponent implements Component {
|
|
|
260
293
|
this.send({ type: "input", data });
|
|
261
294
|
return;
|
|
262
295
|
}
|
|
296
|
+
if (matchesKey(data, "ctrl+\\")) {
|
|
297
|
+
// Unconditional, terminal-independent escape (issue #126): 0x1c is a
|
|
298
|
+
// single raw ASCII byte every terminal forwards unchanged in raw
|
|
299
|
+
// mode — no modifier-encoding dependency like Ctrl+Left (macOS
|
|
300
|
+
// Terminal.app never emits it, WezTerm consumes it for tab
|
|
301
|
+
// switching). Only caveat (documented in README): layouts where
|
|
302
|
+
// `\` sits on AltGr may never produce this byte; the escape ladder
|
|
303
|
+
// still covers those. matchesKey also covers the kitty CSI-u and
|
|
304
|
+
// modifyOtherKeys encodings of the same chord.
|
|
305
|
+
this.detach();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
263
308
|
if (matchesKey(data, Key.ctrl("left"))) {
|
|
264
309
|
// Explicit detach chord (issue #89): single ← is gated on editor state
|
|
265
310
|
// (it doubles as cursor-left inside a non-empty draft), so a user with
|
|
@@ -275,7 +320,15 @@ export class PtyAttachComponent implements Component {
|
|
|
275
320
|
// escape unconditionally — the view must always be exitable, even
|
|
276
321
|
// after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
|
|
277
322
|
// a detach key — it passes through to Pi (tui.editor.jumpForward).
|
|
278
|
-
|
|
323
|
+
// Issue #91 Phase 6 (spec §D1): the gate reads ONLY the pushed
|
|
324
|
+
// editorEmpty side channel — true detaches; false and null (child
|
|
325
|
+
// extension missing, hello not yet arrived, old runner) forward via
|
|
326
|
+
// the explicit conservative policy. The old terminal-buffer
|
|
327
|
+
// heuristics are deleted for good: rendered bytes carry no
|
|
328
|
+
// input-buffer semantics, so that chain guessed at a fact it
|
|
329
|
+
// could not know (issues #42/#66/#69/#103). Escape without a pushed
|
|
330
|
+
// state is Ctrl+← (issue #89), never a buffer guess.
|
|
331
|
+
if (shouldEscapeAttach(this.connected, this.editorEmpty === true)) {
|
|
279
332
|
this.detach();
|
|
280
333
|
return;
|
|
281
334
|
}
|
|
@@ -305,7 +358,7 @@ export class PtyAttachComponent implements Component {
|
|
|
305
358
|
}
|
|
306
359
|
const header =
|
|
307
360
|
this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
|
|
308
|
-
this.theme.fg("muted", `${this.status} ·
|
|
361
|
+
this.theme.fg("muted", `${this.status} · opens links · drag selects+copies · Ctrl+←/Ctrl+\\ detach`);
|
|
309
362
|
return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
|
|
310
363
|
}
|
|
311
364
|
|
|
@@ -321,7 +374,7 @@ export class PtyAttachComponent implements Component {
|
|
|
321
374
|
out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
|
|
322
375
|
out.push(center(this.theme.fg("muted", detail), width));
|
|
323
376
|
out.push("");
|
|
324
|
-
out.push(center(this.theme.fg("dim", "
|
|
377
|
+
out.push(center(this.theme.fg("dim", "Ctrl+←/Ctrl+\\ to detach"), width));
|
|
325
378
|
while (out.length < height) out.push("");
|
|
326
379
|
return out.slice(0, height);
|
|
327
380
|
}
|
|
@@ -344,60 +397,15 @@ export class PtyAttachComponent implements Component {
|
|
|
344
397
|
// the socket immediately after receiving detach, so sending detach first
|
|
345
398
|
// can drop the G3 restore resize (issue #42).
|
|
346
399
|
this.jiggleRetry.restoreAndStop();
|
|
347
|
-
|
|
400
|
+
// Phase 5 (D4): enveloped detach (correlated applied ack) with legacy
|
|
401
|
+
// fallback; both reach the same runner detach handler.
|
|
402
|
+
if (!this.attachClient.sendControl("detach")) {
|
|
403
|
+
this.send({ type: "detach" });
|
|
404
|
+
}
|
|
348
405
|
this.close(true);
|
|
349
406
|
this.done({ action: "detached" });
|
|
350
407
|
}
|
|
351
408
|
|
|
352
|
-
/** Bottom-most line whose cells include an inverse-video cell — Pi renders
|
|
353
|
-
* its editor cursor as an inverse "fake cursor" (`ESC[7m`), and the cell
|
|
354
|
-
* persists in the buffer even while streaming differential frames skip
|
|
355
|
-
* repainting the editor line. */
|
|
356
|
-
private findLastInverseCellLine(active: {
|
|
357
|
-
baseY: number;
|
|
358
|
-
length: number;
|
|
359
|
-
getLine(index: number): BufferLineLike | undefined;
|
|
360
|
-
}): number | null {
|
|
361
|
-
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
362
|
-
const line = active.getLine(y);
|
|
363
|
-
if (!line) continue;
|
|
364
|
-
for (let x = 0; x < line.length; x++) {
|
|
365
|
-
if (line.getCell(x)?.isInverse()) return y;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
return null;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
private childInputLooksEmpty(): boolean {
|
|
372
|
-
if (!this.receivedOutput) return true;
|
|
373
|
-
const active = this.term.buffer.active;
|
|
374
|
-
// The terminal cursor is not a reliable anchor for the editor line:
|
|
375
|
-
// while Pi streams output (or right after attach) the cursor rests on
|
|
376
|
-
// working/output lines, never the input line, so a genuinely empty
|
|
377
|
-
// editor was misread as non-empty and ← stopped detaching (issue #66).
|
|
378
|
-
// Pi's editor line always carries an inverse-video fake-cursor cell,
|
|
379
|
-
// so anchor on that instead.
|
|
380
|
-
const fakeCursorLine = this.findLastInverseCellLine(active);
|
|
381
|
-
if (fakeCursorLine !== null) {
|
|
382
|
-
const line = active.getLine(fakeCursorLine)?.translateToString(true) ?? "";
|
|
383
|
-
return isProbablyEmptyPiInputLine(line);
|
|
384
|
-
}
|
|
385
|
-
// Fallback: Pi variants that render no fake cursor — look for an EMPTY
|
|
386
|
-
// prompt-glyph line. Only an empty glyph line proves an empty editor:
|
|
387
|
-
// content glyph lines (markdown table rows `│ … │`, quotes `> …`, or a
|
|
388
|
-
// real draft in a no-fake-cursor Pi variant) cannot be told apart, and
|
|
389
|
-
// trapping the user is worse than a spurious detach (issue #69) — skip
|
|
390
|
-
// them and keep scanning; the loop-end escape below stays authoritative.
|
|
391
|
-
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
392
|
-
const line = active.getLine(y)?.translateToString(true) ?? "";
|
|
393
|
-
if (isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)) return true;
|
|
394
|
-
}
|
|
395
|
-
// No editor line recoverable (e.g. a garbled replay buffer): treat the
|
|
396
|
-
// input as empty — ← is the only detach key left on the attach surface,
|
|
397
|
-
// so it must always escape rather than trap the user.
|
|
398
|
-
return true;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
409
|
private connect(): void {
|
|
402
410
|
if (this.closed || this.connected || this.socket) return;
|
|
403
411
|
// On Windows the control socket is a named pipe — it never exists as a
|
|
@@ -424,7 +432,18 @@ export class PtyAttachComponent implements Component {
|
|
|
424
432
|
this.disconnectedAt = null;
|
|
425
433
|
this.status = "attached";
|
|
426
434
|
this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
|
|
427
|
-
|
|
435
|
+
// Snapshot+subscribe attach (issue #91 phase 4): the first connect
|
|
436
|
+
// probes with subscribe_terminal (old runners stay silent → probe
|
|
437
|
+
// timeout → legacy); protocol reconnects resume from the applied
|
|
438
|
+
// cursor over the SAME client instance (ring replay is seamless;
|
|
439
|
+
// eviction/restart answers a fresh snapshot). hello stays UI-owned
|
|
440
|
+
// and precedes the client's subscribe (established ordering).
|
|
441
|
+
if (this.attachMode === "undecided") this.attachClient.start();
|
|
442
|
+
else if (this.attachMode === "protocol") this.attachClient.reconnect(this.attachClient.getLastSeq());
|
|
443
|
+
// The shrink-and-hold redraw protocol is a LEGACY-path correctness
|
|
444
|
+
// tool: snapshot mode owns screen correctness via canonical frames,
|
|
445
|
+
// so the jiggle never runs there (armLegacyJiggle guards the race).
|
|
446
|
+
this.armLegacyJiggle();
|
|
428
447
|
this.enableMouseScroll();
|
|
429
448
|
this.scheduleRender();
|
|
430
449
|
this.startAttachSettle();
|
|
@@ -440,6 +459,11 @@ export class PtyAttachComponent implements Component {
|
|
|
440
459
|
this.socket = null;
|
|
441
460
|
this.connected = false;
|
|
442
461
|
this.disconnectedAt ??= Date.now();
|
|
462
|
+
// A drop inside the probe window must not leave the absolute 1500ms
|
|
463
|
+
// deadline armed: firing during the dead window would permanently
|
|
464
|
+
// downgrade a protocol-capable runner to legacy (CR R1 advisory). The
|
|
465
|
+
// reconnect's start()/reconnect() re-arms on the new socket.
|
|
466
|
+
this.attachClient.onDisconnect();
|
|
443
467
|
if (!this.closed && this.status !== "host exited") {
|
|
444
468
|
this.status = "disconnected";
|
|
445
469
|
this.scheduleReconnect();
|
|
@@ -455,6 +479,10 @@ export class PtyAttachComponent implements Component {
|
|
|
455
479
|
}
|
|
456
480
|
this.socket = null;
|
|
457
481
|
this.connected = false;
|
|
482
|
+
// Notify before nulling: the close event that follows will hit the
|
|
483
|
+
// stale-socket guard (this.socket no longer matches) and must not be
|
|
484
|
+
// the only path that cancels the probe deadline (CR R1 advisory).
|
|
485
|
+
this.attachClient.onDisconnect();
|
|
458
486
|
try { socket.destroy(); } catch {}
|
|
459
487
|
this.disconnectedAt ??= Date.now();
|
|
460
488
|
if (this.closed) return;
|
|
@@ -558,6 +586,56 @@ export class PtyAttachComponent implements Component {
|
|
|
558
586
|
// buffer, instead of diffing banner lines into buffer lines.
|
|
559
587
|
this.scheduleRender(true);
|
|
560
588
|
this.startDesyncProbe();
|
|
589
|
+
// Issue #128: the settle transition is the single point where `attaching`
|
|
590
|
+
// flips false (never re-armed), so both hooks run at most once per attach.
|
|
591
|
+
this.attachColorSchemeBridge();
|
|
592
|
+
this.replayBackgroundQuery();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Issue #128 D2: the local pi-tui consumes the child's color-scheme
|
|
596
|
+
* traffic (996 queries / 2031 notifications forwarded by D1) — reports
|
|
597
|
+
* never reach the child. onTerminalColorSchemeChange fires from that same
|
|
598
|
+
* consumption point, so the bridge repackages each event as a 997 report
|
|
599
|
+
* on the child pty, where the child's pi-tui parser consumes it. Once per
|
|
600
|
+
* attach (settle is single-shot + explicit guard); kill switch skips it. */
|
|
601
|
+
private attachColorSchemeBridge(): void {
|
|
602
|
+
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
|
|
603
|
+
if (this.colorSchemeBridgeUnsubscribe) return;
|
|
604
|
+
try {
|
|
605
|
+
this.colorSchemeBridgeUnsubscribe = this.tui.onTerminalColorSchemeChange((scheme) => {
|
|
606
|
+
const data = toColorSchemeReport(scheme);
|
|
607
|
+
if (data) this.send({ type: "input", data });
|
|
608
|
+
});
|
|
609
|
+
} catch {
|
|
610
|
+
/* best-effort: an unbridgeable TUI keeps pre-#128 behavior */
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Called on every terminal path (close() covers detach/dispose; the exit
|
|
615
|
+
* branch calls done() without close(), so it unsubscribes explicitly). */
|
|
616
|
+
private detachColorSchemeBridge(): void {
|
|
617
|
+
const unsubscribe = this.colorSchemeBridgeUnsubscribe;
|
|
618
|
+
this.colorSchemeBridgeUnsubscribe = null;
|
|
619
|
+
if (!unsubscribe) return;
|
|
620
|
+
try {
|
|
621
|
+
unsubscribe();
|
|
622
|
+
} catch {
|
|
623
|
+
/* best-effort: a stale listener must not block teardown */
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Issue #128 D3: the child probed OSC 11 at spawn, before any attach
|
|
628
|
+
* client existed, so its background-color answer was lost. Re-ask the REAL
|
|
629
|
+
* terminal once now; the reply travels back through the existing input
|
|
630
|
+
* passthrough (handleInput fallback) to the child. Silent on failure — a
|
|
631
|
+
* missing answer keeps the pre-#128 fallback theme. */
|
|
632
|
+
private replayBackgroundQuery(): void {
|
|
633
|
+
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
|
|
634
|
+
try {
|
|
635
|
+
this.tui.terminal.write("\x1b]11;?\x07");
|
|
636
|
+
} catch {
|
|
637
|
+
/* best-effort: enhancement, never critical */
|
|
638
|
+
}
|
|
561
639
|
}
|
|
562
640
|
|
|
563
641
|
private startDesyncProbe(): void {
|
|
@@ -586,6 +664,13 @@ export class PtyAttachComponent implements Component {
|
|
|
586
664
|
*/
|
|
587
665
|
private checkDesync(): void {
|
|
588
666
|
if (this.closed || this.attaching || !this.connected) return; // gates 1, 7
|
|
667
|
+
// Protocol mode owns screen correctness via canonical frames (issue #91
|
|
668
|
+
// phase 4, CR R1 advisory): a "misaligned cursor" there is frame content
|
|
669
|
+
// or a stale-frame transient that resync heals — a jiggle heal would fire
|
|
670
|
+
// resize pulses in a mode whose e2e pins zero resizes. The undecided
|
|
671
|
+
// window keeps legacy semantics on purpose: until the snapshot answer
|
|
672
|
+
// arrives the jiggle IS armed and the legacy fallthrough owns the screen.
|
|
673
|
+
if (this.attachMode === "protocol") return;
|
|
589
674
|
const chain = this.jiggleRetry.getState();
|
|
590
675
|
if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
|
|
591
676
|
if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
|
|
@@ -606,6 +691,37 @@ export class PtyAttachComponent implements Component {
|
|
|
606
691
|
this.mouseRefreshTimers = [];
|
|
607
692
|
}
|
|
608
693
|
|
|
694
|
+
/**
|
|
695
|
+
* Legacy-path jiggle arming with a protocol-race guard (issue #91 phase 4).
|
|
696
|
+
* Legacy decided immediately (env-forced) → start now; undecided → start
|
|
697
|
+
* after the guard delay unless the snapshot answer wins (protocol). The
|
|
698
|
+
* timer guard accepts ANY non-protocol mode, so a fast in-flight decision
|
|
699
|
+
* (e.g. frame_version_mismatch landing ~1ms into the probe window) still
|
|
700
|
+
* arms the chain — an early-decided legacy session must not end up
|
|
701
|
+
* jiggle-less. start() re-arms idempotently, so a late duplicate is safe.
|
|
702
|
+
*/
|
|
703
|
+
private armLegacyJiggle(): void {
|
|
704
|
+
this.clearJiggleStartTimer();
|
|
705
|
+
if (this.attachMode === "protocol") return;
|
|
706
|
+
if (this.attachMode === "legacy") {
|
|
707
|
+
if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
this.jiggleStartTimer = setTimeout(() => {
|
|
711
|
+
this.jiggleStartTimer = null;
|
|
712
|
+
if (!this.closed && this.connected && this.attachMode !== "protocol") {
|
|
713
|
+
this.jiggleRetry.start(this.cols, this.rows);
|
|
714
|
+
}
|
|
715
|
+
}, LEGACY_JIGGLE_ARM_DELAY_MS);
|
|
716
|
+
this.jiggleStartTimer.unref?.();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private clearJiggleStartTimer(): void {
|
|
720
|
+
if (!this.jiggleStartTimer) return;
|
|
721
|
+
clearTimeout(this.jiggleStartTimer);
|
|
722
|
+
this.jiggleStartTimer = null;
|
|
723
|
+
}
|
|
724
|
+
|
|
609
725
|
private clearRetry(): void {
|
|
610
726
|
if (!this.retryTimer) return;
|
|
611
727
|
clearTimeout(this.retryTimer);
|
|
@@ -973,7 +1089,13 @@ export class PtyAttachComponent implements Component {
|
|
|
973
1089
|
}
|
|
974
1090
|
|
|
975
1091
|
private sendResize(cols = this.cols, rows = this.rows): void {
|
|
976
|
-
|
|
1092
|
+
// Phase 5 (D4): enveloped resize when the host has an instance fence
|
|
1093
|
+
// (correlated applied/superseded acks; starting-window host_starting is
|
|
1094
|
+
// retried client-side); legacy plain message otherwise. Both paths reach
|
|
1095
|
+
// the same runner resize handler.
|
|
1096
|
+
if (!this.attachClient.sendControl("resize", { cols, rows })) {
|
|
1097
|
+
this.send({ type: "resize", cols, rows });
|
|
1098
|
+
}
|
|
977
1099
|
this.clampViewportTop(this.bodyHeight());
|
|
978
1100
|
}
|
|
979
1101
|
|
|
@@ -1045,6 +1167,12 @@ export class PtyAttachComponent implements Component {
|
|
|
1045
1167
|
if (!line.trim()) continue;
|
|
1046
1168
|
try {
|
|
1047
1169
|
const msg = JSON.parse(line);
|
|
1170
|
+
// Protocol-managed messages (snapshot windows, seq-checked live
|
|
1171
|
+
// output, recovery) are consumed by the attach client and re-emitted
|
|
1172
|
+
// through handleAttachEvent. UI-owned messages (hello/status/
|
|
1173
|
+
// editor_state/exit/error) and pre-decision broadcast output return
|
|
1174
|
+
// false and fall through to the legacy path below.
|
|
1175
|
+
if (this.attachClient.handleMessage(msg)) continue;
|
|
1048
1176
|
if (msg.type === "output" && typeof msg.data === "string") {
|
|
1049
1177
|
this.pushOutput(msg.data, { forwardProtocols: true });
|
|
1050
1178
|
this.checkClearSequence(msg.data);
|
|
@@ -1057,6 +1185,10 @@ export class PtyAttachComponent implements Component {
|
|
|
1057
1185
|
this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
1058
1186
|
} else if (msg.type === "exit") {
|
|
1059
1187
|
this.status = "host exited";
|
|
1188
|
+
// The exit path calls done() directly (host disposes later);
|
|
1189
|
+
// drop the #128 bridge now so A7 symmetry doesn't depend on
|
|
1190
|
+
// the host honoring dispose().
|
|
1191
|
+
this.detachColorSchemeBridge();
|
|
1060
1192
|
this.done({ action: "closed", exitCode: msg.exitCode ?? null });
|
|
1061
1193
|
} else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
|
|
1062
1194
|
if (shouldScheduleAttachRenderForMessage(msg.type)) needsRender = true;
|
|
@@ -1067,6 +1199,95 @@ export class PtyAttachComponent implements Component {
|
|
|
1067
1199
|
if (needsRender) this.scheduleRender();
|
|
1068
1200
|
}
|
|
1069
1201
|
|
|
1202
|
+
/**
|
|
1203
|
+
* Attach-client events (issue #91 phase 4). The client owns protocol
|
|
1204
|
+
* recovery (it resubscribes internally); the UI only switches paths and
|
|
1205
|
+
* hydrates frames.
|
|
1206
|
+
*/
|
|
1207
|
+
private handleAttachEvent(
|
|
1208
|
+
event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "resubscribing" | "protocolError" | "cmdAck" | "reconciled" | "epochReset",
|
|
1209
|
+
payload: any,
|
|
1210
|
+
): void {
|
|
1211
|
+
if (event === "mode") {
|
|
1212
|
+
const prev = this.attachMode;
|
|
1213
|
+
this.attachMode = payload as "protocol" | "legacy";
|
|
1214
|
+
if (this.attachMode === "protocol") {
|
|
1215
|
+
// Snapshot mode owns screen correctness: cancel any pending legacy
|
|
1216
|
+
// jiggle arming and unwind anything already armed (restores a held
|
|
1217
|
+
// resize; no-ops otherwise).
|
|
1218
|
+
this.clearJiggleStartTimer();
|
|
1219
|
+
this.jiggleRetry.restoreAndStop();
|
|
1220
|
+
} else if (prev === "protocol") {
|
|
1221
|
+
// Downgrade after a protocol session (recovery budget exhausted):
|
|
1222
|
+
// re-arm the legacy screen-healing machinery. This must run whenever
|
|
1223
|
+
// the socket is alive — not only during the attach transition. A
|
|
1224
|
+
// post-settle downgrade that skipped start() left the chain stopped
|
|
1225
|
+
// forever (restoreAndStop on the protocol side), so feed() early-
|
|
1226
|
+
// returned and raw output could never heal a desynced screen.
|
|
1227
|
+
this.clearJiggleStartTimer();
|
|
1228
|
+
if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
|
|
1229
|
+
}
|
|
1230
|
+
// Legacy decided during the undecided window: the race-guard timer
|
|
1231
|
+
// already fired at 100ms (≪ the 1500ms probe timeout) — nothing to do.
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (event === "snapshotBegin") {
|
|
1235
|
+
// Protocol attach size-sync (CR R1 blocking): the legacy attach resized
|
|
1236
|
+
// the child at every connect (jiggle start); subscribe_terminal carries
|
|
1237
|
+
// no size, so without this the child keeps its host-creation geometry
|
|
1238
|
+
// and full-screen TUI children lay out at a stale size. begin is
|
|
1239
|
+
// authoritative for the runner's CURRENT size; the in-flight frame stays
|
|
1240
|
+
// old-geometry either way, but resizing now starts the child's redraw at
|
|
1241
|
+
// the true size sooner. Matched sizes send nothing (zero-resize
|
|
1242
|
+
// contract for same-size attach stays intact).
|
|
1243
|
+
const beginSize = (payload ?? {}) as { cols?: number; rows?: number };
|
|
1244
|
+
if (
|
|
1245
|
+
typeof beginSize.cols === "number" &&
|
|
1246
|
+
typeof beginSize.rows === "number" &&
|
|
1247
|
+
(beginSize.cols !== this.cols || beginSize.rows !== this.rows)
|
|
1248
|
+
) {
|
|
1249
|
+
this.sendResize();
|
|
1250
|
+
}
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (event === "snapshotReady") {
|
|
1254
|
+
const snap = (payload ?? {}) as { frame?: string; empty?: boolean; resnapshot?: boolean };
|
|
1255
|
+
// The synthesized frame is self-contained on dirty terminals (phase 3
|
|
1256
|
+
// torture-proven: DECSTR + clear preamble wipes the constructor
|
|
1257
|
+
// screen.log replay and any pre-probe broadcast bytes), so hydrating is
|
|
1258
|
+
// a plain push: the write callback drives receivedOutput/settle/render
|
|
1259
|
+
// exactly like the legacy replay path. No protocol forwarding — the
|
|
1260
|
+
// frame is runner-synthesized grid content, not child passthrough
|
|
1261
|
+
// sequences. Empty baselines (the COMMON initial attach state: the host
|
|
1262
|
+
// publishes alive+childPid before the child's first output) carry no
|
|
1263
|
+
// frame; the loading banner persists until the first live output.
|
|
1264
|
+
// Empty/resnapshot answers ALWAYS wipe the local buffer, attaching or
|
|
1265
|
+
// not: while attaching the banner hides it (zero visual cost), and the
|
|
1266
|
+
// constructor's screen.log warm-start must not outlive the protocol
|
|
1267
|
+
// answer — a gated reset let the dead session's tail render as a
|
|
1268
|
+
// cold-start ghost once the banner lifted (final-review F1).
|
|
1269
|
+
if (typeof snap.frame === "string") this.pushOutput(snap.frame);
|
|
1270
|
+
else if (snap.empty || snap.resnapshot) this.term.reset();
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (event === "output") {
|
|
1274
|
+
// Live/replay protocol output: no jiggle clear-detection — that feed
|
|
1275
|
+
// exists to cancel the shrink-and-hold chain, which never runs in
|
|
1276
|
+
// protocol mode.
|
|
1277
|
+
if (typeof payload === "string") this.pushOutput(payload, { forwardProtocols: true });
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
// "resubscribing": recovery is client-internal (informational only).
|
|
1281
|
+
// "protocolError": observability only — recovery is automatic until the
|
|
1282
|
+
// legacy fallback, whose mode event the UI acts on above.
|
|
1283
|
+
// "cmdAck"/"reconciled"/"epochReset" (phase 5): correlation/observability
|
|
1284
|
+
// only — applied/superseded bookkeeping, reconcile baselines and the
|
|
1285
|
+
// generation epoch rule live inside the client; the UI's behavioral
|
|
1286
|
+
// surface (hydrate, mode switching, size-sync) is unchanged. host_starting
|
|
1287
|
+
// resize retries are also client-internal (parity with the legacy runner-
|
|
1288
|
+
// side cachedResize).
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1070
1291
|
private forwardTerminalProtocols(data: string): void {
|
|
1071
1292
|
const toWrite: string[] = [];
|
|
1072
1293
|
if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") {
|
|
@@ -1079,6 +1300,15 @@ export class PtyAttachComponent implements Component {
|
|
|
1079
1300
|
this.passthroughCarry = carry;
|
|
1080
1301
|
toWrite.push(...sequences);
|
|
1081
1302
|
}
|
|
1303
|
+
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES !== "0") {
|
|
1304
|
+
// Issue #128 D1: forward the child's terminal capability queries
|
|
1305
|
+
// (OSC 11 background probe, 2031 scheme-notify switch) to the real
|
|
1306
|
+
// terminal; set-forms and kitty/DA negotiation stay excluded (see
|
|
1307
|
+
// extractOscQuerySequences).
|
|
1308
|
+
const { sequences, carry } = extractOscQuerySequences(this.oscQueryCarry + data);
|
|
1309
|
+
this.oscQueryCarry = carry;
|
|
1310
|
+
toWrite.push(...sequences);
|
|
1311
|
+
}
|
|
1082
1312
|
for (const seq of toWrite) {
|
|
1083
1313
|
try {
|
|
1084
1314
|
this.tui.terminal.write(seq);
|
|
@@ -1088,6 +1318,15 @@ export class PtyAttachComponent implements Component {
|
|
|
1088
1318
|
}
|
|
1089
1319
|
}
|
|
1090
1320
|
|
|
1321
|
+
/** LEGACY FALLBACK (issue #91 phase 6 marking): the screen.log tail replay
|
|
1322
|
+
* is kept only for pre-protocol runners and the
|
|
1323
|
+
* AGENT_BOARD_TERMINAL_SNAPSHOT=0 kill switch. It is NOT a correctness
|
|
1324
|
+
* path of the snapshot+subscribe protocol — the protocol hydrates from
|
|
1325
|
+
* runner-owned snapshots and resumes from a sequence cursor (phase 4),
|
|
1326
|
+
* and this replay runs before the probe resolves. Removal condition: the
|
|
1327
|
+
* installed runner fleet is on the snapshot protocol baseline
|
|
1328
|
+
* (wire-detectable via hello protocol fields).
|
|
1329
|
+
*/
|
|
1091
1330
|
private replayScreenLog(): void {
|
|
1092
1331
|
if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return;
|
|
1093
1332
|
try {
|
|
@@ -1147,8 +1386,9 @@ export class PtyAttachComponent implements Component {
|
|
|
1147
1386
|
// the child terminal); cursorX may equal cols (one past the last cell). Only render
|
|
1148
1387
|
// it when it lands inside the projected viewport.
|
|
1149
1388
|
const cursor = projectPtyCursor(buf, start, height);
|
|
1389
|
+
const cursorHidden = isPtyCursorHidden(this.term);
|
|
1150
1390
|
for (let i = start; i < end; i++) {
|
|
1151
|
-
out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor));
|
|
1391
|
+
out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor, cursorHidden));
|
|
1152
1392
|
}
|
|
1153
1393
|
if (out.length === 0) out.push("Waiting for PTY output…");
|
|
1154
1394
|
return { lines: out.slice(-height), cursor };
|
|
@@ -1163,6 +1403,8 @@ export class PtyAttachComponent implements Component {
|
|
|
1163
1403
|
}
|
|
1164
1404
|
this.closed = true;
|
|
1165
1405
|
this.imeCoalesceUninstall?.();
|
|
1406
|
+
this.detachColorSchemeBridge();
|
|
1407
|
+
this.attachClient.close();
|
|
1166
1408
|
this.jiggleRetry.restoreAndStop();
|
|
1167
1409
|
this.disableMouseScroll();
|
|
1168
1410
|
this.clearMouseRefreshTimers();
|
|
@@ -1280,12 +1522,14 @@ function lineToAnsi(
|
|
|
1280
1522
|
lineIndex: number,
|
|
1281
1523
|
selection: NormalizedSelection | null,
|
|
1282
1524
|
cursor: { row: number; col: number } | null,
|
|
1525
|
+
cursorHidden = false,
|
|
1283
1526
|
): string {
|
|
1284
1527
|
const isCursorRow = cursor !== null && cursor.row === lineIndex;
|
|
1285
1528
|
let last = -1;
|
|
1286
1529
|
if (!line) {
|
|
1287
|
-
// No buffer line:
|
|
1288
|
-
|
|
1530
|
+
// No buffer line: keep the marker (IME positioning) and paint the inverse block
|
|
1531
|
+
// only while the child reports a visible cursor.
|
|
1532
|
+
if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1289
1533
|
return "";
|
|
1290
1534
|
}
|
|
1291
1535
|
for (let x = 0; x < line.length; x++) {
|
|
@@ -1294,9 +1538,8 @@ function lineToAnsi(
|
|
|
1294
1538
|
if (cell.getChars()) last = x;
|
|
1295
1539
|
}
|
|
1296
1540
|
if (last < 0) {
|
|
1297
|
-
// Empty line:
|
|
1298
|
-
|
|
1299
|
-
if (isCursorRow && cursor!.col >= 0) return CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1541
|
+
// Empty line: same split as above.
|
|
1542
|
+
if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1300
1543
|
return "";
|
|
1301
1544
|
}
|
|
1302
1545
|
|
|
@@ -1313,24 +1556,27 @@ function lineToAnsi(
|
|
|
1313
1556
|
prevUri = uri;
|
|
1314
1557
|
}
|
|
1315
1558
|
const selected = pointWithinSelection(lineIndex, x, selection);
|
|
1316
|
-
//
|
|
1317
|
-
// the
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1559
|
+
// Position and visibility are separate concerns: the zero-width CURSOR_MARKER
|
|
1560
|
+
// (stripped by the TUI) always marks where the hardware cursor belongs for IME
|
|
1561
|
+
// and PI_HARDWARE_CURSOR=1 terminals, while the solid inverse block is only
|
|
1562
|
+
// painted when the child terminal itself reports the cursor as visible. pi-tui
|
|
1563
|
+
// parks a hidden cursor at a diff-write byproduct position, so painting it
|
|
1564
|
+
// unconditionally showed a ghost block (issue #102).
|
|
1320
1565
|
const isCursor = isCursorRow && x === cursor!.col;
|
|
1321
1566
|
if (isCursor) out += CURSOR_MARKER;
|
|
1322
|
-
const
|
|
1567
|
+
const paintCursor = isCursor && !cursorHidden;
|
|
1568
|
+
const key = attrKey(cell, selected, paintCursor);
|
|
1323
1569
|
if (key !== prevAttr) {
|
|
1324
|
-
out += attrsToAnsi(cell, selected,
|
|
1570
|
+
out += attrsToAnsi(cell, selected, paintCursor);
|
|
1325
1571
|
prevAttr = key;
|
|
1326
1572
|
}
|
|
1327
1573
|
out += cell.getChars() || " ";
|
|
1328
1574
|
}
|
|
1329
1575
|
if (prevUri) out += closeOsc8();
|
|
1330
1576
|
// Cursor past the end of the line content (cursorX == cols or beyond last cell):
|
|
1331
|
-
// append an inverse space so
|
|
1577
|
+
// append an inverse space so a VISIBLE position shows, keeping the marker either way.
|
|
1332
1578
|
if (isCursorRow && cursor!.col > last) {
|
|
1333
|
-
out += CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1579
|
+
out += CURSOR_MARKER + (cursorHidden ? "" : "\x1b[7m \x1b[0m");
|
|
1334
1580
|
}
|
|
1335
1581
|
return out + "\x1b[0m";
|
|
1336
1582
|
}
|
package/src/core/pty-input.mjs
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/** Helpers for deciding whether local attach shortcuts should be handled. */
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Best-effort detection of Pi's empty prompt/input line from projected terminal text.
|
|
5
|
-
* The attach surface must not steal editing keys (notably ←) while the child Pi editor
|
|
6
|
-
* contains text. Pi renders empty editor lines with prompt/continuation glyphs such as
|
|
7
|
-
* `›`, `┃`, or `│`; once user text is present, non-prompt content remains after this trim.
|
|
8
|
-
* @param {string} line
|
|
9
|
-
* @returns {boolean}
|
|
10
|
-
*/
|
|
11
|
-
export function isProbablyEmptyPiInputLine(line) {
|
|
12
|
-
const withoutRightPadding = String(line || "").replace(/[\s\u00a0]+$/u, "");
|
|
13
|
-
const content = withoutRightPadding.replace(/^[\s\u00a0›>┃│|┆╎╏:]+/u, "");
|
|
14
|
-
return content.length === 0;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Glyphs Pi uses to render editor prompt / continuation lines (`>` main prompt,
|
|
18
|
-
* `›`/`┃`/`│` and variants in older releases). Must stay in sync with the
|
|
19
|
-
* trim charset of isProbablyEmptyPiInputLine below. */
|
|
20
|
-
const PROMPT_GLYPHS = "›>┃│|┆╎╏:";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Whether the given terminal line looks like a Pi editor input line: leading
|
|
24
|
-
* whitespace followed by a prompt/continuation glyph. The attach surface uses
|
|
25
|
-
* this to locate the editor line inside the buffer instead of trusting the
|
|
26
|
-
* terminal cursor, which wanders onto output/working lines while Pi streams
|
|
27
|
-
* (issue #66).
|
|
28
|
-
* @param {string} line
|
|
29
|
-
* @returns {boolean}
|
|
30
|
-
*/
|
|
31
|
-
export function isProbablyPiInputLine(line) {
|
|
32
|
-
const withoutLeftPadding = String(line || "").replace(/^[\s\u00a0]+/u, "");
|
|
33
|
-
return withoutLeftPadding.length > 0 && PROMPT_GLYPHS.includes(withoutLeftPadding[0]);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Resolve the ← detach gate's emptiness signal: when the child Pi pushes its
|
|
38
|
-
* authoritative editor state (boolean), it wins; when it is unknown (null/
|
|
39
|
-
* undefined — child extension missing or socket never connected), fall back
|
|
40
|
-
* to the render heuristic.
|
|
41
|
-
* @param {boolean | null | undefined} editorEmpty
|
|
42
|
-
* @param {boolean} heuristic
|
|
43
|
-
* @returns {boolean}
|
|
44
|
-
*/
|
|
45
|
-
export function resolveEditorEmpty(editorEmpty, heuristic) {
|
|
46
|
-
return editorEmpty === null || editorEmpty === undefined ? heuristic : editorEmpty;
|
|
47
|
-
}
|