@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,3184 @@
|
|
|
1
|
+
/** Live PTY attach surface for hosted agent-board rows. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
5
|
+
import { createConnection, type Socket } from "node:net";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
import type { Component, KeybindingsManager, RgbColor, TUI, TerminalColorScheme, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
8
|
+
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { resolveAttachDeleteKey } from "../core/attach-delete.mjs";
|
|
10
|
+
import { createDoublePress, QUIT_PRESS_MS } from "../core/double-press.mjs";
|
|
11
|
+
import { isOpenableLink, findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
12
|
+
import { holdInputListeners } from "../core/hold-input.mjs";
|
|
13
|
+
import { suspendToShell } from "../core/suspend.mjs";
|
|
14
|
+
import { canPublishAttachView, createAttachOutputRenderScheduler, decideAttachPresentation, detectCursorDesync, isPtyCursorHidden, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
15
|
+
import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
|
|
16
|
+
import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
|
|
17
|
+
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
18
|
+
import { createTerminalAttachClient } from "../core/terminal-attach-client.mjs";
|
|
19
|
+
import { MAX_VIEW_PROOF_RECORDS } from "../core/terminal-attach-protocol.mjs";
|
|
20
|
+
import { createSafeTrailingClassifier } from "../core/attach-view-proof.mjs";
|
|
21
|
+
import { backgroundRgbFromTerminalColors, buildSettleSchemePatch, colorSchemeForBackgroundRgb, extractOscQuerySequences, resolveProbeApi, toColorSchemeReport } from "../core/terminal-query-sequences.mjs";
|
|
22
|
+
import { REPLY_GUARD_WINDOW_MS, createReplyGuard, isReplyGuardEnabled } from "../core/terminal-reply-guard.mjs";
|
|
23
|
+
import { clampInt, encodeWheelReport, fullscreenMouseEnableSequence, historyFloorAfterHydrate, isFullscreenSelectionEnd, resolveAttachMouseAction, resolveAttachWheelRoute, resolvePasteSelectionSource, resolveWheelLines, sameMousePoint, scrollableHistoryLines, scrollViewportTop, selectionDragScrollLines, selectionReleaseIsDrag, shouldOwnOuterMouseMode, splitAttachInput, wheelScrollLinesUp } from "../core/pty-scroll.mjs";
|
|
24
|
+
|
|
25
|
+
export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null } | { action: "delete" } | { action: "quit" };
|
|
26
|
+
|
|
27
|
+
type ThemeLike = {
|
|
28
|
+
fg(color: string, text: string): string;
|
|
29
|
+
bold(text: string): string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The pi-tui members the attach view reads without a public typing contract:
|
|
33
|
+
* getActiveSelectionText / clearTextSelection are private in TuiAltScreen's d.ts
|
|
34
|
+
* (runtime-reachable plain methods), getCopyOnSelect is public. Every access is
|
|
35
|
+
* feature-detected so an older or newer pi-tui degrades instead of throwing. */
|
|
36
|
+
interface FullscreenSelectionHost {
|
|
37
|
+
getActiveSelectionText?: () => string | undefined;
|
|
38
|
+
getCopyOnSelect?: () => boolean;
|
|
39
|
+
clearTextSelection?: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Diagnostic patch emitted at attach settle (DiagnosticEvent-compatible; built by buildSettleSchemePatch). */
|
|
43
|
+
export interface AttachSettleDiagnosticPatch {
|
|
44
|
+
source: string;
|
|
45
|
+
level: "info" | "warn";
|
|
46
|
+
code: string;
|
|
47
|
+
message: string;
|
|
48
|
+
details: { probeApi?: string; outcome: string; report?: string; late?: boolean };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PtyAttachOptions {
|
|
52
|
+
socketPath: string;
|
|
53
|
+
screenLogPath?: string;
|
|
54
|
+
title: string;
|
|
55
|
+
/** Issue #161: receives settle-scheme diagnostic patches. Default: no-op. */
|
|
56
|
+
onDiagnostic?: (event: AttachSettleDiagnosticPatch) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const require = createRequire(import.meta.url);
|
|
60
|
+
const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record<string, unknown>) => XtermLike };
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
64
|
+
const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
|
|
65
|
+
const XTSHIFTESCAPE_SELECT = "\x1b[>0s";
|
|
66
|
+
const DOUBLE_CLICK_MS = 260;
|
|
67
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
68
|
+
const LOADING_TICK_MS = 120;
|
|
69
|
+
/** How long to keep the loading banner after the last resize-jiggle during attach. */
|
|
70
|
+
const ATTACH_SETTLE_MS = 250;
|
|
71
|
+
/** Hard cap on the attach transition so a silent session can't stall the banner. */
|
|
72
|
+
const ATTACH_HARD_TIMEOUT_MS = 2500;
|
|
73
|
+
/** A session can redraw for a resize without proving it, and then idles, so nothing would ever
|
|
74
|
+
* finish the attach. It's resized away and back to draw again, a few times at most. */
|
|
75
|
+
const GEOMETRY_NUDGE_LIMIT = 3;
|
|
76
|
+
const GEOMETRY_NUDGE_SETTLE_MS = 100;
|
|
77
|
+
const GEOMETRY_NUDGE_WAIT_MS = 1000;
|
|
78
|
+
/** Protocol-race guard for legacy jiggle arming (issue #91 phase 4): the
|
|
79
|
+
* undecided window must not pulse the child before the snapshot probe
|
|
80
|
+
* resolves — a resize-free attach is the protocol-mode contract. Invisible
|
|
81
|
+
* for legacy (the probe timeout decides at 1500ms ≫ 100ms) and the protocol
|
|
82
|
+
* answer (~ms) reliably beats it; a pathologically late snapshot merely costs
|
|
83
|
+
* one harmless shrink pulse that the protocol mode event unwinds. */
|
|
84
|
+
const LEGACY_JIGGLE_ARM_DELAY_MS = 100;
|
|
85
|
+
/** Give ordered detach packets time to flush before using destroy as a fallback. */
|
|
86
|
+
const GRACEFUL_SOCKET_CLOSE_MS = 1000;
|
|
87
|
+
/** Desync detection window: how long output must stay silent before a
|
|
88
|
+
* misaligned cursor counts as desync (issue #11). Streaming keeps the cursor
|
|
89
|
+
* on plain output cells legitimately; a healthy idle child re-parks it on the
|
|
90
|
+
* inverse fake-cursor cell on its last rendered frame. */
|
|
91
|
+
const DESYNC_QUIET_MS = 1500;
|
|
92
|
+
/** How often the post-settle probe runs checkDesync() (issue #11). */
|
|
93
|
+
const DESYNC_PROBE_INTERVAL_MS = 2000;
|
|
94
|
+
/** Minimum spacing between two runtime heals (issue #11). */
|
|
95
|
+
const HEAL_RATELIMIT_MS = 10000;
|
|
96
|
+
/** Issue #148: probe budget for asking the REAL terminal for its background
|
|
97
|
+
* color. Deliberately ONE probe: every timed-out probe leaves a settled entry in
|
|
98
|
+
* the LOCAL pi-tui's pending-query queue (its timeout path neither dequeues nor
|
|
99
|
+
* decrements) and that stale entry eats the next reply, so a retry loop cannot
|
|
100
|
+
* converge — it would only add another stale entry per attempt and degrade the
|
|
101
|
+
* host's own background detection. One probe keeps the clean-queue case working
|
|
102
|
+
* and the leaked case degrading silently. */
|
|
103
|
+
const REAL_TERMINAL_SCHEME_PROBE_TIMEOUT_MS = 500;
|
|
104
|
+
/** How many tail bytes of the screen log to replay on attach. Read from the file tail
|
|
105
|
+
* (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
|
|
106
|
+
* handful of screens, which is all a fresh attach needs. */
|
|
107
|
+
const ATTACH_REPLAY_BYTES = 60_000;
|
|
108
|
+
/** A frame the session draws this soon after a key or wheel event is the answer to it, so it paints
|
|
109
|
+
* as soon as it's whole rather than waiting out the output throttle. */
|
|
110
|
+
const INTERACTIVE_RENDER_WINDOW_MS = 250;
|
|
111
|
+
const OSC52_PREFIX = "\x1b]52;";
|
|
112
|
+
const OSC52_MAX_BYTES = 1_000_000;
|
|
113
|
+
const OSC52_CARRY_MAX_BYTES = OSC52_MAX_BYTES + 4096;
|
|
114
|
+
const TERMINAL_PASSTHROUGH_MAX_BYTES = 5_000_000;
|
|
115
|
+
const TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES = TERMINAL_PASSTHROUGH_MAX_BYTES + 4096;
|
|
116
|
+
const KITTY_IMAGE_PREFIX = "\x1b_G";
|
|
117
|
+
const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
|
|
118
|
+
|
|
119
|
+
interface XtermParserLike {
|
|
120
|
+
registerCsiHandler(opts: { prefix: string; final: string }, cb: (params: (number | number[])[]) => boolean): { dispose(): void };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface XtermLike {
|
|
124
|
+
write(data: string, cb?: () => void): void;
|
|
125
|
+
resize(cols: number, rows: number): void;
|
|
126
|
+
parser?: XtermParserLike;
|
|
127
|
+
dispose?(): void;
|
|
128
|
+
// Full buffer wipe (@xterm/headless Terminal.reset): the attach client's
|
|
129
|
+
// "empty/resnapshot → UI resets its buffer" contract (phase 4, F3).
|
|
130
|
+
reset(): void;
|
|
131
|
+
buffer: {
|
|
132
|
+
active: {
|
|
133
|
+
baseY: number;
|
|
134
|
+
// "normal" | "alternate" (@xterm/headless IBuffer.type): an alternate
|
|
135
|
+
// buffer keeps no scrollback, which is what routes the fullscreen wheel
|
|
136
|
+
// to the child instead of the attach's own viewport (issue #185).
|
|
137
|
+
type: string;
|
|
138
|
+
cursorX?: number;
|
|
139
|
+
cursorY?: number;
|
|
140
|
+
length: number;
|
|
141
|
+
getLine(index: number): BufferLineLike | undefined;
|
|
142
|
+
getNullCell(): BufferCellLike;
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
_core?: {
|
|
146
|
+
coreService?: { isCursorHidden?: boolean };
|
|
147
|
+
_oscLinkService?: {
|
|
148
|
+
getLinkData?: (id: number) => { uri?: string } | undefined;
|
|
149
|
+
_dataByLinkId?: Map<number, { data?: { uri?: string } }>;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface BufferLineLike {
|
|
155
|
+
length: number;
|
|
156
|
+
getCell(x: number, cell?: BufferCellLike): BufferCellLike | undefined;
|
|
157
|
+
translateToString(trimRight?: boolean): string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface BufferCellLike {
|
|
161
|
+
getWidth(): number;
|
|
162
|
+
getChars(): string;
|
|
163
|
+
hasExtendedAttrs(): number;
|
|
164
|
+
extended?: { urlId?: number; _urlId?: number };
|
|
165
|
+
getFgColor(): number;
|
|
166
|
+
getBgColor(): number;
|
|
167
|
+
isFgRGB(): boolean;
|
|
168
|
+
isBgRGB(): boolean;
|
|
169
|
+
isFgPalette(): boolean;
|
|
170
|
+
isBgPalette(): boolean;
|
|
171
|
+
isFgDefault(): boolean;
|
|
172
|
+
isBgDefault(): boolean;
|
|
173
|
+
isBold(): number;
|
|
174
|
+
isItalic(): number;
|
|
175
|
+
isDim(): number;
|
|
176
|
+
isUnderline(): number;
|
|
177
|
+
isBlink(): number;
|
|
178
|
+
isInverse(): number;
|
|
179
|
+
isInvisible(): number;
|
|
180
|
+
isStrikethrough(): number;
|
|
181
|
+
isOverline(): number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface CommittedRow { cells: SnapshotLine; }
|
|
185
|
+
|
|
186
|
+
interface CommittedView {
|
|
187
|
+
identity: {
|
|
188
|
+
connectionToken: number;
|
|
189
|
+
baselineToken: number;
|
|
190
|
+
generation: string;
|
|
191
|
+
parser: XtermLike;
|
|
192
|
+
geometryRevision: number;
|
|
193
|
+
targetCols: number;
|
|
194
|
+
targetRows: number;
|
|
195
|
+
closed: boolean;
|
|
196
|
+
};
|
|
197
|
+
start: number;
|
|
198
|
+
end: number;
|
|
199
|
+
rows: CommittedRow[];
|
|
200
|
+
cursor: { row: number; col: number } | null;
|
|
201
|
+
cursorHidden: boolean;
|
|
202
|
+
viewportTop: number | null;
|
|
203
|
+
proofSeq: number | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Primitive cell snapshot used by committed rendering and interactions. */
|
|
207
|
+
class SnapshotCell implements BufferCellLike {
|
|
208
|
+
private constructor(
|
|
209
|
+
private readonly charsValue: string,
|
|
210
|
+
private readonly widthValue: number,
|
|
211
|
+
private readonly attrs: {
|
|
212
|
+
bold: number; dim: number; italic: number; underline: number; blink: number; inverse: number;
|
|
213
|
+
invisible: number; strikethrough: number; overline: number;
|
|
214
|
+
fgRGB: boolean; fgPalette: boolean; fg: number;
|
|
215
|
+
bgRGB: boolean; bgPalette: boolean; bg: number;
|
|
216
|
+
},
|
|
217
|
+
private readonly uriValue: string,
|
|
218
|
+
) {
|
|
219
|
+
this.extended = this.uriValue ? { urlId: 1, _urlId: 1 } : undefined;
|
|
220
|
+
}
|
|
221
|
+
static empty(): SnapshotCell {
|
|
222
|
+
return new SnapshotCell("", 0, {
|
|
223
|
+
bold: 0, dim: 0, italic: 0, underline: 0, blink: 0, inverse: 0,
|
|
224
|
+
invisible: 0, strikethrough: 0, overline: 0,
|
|
225
|
+
fgRGB: false, fgPalette: false, fg: -1, bgRGB: false, bgPalette: false, bg: -1,
|
|
226
|
+
}, "");
|
|
227
|
+
}
|
|
228
|
+
static fromBufferCell(cell: BufferCellLike, uri: string): SnapshotCell {
|
|
229
|
+
return new SnapshotCell(cell.getChars(), cell.getWidth(), {
|
|
230
|
+
bold: cell.isBold(), dim: cell.isDim(), italic: cell.isItalic(), underline: cell.isUnderline(),
|
|
231
|
+
blink: cell.isBlink(), inverse: cell.isInverse(), invisible: cell.isInvisible(),
|
|
232
|
+
strikethrough: cell.isStrikethrough(), overline: cell.isOverline(),
|
|
233
|
+
fgRGB: cell.isFgRGB(), fgPalette: cell.isFgPalette(), fg: cell.getFgColor(),
|
|
234
|
+
bgRGB: cell.isBgRGB(), bgPalette: cell.isBgPalette(), bg: cell.getBgColor(),
|
|
235
|
+
}, uri);
|
|
236
|
+
}
|
|
237
|
+
getWidth(): number { return this.widthValue; }
|
|
238
|
+
getChars(): string { return this.charsValue; }
|
|
239
|
+
hasExtendedAttrs(): number { return this.uriValue ? 1 : 0; }
|
|
240
|
+
extended?: { urlId?: number; _urlId?: number };
|
|
241
|
+
getFgColor(): number { return this.attrs.fg; }
|
|
242
|
+
getBgColor(): number { return this.attrs.bg; }
|
|
243
|
+
isFgRGB(): boolean { return this.attrs.fgRGB; }
|
|
244
|
+
isBgRGB(): boolean { return this.attrs.bgRGB; }
|
|
245
|
+
isFgPalette(): boolean { return this.attrs.fgPalette; }
|
|
246
|
+
isBgPalette(): boolean { return this.attrs.bgPalette; }
|
|
247
|
+
isFgDefault(): boolean { return !this.attrs.fgRGB && !this.attrs.fgPalette; }
|
|
248
|
+
isBgDefault(): boolean { return !this.attrs.bgRGB && !this.attrs.bgPalette; }
|
|
249
|
+
isBold(): number { return this.attrs.bold; }
|
|
250
|
+
isItalic(): number { return this.attrs.italic; }
|
|
251
|
+
isDim(): number { return this.attrs.dim; }
|
|
252
|
+
isUnderline(): number { return this.attrs.underline; }
|
|
253
|
+
isBlink(): number { return this.attrs.blink; }
|
|
254
|
+
isInverse(): number { return this.attrs.inverse; }
|
|
255
|
+
isInvisible(): number { return this.attrs.invisible; }
|
|
256
|
+
isStrikethrough(): number { return this.attrs.strikethrough; }
|
|
257
|
+
isOverline(): number { return this.attrs.overline; }
|
|
258
|
+
osc8Uri(): string { return this.uriValue; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
class SnapshotLine implements BufferLineLike {
|
|
262
|
+
constructor(readonly cells: SnapshotCell[]) {}
|
|
263
|
+
get length(): number { return this.cells.length; }
|
|
264
|
+
getCell(x: number): BufferCellLike | undefined { return this.cells[x]; }
|
|
265
|
+
translateToString(trimRight = false): string {
|
|
266
|
+
const text = this.cells.map((cell) => cell.getChars() || " ").join("");
|
|
267
|
+
return trimRight ? text.replace(/\s+$/u, "") : text;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
type MousePoint = { line: number; col: number };
|
|
272
|
+
type ScreenCell = { row: number; col: number };
|
|
273
|
+
/** Issue #187: `screenAnchor` is the RAW reported cell of the press; the drag/click
|
|
274
|
+
* decision compares it against the release cell, because buffer coordinates drift
|
|
275
|
+
* while the session streams. Absent on word selections (they are created on release). */
|
|
276
|
+
type MouseSelection = { anchor: MousePoint; focus: MousePoint; screenAnchor?: ScreenCell };
|
|
277
|
+
type NormalizedSelection = { start: MousePoint; end: MousePoint };
|
|
278
|
+
|
|
279
|
+
export class PtyAttachComponent implements Component {
|
|
280
|
+
private socket: Socket | null = null;
|
|
281
|
+
private connected = false;
|
|
282
|
+
private closed = false;
|
|
283
|
+
private status = "connecting";
|
|
284
|
+
/** Issue #147: in-place delete confirm is armed (first ctrl+x). */
|
|
285
|
+
private confirmArmed = false;
|
|
286
|
+
private parserBuffer = "";
|
|
287
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
288
|
+
private loadingTimer: ReturnType<typeof setInterval> | null = null;
|
|
289
|
+
private mouseRefreshTimers: Array<ReturnType<typeof setTimeout>> = [];
|
|
290
|
+
// Shrink-and-hold jiggle protocol (issue #25): on attach we resize the child to
|
|
291
|
+
// (cols-1, rows-1) and hold it there until the child emits a full clear
|
|
292
|
+
// (\x1b[2J) — any render the child makes while held sees a width delta and
|
|
293
|
+
// must fullRender, so boot-storm coalescing of ±1 pulses can no longer
|
|
294
|
+
// produce a net-zero change. The controller restores the original size on
|
|
295
|
+
// clear / first TUI frame (re-arm) / no-frame 6s guard / budget exhaustion /
|
|
296
|
+
// close / external resize (guards G1-G5, see controller module).
|
|
297
|
+
private readonly jiggleRetry = createJiggleRetryController({
|
|
298
|
+
sendResize: (cols, rows) => this.sendResize(cols, rows),
|
|
299
|
+
setTimeoutFn: (fn, ms) => {
|
|
300
|
+
const t = setTimeout(fn, ms);
|
|
301
|
+
t.unref?.();
|
|
302
|
+
return t;
|
|
303
|
+
},
|
|
304
|
+
clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
|
|
305
|
+
});
|
|
306
|
+
// Snapshot+subscribe attach client (issue #91 phase 4, D2). Created ONCE —
|
|
307
|
+
// reconnects re-point send at the CURRENT socket (this.send routes through
|
|
308
|
+
// this.socket) and resume from the applied cursor; wiring shape per the
|
|
309
|
+
// verified reference in test/terminal-snapshot.integration.test.mjs
|
|
310
|
+
// (wireClient). AGENT_BOARD_TERMINAL_SNAPSHOT=0 forces the legacy path
|
|
311
|
+
// (escape hatch + deterministic legacy tests).
|
|
312
|
+
private readonly attachClient = createTerminalAttachClient({
|
|
313
|
+
send: (msg) => this.send(msg),
|
|
314
|
+
emit: (event, payload) => this.handleAttachEvent(event, payload),
|
|
315
|
+
});
|
|
316
|
+
/** Decided attach path: "undecided" until the probe resolves (snapshot
|
|
317
|
+
* begin → protocol; probe timeout / version mismatch → legacy). Gates
|
|
318
|
+
* jiggle arming and probe-vs-cursor reconnects. */
|
|
319
|
+
private attachMode: "undecided" | "protocol" | "legacy" = "undecided";
|
|
320
|
+
private jiggleStartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
321
|
+
private osc52Carry = "";
|
|
322
|
+
private passthroughCarry = "";
|
|
323
|
+
private oscQueryCarry = "";
|
|
324
|
+
// Issue #170: inbound reply fragments (device attributes, kitty flags, cell
|
|
325
|
+
// size, DCS) are swallowed, and color replies are forwarded as one whole
|
|
326
|
+
// sequence instead of leaking into the child as garbage. Null when
|
|
327
|
+
// AGENT_BOARD_REPLY_GUARD=0 — the pre-#170 passthrough is the escape hatch.
|
|
328
|
+
private readonly replyGuard: ReturnType<typeof createReplyGuard> | null;
|
|
329
|
+
private replyGuardTimer: ReturnType<typeof setTimeout> | null = null;
|
|
330
|
+
private readonly connectStartedAt = Date.now();
|
|
331
|
+
// Set once the first socket connect succeeds; drives the reconnect give-up
|
|
332
|
+
// policy (short window after a live session dies, long window during cold
|
|
333
|
+
// host start — see pty-attach-reconnect.mjs).
|
|
334
|
+
private everConnected = false;
|
|
335
|
+
// Timestamp of the first disconnect that started the current reconnect
|
|
336
|
+
// cycle; reset on every successful (re)connect.
|
|
337
|
+
private disconnectedAt: number | null = null;
|
|
338
|
+
// Lines scrolled per mouse-wheel notch on the legacy (regular-mode) raw-SGR path,
|
|
339
|
+
// configurable via $AGENT_BOARD_WHEEL_LINES. The fullscreen handleMouse path uses
|
|
340
|
+
// the TUI's wheelDelta instead, which is already accelerated and line-quantified.
|
|
341
|
+
private readonly mouseWheelLines = resolveWheelLines();
|
|
342
|
+
/** Current local parser. Replaced atomically at an accepted snapshotReady. */
|
|
343
|
+
private term: XtermLike;
|
|
344
|
+
/** Monotonic identity axes: one per accepted socket / authoritative baseline / parser. */
|
|
345
|
+
private connectionToken = 0;
|
|
346
|
+
private baselineToken = 0;
|
|
347
|
+
private parserEpoch = 1;
|
|
348
|
+
private parserObservers: Array<{ dispose(): void }> = [];
|
|
349
|
+
private parserObservationSupported = true;
|
|
350
|
+
private replacementFailureCount = 0;
|
|
351
|
+
private parserSyncOpen = false;
|
|
352
|
+
private parserObserverEvents = 0;
|
|
353
|
+
private collectingConnectionToken = 0;
|
|
354
|
+
private collectingBaselineToken = 0;
|
|
355
|
+
private collectingParserEpoch = 0;
|
|
356
|
+
/** Distinct facts; none of these is presentation truth in this stage. */
|
|
357
|
+
private protocolBaselineState: "unverified" | "valid-empty" | "valid-content" = "unverified";
|
|
358
|
+
private localHydrationState: "idle" | "hydrating" | "complete" | "failed" = "idle";
|
|
359
|
+
private publishabilityState: "wait" | "unknown" | "ready" = "wait";
|
|
360
|
+
private firstAttachSideEffectsApplied = false;
|
|
361
|
+
private latestViewProof: unknown = null;
|
|
362
|
+
private viewProofRecords: unknown[] = [];
|
|
363
|
+
private viewProofRecordCount = 0;
|
|
364
|
+
/** Identity captured when the current proof/coverage was received. */
|
|
365
|
+
private viewProofIdentity: ReturnType<PtyAttachComponent["currentViewIdentity"]> | null = null;
|
|
366
|
+
/** Local geometry revision: incremented once per real differing-size transition. */
|
|
367
|
+
private geometryRevision = 0;
|
|
368
|
+
private resizeIntentRequired = false;
|
|
369
|
+
private resizeIntentSatisfied = false;
|
|
370
|
+
private resizeIntentId = 0;
|
|
371
|
+
// What the runner calls this resize. Its count spans every attach to the session and ours starts
|
|
372
|
+
// again at each attach, so a frame's proof can only be matched against the runner's own numbers.
|
|
373
|
+
private geometryNudges = 0;
|
|
374
|
+
private proofGeometryRevision = 0;
|
|
375
|
+
private lastProofMetadata: { geometryRevision?: unknown; intentId?: unknown } | undefined;
|
|
376
|
+
private proofIntentId = 0;
|
|
377
|
+
private pendingEarlySyncTarget: string | null = null;
|
|
378
|
+
/** Protocol transition facts; timers may mark expiry but never publish. */
|
|
379
|
+
private deadlineState: "pending" | "expired" = "pending";
|
|
380
|
+
private notReadyReason: string | null = null;
|
|
381
|
+
private parserWritesInFlight = 0;
|
|
382
|
+
private readonly attachStartedAt = Date.now();
|
|
383
|
+
private readonly viewProofTrailing = createSafeTrailingClassifier();
|
|
384
|
+
private recentProtocolOutput: Array<{ seq: number; data: string }> = [];
|
|
385
|
+
private proofResyncRequested = false;
|
|
386
|
+
/** Runner-reported reporter capability. Without it the attach keeps the
|
|
387
|
+
* accepted legacy deadline path (old/no-reporter hosts are not stranded). */
|
|
388
|
+
private frameWitnessAvailable = false;
|
|
389
|
+
/** Last complete modern projection installed at a real ?2026l boundary. */
|
|
390
|
+
private committedView: CommittedView | null = null;
|
|
391
|
+
/** Display-only same-generation/geometry recovery view; never readiness. */
|
|
392
|
+
private recoveryView: CommittedView | null = null;
|
|
393
|
+
private recoveryGeneration: string | null = null;
|
|
394
|
+
private currentWireGeneration: string | null = null;
|
|
395
|
+
private emptyResetPending = false;
|
|
396
|
+
private attachWaitToken = 0;
|
|
397
|
+
private cols = 120;
|
|
398
|
+
private rows = 24;
|
|
399
|
+
// Absolute buffer line shown at the top of the viewport. null means follow bottom.
|
|
400
|
+
private viewportTop: number | null = null;
|
|
401
|
+
// Issue #205: buffer line below which every row is pseudo scrollback that
|
|
402
|
+
// hydrating a snapshot frame into this (smaller) terminal manufactured. Only
|
|
403
|
+
// rows above it may answer a local wheel notch; the rest is forwarded.
|
|
404
|
+
private historyFloor = 0;
|
|
405
|
+
// Runtime desync backstop state (issue #11): last socket-output timestamp,
|
|
406
|
+
// last heal timestamp, the probe timer, and an injectable clock for tests.
|
|
407
|
+
private lastOutputAt = 0;
|
|
408
|
+
private lastHealAt = 0;
|
|
409
|
+
private desyncProbeTimer: ReturnType<typeof setInterval> | null = null;
|
|
410
|
+
/** Injectable clock for desync gating (tests override this). */
|
|
411
|
+
private nowFn: () => number = () => Date.now();
|
|
412
|
+
private selection: MouseSelection | null = null;
|
|
413
|
+
/** Issue #172: bumped on every left press so a settle still queued for the previous
|
|
414
|
+
* gesture cannot read or clear a selection the next press has already started. */
|
|
415
|
+
private selectionGestureSerial = 0;
|
|
416
|
+
private selectionDragging = false;
|
|
417
|
+
private selectionAutoScrollTimer: ReturnType<typeof setInterval> | null = null;
|
|
418
|
+
private selectionAutoScrollLines = 0;
|
|
419
|
+
private selectionAutoScrollMouse: { row: number; col: number } | null = null;
|
|
420
|
+
private pendingClickTimer: ReturnType<typeof setTimeout> | null = null;
|
|
421
|
+
private readonly quitPresses = createDoublePress(Date.now, QUIT_PRESS_MS);
|
|
422
|
+
private lastClickPoint: MousePoint | null = null;
|
|
423
|
+
private lastClickAt = 0;
|
|
424
|
+
/** Authoritative editor emptiness pushed by the child Pi extension via the
|
|
425
|
+
* control socket (issue #68). null = unknown — forward conservatively
|
|
426
|
+
* (spec §D1); only true detaches. */
|
|
427
|
+
private editorEmpty: boolean | null = null;
|
|
428
|
+
// Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
|
|
429
|
+
// loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
|
|
430
|
+
// the previous screen visible.
|
|
431
|
+
private receivedOutput = false;
|
|
432
|
+
// Attach transition: hold the loading banner until the screen-log replay and the
|
|
433
|
+
// initial resize-jiggle redraws settle, so attach never visibly scrolls/flashes the
|
|
434
|
+
// buffer. `receivedOutput` tracks buffer content; `attaching` gates whether we paint it.
|
|
435
|
+
private attaching = true;
|
|
436
|
+
private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
437
|
+
private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
438
|
+
// Last scheme delivered to the child at settle (issue #161). Starts null:
|
|
439
|
+
// null never matches the duplicate check, so a timeout-then-late sequence
|
|
440
|
+
// still delivers. Single-shot per attach — settle runs at most once.
|
|
441
|
+
private sentScheme: TerminalColorScheme | null = null;
|
|
442
|
+
// Pi enables bracketed paste AFTER entering raw mode and disables it at
|
|
443
|
+
// shutdown. Only current runner output/snapshots may establish readiness;
|
|
444
|
+
// the screen.log warm-start can belong to a previous child incarnation.
|
|
445
|
+
private childInputReady = false;
|
|
446
|
+
private childInputModeCarry = "";
|
|
447
|
+
private pendingSchemeReport: { scheme: TerminalColorScheme; api?: "colors" | "background" | "none"; late?: boolean } | null = null;
|
|
448
|
+
// Issue #128 D2: unsubscriber for the color-scheme bridge registered at
|
|
449
|
+
// attach settle; nulled on every terminal path so the TUI never keeps a
|
|
450
|
+
// listener pointing at a dead attach surface.
|
|
451
|
+
private colorSchemeBridgeUnsubscribe: (() => void) | null = null;
|
|
452
|
+
private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
453
|
+
// Force a single full-clear on the first paint so the prior session/dashboard can't
|
|
454
|
+
// ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
|
|
455
|
+
// differential renderer so wheel/output bursts don't each trigger a full repaint.
|
|
456
|
+
private firstPaint = true;
|
|
457
|
+
// Coalesce live PTY output repaints to ~25fps. node-pty splits one child-TUI update
|
|
458
|
+
// into many small chunks; painting after each chunk would drive the outer TUI to its
|
|
459
|
+
// frame cap and expose intermediate frames (visible as flicker on a busy session).
|
|
460
|
+
private readonly outputRenderScheduler = createAttachOutputRenderScheduler(() => this.scheduleRender());
|
|
461
|
+
// Fold pi-tui's post-frame cursor-park writes back into the frame's sync block so the
|
|
462
|
+
// terminal reports one stable IME cursor rect per frame instead of two (issue #28).
|
|
463
|
+
// Never active when AGENT_BOARD_IME_FIX=0; no-op passthrough if pi-tui changes shape.
|
|
464
|
+
private readonly imeCoalesceUninstall: (() => void) | null;
|
|
465
|
+
private readonly releaseInput: () => void;
|
|
466
|
+
private lastUserInputAt = Number.NEGATIVE_INFINITY;
|
|
467
|
+
|
|
468
|
+
constructor(
|
|
469
|
+
private readonly tui: TUI,
|
|
470
|
+
private readonly theme: ThemeLike,
|
|
471
|
+
private readonly keybindings: KeybindingsManager,
|
|
472
|
+
private readonly done: (result: PtyAttachResult) => void,
|
|
473
|
+
private readonly opts: PtyAttachOptions,
|
|
474
|
+
) {
|
|
475
|
+
const size = this.currentSize();
|
|
476
|
+
this.cols = size.cols;
|
|
477
|
+
this.rows = size.rows;
|
|
478
|
+
// Assigned in the body (not as a field initializer) so it runs after the `tui`
|
|
479
|
+
// parameter property is set regardless of the TS loader's field-init semantics.
|
|
480
|
+
this.imeCoalesceUninstall = installImeCursorCoalesce(this.tui);
|
|
481
|
+
this.releaseInput = holdInputListeners(this.tui);
|
|
482
|
+
this.term = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true });
|
|
483
|
+
this.parserObservers = this.installParserObservers(this.term, this.parserEpoch);
|
|
484
|
+
this.parserObservationSupported = this.parserObservers.length > 0;
|
|
485
|
+
if (!this.parserObservationSupported) this.attachClient.forceLegacy("parser_observation_unavailable");
|
|
486
|
+
// Keep mouse reporting enabled by default so wheel scrolling and local drag-to-copy
|
|
487
|
+
// selection can coexist inside the attach surface. Set AGENT_BOARD_ATTACH_MOUSE=0
|
|
488
|
+
// to fall back to terminal-native selection only.
|
|
489
|
+
this.disableMouseScroll();
|
|
490
|
+
this.enableMouseScroll();
|
|
491
|
+
this.refreshMouseScrollMode();
|
|
492
|
+
this.assertFullscreenMouseMode();
|
|
493
|
+
this.replayScreenLog();
|
|
494
|
+
this.replyGuard = isReplyGuardEnabled(process.env.AGENT_BOARD_REPLY_GUARD) ? createReplyGuard() : null;
|
|
495
|
+
this.connect();
|
|
496
|
+
this.startLoadingTicker();
|
|
497
|
+
// Paint immediately (forced once) so the loading banner replaces the previous
|
|
498
|
+
// surface the instant we attach, rather than after the first reconnect tick.
|
|
499
|
+
this.scheduleRender();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
handleInput(data: string): void {
|
|
503
|
+
// Issue #189: dispatch per segment instead of all-or-nothing. A read that mixes mouse
|
|
504
|
+
// reports with other bytes used to fall through to the keyboard branch as a whole: the
|
|
505
|
+
// gesture was dropped, an in-progress selection cleared, the viewport reset, and the raw
|
|
506
|
+
// mouse bytes forwarded into the hosted session as typed input.
|
|
507
|
+
//
|
|
508
|
+
// Issue #170/#194 composition: the inbound reply guard runs per non-mouse segment, so a
|
|
509
|
+
// recognized report segment can never absorb keys that arrived in the same read — the
|
|
510
|
+
// coalescing case #189 exists for (a still-fragmented head keeps the bounded #194
|
|
511
|
+
// absorption: 64 bytes / 250ms, kill-switchable). The guard's decision wins for complete
|
|
512
|
+
// protocol replies (dropped) and colour replies (forwarded as one whole sequence);
|
|
513
|
+
// whatever it passes falls through to the kind-based dispatch below.
|
|
514
|
+
for (const segment of splitAttachInput(data)) {
|
|
515
|
+
if (segment.kind === "mouse") {
|
|
516
|
+
// Issue #147: a mouse gesture on either channel cancels the armed
|
|
517
|
+
// delete confirm — it is real user intent, unlike a terminal report.
|
|
518
|
+
this.clearConfirmArmed();
|
|
519
|
+
this.handleMouseInputChunk(segment.events);
|
|
520
|
+
} else if (!this.consumeByReplyGuard(segment.data)) {
|
|
521
|
+
if (segment.kind === "report") this.send({ type: "input", data: segment.data });
|
|
522
|
+
else this.handleKeyInput(segment.data);
|
|
523
|
+
}
|
|
524
|
+
// A detach/close inside a segment must stop the rest of this read.
|
|
525
|
+
if (this.closed) return;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Issue #170/#194: feed one non-mouse segment through the inbound reply guard. Returns true
|
|
530
|
+
* when the guard took the segment — a complete protocol reply is dropped, a colour reply is
|
|
531
|
+
* forwarded as one whole sequence — and false when the caller should handle it. */
|
|
532
|
+
private consumeByReplyGuard(data: string): boolean {
|
|
533
|
+
const guard = this.replyGuard;
|
|
534
|
+
if (!guard) return false;
|
|
535
|
+
const decision = guard.feed(data, this.nowFn());
|
|
536
|
+
if (guard.pendingSince() !== null) this.armReplyGuardTimer();
|
|
537
|
+
if (decision.action === "forward") {
|
|
538
|
+
// Color replies only work for the child's parsers as one sequence.
|
|
539
|
+
this.send({ type: "input", data: decision.data });
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
return decision.action === "swallow";
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Keyboard branch of `handleInput` (issue #189: one segment of the read at a time). Terminal
|
|
546
|
+
* *reports* never reach the key branches: a report the reply guard does not own is forwarded
|
|
547
|
+
* by `handleInput` without touching the selection or the viewport, because it carries no user
|
|
548
|
+
* intent (focus events, query replies). */
|
|
549
|
+
private handleKeyInput(data: string): void {
|
|
550
|
+
this.lastUserInputAt = this.nowFn();
|
|
551
|
+
if (this.keybindings?.matches?.(data, "app.suspend")) {
|
|
552
|
+
suspendToShell(this.tui, {
|
|
553
|
+
// This view's mouse reporting isn't Pi's to turn off, and the shell mustn't get it.
|
|
554
|
+
beforeStop: () => this.disableMouseScroll(),
|
|
555
|
+
afterResume: () => {
|
|
556
|
+
this.enableMouseScroll();
|
|
557
|
+
this.assertFullscreenMouseMode();
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// Issue #147: in-place delete gesture. The resolver is pure and never
|
|
563
|
+
// reads connection state, so the gesture works even while the host is
|
|
564
|
+
// down. cancel falls through so input is never swallowed.
|
|
565
|
+
const deleteKey = resolveAttachDeleteKey({ confirmArmed: this.confirmArmed }, data);
|
|
566
|
+
this.confirmArmed = deleteKey.confirmArmed;
|
|
567
|
+
if (deleteKey.action === "arm") {
|
|
568
|
+
this.scheduleRender();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (deleteKey.action === "confirm") {
|
|
572
|
+
// Teardown first — same shape as detach() minus the control frame:
|
|
573
|
+
// close(true) restores a held PTY (G3, issue #42) and gracefully
|
|
574
|
+
// ends the socket; the consumer archives and the host is terminated,
|
|
575
|
+
// so no detach packet is sent. done() before archive so the host's
|
|
576
|
+
// death is never rendered here.
|
|
577
|
+
this.close(true);
|
|
578
|
+
this.done({ action: "delete" });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (deleteKey.action === "cancel-swallow") {
|
|
582
|
+
this.scheduleRender();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (deleteKey.action === "cancel") this.scheduleRender();
|
|
586
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
587
|
+
this.clearPendingClick();
|
|
588
|
+
this.clearSelection();
|
|
589
|
+
if (this.tryScrollBy(this.pageSize())) return;
|
|
590
|
+
this.send({ type: "input", data });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
594
|
+
this.clearPendingClick();
|
|
595
|
+
this.clearSelection();
|
|
596
|
+
if (this.tryScrollBy(-this.pageSize())) return;
|
|
597
|
+
this.send({ type: "input", data });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (matchesKey(data, Key.home)) {
|
|
601
|
+
this.clearPendingClick();
|
|
602
|
+
this.clearSelection();
|
|
603
|
+
if (this.tryScrollToTop()) return;
|
|
604
|
+
this.send({ type: "input", data });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (matchesKey(data, Key.end)) {
|
|
608
|
+
this.clearPendingClick();
|
|
609
|
+
this.clearSelection();
|
|
610
|
+
if (this.tryScrollToBottom()) return;
|
|
611
|
+
this.send({ type: "input", data });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
// The first goes to the session as usual. The second never does, so the session lives on.
|
|
615
|
+
if (!matchesKey(data, Key.ctrl("c"))) this.quitPresses.reset();
|
|
616
|
+
else if (this.quitPresses.press()) return this.detach({ action: "quit" });
|
|
617
|
+
if (matchesKey(data, "ctrl+\\")) {
|
|
618
|
+
// Unconditional, terminal-independent escape (issue #126): 0x1c is a
|
|
619
|
+
// single raw ASCII byte every terminal forwards unchanged in raw
|
|
620
|
+
// mode — no modifier-encoding dependency like Ctrl+Left (macOS
|
|
621
|
+
// Terminal.app never emits it, WezTerm consumes it for tab
|
|
622
|
+
// switching). Only caveat (documented in README): layouts where
|
|
623
|
+
// `\` sits on AltGr may never produce this byte; the escape ladder
|
|
624
|
+
// still covers those. matchesKey also covers the kitty CSI-u and
|
|
625
|
+
// modifyOtherKeys encodings of the same chord.
|
|
626
|
+
this.detach();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (matchesKey(data, Key.ctrl("left"))) {
|
|
630
|
+
// Explicit detach chord (issue #89): single ← is gated on editor state
|
|
631
|
+
// (it doubles as cursor-left inside a non-empty draft), so a user with
|
|
632
|
+
// a draft had no way out short of Ctrl+C/D, which kills the child Pi.
|
|
633
|
+
// Ctrl+← is unambiguous intent — detach unconditionally, regardless of
|
|
634
|
+
// editor state or socket liveness (same always-exitable guarantee as
|
|
635
|
+
// the disconnected-← escape, issue #48).
|
|
636
|
+
this.detach();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (matchesKey(data, Key.left)) {
|
|
640
|
+
// While the socket is down the key can never reach the child, so
|
|
641
|
+
// escape unconditionally — the view must always be exitable, even
|
|
642
|
+
// after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
|
|
643
|
+
// a detach key — it passes through to Pi (tui.editor.jumpForward).
|
|
644
|
+
// Issue #91 Phase 6 (spec §D1): the gate reads ONLY the pushed
|
|
645
|
+
// editorEmpty side channel — true detaches; false and null (child
|
|
646
|
+
// extension missing, hello not yet arrived, old runner) forward via
|
|
647
|
+
// the explicit conservative policy. The old terminal-buffer
|
|
648
|
+
// heuristics are deleted for good: rendered bytes carry no
|
|
649
|
+
// input-buffer semantics, so that chain guessed at a fact it
|
|
650
|
+
// could not know (issues #42/#66/#69/#103). Escape without a pushed
|
|
651
|
+
// state is Ctrl+← (issue #89), never a buffer guess.
|
|
652
|
+
if (shouldEscapeAttach(this.connected, this.editorEmpty === true)) {
|
|
653
|
+
this.detach();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
this.clearPendingClick();
|
|
657
|
+
this.clearSelection();
|
|
658
|
+
this.scrollToBottom();
|
|
659
|
+
this.send({ type: "input", data });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
this.clearPendingClick();
|
|
663
|
+
this.clearSelection();
|
|
664
|
+
this.scrollToBottom();
|
|
665
|
+
this.send({ type: "input", data });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Issue #167: fullscreen TUI (pi ≥1.0) consumes raw SGR mouse sequences in
|
|
669
|
+
* TuiAltScreen and delivers normalized events to overlays here instead — the
|
|
670
|
+
* legacy handleInput mouse path never runs in that mode. This handler owns the
|
|
671
|
+
* wheel and the middle-click paste; every other event returns undefined so pi's
|
|
672
|
+
* own fullscreen selection keeps working. */
|
|
673
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
674
|
+
// Issue #147: clear the armed delete confirm on the fullscreen mouse
|
|
675
|
+
// channel BEFORE any routing — wheel consumed locally, wheel forwarded to
|
|
676
|
+
// the child (#185), selection mirror, and paste all count as "any other
|
|
677
|
+
// key cancels", whether or not the event ends up consumed.
|
|
678
|
+
this.clearConfirmArmed();
|
|
679
|
+
// Issue #173 follow-up: the renderer's routeWheel() fallback only scrolls real
|
|
680
|
+
// ScrollView components, while the attach viewport paints its own xterm
|
|
681
|
+
// scrollback — so a wheel event returned unconsumed scrolls nothing and the
|
|
682
|
+
// fullscreen wheel goes dead (regular mode is unaffected: it arrives as a raw
|
|
683
|
+
// SGR sequence through handleInput instead). Consume it locally, honoring the
|
|
684
|
+
// same kill switches as the legacy path, and fall through when scrolling is
|
|
685
|
+
// disabled or the viewport cannot actually move.
|
|
686
|
+
//
|
|
687
|
+
// Issue #185: an alternate-screen child leaves that scrollback inert (an
|
|
688
|
+
// alternate buffer keeps no history), so "local" routing can never move
|
|
689
|
+
// there. Forward one synthesized SGR wheel report instead and let the child
|
|
690
|
+
// scroll its own viewport — the same fallback PageUp/PageDown already use
|
|
691
|
+
// when the local viewport cannot move.
|
|
692
|
+
//
|
|
693
|
+
// Issue #198: the buffer type alone cannot decide this. A real fullscreen
|
|
694
|
+
// child sends `\x1b[?1049h` once at startup, so for any session older than
|
|
695
|
+
// the 100 KB screen-log replay window (and with no alternate-screen state
|
|
696
|
+
// in the snapshot DTO) this buffer reads `normal` while holding no
|
|
697
|
+
// scrollback at all. The wheel must therefore forward whenever the local
|
|
698
|
+
// viewport cannot actually move, not only when the type is known.
|
|
699
|
+
const wheelLinesUp = wheelScrollLinesUp(event);
|
|
700
|
+
if (wheelLinesUp !== 0) {
|
|
701
|
+
const route = resolveAttachWheelRoute({
|
|
702
|
+
bufferType: this.term.buffer.active.type,
|
|
703
|
+
wheelLinesUp,
|
|
704
|
+
mouseEnabled: this.mouseScrollEnabled(),
|
|
705
|
+
});
|
|
706
|
+
if (route === "none") return undefined;
|
|
707
|
+
if (route === "local" && this.tryScrollBy(wheelLinesUp)) return { handled: true, render: false };
|
|
708
|
+
// "child", or "local" with a viewport that cannot move. Consume only on
|
|
709
|
+
// effect: when the runner socket is down the forward would be a silent
|
|
710
|
+
// no-op (send() drops it), so hand the event back to pi instead of
|
|
711
|
+
// swallowing it pointlessly.
|
|
712
|
+
if (!this.socket || !this.connected) return undefined;
|
|
713
|
+
this.clearPendingClick();
|
|
714
|
+
this.clearSelection();
|
|
715
|
+
this.forwardWheelToChild(wheelLinesUp, event);
|
|
716
|
+
return { handled: true, render: false };
|
|
717
|
+
}
|
|
718
|
+
const nativePasteEnabled = process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE !== "0";
|
|
719
|
+
// Issue #172: a press starts a new gesture. Two reports can share one stdin chunk
|
|
720
|
+
// (pi-tui emits them synchronously in a loop), so the settle queued by the previous
|
|
721
|
+
// release must not consume the selection this press just started.
|
|
722
|
+
if (event.type === "press" && event.button === "left") this.selectionGestureSerial += 1;
|
|
723
|
+
if (isFullscreenSelectionEnd(event)) {
|
|
724
|
+
// Observe only — pi's fullscreen selection keeps the event. Overlays are
|
|
725
|
+
// dispatched before TuiAltScreen finalizes (and copies) the selection for this
|
|
726
|
+
// event, so settling must run deferred (issue #174).
|
|
727
|
+
const serial = this.selectionGestureSerial;
|
|
728
|
+
process.nextTick(() => {
|
|
729
|
+
if (serial !== this.selectionGestureSerial) return;
|
|
730
|
+
this.settleFullscreenSelection(nativePasteEnabled);
|
|
731
|
+
});
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
const action = resolveAttachMouseAction(event, { nativePasteEnabled });
|
|
735
|
+
if (action !== "paste-primary") return undefined;
|
|
736
|
+
this.clearPendingClick();
|
|
737
|
+
this.clearSelection();
|
|
738
|
+
this.pastePrimarySelection();
|
|
739
|
+
return { handled: true, render: false };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
render(width: number): string[] {
|
|
743
|
+
if (this.closed) return [];
|
|
744
|
+
this.resizeIfNeeded(width);
|
|
745
|
+
const height = this.tui.terminal?.rows ?? 24;
|
|
746
|
+
const bodyHeight = Math.max(1, height - 2);
|
|
747
|
+
let body: string[];
|
|
748
|
+
if (this.modernCommittedMode() && this.emptyResetPending) {
|
|
749
|
+
body = Array.from({ length: bodyHeight }, () => "");
|
|
750
|
+
} else if (!this.attaching && this.receivedOutput) {
|
|
751
|
+
const projected = this.project(bodyHeight);
|
|
752
|
+
body = projected.lines;
|
|
753
|
+
while (body.length < bodyHeight) body.unshift("");
|
|
754
|
+
} else {
|
|
755
|
+
body = this.renderLoading(bodyHeight, width);
|
|
756
|
+
}
|
|
757
|
+
const header = this.confirmArmed
|
|
758
|
+
? this.theme.fg("accent", this.theme.bold(` Delete "${truncateToWidth(this.opts.title, 24, "")}"? `)) +
|
|
759
|
+
this.theme.fg("muted", "y confirms · any other key cancels")
|
|
760
|
+
: this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
|
|
761
|
+
this.theme.fg("muted", `${this.status} · opens links · drag selects+copies · Ctrl+←/Ctrl+\\ detach`);
|
|
762
|
+
return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Centered "loading" surface shown until the first PTY output paints the session. */
|
|
766
|
+
private renderLoading(height: number, width: number): string[] {
|
|
767
|
+
const elapsedMs = Date.now() - this.connectStartedAt;
|
|
768
|
+
const spinner = this.closed ? "·" : SPINNER[Math.floor(elapsedMs / LOADING_TICK_MS) % SPINNER.length];
|
|
769
|
+
const title = `${spinner} Loading "${this.opts.title}"…`;
|
|
770
|
+
const detail = this.loadingDetail(Math.max(0, Math.round(elapsedMs / 1000)));
|
|
771
|
+
const out: string[] = [];
|
|
772
|
+
const top = Math.max(0, Math.floor((height - 3) / 2));
|
|
773
|
+
for (let i = 0; i < top; i++) out.push("");
|
|
774
|
+
out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
|
|
775
|
+
out.push(center(this.theme.fg("muted", detail), width));
|
|
776
|
+
out.push("");
|
|
777
|
+
out.push(center(this.theme.fg("dim", "Ctrl+←/Ctrl+\\ to detach"), width));
|
|
778
|
+
while (out.length < height) out.push("");
|
|
779
|
+
return out.slice(0, height);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private loadingDetail(elapsedSeconds: number): string {
|
|
783
|
+
if (this.status === "attached") return "Attached · waiting for the session to render…";
|
|
784
|
+
if (this.status.startsWith("error") || this.status === "host exited" || this.status === "host not reachable") return this.status;
|
|
785
|
+
if (this.status === "disconnected") return `Reconnecting to the session host… ${elapsedSeconds}s`;
|
|
786
|
+
return `Starting the session host… ${elapsedSeconds}s`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
invalidate(): void {}
|
|
790
|
+
|
|
791
|
+
dispose(): void {
|
|
792
|
+
this.close();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private clearConfirmArmed(): void {
|
|
796
|
+
if (!this.confirmArmed) return;
|
|
797
|
+
this.confirmArmed = false;
|
|
798
|
+
this.scheduleRender();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private detach(result: PtyAttachResult = { action: "detached" }): void {
|
|
802
|
+
// Restore a held PTY before ending the control socket. The runner closes
|
|
803
|
+
// the socket immediately after receiving detach, so sending detach first
|
|
804
|
+
// can drop the G3 restore resize (issue #42).
|
|
805
|
+
this.jiggleRetry.restoreAndStop();
|
|
806
|
+
// Phase 5 (D4): enveloped detach (correlated applied ack) with legacy
|
|
807
|
+
// fallback; both reach the same runner detach handler.
|
|
808
|
+
if (!this.attachClient.sendControl("detach")) {
|
|
809
|
+
this.send({ type: "detach" });
|
|
810
|
+
}
|
|
811
|
+
this.close(true);
|
|
812
|
+
this.done(result);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Install the component's public ?2026 observers; false return keeps xterm defaults. */
|
|
816
|
+
private installParserObservers(term: XtermLike, epoch: number): Array<{ dispose(): void }> {
|
|
817
|
+
try {
|
|
818
|
+
const parser = term.parser;
|
|
819
|
+
if (!parser || typeof parser.registerCsiHandler !== "function") return [];
|
|
820
|
+
const has2026 = (params: readonly (number | number[])[]) => params.some((p) => p === 2026 || (Array.isArray(p) && p.includes(2026)));
|
|
821
|
+
return [
|
|
822
|
+
parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
|
|
823
|
+
if (this.closed || this.term !== term || this.parserEpoch !== epoch) return false;
|
|
824
|
+
if (has2026(params)) {
|
|
825
|
+
this.parserSyncOpen = true;
|
|
826
|
+
this.parserObserverEvents += 1;
|
|
827
|
+
}
|
|
828
|
+
return false;
|
|
829
|
+
}),
|
|
830
|
+
parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
|
|
831
|
+
if (this.closed || this.term !== term || this.parserEpoch !== epoch) return false;
|
|
832
|
+
if (has2026(params)) {
|
|
833
|
+
this.parserSyncOpen = false;
|
|
834
|
+
this.parserObserverEvents += 1;
|
|
835
|
+
this.commitCommittedView(term, epoch);
|
|
836
|
+
}
|
|
837
|
+
return false;
|
|
838
|
+
}),
|
|
839
|
+
];
|
|
840
|
+
} catch {
|
|
841
|
+
return [];
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Replace the local parser exactly at an accepted authoritative snapshotReady:
|
|
847
|
+
* same constructor options, observers installed before the first frame write.
|
|
848
|
+
* Creation/observer failure is fail-closed (old parser retained, no write).
|
|
849
|
+
*/
|
|
850
|
+
private replaceParser(): boolean {
|
|
851
|
+
// Preserve the last complete same-generation view before a nonempty
|
|
852
|
+
// resnapshot replaces the parser. An empty baseline clears it later in
|
|
853
|
+
// the same snapshotReady event.
|
|
854
|
+
this.captureRecoveryView();
|
|
855
|
+
let next: XtermLike;
|
|
856
|
+
try {
|
|
857
|
+
next = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true });
|
|
858
|
+
} catch {
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
const nextEpoch = this.parserEpoch + 1;
|
|
862
|
+
const observers = this.installParserObservers(next, nextEpoch);
|
|
863
|
+
if (observers.length === 0) {
|
|
864
|
+
try { next.dispose?.(); } catch { /* best effort */ }
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
const previous = this.term;
|
|
868
|
+
const previousObservers = this.parserObservers;
|
|
869
|
+
this.term = next;
|
|
870
|
+
this.parserObservers = observers;
|
|
871
|
+
this.parserEpoch = nextEpoch;
|
|
872
|
+
this.baselineToken += 1;
|
|
873
|
+
this.replacementFailureCount = 0;
|
|
874
|
+
this.parserObservationSupported = true;
|
|
875
|
+
this.parserSyncOpen = false;
|
|
876
|
+
this.parserWritesInFlight = 0;
|
|
877
|
+
this.committedView = null;
|
|
878
|
+
this.historyFloor = 0;
|
|
879
|
+
this.viewportTop = null;
|
|
880
|
+
this.selection = null;
|
|
881
|
+
this.selectionDragging = false;
|
|
882
|
+
this.localHydrationState = "idle";
|
|
883
|
+
for (const observer of previousObservers) {
|
|
884
|
+
try { observer.dispose(); } catch { /* best effort */ }
|
|
885
|
+
}
|
|
886
|
+
try { previous.dispose?.(); } catch { /* best effort */ }
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private disposeCurrentParser(): void {
|
|
891
|
+
for (const observer of this.parserObservers) {
|
|
892
|
+
try { observer.dispose(); } catch { /* best effort */ }
|
|
893
|
+
}
|
|
894
|
+
this.parserObservers = [];
|
|
895
|
+
try { this.term.dispose?.(); } catch { /* best effort */ }
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Bounded fail-closed policy for a failed authoritative parser replacement. */
|
|
899
|
+
private handleParserReplacementFailure(): void {
|
|
900
|
+
this.protocolBaselineState = "unverified";
|
|
901
|
+
this.localHydrationState = "failed";
|
|
902
|
+
this.publishabilityState = "wait";
|
|
903
|
+
this.latestViewProof = null;
|
|
904
|
+
this.viewProofRecords = [];
|
|
905
|
+
this.viewProofRecordCount = 0;
|
|
906
|
+
this.replacementFailureCount += 1;
|
|
907
|
+
if (this.replacementFailureCount <= 3) this.attachClient.requestResync("parser_replacement_failed");
|
|
908
|
+
else this.attachClient.forceLegacy("parser_replacement_failed");
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private connect(): void {
|
|
912
|
+
if (this.closed || this.connected || this.socket) return;
|
|
913
|
+
// On Windows the control socket is a named pipe — it never exists as a
|
|
914
|
+
// filesystem entry, so existsSync can't gate the connect; a missing pipe
|
|
915
|
+
// surfaces as an error event on the socket and retries via scheduleReconnect.
|
|
916
|
+
if (process.platform !== "win32" && !existsSync(this.opts.socketPath)) {
|
|
917
|
+
this.status = `starting host… ${Math.ceil((Date.now() - this.connectStartedAt) / 1000)}s`;
|
|
918
|
+
this.scheduleReconnect();
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const socket = createConnection(this.opts.socketPath);
|
|
922
|
+
this.socket = socket;
|
|
923
|
+
// A partial protocol line left over from a failed socket would prefix the
|
|
924
|
+
// replacement connection's first line; start each socket with a clean buffer.
|
|
925
|
+
this.parserBuffer = "";
|
|
926
|
+
socket.on("connect", () => {
|
|
927
|
+
if (this.socket !== socket) {
|
|
928
|
+
try { socket.destroy(); } catch { /* best-effort stale-socket cleanup */ }
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
this.clearRetry();
|
|
932
|
+
this.outputRenderScheduler.cancel();
|
|
933
|
+
this.captureRecoveryView();
|
|
934
|
+
this.connected = true;
|
|
935
|
+
this.everConnected = true;
|
|
936
|
+
this.connectionToken += 1;
|
|
937
|
+
this.committedView = null;
|
|
938
|
+
this.currentWireGeneration = null;
|
|
939
|
+
// Do not paint the previous generation between reconnect and its
|
|
940
|
+
// confirming hello/status; same-generation status clears this gate.
|
|
941
|
+
this.emptyResetPending = true;
|
|
942
|
+
this.disconnectedAt = null;
|
|
943
|
+
this.status = this.recoveryView !== null ? "reconnecting…" : "attached";
|
|
944
|
+
this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
|
|
945
|
+
// Snapshot+subscribe attach (issue #91 phase 4): the first connect
|
|
946
|
+
// probes with subscribe_terminal (old runners stay silent → probe
|
|
947
|
+
// timeout → legacy); protocol reconnects resume from the applied
|
|
948
|
+
// cursor over the SAME client instance (ring replay is seamless;
|
|
949
|
+
// eviction/restart answers a fresh snapshot). hello stays UI-owned
|
|
950
|
+
// and precedes the client's subscribe (established ordering).
|
|
951
|
+
if (this.attachMode === "undecided") this.attachClient.start();
|
|
952
|
+
else if (this.attachMode === "protocol") this.attachClient.reconnect(this.attachClient.getLastSeq());
|
|
953
|
+
// The shrink-and-hold redraw protocol is a LEGACY-path correctness
|
|
954
|
+
// tool: snapshot mode owns screen correctness via canonical frames,
|
|
955
|
+
// so the jiggle never runs there (armLegacyJiggle guards the race).
|
|
956
|
+
this.armLegacyJiggle();
|
|
957
|
+
this.enableMouseScroll();
|
|
958
|
+
if (!this.emptyResetPending) this.scheduleRender();
|
|
959
|
+
this.startAttachSettle();
|
|
960
|
+
});
|
|
961
|
+
// One read can end halfway through a character, and big frames always cross reads.
|
|
962
|
+
const decoder = new StringDecoder("utf8");
|
|
963
|
+
socket.on("data", (chunk) => {
|
|
964
|
+
if (this.socket !== socket) return;
|
|
965
|
+
this.onSocketData(decoder.write(chunk));
|
|
966
|
+
});
|
|
967
|
+
socket.on("close", () => {
|
|
968
|
+
// A failed socket can close after a replacement connection has already
|
|
969
|
+
// succeeded. Never let that stale event clear the replacement state.
|
|
970
|
+
if (this.socket !== socket) return;
|
|
971
|
+
this.captureRecoveryView();
|
|
972
|
+
this.socket = null;
|
|
973
|
+
this.connected = false;
|
|
974
|
+
this.disconnectedAt ??= Date.now();
|
|
975
|
+
// A drop inside the probe window must not leave the absolute 1500ms
|
|
976
|
+
// deadline armed: firing during the dead window would permanently
|
|
977
|
+
// downgrade a protocol-capable runner to legacy (CR R1 advisory). The
|
|
978
|
+
// reconnect's start()/reconnect() re-arms on the new socket.
|
|
979
|
+
this.attachClient.onDisconnect();
|
|
980
|
+
if (!this.closed && this.status !== "host exited") {
|
|
981
|
+
this.status = "disconnected";
|
|
982
|
+
this.scheduleReconnect();
|
|
983
|
+
}
|
|
984
|
+
if (!this.closed) this.scheduleRender();
|
|
985
|
+
});
|
|
986
|
+
socket.on("error", (err) => {
|
|
987
|
+
// The error may belong to an old socket after reconnect. Dispose that
|
|
988
|
+
// socket, but do not alter the state of the current connection.
|
|
989
|
+
if (this.socket !== socket) {
|
|
990
|
+
try { socket.destroy(); } catch { /* best-effort stale-socket cleanup */ }
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
this.captureRecoveryView();
|
|
994
|
+
this.socket = null;
|
|
995
|
+
this.connected = false;
|
|
996
|
+
// Notify before nulling: the close event that follows will hit the
|
|
997
|
+
// stale-socket guard (this.socket no longer matches) and must not be
|
|
998
|
+
// the only path that cancels the probe deadline (CR R1 advisory).
|
|
999
|
+
this.attachClient.onDisconnect();
|
|
1000
|
+
try { socket.destroy(); } catch { /* already handling socket failure */ }
|
|
1001
|
+
this.disconnectedAt ??= Date.now();
|
|
1002
|
+
if (this.closed) return;
|
|
1003
|
+
this.status = `waiting for host… ${err.message}`;
|
|
1004
|
+
this.scheduleReconnect();
|
|
1005
|
+
this.scheduleRender();
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Request a repaint. The first paint is forced (clears any prior screen so the previous
|
|
1011
|
+
* session can't ghost behind us); all later paints are coalesced + throttled + differential
|
|
1012
|
+
* by the TUI, so bursts of wheel/output events don't each clear-and-repaint the whole screen.
|
|
1013
|
+
*/
|
|
1014
|
+
private scheduleRender(force = false): void {
|
|
1015
|
+
const next = nextAttachRender(this.firstPaint, force);
|
|
1016
|
+
this.firstPaint = next.firstPaint;
|
|
1017
|
+
this.tui.requestRender(next.force);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private scheduleReconnect(): void {
|
|
1021
|
+
if (this.closed || this.retryTimer) return;
|
|
1022
|
+
const verdict = evaluateAttachReconnect({
|
|
1023
|
+
everConnected: this.everConnected,
|
|
1024
|
+
disconnectedAt: this.disconnectedAt,
|
|
1025
|
+
connectStartedAt: this.connectStartedAt,
|
|
1026
|
+
now: Date.now(),
|
|
1027
|
+
});
|
|
1028
|
+
if (verdict.giveUp) {
|
|
1029
|
+
this.status = verdict.status ?? "host exited";
|
|
1030
|
+
this.scheduleRender();
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
this.retryTimer = setTimeout(() => {
|
|
1034
|
+
this.retryTimer = null;
|
|
1035
|
+
this.connect();
|
|
1036
|
+
this.scheduleRender();
|
|
1037
|
+
}, 150);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Animate the loading banner until the first PTY output arrives (or we close). */
|
|
1041
|
+
private startLoadingTicker(): void {
|
|
1042
|
+
if (this.loadingTimer || !this.attaching || this.closed) return;
|
|
1043
|
+
this.loadingTimer = setInterval(() => {
|
|
1044
|
+
if (this.closed || !this.attaching) return this.stopLoadingTicker();
|
|
1045
|
+
this.tui.requestRender();
|
|
1046
|
+
}, LOADING_TICK_MS);
|
|
1047
|
+
this.loadingTimer.unref?.();
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private stopLoadingTicker(): void {
|
|
1051
|
+
if (!this.loadingTimer) return;
|
|
1052
|
+
clearInterval(this.loadingTimer);
|
|
1053
|
+
this.loadingTimer = null;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Attach transition lifecycle. Keep the loading banner up while the screen-log replay
|
|
1058
|
+
* and the initial resize-jiggle redraws settle, so the buffer doesn't visibly scroll or
|
|
1059
|
+
* flash through the viewport on attach. Each output chunk defers the settle
|
|
1060
|
+
* window; a hard timeout guards against a session that never produces output.
|
|
1061
|
+
*/
|
|
1062
|
+
private startAttachSettle(): void {
|
|
1063
|
+
if (!this.attaching) return;
|
|
1064
|
+
this.attachWaitToken += 1;
|
|
1065
|
+
const waitToken = this.attachWaitToken;
|
|
1066
|
+
this.deferAttachSettle();
|
|
1067
|
+
if (this.attachHardTimeout) clearTimeout(this.attachHardTimeout);
|
|
1068
|
+
const remainingHard = Math.max(0, ATTACH_HARD_TIMEOUT_MS - (Date.now() - this.attachStartedAt));
|
|
1069
|
+
this.attachHardTimeout = setTimeout(() => this.handleAttachHardTimeout(waitToken), remainingHard);
|
|
1070
|
+
this.attachHardTimeout.unref?.();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
private deferAttachSettle(): void {
|
|
1074
|
+
if (!this.attaching || (this.attachMode === "protocol" && this.frameWitnessAvailable)) return;
|
|
1075
|
+
if (this.attachSettleTimer) clearTimeout(this.attachSettleTimer);
|
|
1076
|
+
const waitToken = this.attachWaitToken;
|
|
1077
|
+
this.attachSettleTimer = setTimeout(() => this.handleAttachSettleTimeout(waitToken), ATTACH_SETTLE_MS);
|
|
1078
|
+
this.attachSettleTimer.unref?.();
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** Legacy settle callback. In protocol mode the timer is cancelled; an
|
|
1082
|
+
* undecided timer may not consume the first transition either. */
|
|
1083
|
+
private handleAttachSettleTimeout(waitToken = this.attachWaitToken): void {
|
|
1084
|
+
if (waitToken !== this.attachWaitToken) return;
|
|
1085
|
+
this.attachSettleTimer = null;
|
|
1086
|
+
if (!this.attaching || this.closed) return;
|
|
1087
|
+
const legacyCompatible = this.attachMode === "legacy" || (this.attachMode === "protocol" && !this.frameWitnessAvailable);
|
|
1088
|
+
if (legacyCompatible) this.finishAttachTransition();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** The 2500 ms cap never forces a modern publish of an UNFINISHED frame. It
|
|
1092
|
+
* records an explicit expired/degraded wait; legacy keeps its old reveal.
|
|
1093
|
+
* Idle-session downgrade (post-#261 regression): when the cap fires with a
|
|
1094
|
+
* COMPLETE committed snapshot already hydrated, no pending geometry intent,
|
|
1095
|
+
* and the proof channel never covered this baseline (e.g. the child emitted
|
|
1096
|
+
* S and went idle, so viewProof stays unavailable/candidate-open forever),
|
|
1097
|
+
* reveal that complete runner-synthesized frame through the standard legacy
|
|
1098
|
+
* transition instead of waiting for a proof that cannot arrive. Empty
|
|
1099
|
+
* baselines and geometry-gated waits keep the original never-publish wait. */
|
|
1100
|
+
private handleAttachHardTimeout(waitToken = this.attachWaitToken): void {
|
|
1101
|
+
if (waitToken !== this.attachWaitToken) return;
|
|
1102
|
+
this.attachHardTimeout = null;
|
|
1103
|
+
if (!this.attaching || this.closed) return;
|
|
1104
|
+
const legacyCompatible = this.attachMode === "legacy" || (this.attachMode === "protocol" && !this.frameWitnessAvailable);
|
|
1105
|
+
if (legacyCompatible) {
|
|
1106
|
+
this.finishAttachTransition();
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (this.attachMode === "protocol" && this.resizeIntentRequired && this.connected && this.geometryNudges < GEOMETRY_NUDGE_LIMIT) {
|
|
1110
|
+
this.nudgeGeometry(waitToken);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
this.deadlineState = "expired";
|
|
1114
|
+
this.notReadyReason = this.attachMode === "protocol" ? "deadline-expired:waiting-for-complete-frame" : "deadline-expired:mode-undecided";
|
|
1115
|
+
this.publishabilityState = "unknown";
|
|
1116
|
+
const proof = this.latestViewProof as { status?: string } | null;
|
|
1117
|
+
const nudgedInVain = this.resizeIntentRequired && this.geometryNudges >= GEOMETRY_NUDGE_LIMIT;
|
|
1118
|
+
if (
|
|
1119
|
+
this.committedView !== null &&
|
|
1120
|
+
(!this.resizeIntentRequired || nudgedInVain) &&
|
|
1121
|
+
(proof === null || proof.status !== "covered" || nudgedInVain)
|
|
1122
|
+
) {
|
|
1123
|
+
this.finishAttachTransition();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
this.scheduleRender();
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** A late legacy decision restores the ORIGINAL deadlines (remaining time),
|
|
1130
|
+
* not a fresh extended window. */
|
|
1131
|
+
private restoreLegacyDeadlines(): void {
|
|
1132
|
+
if (!this.attaching || this.closed) return;
|
|
1133
|
+
const now = Date.now();
|
|
1134
|
+
if (now - this.attachStartedAt >= ATTACH_HARD_TIMEOUT_MS) {
|
|
1135
|
+
this.finishAttachTransition();
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const settleBase = this.lastOutputAt > 0 ? this.lastOutputAt : this.attachStartedAt;
|
|
1139
|
+
const remaining = Math.max(0, ATTACH_SETTLE_MS - (now - settleBase));
|
|
1140
|
+
if (this.attachSettleTimer) clearTimeout(this.attachSettleTimer);
|
|
1141
|
+
const waitToken = this.attachWaitToken;
|
|
1142
|
+
this.attachSettleTimer = setTimeout(() => this.handleAttachSettleTimeout(waitToken), remaining);
|
|
1143
|
+
this.attachSettleTimer.unref?.();
|
|
1144
|
+
if (!this.attachHardTimeout) {
|
|
1145
|
+
this.attachHardTimeout = setTimeout(() => this.handleAttachHardTimeout(waitToken), Math.max(0, ATTACH_HARD_TIMEOUT_MS - (now - this.attachStartedAt)));
|
|
1146
|
+
this.attachHardTimeout.unref?.();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
private currentViewIdentity() {
|
|
1151
|
+
return {
|
|
1152
|
+
connectionToken: this.connectionToken,
|
|
1153
|
+
baselineToken: this.baselineToken,
|
|
1154
|
+
generation: this.attachClient.getIdentity().generation ?? "local",
|
|
1155
|
+
parser: this.term,
|
|
1156
|
+
geometryRevision: this.geometryRevision,
|
|
1157
|
+
targetCols: this.cols,
|
|
1158
|
+
targetRows: this.rows,
|
|
1159
|
+
closed: this.closed,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Snapshot the visible complete grid at a real close boundary. Only
|
|
1164
|
+
* primitives are copied; no xterm BufferLine/Cell survives this call. */
|
|
1165
|
+
private commitCommittedView(term: XtermLike, epoch: number): void {
|
|
1166
|
+
if (this.closed || this.term !== term || this.parserEpoch !== epoch) return;
|
|
1167
|
+
if (!this.modernCommittedMode() || this.localHydrationState === "hydrating") return;
|
|
1168
|
+
const height = this.bodyHeight();
|
|
1169
|
+
const buf = term.buffer.active;
|
|
1170
|
+
const start = Math.max(0, Math.min(this.viewportTop ?? this.liveBottomViewportTop(height), Math.max(0, buf.length - height)));
|
|
1171
|
+
const end = Math.min(buf.length, start + height);
|
|
1172
|
+
const rows: CommittedRow[] = [];
|
|
1173
|
+
for (let lineIndex = start; lineIndex < end; lineIndex += 1) {
|
|
1174
|
+
const line = buf.getLine(lineIndex);
|
|
1175
|
+
const cells: SnapshotCell[] = [];
|
|
1176
|
+
if (line) {
|
|
1177
|
+
for (let x = 0; x < line.length; x += 1) {
|
|
1178
|
+
const cell = line.getCell(x);
|
|
1179
|
+
if (!cell || cell.getWidth() === 0) {
|
|
1180
|
+
cells.push(SnapshotCell.empty());
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
cells.push(SnapshotCell.fromBufferCell(cell, osc8UriForCell(term, cell)));
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
rows.push({ cells: new SnapshotLine(cells) });
|
|
1187
|
+
}
|
|
1188
|
+
const cursor = projectPtyCursor(buf, start, height);
|
|
1189
|
+
this.committedView = {
|
|
1190
|
+
identity: this.currentViewIdentity(),
|
|
1191
|
+
start,
|
|
1192
|
+
end,
|
|
1193
|
+
rows,
|
|
1194
|
+
cursor,
|
|
1195
|
+
cursorHidden: isPtyCursorHidden(term),
|
|
1196
|
+
viewportTop: this.viewportTop,
|
|
1197
|
+
proofSeq: null,
|
|
1198
|
+
};
|
|
1199
|
+
this.recoveryView = null;
|
|
1200
|
+
this.recoveryGeneration = null;
|
|
1201
|
+
this.emptyResetPending = false;
|
|
1202
|
+
if (this.connected) this.status = "attached";
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
private captureRecoveryView(): void {
|
|
1206
|
+
if (!this.modernCommittedMode() || this.committedView === null || this.closed) return;
|
|
1207
|
+
if (!canPublishAttachView(this.committedView.identity, this.currentViewIdentity())) return;
|
|
1208
|
+
this.recoveryView = this.committedView;
|
|
1209
|
+
this.recoveryGeneration = this.committedView.identity.generation;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
private invalidateRecoveryView(): void {
|
|
1213
|
+
this.recoveryView = null;
|
|
1214
|
+
this.recoveryGeneration = null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
private recoveryViewValid(): boolean {
|
|
1218
|
+
if (!this.modernCommittedMode() || this.recoveryView === null || this.closed) return false;
|
|
1219
|
+
if (this.recoveryView.identity.closed === true) return false;
|
|
1220
|
+
if (this.recoveryView.identity.targetCols !== this.cols || this.recoveryView.identity.targetRows !== this.rows) return false;
|
|
1221
|
+
if (this.recoveryView.identity.geometryRevision !== this.geometryRevision) return false;
|
|
1222
|
+
if (typeof this.currentWireGeneration !== "string" || this.currentWireGeneration.length === 0) return false;
|
|
1223
|
+
if (this.currentWireGeneration !== this.recoveryGeneration) return false;
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
private activeCommittedView(): CommittedView | null {
|
|
1228
|
+
if (this.committedViewValid()) return this.committedView;
|
|
1229
|
+
if (this.recoveryViewValid()) return this.recoveryView;
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
private modernCommittedMode(): boolean {
|
|
1234
|
+
return this.attachMode === "protocol" && this.frameWitnessAvailable;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private committedViewValid(): boolean {
|
|
1238
|
+
if (this.committedView === null) return false;
|
|
1239
|
+
return canPublishAttachView(this.committedView.identity, this.currentViewIdentity());
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
private liveBottomViewportTop(height: number): number {
|
|
1243
|
+
const buf = this.term.buffer.active;
|
|
1244
|
+
const bottom = Math.min(buf.length, buf.baseY + this.rows);
|
|
1245
|
+
return Math.max(0, bottom - height);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
private committedLineAt(lineIndex: number): BufferLineLike | null {
|
|
1249
|
+
if (!this.modernCommittedMode()) return null;
|
|
1250
|
+
const view = this.activeCommittedView();
|
|
1251
|
+
if (view === null || lineIndex < view.start || lineIndex >= view.end) return null;
|
|
1252
|
+
return view.rows[lineIndex - view.start]?.cells ?? null;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
private captureViewProofIdentity(): void {
|
|
1256
|
+
this.viewProofIdentity = this.currentViewIdentity();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
private invalidateViewProof(reason: string): void {
|
|
1260
|
+
this.latestViewProof = { status: "unknown", reason, proof: null, throughSeq: null, cursorVisible: null };
|
|
1261
|
+
this.viewProofIdentity = null;
|
|
1262
|
+
this.viewProofTrailing.reset();
|
|
1263
|
+
this.resizeIntentSatisfied = false;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** Feed ordinary output after the last F proof through the safe-trailing
|
|
1267
|
+
* classifier. Only a completely safe window may extend coverage. */
|
|
1268
|
+
private noteViewProofOutput(data: string): void {
|
|
1269
|
+
const coverage = this.latestViewProof as { status?: string; proof?: unknown } | null;
|
|
1270
|
+
if (this.attachMode !== "protocol" || coverage === null || coverage.status !== "covered" || coverage.proof === null) return;
|
|
1271
|
+
const localSeq = this.attachClient.getLastSeq();
|
|
1272
|
+
const state = this.viewProofTrailing.push(data);
|
|
1273
|
+
if (state.status === "safe") {
|
|
1274
|
+
this.latestViewProof = {
|
|
1275
|
+
status: "covered",
|
|
1276
|
+
reason: "safe-trailing",
|
|
1277
|
+
proof: (coverage as { proof: unknown }).proof,
|
|
1278
|
+
throughSeq: localSeq,
|
|
1279
|
+
cursorVisible: state.cursorVisible,
|
|
1280
|
+
};
|
|
1281
|
+
this.captureViewProofIdentity();
|
|
1282
|
+
} else {
|
|
1283
|
+
this.invalidateViewProof(state.status === "pending" ? "view-proof-trailing-pending" : `view-proof-trailing-${state.reason ?? "unsafe"}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private noteViewProofRecord(record: Record<string, unknown>): void {
|
|
1288
|
+
if (record.marker === "S") {
|
|
1289
|
+
this.viewProofRecords.push(record);
|
|
1290
|
+
if (this.viewProofRecords.length > MAX_VIEW_PROOF_RECORDS) this.viewProofRecords.shift();
|
|
1291
|
+
this.viewProofRecordCount += 1;
|
|
1292
|
+
this.invalidateViewProof("candidate-open");
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (record.marker === "A") {
|
|
1296
|
+
this.viewProofRecords.push(record);
|
|
1297
|
+
if (this.viewProofRecords.length > MAX_VIEW_PROOF_RECORDS) this.viewProofRecords.shift();
|
|
1298
|
+
this.viewProofRecordCount += 1;
|
|
1299
|
+
this.invalidateViewProof("candidate-aborted");
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (record.marker !== "F") return;
|
|
1303
|
+
this.viewProofRecords.push(record);
|
|
1304
|
+
if (this.viewProofRecords.length > MAX_VIEW_PROOF_RECORDS) this.viewProofRecords.shift();
|
|
1305
|
+
this.viewProofRecordCount += 1;
|
|
1306
|
+
this.latestViewProof = {
|
|
1307
|
+
status: "covered",
|
|
1308
|
+
reason: "safe-trailing",
|
|
1309
|
+
proof: record,
|
|
1310
|
+
throughSeq: record.seq,
|
|
1311
|
+
cursorVisible: typeof record.cursorVisible === "boolean" ? record.cursorVisible : null,
|
|
1312
|
+
};
|
|
1313
|
+
this.captureViewProofIdentity();
|
|
1314
|
+
this.viewProofTrailing.reset(typeof record.cursorVisible === "boolean" ? { cursorVisible: record.cursorVisible } : {});
|
|
1315
|
+
const proofSeq = Number(record.seq);
|
|
1316
|
+
const following = this.recentProtocolOutput.filter((chunk) => chunk.seq > proofSeq).sort((a, b) => a.seq - b.seq);
|
|
1317
|
+
let expectedSeq = proofSeq + 1;
|
|
1318
|
+
for (const chunk of following) {
|
|
1319
|
+
if (chunk.seq !== expectedSeq) {
|
|
1320
|
+
this.invalidateViewProof("view-proof-replay-gap");
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
expectedSeq = chunk.seq + 1;
|
|
1324
|
+
const state = this.viewProofTrailing.push(chunk.data);
|
|
1325
|
+
if (state.status !== "safe") {
|
|
1326
|
+
this.invalidateViewProof(state.status === "pending" ? "view-proof-trailing-pending" : `view-proof-trailing-${state.reason ?? "unsafe"}`);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
this.latestViewProof = {
|
|
1330
|
+
status: "covered",
|
|
1331
|
+
reason: "safe-trailing",
|
|
1332
|
+
proof: record,
|
|
1333
|
+
throughSeq: chunk.seq,
|
|
1334
|
+
cursorVisible: state.cursorVisible,
|
|
1335
|
+
};
|
|
1336
|
+
this.captureViewProofIdentity();
|
|
1337
|
+
}
|
|
1338
|
+
if (this.attachClient.getLastSeq() >= expectedSeq) {
|
|
1339
|
+
this.invalidateViewProof("view-proof-replay-gap");
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** Pure fact assembly for the accepted decideAttachPresentation gate. */
|
|
1344
|
+
private evaluateAttachReady(): void {
|
|
1345
|
+
if (!this.attaching || this.closed || this.attachMode !== "protocol" || !this.frameWitnessAvailable) return;
|
|
1346
|
+
if (this.parserWritesInFlight > 0) return;
|
|
1347
|
+
if (this.committedView === null) {
|
|
1348
|
+
this.notReadyReason = "committed-view-missing";
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const coverage = this.latestViewProof as { status?: string; proof?: { metadata?: unknown } | null } | null;
|
|
1352
|
+
const proof = coverage?.status === "covered" && coverage.proof !== null ? coverage.proof : null;
|
|
1353
|
+
const localSeq = this.attachClient.getLastSeq();
|
|
1354
|
+
const baselineState =
|
|
1355
|
+
this.protocolBaselineState === "valid-empty" || this.protocolBaselineState === "valid-content"
|
|
1356
|
+
? this.protocolBaselineState
|
|
1357
|
+
: this.localHydrationState === "hydrating"
|
|
1358
|
+
? "hydrating"
|
|
1359
|
+
: this.protocolBaselineState;
|
|
1360
|
+
const decision = decideAttachPresentation({
|
|
1361
|
+
closed: this.closed,
|
|
1362
|
+
mode: this.attachMode,
|
|
1363
|
+
baselineState,
|
|
1364
|
+
localParseComplete: this.parserWritesInFlight === 0 && this.localHydrationState !== "hydrating",
|
|
1365
|
+
frameState: this.parserSyncOpen ? "open" : this.parserWritesInFlight > 0 ? "unknown" : "complete",
|
|
1366
|
+
contentAvailable: this.receivedOutput === true && proof !== null,
|
|
1367
|
+
contentKind: proof === null ? "unknown" : "complete",
|
|
1368
|
+
localSeq,
|
|
1369
|
+
viewProof: coverage,
|
|
1370
|
+
viewIdentity: this.viewProofIdentity,
|
|
1371
|
+
currentIdentity: this.currentViewIdentity(),
|
|
1372
|
+
targetCols: this.cols,
|
|
1373
|
+
targetRows: this.rows,
|
|
1374
|
+
geometryRevision: this.geometryRevision,
|
|
1375
|
+
requiredGeometryRevision: this.resizeIntentRequired ? this.proofGeometryRevision : undefined,
|
|
1376
|
+
resizeIntentRequired: this.resizeIntentRequired,
|
|
1377
|
+
resizeIntentSatisfied: this.resizeIntentSatisfied,
|
|
1378
|
+
intentId: this.proofIntentId,
|
|
1379
|
+
deadlineState: this.deadlineState,
|
|
1380
|
+
});
|
|
1381
|
+
this.notReadyReason = decision.action === "wait" ? decision.reason : null;
|
|
1382
|
+
if (decision.action === "publish") {
|
|
1383
|
+
this.publishabilityState = "ready";
|
|
1384
|
+
this.finishAttachTransition();
|
|
1385
|
+
} else if (decision.action === "wait") {
|
|
1386
|
+
this.publishabilityState = this.deadlineState === "expired" ? "unknown" : "wait";
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
private finishAttachTransition(): void {
|
|
1391
|
+
if (!this.attaching || this.closed) return;
|
|
1392
|
+
// Note: jiggle retry chain is NOT cancelled here — it survives the settle
|
|
1393
|
+
// transition because any successful jiggle triggers a fullRender which emits
|
|
1394
|
+
// \x1b[2J, self-cancelling the chain. Post-settle jiggles only occur when the
|
|
1395
|
+
// child consumed none of the earlier ones (the exact failure mode this
|
|
1396
|
+
// feature fixes); in that case the screen is already stale, so the trade-off
|
|
1397
|
+
// of a brief full-render flicker (which also clears any in-progress text
|
|
1398
|
+
// selection) is acceptable in exchange for self-healing. When the child is
|
|
1399
|
+
// healthy, the very first jiggle's fullRender is detected and the chain
|
|
1400
|
+
// stops before settle ends, so no post-settle jiggle fires at all.
|
|
1401
|
+
this.attaching = false;
|
|
1402
|
+
this.publishabilityState = "ready";
|
|
1403
|
+
this.notReadyReason = null;
|
|
1404
|
+
this.firstAttachSideEffectsApplied = true;
|
|
1405
|
+
if (this.attachSettleTimer) {
|
|
1406
|
+
clearTimeout(this.attachSettleTimer);
|
|
1407
|
+
this.attachSettleTimer = null;
|
|
1408
|
+
}
|
|
1409
|
+
if (this.attachHardTimeout) {
|
|
1410
|
+
clearTimeout(this.attachHardTimeout);
|
|
1411
|
+
this.attachHardTimeout = null;
|
|
1412
|
+
}
|
|
1413
|
+
this.stopLoadingTicker();
|
|
1414
|
+
// Force a full clear so the loading banner is replaced atomically by the settled
|
|
1415
|
+
// buffer, instead of diffing banner lines into buffer lines.
|
|
1416
|
+
this.scheduleRender(true);
|
|
1417
|
+
this.startDesyncProbe();
|
|
1418
|
+
// Issue #128: the settle transition is the single point where `attaching`
|
|
1419
|
+
// flips false (never re-armed), so these hooks run at most once per attach.
|
|
1420
|
+
this.attachColorSchemeBridge();
|
|
1421
|
+
// Use only the tracked TUI probe. An extra terminal.write(OSC 11) has
|
|
1422
|
+
// no local query owner, so handleInput's report branch forwards its
|
|
1423
|
+
// reply to the child PTY. Settle can precede the child's raw-mode
|
|
1424
|
+
// startup, making the line discipline echo that reply as visible
|
|
1425
|
+
// ^[]11;rgb:... text.
|
|
1426
|
+
this.reportRealTerminalColorScheme();
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** Issue #128 D2: the local pi-tui consumes the child's color-scheme
|
|
1430
|
+
* traffic (996 queries / 2031 notifications forwarded by D1) — reports
|
|
1431
|
+
* never reach the child. onTerminalColorSchemeChange fires from that same
|
|
1432
|
+
* consumption point, so the bridge repackages each event as a 997 report
|
|
1433
|
+
* on the child pty, where the child's pi-tui parser consumes it. Once per
|
|
1434
|
+
* attach (settle is single-shot + explicit guard); kill switch skips it. */
|
|
1435
|
+
private attachColorSchemeBridge(): void {
|
|
1436
|
+
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
|
|
1437
|
+
if (this.colorSchemeBridgeUnsubscribe) return;
|
|
1438
|
+
try {
|
|
1439
|
+
this.colorSchemeBridgeUnsubscribe = this.tui.onTerminalColorSchemeChange((scheme) => {
|
|
1440
|
+
if (!toColorSchemeReport(scheme)) return;
|
|
1441
|
+
this.pendingSchemeReport = { scheme };
|
|
1442
|
+
this.flushPendingSchemeReport();
|
|
1443
|
+
});
|
|
1444
|
+
} catch {
|
|
1445
|
+
/* best-effort: an unbridgeable TUI keeps pre-#128 behavior */
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** Called on every terminal path (close() covers detach/dispose; the exit
|
|
1450
|
+
* branch calls done() without close(), so it unsubscribes explicitly). */
|
|
1451
|
+
private detachColorSchemeBridge(): void {
|
|
1452
|
+
this.pendingSchemeReport = null;
|
|
1453
|
+
this.childInputReady = false;
|
|
1454
|
+
const unsubscribe = this.colorSchemeBridgeUnsubscribe;
|
|
1455
|
+
this.colorSchemeBridgeUnsubscribe = null;
|
|
1456
|
+
if (!unsubscribe) return;
|
|
1457
|
+
try {
|
|
1458
|
+
unsubscribe();
|
|
1459
|
+
} catch {
|
|
1460
|
+
/* best-effort: a stale listener must not block teardown */
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** Issue #148/#161: at settle, ask the REAL terminal itself (feature-detected
|
|
1465
|
+
* public query API — pi-tui >= 0.99 renamed it to queryTerminalColors) and
|
|
1466
|
+
* hand the child an unconditional 997 color-scheme report. One probe per
|
|
1467
|
+
* attach; the pre-#148 silence on failure is now observable via
|
|
1468
|
+
* onDiagnostic. Same kill switch as #128 D1-D3. */
|
|
1469
|
+
private reportRealTerminalColorScheme(): void {
|
|
1470
|
+
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") {
|
|
1471
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ outcome: "suppressed" }));
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
void this.probeAndReportRealTerminalColorScheme();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
private async probeAndReportRealTerminalColorScheme(): Promise<void> {
|
|
1478
|
+
const probe = resolveProbeApi(this.tui as never);
|
|
1479
|
+
if (probe.api === "none") {
|
|
1480
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: "none", outcome: "no_probe_api" }));
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
let result: { rgb?: RgbColor; anyColors: boolean };
|
|
1484
|
+
try {
|
|
1485
|
+
result = await probe.invoke(
|
|
1486
|
+
this.tui as never,
|
|
1487
|
+
REAL_TERMINAL_SCHEME_PROBE_TIMEOUT_MS,
|
|
1488
|
+
(colors) => this.handleLateSettleColors(probe.api, colors),
|
|
1489
|
+
);
|
|
1490
|
+
} catch {
|
|
1491
|
+
/* best-effort: a failed probe keeps the pre-#148 behavior, but recorded */
|
|
1492
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: probe.api, outcome: "error" }));
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const rgb = result?.rgb;
|
|
1496
|
+
if (!rgb) {
|
|
1497
|
+
// Outcome rule (spec §2.3): any color answered but no background =>
|
|
1498
|
+
// no_background; nothing answered at all => timeout. The old API only
|
|
1499
|
+
// queries OSC 11, so its undefined is always a timeout.
|
|
1500
|
+
const outcome = result && result.anyColors ? "no_background" : "timeout";
|
|
1501
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: probe.api, outcome }));
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
this.deliverSettleScheme(colorSchemeForBackgroundRgb(rgb) ?? undefined, probe.api, false);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/** pi-tui >= 0.99 late replies: deliver only when the scheme differs from
|
|
1508
|
+
* what was already sent (sentScheme starts null and never matches, so the
|
|
1509
|
+
* timeout-then-late path delivers). Closed => dropped, but recorded. */
|
|
1510
|
+
private handleLateSettleColors(api: "colors" | "background" | "none", colors: unknown): void {
|
|
1511
|
+
if (this.closed) {
|
|
1512
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "dropped_closed" }));
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
const rgb = backgroundRgbFromTerminalColors(colors as never);
|
|
1516
|
+
const scheme = rgb ? colorSchemeForBackgroundRgb(rgb) ?? undefined : undefined;
|
|
1517
|
+
if (!scheme) return;
|
|
1518
|
+
if (this.sentScheme === scheme && this.childInputReady) {
|
|
1519
|
+
this.pendingSchemeReport = null;
|
|
1520
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "duplicate_skipped" }));
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
this.deliverSettleScheme(scheme, api, true);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
private deliverSettleScheme(scheme: TerminalColorScheme | undefined, api: "colors" | "background" | "none", late: boolean): void {
|
|
1527
|
+
if (!scheme || this.closed) return;
|
|
1528
|
+
const data = toColorSchemeReport(scheme);
|
|
1529
|
+
if (!data) return;
|
|
1530
|
+
// CR round-1 advisory: send() silently no-ops while the control socket is
|
|
1531
|
+
// disconnected (hard-timeout settle before the host finished connecting).
|
|
1532
|
+
// Claiming "reported" there would be a lie the dedup then acts on, so
|
|
1533
|
+
// record the drop and leave sentScheme unset — a later same-scheme late
|
|
1534
|
+
// reply re-attempts instead of being swallowed as duplicate_skipped.
|
|
1535
|
+
if (!this.connected) {
|
|
1536
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "dropped_disconnected" }));
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (!this.childInputReady) {
|
|
1540
|
+
// Settle/probe completion is not a child-readiness handshake. Keep
|
|
1541
|
+
// only the latest scheme, then send when the child enters raw mode.
|
|
1542
|
+
this.pendingSchemeReport = { scheme, api, late };
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
this.sentScheme = scheme;
|
|
1546
|
+
this.send({ type: "input", data });
|
|
1547
|
+
const details: { probeApi: "colors" | "background" | "none"; outcome: "reported"; report: string; late?: boolean } = {
|
|
1548
|
+
probeApi: api,
|
|
1549
|
+
outcome: "reported",
|
|
1550
|
+
report: scheme === "light" ? "997;2" : "997;1",
|
|
1551
|
+
};
|
|
1552
|
+
if (late) details.late = true;
|
|
1553
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch(details));
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private updateChildInputReadiness(data: string): void {
|
|
1557
|
+
// Scan only LIVE bytes, not warm-start history. A reused xterm buffer
|
|
1558
|
+
// can still have the old child's paste mode enabled during cold boot.
|
|
1559
|
+
const input = this.childInputModeCarry + data;
|
|
1560
|
+
for (const match of input.matchAll(/\x1b\[\?2004([hl])/g)) {
|
|
1561
|
+
this.childInputReady = match[1] === "h";
|
|
1562
|
+
}
|
|
1563
|
+
// Keep at most an incomplete eight-byte mode sequence across chunks.
|
|
1564
|
+
this.childInputModeCarry = input.slice(-7);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
private flushPendingSchemeReport(): void {
|
|
1568
|
+
if (this.closed || !this.connected || !this.childInputReady) return;
|
|
1569
|
+
const pending = this.pendingSchemeReport;
|
|
1570
|
+
this.pendingSchemeReport = null;
|
|
1571
|
+
if (!pending) return;
|
|
1572
|
+
if (pending.api) this.deliverSettleScheme(pending.scheme, pending.api, pending.late ?? false);
|
|
1573
|
+
else this.send({ type: "input", data: toColorSchemeReport(pending.scheme) });
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
private startDesyncProbe(): void {
|
|
1577
|
+
this.stopDesyncProbe();
|
|
1578
|
+
// The probe is deliberately NOT hooked into the render path: rendering is
|
|
1579
|
+
// event-driven (socket output / keypress / resize) and stops exactly when
|
|
1580
|
+
// desync strikes (output goes quiet). A self-contained timer is the only
|
|
1581
|
+
// way an idle desynced screen gets its self-heal without user action.
|
|
1582
|
+
this.desyncProbeTimer = setInterval(() => this.checkDesync(), DESYNC_PROBE_INTERVAL_MS);
|
|
1583
|
+
this.desyncProbeTimer.unref?.();
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
private stopDesyncProbe(): void {
|
|
1587
|
+
if (this.desyncProbeTimer) {
|
|
1588
|
+
clearInterval(this.desyncProbeTimer);
|
|
1589
|
+
this.desyncProbeTimer = null;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Runtime desync backstop (issue #11). Seven gates, cheapest first:
|
|
1595
|
+
* settled+connected, child is a TUI (frame seen), chain idle, output quiet,
|
|
1596
|
+
* heal rate limit, misaligned cursor. All pass → heal() re-arms
|
|
1597
|
+
* shrink-and-hold; the child's fullRender clear then restores the size
|
|
1598
|
+
* and repaints a consistent screen.
|
|
1599
|
+
*/
|
|
1600
|
+
private checkDesync(): void {
|
|
1601
|
+
if (this.closed || this.attaching || !this.connected) return; // gates 1, 7
|
|
1602
|
+
// Protocol mode owns screen correctness via canonical frames (issue #91
|
|
1603
|
+
// phase 4, CR R1 advisory): a "misaligned cursor" there is frame content
|
|
1604
|
+
// or a stale-frame transient that resync heals — a jiggle heal would fire
|
|
1605
|
+
// resize pulses in a mode whose e2e pins zero resizes. The undecided
|
|
1606
|
+
// window keeps legacy semantics on purpose: until the snapshot answer
|
|
1607
|
+
// arrives the jiggle IS armed and the legacy fallthrough owns the screen.
|
|
1608
|
+
if (this.attachMode === "protocol") return;
|
|
1609
|
+
const chain = this.jiggleRetry.getState();
|
|
1610
|
+
if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
|
|
1611
|
+
if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
|
|
1612
|
+
const now = this.nowFn();
|
|
1613
|
+
if (now - this.lastOutputAt <= DESYNC_QUIET_MS) return; // gate 4: streaming
|
|
1614
|
+
if (now - this.lastHealAt <= HEAL_RATELIMIT_MS) return; // gate 6: rate limit
|
|
1615
|
+
const height = this.bodyHeight();
|
|
1616
|
+
this.clampViewportTop(height);
|
|
1617
|
+
const start = this.viewportTop ?? this.bottomViewportTop(height);
|
|
1618
|
+
const buf = this.term.buffer.active;
|
|
1619
|
+
if (detectCursorDesync(buf, projectPtyCursor(buf, start, height)) !== "misaligned") return; // gate 3
|
|
1620
|
+
this.lastHealAt = now;
|
|
1621
|
+
this.jiggleRetry.heal(this.cols, this.rows);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
private clearMouseRefreshTimers(): void {
|
|
1625
|
+
for (const timer of this.mouseRefreshTimers) clearTimeout(timer);
|
|
1626
|
+
this.mouseRefreshTimers = [];
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Legacy-path jiggle arming with a protocol-race guard (issue #91 phase 4).
|
|
1631
|
+
* Legacy decided immediately (env-forced) → start now; undecided → start
|
|
1632
|
+
* after the guard delay unless the snapshot answer wins (protocol). The
|
|
1633
|
+
* timer guard accepts ANY non-protocol mode, so a fast in-flight decision
|
|
1634
|
+
* (e.g. frame_version_mismatch landing ~1ms into the probe window) still
|
|
1635
|
+
* arms the chain — an early-decided legacy session must not end up
|
|
1636
|
+
* jiggle-less. start() re-arms idempotently, so a late duplicate is safe.
|
|
1637
|
+
*/
|
|
1638
|
+
private armLegacyJiggle(): void {
|
|
1639
|
+
this.clearJiggleStartTimer();
|
|
1640
|
+
if (this.attachMode === "protocol") return;
|
|
1641
|
+
if (this.attachMode === "legacy") {
|
|
1642
|
+
if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
this.jiggleStartTimer = setTimeout(() => {
|
|
1646
|
+
this.jiggleStartTimer = null;
|
|
1647
|
+
if (!this.closed && this.connected && this.attachMode !== "protocol") {
|
|
1648
|
+
this.jiggleRetry.start(this.cols, this.rows);
|
|
1649
|
+
}
|
|
1650
|
+
}, LEGACY_JIGGLE_ARM_DELAY_MS);
|
|
1651
|
+
this.jiggleStartTimer.unref?.();
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
private clearJiggleStartTimer(): void {
|
|
1655
|
+
if (!this.jiggleStartTimer) return;
|
|
1656
|
+
clearTimeout(this.jiggleStartTimer);
|
|
1657
|
+
this.jiggleStartTimer = null;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
private clearRetry(): void {
|
|
1661
|
+
if (!this.retryTimer) return;
|
|
1662
|
+
clearTimeout(this.retryTimer);
|
|
1663
|
+
this.retryTimer = null;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
private clearGracefulSocketCloseTimer(): void {
|
|
1667
|
+
if (!this.gracefulSocketCloseTimer) return;
|
|
1668
|
+
clearTimeout(this.gracefulSocketCloseTimer);
|
|
1669
|
+
this.gracefulSocketCloseTimer = null;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/** Issue #170: an open reply window arms exactly one timer. Fragments do not
|
|
1673
|
+
* re-arm it (the window is anchored at the head); a timer that fires while
|
|
1674
|
+
* the window is still open — a newer head moved `since` forward — re-arms
|
|
1675
|
+
* instead of dropping the still-open sequence. */
|
|
1676
|
+
private armReplyGuardTimer(): void {
|
|
1677
|
+
if (this.replyGuardTimer) return;
|
|
1678
|
+
this.replyGuardTimer = setTimeout(() => {
|
|
1679
|
+
this.replyGuardTimer = null;
|
|
1680
|
+
this.replyGuard?.expire(this.nowFn());
|
|
1681
|
+
if (this.replyGuard?.pendingSince() !== null) this.armReplyGuardTimer();
|
|
1682
|
+
}, REPLY_GUARD_WINDOW_MS);
|
|
1683
|
+
this.replyGuardTimer.unref?.();
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
private clearReplyGuardTimer(): void {
|
|
1687
|
+
if (!this.replyGuardTimer) return;
|
|
1688
|
+
clearTimeout(this.replyGuardTimer);
|
|
1689
|
+
this.replyGuardTimer = null;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
private enableMouseScroll(): void {
|
|
1693
|
+
if (!this.ownsOuterMouseMode()) return;
|
|
1694
|
+
try {
|
|
1695
|
+
this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
|
|
1696
|
+
this.tui.terminal.write(MOUSE_ENABLE);
|
|
1697
|
+
} catch {
|
|
1698
|
+
/* best-effort: some terminals reject these sequences; mouse reporting is optional */
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/** Issue #169: in fullscreen mode TuiAltScreen owns outer-terminal mouse
|
|
1703
|
+
* reporting (asserted once at startup, never re-asserted) — writing any
|
|
1704
|
+
* mouse-mode sequence from here can only downgrade or kill it. tui.mode is
|
|
1705
|
+
* typed non-optional but old pi runtimes lack it; the strict inequality
|
|
1706
|
+
* then keeps today's (regular) behavior. */
|
|
1707
|
+
private ownsOuterMouseMode(): boolean {
|
|
1708
|
+
return shouldOwnOuterMouseMode(this.tui.mode, this.mouseScrollEnabled());
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
private mouseScrollEnabled(): boolean {
|
|
1712
|
+
const mode = (process.env.AGENT_BOARD_ATTACH_MOUSE ?? "").trim().toLowerCase();
|
|
1713
|
+
if (mode === "0" || mode === "off" || mode === "false") return false;
|
|
1714
|
+
if (mode === "1" || mode === "on" || mode === "true" || mode === "classic") return true;
|
|
1715
|
+
if ((process.env.AGENT_BOARD_ENABLE_MOUSE_SCROLL ?? "").trim() === "0") return false;
|
|
1716
|
+
return true;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
private refreshMouseScrollMode(): void {
|
|
1720
|
+
this.clearMouseRefreshTimers();
|
|
1721
|
+
if (!this.mouseScrollEnabled()) return;
|
|
1722
|
+
for (const delay of [0, 50, 250]) {
|
|
1723
|
+
const timer = setTimeout(() => {
|
|
1724
|
+
if (!this.closed) this.enableMouseScroll();
|
|
1725
|
+
}, delay);
|
|
1726
|
+
timer.unref?.();
|
|
1727
|
+
this.mouseRefreshTimers.push(timer);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
private disableMouseScroll(): void {
|
|
1732
|
+
if (!this.ownsOuterMouseMode()) return;
|
|
1733
|
+
try {
|
|
1734
|
+
this.tui.terminal.write(MOUSE_DISABLE);
|
|
1735
|
+
} catch {
|
|
1736
|
+
/* best-effort: terminal may already be gone at teardown */
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean {
|
|
1741
|
+
let handled = false;
|
|
1742
|
+
for (const entry of events) {
|
|
1743
|
+
const { mouse, raw } = entry;
|
|
1744
|
+
if ((mouse.button & 64) !== 0) {
|
|
1745
|
+
handled = true;
|
|
1746
|
+
const wheelButton = mouse.button & 3;
|
|
1747
|
+
const wheel = wheelButton === 0 ? 1 : wheelButton === 1 ? -1 : 0;
|
|
1748
|
+
if (wheel === 0) continue;
|
|
1749
|
+
this.clearPendingClick();
|
|
1750
|
+
this.clearSelection();
|
|
1751
|
+
this.lastUserInputAt = this.nowFn();
|
|
1752
|
+
if (!this.tryScrollBy(wheel * this.mouseWheelLines)) this.send({ type: "input", data: raw });
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
handled = true;
|
|
1756
|
+
this.handleLocalMouseEvent(mouse);
|
|
1757
|
+
}
|
|
1758
|
+
return handled;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
private updateSelectionAutoScroll(row: number, col: number): void {
|
|
1762
|
+
this.selectionAutoScrollMouse = { row, col };
|
|
1763
|
+
if (!this.selection || !this.selectionDragging) return this.clearSelectionAutoScroll(false);
|
|
1764
|
+
const lines = selectionDragScrollLines(row, this.bodyHeight());
|
|
1765
|
+
if (lines === 0) return this.clearSelectionAutoScroll(false);
|
|
1766
|
+
if (this.selectionAutoScrollTimer && this.selectionAutoScrollLines === lines) return;
|
|
1767
|
+
this.clearSelectionAutoScroll(false);
|
|
1768
|
+
this.selectionAutoScrollLines = lines;
|
|
1769
|
+
this.selectionAutoScrollTimer = setInterval(() => this.tickSelectionAutoScroll(), 50);
|
|
1770
|
+
this.selectionAutoScrollTimer.unref?.();
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
private tickSelectionAutoScroll(): void {
|
|
1774
|
+
if (this.closed || !this.selection || !this.selectionDragging || !this.selectionAutoScrollMouse || !this.selectionAutoScrollLines) {
|
|
1775
|
+
this.clearSelectionAutoScroll();
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const changed = this.tryScrollBy(this.selectionAutoScrollLines);
|
|
1779
|
+
const point = this.mousePointForEvent(this.selectionAutoScrollMouse.row, this.selectionAutoScrollMouse.col, true);
|
|
1780
|
+
if (point) this.selection.focus = point;
|
|
1781
|
+
if (!changed) this.clearSelectionAutoScroll(false);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
private clearSelectionAutoScroll(clearPointer = true): void {
|
|
1785
|
+
if (this.selectionAutoScrollTimer) {
|
|
1786
|
+
clearInterval(this.selectionAutoScrollTimer);
|
|
1787
|
+
this.selectionAutoScrollTimer = null;
|
|
1788
|
+
}
|
|
1789
|
+
this.selectionAutoScrollLines = 0;
|
|
1790
|
+
if (clearPointer) this.selectionAutoScrollMouse = null;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
private handleLocalMouseEvent(mouse: { button: number; row: number; col: number; action: string }): void {
|
|
1794
|
+
const primary = (mouse.button & 3) === 0;
|
|
1795
|
+
const middleButton = (mouse.button & 3) === 1;
|
|
1796
|
+
if (mouse.action === "press") {
|
|
1797
|
+
this.clearSelectionAutoScroll();
|
|
1798
|
+
if (middleButton) {
|
|
1799
|
+
// Middle-click paste: forward the X11 PRIMARY selection to the hosted
|
|
1800
|
+
// session as input, mimicking the terminal-native middle-click paste
|
|
1801
|
+
// that mouse reporting (kept on for wheel scrolling) would swallow.
|
|
1802
|
+
this.pastePrimarySelection();
|
|
1803
|
+
this.clearPendingClick();
|
|
1804
|
+
this.clearSelection();
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (!primary) {
|
|
1808
|
+
this.clearPendingClick();
|
|
1809
|
+
this.clearSelection();
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const point = this.mousePointForEvent(mouse.row, mouse.col, false);
|
|
1813
|
+
if (!point) {
|
|
1814
|
+
this.clearPendingClick();
|
|
1815
|
+
this.clearSelection();
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
if (this.isDoubleClickCandidate(point)) this.clearPendingClick();
|
|
1819
|
+
this.selection = { anchor: point, focus: point, screenAnchor: { row: mouse.row, col: mouse.col } };
|
|
1820
|
+
this.selectionDragging = false;
|
|
1821
|
+
this.scheduleRender();
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
if (!this.selection) return;
|
|
1825
|
+
if (mouse.action === "move") {
|
|
1826
|
+
if (!primary) return;
|
|
1827
|
+
const point = this.mousePointForEvent(mouse.row, mouse.col, true);
|
|
1828
|
+
if (!point) return;
|
|
1829
|
+
this.selection.focus = point;
|
|
1830
|
+
this.selectionDragging = true;
|
|
1831
|
+
this.updateSelectionAutoScroll(mouse.row, mouse.col);
|
|
1832
|
+
this.scheduleRender();
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
if (mouse.action === "release") {
|
|
1836
|
+
this.clearSelectionAutoScroll();
|
|
1837
|
+
const point = this.mousePointForEvent(mouse.row, mouse.col, true);
|
|
1838
|
+
if (point) this.selection.focus = point;
|
|
1839
|
+
// Issue #187: a Warp fast flick arrives as press + release with no motion
|
|
1840
|
+
// event, so the move flag alone cannot decide. Compare the RAW reported
|
|
1841
|
+
// screen cells (never the clamped buffer points, whose base drifts as the
|
|
1842
|
+
// session streams).
|
|
1843
|
+
const shouldCopy = selectionReleaseIsDrag({
|
|
1844
|
+
dragging: this.selectionDragging,
|
|
1845
|
+
pressScreen: this.selection.screenAnchor,
|
|
1846
|
+
releaseScreen: { row: mouse.row, col: mouse.col },
|
|
1847
|
+
});
|
|
1848
|
+
this.selectionDragging = false;
|
|
1849
|
+
if (shouldCopy) {
|
|
1850
|
+
this.clearPendingClick();
|
|
1851
|
+
this.lastClickPoint = null;
|
|
1852
|
+
this.lastClickAt = 0;
|
|
1853
|
+
this.copySelectionToClipboard();
|
|
1854
|
+
this.scheduleRender();
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (!point) {
|
|
1858
|
+
this.clearPendingClick();
|
|
1859
|
+
this.clearSelection();
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
if (this.isDoubleClickCandidate(point)) {
|
|
1863
|
+
this.clearPendingClick();
|
|
1864
|
+
this.lastClickPoint = null;
|
|
1865
|
+
this.lastClickAt = 0;
|
|
1866
|
+
if (!this.selectWordAtPoint(point)) this.selection = null;
|
|
1867
|
+
this.scheduleRender();
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
this.lastClickPoint = point;
|
|
1871
|
+
this.lastClickAt = Date.now();
|
|
1872
|
+
this.schedulePendingClick(point);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
private interactionLine(lineIndex: number): BufferLineLike | null {
|
|
1877
|
+
if (this.modernCommittedMode()) return this.committedLineAt(lineIndex);
|
|
1878
|
+
return this.term.buffer.active.getLine(lineIndex) ?? null;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
private interactionNullCell(): BufferCellLike {
|
|
1882
|
+
return this.modernCommittedMode() ? SnapshotCell.empty() : this.term.buffer.active.getNullCell();
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
private mousePointForEvent(row: number, col: number, clampToBody: boolean): MousePoint | null {
|
|
1886
|
+
const height = this.bodyHeight();
|
|
1887
|
+
const bodyRow = row - 2;
|
|
1888
|
+
if (!clampToBody && (bodyRow < 0 || bodyRow >= height)) return null;
|
|
1889
|
+
const clampedRow = clampInt(bodyRow, 0, Math.max(0, height - 1));
|
|
1890
|
+
if (this.modernCommittedMode()) {
|
|
1891
|
+
const view = this.activeCommittedView();
|
|
1892
|
+
if (view === null) return null;
|
|
1893
|
+
const line = clampInt(view.start + clampedRow, view.start, Math.max(view.start, view.end - 1));
|
|
1894
|
+
return { line, col: clampInt(col - 1, 0, Math.max(0, this.cols - 1)) };
|
|
1895
|
+
}
|
|
1896
|
+
this.clampViewportTop(height);
|
|
1897
|
+
const start = this.viewportTop ?? this.bottomViewportTop(height);
|
|
1898
|
+
const line = clampInt(start + clampedRow, 0, Math.max(0, this.term.buffer.active.length - 1));
|
|
1899
|
+
const cellCol = clampInt(col - 1, 0, Math.max(0, this.cols - 1));
|
|
1900
|
+
return { line, col: cellCol };
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
private isDoubleClickCandidate(point: MousePoint): boolean {
|
|
1904
|
+
return !!this.lastClickPoint && Date.now() - this.lastClickAt <= DOUBLE_CLICK_MS && sameMousePoint(this.lastClickPoint, point);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
private schedulePendingClick(point: MousePoint): void {
|
|
1908
|
+
this.clearPendingClick();
|
|
1909
|
+
this.pendingClickTimer = setTimeout(() => {
|
|
1910
|
+
this.pendingClickTimer = null;
|
|
1911
|
+
this.lastClickPoint = null;
|
|
1912
|
+
this.lastClickAt = 0;
|
|
1913
|
+
if (this.closed) return;
|
|
1914
|
+
this.openLinkAtPoint(point);
|
|
1915
|
+
this.selection = null;
|
|
1916
|
+
this.selectionDragging = false;
|
|
1917
|
+
this.tui.requestRender();
|
|
1918
|
+
}, DOUBLE_CLICK_MS);
|
|
1919
|
+
this.pendingClickTimer.unref?.();
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
private clearPendingClick(): void {
|
|
1923
|
+
if (!this.pendingClickTimer) return;
|
|
1924
|
+
clearTimeout(this.pendingClickTimer);
|
|
1925
|
+
this.pendingClickTimer = null;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
private selectWordAtPoint(point: MousePoint): boolean {
|
|
1929
|
+
const line = this.interactionLine(point.line);
|
|
1930
|
+
if (!line) return false;
|
|
1931
|
+
const reusable = this.interactionNullCell();
|
|
1932
|
+
const range = findWordRangeAtCells(asciiCellsForBufferLine(line, reusable), point.col);
|
|
1933
|
+
if (!range) return false;
|
|
1934
|
+
this.selection = {
|
|
1935
|
+
anchor: { line: point.line, col: range.start },
|
|
1936
|
+
focus: { line: point.line, col: range.end },
|
|
1937
|
+
};
|
|
1938
|
+
this.selectionDragging = false;
|
|
1939
|
+
this.copySelectionToClipboard();
|
|
1940
|
+
return true;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
private openLinkAtPoint(point: MousePoint): boolean {
|
|
1944
|
+
const target = this.linkAtPoint(point);
|
|
1945
|
+
return target ? openExternalTarget(target) : false;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
private linkAtPoint(point: MousePoint): string | null {
|
|
1949
|
+
const line = this.interactionLine(point.line);
|
|
1950
|
+
if (!line) return null;
|
|
1951
|
+
const reusable = this.interactionNullCell();
|
|
1952
|
+
const cell = line.getCell(point.col, reusable);
|
|
1953
|
+
const osc8 = cell ? osc8UriForCell(this.term, cell) : "";
|
|
1954
|
+
if (osc8) return osc8;
|
|
1955
|
+
return findHttpUrlAtCells(asciiCellsForBufferLine(line, reusable), point.col);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
private clearSelection(): void {
|
|
1959
|
+
this.clearSelectionAutoScroll();
|
|
1960
|
+
if (!this.selection && !this.selectionDragging) return;
|
|
1961
|
+
this.selection = null;
|
|
1962
|
+
this.selectionDragging = false;
|
|
1963
|
+
this.scheduleRender();
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
private copySelectionToClipboard(): void {
|
|
1967
|
+
const text = this.selectionText();
|
|
1968
|
+
if (!text) return;
|
|
1969
|
+
const seq = osc52CopySequence(text);
|
|
1970
|
+
if (seq) {
|
|
1971
|
+
try {
|
|
1972
|
+
this.tui.terminal.write(seq);
|
|
1973
|
+
} catch {
|
|
1974
|
+
/* best-effort: OSC52 clipboard support is optional */
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
// Also mirror the selection into the X11 PRIMARY selection so the rest of the
|
|
1978
|
+
// desktop can middle-click-paste it — closes the loop with pastePrimarySelection().
|
|
1979
|
+
this.writePrimarySelection(text);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* Middle-click paste. Reads the X11 selection (PRIMARY by default, or CLIPBOARD via
|
|
1984
|
+
* AGENT_BOARD_ATTACH_PASTE_SOURCE=clipboard) via xclip and forwards it to the hosted
|
|
1985
|
+
* session as input, mimicking the terminal-native middle-click paste that mouse
|
|
1986
|
+
* reporting (kept on for wheel scrolling) would otherwise swallow. Silent no-op when
|
|
1987
|
+
* xclip is absent or AGENT_BOARD_ATTACH_NATIVE_PASTE=0.
|
|
1988
|
+
*/
|
|
1989
|
+
private pastePrimarySelection(): void {
|
|
1990
|
+
if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
|
|
1991
|
+
try {
|
|
1992
|
+
const child = spawn("xclip", ["-o", "-selection", resolvePasteSelectionSource(process.env.AGENT_BOARD_ATTACH_PASTE_SOURCE)], { stdio: ["ignore", "pipe", "ignore"] });
|
|
1993
|
+
let out = "";
|
|
1994
|
+
const timer = setTimeout(() => {
|
|
1995
|
+
try {
|
|
1996
|
+
child.kill("SIGKILL");
|
|
1997
|
+
} catch {
|
|
1998
|
+
/* the child may have already exited before the timeout fired */
|
|
1999
|
+
}
|
|
2000
|
+
}, 800);
|
|
2001
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
2002
|
+
out += chunk.toString("utf8");
|
|
2003
|
+
});
|
|
2004
|
+
child.on("error", () => clearTimeout(timer));
|
|
2005
|
+
child.on("close", () => {
|
|
2006
|
+
clearTimeout(timer);
|
|
2007
|
+
if (!this.closed && out) this.send({ type: "input", data: out });
|
|
2008
|
+
});
|
|
2009
|
+
} catch {
|
|
2010
|
+
/* silent no-op when xclip is absent — documented contract of this helper */
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/** Issue #174 (B2) + #172: a selection gesture ended inside this overlay and pi has
|
|
2015
|
+
* finalized (and copied) its selection. Mirror the text into X11 PRIMARY so
|
|
2016
|
+
* middle-click paste closes the loop, then drop pi's selection so the inverse-video
|
|
2017
|
+
* highlight does not outlive the copy. Clearing is skipped when copy-on-select is
|
|
2018
|
+
* off (the user copies later with pi's Ctrl+X) and when the pi-tui method is absent
|
|
2019
|
+
* (older/newer runtime) — both degrade to the previous behaviour instead of throwing.
|
|
2020
|
+
* clearTextSelection preserves pi's multi-click history; resetTextSelection would
|
|
2021
|
+
* clear lastClick and break double-click word / triple-click line selection. */
|
|
2022
|
+
private settleFullscreenSelection(nativePasteEnabled: boolean): void {
|
|
2023
|
+
if (this.closed) return;
|
|
2024
|
+
const alt = this.tui as FullscreenSelectionHost;
|
|
2025
|
+
const text = alt.getActiveSelectionText?.();
|
|
2026
|
+
if (typeof text !== "string" || text.length === 0) return;
|
|
2027
|
+
if (nativePasteEnabled) this.writePrimarySelection(text);
|
|
2028
|
+
if (alt.getCopyOnSelect?.() === false) return;
|
|
2029
|
+
if (typeof alt.clearTextSelection !== "function") return;
|
|
2030
|
+
alt.clearTextSelection();
|
|
2031
|
+
this.scheduleRender();
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
|
|
2035
|
+
private writePrimarySelection(text: string): void {
|
|
2036
|
+
if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
|
|
2037
|
+
try {
|
|
2038
|
+
const child = spawn("xclip", ["-selection", "primary"], { stdio: ["pipe", "ignore", "ignore"] });
|
|
2039
|
+
child.stdin?.on("error", () => {});
|
|
2040
|
+
child.on("error", () => {});
|
|
2041
|
+
child.stdin?.end(text);
|
|
2042
|
+
} catch {
|
|
2043
|
+
/* silent no-op when xclip is absent */
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
private selectionText(): string {
|
|
2048
|
+
const range = normalizeSelection(this.selection);
|
|
2049
|
+
if (!range) return "";
|
|
2050
|
+
const reusable = this.interactionNullCell();
|
|
2051
|
+
const parts: string[] = [];
|
|
2052
|
+
for (let lineIndex = range.start.line; lineIndex <= range.end.line; lineIndex++) {
|
|
2053
|
+
const line = this.interactionLine(lineIndex);
|
|
2054
|
+
if (!line) {
|
|
2055
|
+
parts.push("");
|
|
2056
|
+
continue;
|
|
2057
|
+
}
|
|
2058
|
+
const from = lineIndex === range.start.line ? range.start.col : 0;
|
|
2059
|
+
const to = lineIndex === range.end.line ? range.end.col : line.length - 1;
|
|
2060
|
+
let text = "";
|
|
2061
|
+
for (let x = Math.max(0, from); x <= Math.max(from, to); x++) {
|
|
2062
|
+
const cell = line.getCell(x, reusable);
|
|
2063
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
2064
|
+
text += cell.getChars() || " ";
|
|
2065
|
+
}
|
|
2066
|
+
parts.push(text.replace(/\s+$/u, ""));
|
|
2067
|
+
}
|
|
2068
|
+
return parts.join("\n").replace(/^\n+|\n+$/gu, "");
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
private currentSize(): { cols: number; rows: number } {
|
|
2072
|
+
// SAFETY: duck-typed read — Pi TUI's Terminal type does not consistently expose
|
|
2073
|
+
// cols/columns/rows across versions (see resizeIfNeeded below). Runtime
|
|
2074
|
+
// fallbacks (120/24) keep this safe when the fields are absent.
|
|
2075
|
+
const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined;
|
|
2076
|
+
return {
|
|
2077
|
+
cols: Math.max(20, term?.cols ?? term?.columns ?? 120),
|
|
2078
|
+
rows: Math.max(5, (term?.rows ?? 24) - 2),
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
private resizeIfNeeded(width: number): void {
|
|
2083
|
+
const size = this.currentSize();
|
|
2084
|
+
// Render width is authoritative inside ctx.ui.custom; terminal.cols is not
|
|
2085
|
+
// consistently exposed by all Pi TUI versions.
|
|
2086
|
+
size.cols = Math.max(20, width);
|
|
2087
|
+
if (size.cols === this.cols && size.rows === this.rows) {
|
|
2088
|
+
// A same-size render after the early protocol size-sync consumes that
|
|
2089
|
+
// one-shot pair; it must not mint another intent.
|
|
2090
|
+
if (this.pendingEarlySyncTarget === `${size.cols}x${size.rows}`) this.pendingEarlySyncTarget = null;
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
this.cols = size.cols;
|
|
2094
|
+
this.rows = size.rows;
|
|
2095
|
+
this.noteGeometryIntent(size.cols, size.rows);
|
|
2096
|
+
// A real terminal resize supersedes the hold protocol: cancel any armed
|
|
2097
|
+
// hold and adopt the new size as the baseline (guard G4, issue #25).
|
|
2098
|
+
this.jiggleRetry.notifyExternalResize(size.cols, size.rows);
|
|
2099
|
+
this.term.resize(this.cols, this.rows);
|
|
2100
|
+
// Issue #205: reflow after a resize rewrites baseY, so the previous hydrate
|
|
2101
|
+
// floor no longer describes the buffer. Re-base it on the observed baseY —
|
|
2102
|
+
// losing local history is the safe side of this trade. A zero floor means no
|
|
2103
|
+
// hydrate has established one yet, so a legacy attach keeps its replayed
|
|
2104
|
+
// scrollback as real local history.
|
|
2105
|
+
if (this.historyFloor > 0) this.historyFloor = this.term.buffer.active.baseY;
|
|
2106
|
+
this.sendResize();
|
|
2107
|
+
this.enableMouseScroll();
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
/** A differing resize is exactly one local geometry revision/intent; an
|
|
2111
|
+
* applied ack is not completion, only a matching proof is. */
|
|
2112
|
+
private noteGeometryIntent(cols = this.cols, rows = this.rows, options: { early?: boolean } = {}): void {
|
|
2113
|
+
const target = `${cols}x${rows}`;
|
|
2114
|
+
if (options.early === true) {
|
|
2115
|
+
// Every actual early size-sync/mismatch advances once. De-dup is
|
|
2116
|
+
// ONLY early→next render; early→early is never collapsed. The latest
|
|
2117
|
+
// pending target is consumed by the next render of that target.
|
|
2118
|
+
this.pendingEarlySyncTarget = target;
|
|
2119
|
+
} else if (this.pendingEarlySyncTarget === target) {
|
|
2120
|
+
// The next render of the same target consumes the early pair.
|
|
2121
|
+
this.pendingEarlySyncTarget = null;
|
|
2122
|
+
return;
|
|
2123
|
+
} else {
|
|
2124
|
+
this.pendingEarlySyncTarget = null;
|
|
2125
|
+
}
|
|
2126
|
+
this.geometryRevision += 1;
|
|
2127
|
+
this.resizeIntentId = this.geometryRevision;
|
|
2128
|
+
this.proofGeometryRevision = this.geometryRevision;
|
|
2129
|
+
this.proofIntentId = this.resizeIntentId;
|
|
2130
|
+
this.resizeIntentRequired = true;
|
|
2131
|
+
this.resizeIntentSatisfied = false;
|
|
2132
|
+
this.committedView = null;
|
|
2133
|
+
this.recoveryView = null;
|
|
2134
|
+
this.recoveryGeneration = null;
|
|
2135
|
+
this.invalidateViewProof("geometry-resize");
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
private sendResize(cols = this.cols, rows = this.rows): void {
|
|
2139
|
+
// Phase 5 (D4): enveloped resize when the host has an instance fence
|
|
2140
|
+
// (correlated applied/superseded acks; starting-window host_starting is
|
|
2141
|
+
// retried client-side); legacy plain message otherwise. Both paths reach
|
|
2142
|
+
// the same runner resize handler.
|
|
2143
|
+
if (!this.attachClient.sendControl("resize", { cols, rows })) {
|
|
2144
|
+
this.send({ type: "resize", cols, rows });
|
|
2145
|
+
}
|
|
2146
|
+
this.clampViewportTop(this.bodyHeight());
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/** Feed socket output into the jiggle retry controller (clear/frame detection). */
|
|
2150
|
+
private checkClearSequence(data: string): void {
|
|
2151
|
+
this.jiggleRetry.feed(data);
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/** Issue #185: forward one synthesized SGR wheel report so an alternate-screen
|
|
2155
|
+
* child scrolls its own viewport (the attach's scrollback is inert there).
|
|
2156
|
+
* One report per received event — the child applies its own acceleration,
|
|
2157
|
+
* exactly as it would for a real terminal. Coordinates are child-screen
|
|
2158
|
+
* cells: the normalized event is zero-based and local to this overlay, the
|
|
2159
|
+
* body starts at local row 1 (header row 0, footer last row), and SGR is
|
|
2160
|
+
* one-based. */
|
|
2161
|
+
private forwardWheelToChild(linesUp: number, event: TuiMouseEvent): void {
|
|
2162
|
+
this.lastUserInputAt = this.nowFn();
|
|
2163
|
+
this.send({
|
|
2164
|
+
type: "input",
|
|
2165
|
+
data: encodeWheelReport(linesUp > 0 ? "up" : "down", {
|
|
2166
|
+
col: clampInt(event.x + 1, 1, this.cols),
|
|
2167
|
+
row: clampInt(event.y, 1, Math.max(1, this.bodyHeight())),
|
|
2168
|
+
// Issue #214: the overlay already received the Alt bit from pi-tui; carry it
|
|
2169
|
+
// into the synthesized report so the child keeps its 5x wheel accelerator.
|
|
2170
|
+
alt: event.alt === true,
|
|
2171
|
+
}),
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
private tryScrollBy(linesUp: number): boolean {
|
|
2176
|
+
const height = this.bodyHeight();
|
|
2177
|
+
if (this.modernCommittedMode()) {
|
|
2178
|
+
// The committed view is the only visible cache. Record the requested
|
|
2179
|
+
// viewport intent but let the child own history: the display stays on
|
|
2180
|
+
// the current complete range until a later verified boundary.
|
|
2181
|
+
const intent = scrollViewportTop(this.viewportTop, this.bottomViewportTop(height), linesUp, this.scrollFloorStart(height));
|
|
2182
|
+
this.viewportTop = intent.viewportTop;
|
|
2183
|
+
return false;
|
|
2184
|
+
}
|
|
2185
|
+
const result = scrollViewportTop(this.viewportTop, this.bottomViewportTop(height), linesUp, this.scrollFloorStart(height));
|
|
2186
|
+
this.viewportTop = result.viewportTop;
|
|
2187
|
+
if (result.changed) this.requestScrollRender();
|
|
2188
|
+
return result.changed;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
private tryScrollToTop(): boolean {
|
|
2192
|
+
const height = this.bodyHeight();
|
|
2193
|
+
const floorStart = this.scrollFloorStart(height);
|
|
2194
|
+
// Issue #205: compare against follow-bottom rather than against 0. A legacy
|
|
2195
|
+
// attach has `historyFloor === 0`, so `floorStart` is 0 while `bottom` is not —
|
|
2196
|
+
// this is then provably the parent "no local history" guard, and Home still
|
|
2197
|
+
// scrolls the local viewport to line 0. A hydrated overflow frame sits at
|
|
2198
|
+
// `floorStart === bottom`, so Home is forwarded to the child instead of being
|
|
2199
|
+
// spent scrolling hydration residue.
|
|
2200
|
+
if (floorStart >= this.bottomViewportTop(height) || this.viewportTop === floorStart) return false;
|
|
2201
|
+
this.viewportTop = floorStart;
|
|
2202
|
+
this.requestScrollRender();
|
|
2203
|
+
return true;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
private tryScrollToBottom(): boolean {
|
|
2207
|
+
if (this.viewportTop === null) return false;
|
|
2208
|
+
this.viewportTop = null;
|
|
2209
|
+
this.requestScrollRender();
|
|
2210
|
+
return true;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
private scrollToBottom(): void {
|
|
2214
|
+
this.viewportTop = null;
|
|
2215
|
+
this.requestScrollRender();
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
private requestScrollRender(): void {
|
|
2219
|
+
this.scheduleRender();
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
private bodyHeight(): number {
|
|
2223
|
+
return Math.max(1, (this.tui.terminal?.rows ?? 24) - 2);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
private pageSize(): number {
|
|
2227
|
+
return Math.max(1, this.bodyHeight() - 2);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
private bottomViewportTop(height: number): number {
|
|
2231
|
+
const buf = this.term.buffer.active;
|
|
2232
|
+
const bottom = Math.min(buf.length, buf.baseY + this.rows);
|
|
2233
|
+
return Math.max(0, bottom - height);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/** Issue #205: lowest buffer line the local viewport may scroll to — the bottom
|
|
2237
|
+
* minus the rows the runner vouched for as real scrollback. */
|
|
2238
|
+
private scrollFloorStart(height: number): number {
|
|
2239
|
+
const bottom = this.bottomViewportTop(height);
|
|
2240
|
+
return Math.max(0, bottom - scrollableHistoryLines(this.term.buffer.active.baseY, this.historyFloor));
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/** Issue #207: re-assert the outer terminal mouse mode when the full-screen TUI owns it.
|
|
2244
|
+
* pi-tui writes ENABLE_MOUSE exactly once in `beforeTerminalStart` and never again, so a
|
|
2245
|
+
* single lost handshake (WezTerm on Windows is the reported case) turns every wheel
|
|
2246
|
+
* notch into an arrow key for the rest of the session: pi-tui keeps dispatching mouse
|
|
2247
|
+
* events that never arrive, and the attach surface only ever sees `[A`/`[B`.
|
|
2248
|
+
* The sequence is pi-tui's own multiplexer-aware choice, so hover (1003) and focus
|
|
2249
|
+
* (1004) reporting cannot be downgraded. We never write the disable form here, so this
|
|
2250
|
+
* cannot break pi's own state either (the #173 contract stays intact). */
|
|
2251
|
+
private assertFullscreenMouseMode(): void {
|
|
2252
|
+
if (this.tui.mode !== "fullscreen") return;
|
|
2253
|
+
if (!this.mouseScrollEnabled()) return;
|
|
2254
|
+
try {
|
|
2255
|
+
this.tui.terminal.write(fullscreenMouseEnableSequence());
|
|
2256
|
+
} catch {
|
|
2257
|
+
/* best-effort: a dead terminal must not break attach */
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
private clampViewportTop(height: number): void {
|
|
2262
|
+
if (this.viewportTop === null) return;
|
|
2263
|
+
// scrollViewportTop snaps a below-floor viewport up to the floor, so a floor
|
|
2264
|
+
// that rose after the viewport parked (hydrate/resize) has to settle here too.
|
|
2265
|
+
this.viewportTop = clampInt(this.viewportTop, this.scrollFloorStart(height), this.bottomViewportTop(height));
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
private send(msg: Record<string, unknown>): void {
|
|
2269
|
+
if (!this.socket || !this.connected) return;
|
|
2270
|
+
this.socket.write(JSON.stringify(msg) + "\n");
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
private onSocketData(text: string): void {
|
|
2274
|
+
this.parserBuffer += text;
|
|
2275
|
+
const lines = this.parserBuffer.split("\n");
|
|
2276
|
+
this.parserBuffer = lines.pop() ?? "";
|
|
2277
|
+
let needsRender = false;
|
|
2278
|
+
let suppressGenerationRender = false;
|
|
2279
|
+
for (const line of lines) {
|
|
2280
|
+
if (!line.trim()) continue;
|
|
2281
|
+
try {
|
|
2282
|
+
const msg = JSON.parse(line);
|
|
2283
|
+
// Protocol-managed messages (snapshot windows, seq-checked live
|
|
2284
|
+
// output, recovery) are consumed by the attach client and re-emitted
|
|
2285
|
+
// through handleAttachEvent. UI-owned messages (hello/status/
|
|
2286
|
+
// editor_state/exit/error) and pre-decision broadcast output return
|
|
2287
|
+
// false and fall through to the legacy path below.
|
|
2288
|
+
if (this.attachClient.handleMessage(msg)) continue;
|
|
2289
|
+
if (msg.type === "output" && typeof msg.data === "string") {
|
|
2290
|
+
this.pushOutput(msg.data, { forwardProtocols: true });
|
|
2291
|
+
this.checkClearSequence(msg.data);
|
|
2292
|
+
continue;
|
|
2293
|
+
}
|
|
2294
|
+
if (msg.type === "hello" || msg.type === "status") {
|
|
2295
|
+
const wireGeneration = typeof msg.generation === "string" ? msg.generation : typeof msg.status?.generation === "string" ? msg.status.generation : null;
|
|
2296
|
+
if (wireGeneration !== null) {
|
|
2297
|
+
this.currentWireGeneration = wireGeneration;
|
|
2298
|
+
this.emptyResetPending = this.recoveryGeneration !== null && wireGeneration !== this.recoveryGeneration;
|
|
2299
|
+
if (
|
|
2300
|
+
(this.committedView !== null && wireGeneration !== this.committedView.identity.generation) ||
|
|
2301
|
+
(this.recoveryGeneration !== null && wireGeneration !== this.recoveryGeneration)
|
|
2302
|
+
) {
|
|
2303
|
+
this.outputRenderScheduler.cancel();
|
|
2304
|
+
this.committedView = null;
|
|
2305
|
+
this.invalidateRecoveryView();
|
|
2306
|
+
this.invalidateViewProof("generation-change");
|
|
2307
|
+
// Do not paint the previous generation from a status render
|
|
2308
|
+
// before the new empty/snapshot arrives.
|
|
2309
|
+
suppressGenerationRender = true;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
this.status = "attached";
|
|
2313
|
+
const status = (msg.status ?? msg) as { frameWitness?: { enabled?: unknown } };
|
|
2314
|
+
if (status.frameWitness?.enabled === true) {
|
|
2315
|
+
const wasAvailable = this.frameWitnessAvailable;
|
|
2316
|
+
this.frameWitnessAvailable = true;
|
|
2317
|
+
if (!wasAvailable && this.attaching && this.attachMode === "protocol") {
|
|
2318
|
+
if (this.attachSettleTimer) {
|
|
2319
|
+
clearTimeout(this.attachSettleTimer);
|
|
2320
|
+
this.attachSettleTimer = null;
|
|
2321
|
+
}
|
|
2322
|
+
this.deadlineState = "pending";
|
|
2323
|
+
this.evaluateAttachReady();
|
|
2324
|
+
}
|
|
2325
|
+
} else if (status.frameWitness?.enabled === false && this.frameWitnessAvailable) {
|
|
2326
|
+
this.frameWitnessAvailable = false;
|
|
2327
|
+
this.latestViewProof = null;
|
|
2328
|
+
this.viewProofIdentity = null;
|
|
2329
|
+
if (this.attaching && this.attachMode === "protocol") {
|
|
2330
|
+
this.deadlineState = "pending";
|
|
2331
|
+
this.notReadyReason = "legacy-deadlines-without-reporter";
|
|
2332
|
+
this.restoreLegacyDeadlines();
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
if (msg.type === "hello") this.editorEmpty = typeof msg.editorEmpty === "boolean" ? msg.editorEmpty : null;
|
|
2336
|
+
} else if (msg.type === "editor_state") {
|
|
2337
|
+
this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
2338
|
+
} else if (msg.type === "exit") {
|
|
2339
|
+
this.status = "host exited";
|
|
2340
|
+
// The exit path calls done() directly (host disposes later);
|
|
2341
|
+
// drop the #128 bridge now so A7 symmetry doesn't depend on
|
|
2342
|
+
// the host honoring dispose().
|
|
2343
|
+
this.detachColorSchemeBridge();
|
|
2344
|
+
this.clearReplyGuardTimer();
|
|
2345
|
+
this.replyGuard?.reset();
|
|
2346
|
+
this.done({ action: "closed", exitCode: msg.exitCode ?? null });
|
|
2347
|
+
} else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
|
|
2348
|
+
if (shouldScheduleAttachRenderForMessage(msg.type)) needsRender = true;
|
|
2349
|
+
} catch {
|
|
2350
|
+
// Ignore malformed protocol lines; raw PTY data is only legal inside output.data.
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
if (needsRender && !suppressGenerationRender) this.scheduleRender();
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* Attach-client events (issue #91 phase 4). The client owns protocol
|
|
2358
|
+
* recovery (it resubscribes internally); the UI only switches paths and
|
|
2359
|
+
* hydrates frames.
|
|
2360
|
+
*/
|
|
2361
|
+
private handleAttachEvent(
|
|
2362
|
+
event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "viewProof" | "viewProofRecord" | "resubscribing" | "protocolError" | "cmdAck" | "reconciled" | "epochReset",
|
|
2363
|
+
payload: any,
|
|
2364
|
+
): void {
|
|
2365
|
+
if (event === "mode") {
|
|
2366
|
+
const prev = this.attachMode;
|
|
2367
|
+
this.attachMode = payload as "protocol" | "legacy";
|
|
2368
|
+
if (this.attachMode === "protocol") {
|
|
2369
|
+
// Snapshot mode owns screen correctness: cancel any pending legacy
|
|
2370
|
+
// jiggle arming and unwind anything already armed (restores a held
|
|
2371
|
+
// resize; no-ops otherwise). The legacy 250 ms settle is cancelled
|
|
2372
|
+
// permanently for this attach; the hard deadline only marks an
|
|
2373
|
+
// explicit expired wait, never a forced publish.
|
|
2374
|
+
this.clearJiggleStartTimer();
|
|
2375
|
+
this.jiggleRetry.restoreAndStop();
|
|
2376
|
+
if (this.frameWitnessAvailable && this.attachSettleTimer) {
|
|
2377
|
+
clearTimeout(this.attachSettleTimer);
|
|
2378
|
+
this.attachSettleTimer = null;
|
|
2379
|
+
}
|
|
2380
|
+
this.deadlineState = "pending";
|
|
2381
|
+
this.notReadyReason = this.frameWitnessAvailable ? "awaiting-complete-frame" : "legacy-deadlines-without-reporter";
|
|
2382
|
+
this.evaluateAttachReady();
|
|
2383
|
+
} else if (prev === "protocol") {
|
|
2384
|
+
// Downgrade after a protocol session (recovery budget exhausted):
|
|
2385
|
+
// re-arm the legacy screen-healing machinery. This must run whenever
|
|
2386
|
+
// the socket is alive — not only during the attach transition. A
|
|
2387
|
+
// post-settle downgrade that skipped start() left the chain stopped
|
|
2388
|
+
// forever (restoreAndStop on the protocol side), so feed() early-
|
|
2389
|
+
// returned and raw output could never heal a desynced screen.
|
|
2390
|
+
this.clearJiggleStartTimer();
|
|
2391
|
+
if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
|
|
2392
|
+
}
|
|
2393
|
+
if (this.attachMode === "legacy") this.restoreLegacyDeadlines();
|
|
2394
|
+
// Legacy decided during the undecided window: the race-guard timer
|
|
2395
|
+
// already fired at 100ms (≪ the 1500ms probe timeout) — nothing to do.
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
if (event === "snapshotBegin") {
|
|
2399
|
+
// Collecting identity only: replacement happens at snapshotReady.
|
|
2400
|
+
const beginGeneration = (payload ?? {}) as { generation?: unknown; empty?: unknown };
|
|
2401
|
+
if (beginGeneration.empty === true) {
|
|
2402
|
+
// Empty baseline invalidates old views/proof AT THE BEGIN, before
|
|
2403
|
+
// any status/hello render can present the dead generation again.
|
|
2404
|
+
this.outputRenderScheduler.cancel();
|
|
2405
|
+
this.committedView = null;
|
|
2406
|
+
this.invalidateRecoveryView();
|
|
2407
|
+
this.invalidateViewProof("new-empty-baseline");
|
|
2408
|
+
this.emptyResetPending = true;
|
|
2409
|
+
this.scheduleRender();
|
|
2410
|
+
}
|
|
2411
|
+
if (typeof beginGeneration.generation === "string") {
|
|
2412
|
+
const generationChanged =
|
|
2413
|
+
(this.committedView !== null && beginGeneration.generation !== this.committedView.identity.generation) ||
|
|
2414
|
+
(this.recoveryGeneration !== null && beginGeneration.generation !== this.recoveryGeneration);
|
|
2415
|
+
if (generationChanged) {
|
|
2416
|
+
this.committedView = null;
|
|
2417
|
+
this.invalidateRecoveryView();
|
|
2418
|
+
this.invalidateViewProof("generation-change");
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
this.collectingConnectionToken = this.connectionToken;
|
|
2422
|
+
this.collectingBaselineToken = this.baselineToken;
|
|
2423
|
+
this.collectingParserEpoch = this.parserEpoch;
|
|
2424
|
+
// Protocol attach size-sync (CR R1 blocking): the legacy attach resized
|
|
2425
|
+
// the child at every connect (jiggle start); subscribe_terminal carries
|
|
2426
|
+
// no size, so without this the child keeps its host-creation geometry
|
|
2427
|
+
// and full-screen TUI children lay out at a stale size. begin is
|
|
2428
|
+
// authoritative for the runner's CURRENT size; the in-flight frame stays
|
|
2429
|
+
// old-geometry either way, but resizing now starts the child's redraw at
|
|
2430
|
+
// the true size sooner. Matched sizes send nothing (zero-resize
|
|
2431
|
+
// contract for same-size attach stays intact).
|
|
2432
|
+
const beginSize = (payload ?? {}) as { cols?: number; rows?: number };
|
|
2433
|
+
if (
|
|
2434
|
+
typeof beginSize.cols === "number" &&
|
|
2435
|
+
typeof beginSize.rows === "number" &&
|
|
2436
|
+
(beginSize.cols !== this.cols || beginSize.rows !== this.rows)
|
|
2437
|
+
) {
|
|
2438
|
+
// One early size-sync pairs with the next render of the same target.
|
|
2439
|
+
// Later independent size-syncs to an already-seen target still advance.
|
|
2440
|
+
this.noteGeometryIntent(this.cols, this.rows, { early: true });
|
|
2441
|
+
this.sendResize();
|
|
2442
|
+
}
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
if (event === "snapshotReady") {
|
|
2446
|
+
const snap = (payload ?? {}) as { frame?: string; empty?: boolean; resnapshot?: boolean; scrollbackLines?: number; viewProof?: unknown; viewProofRecords?: unknown[] };
|
|
2447
|
+
if (this.collectingConnectionToken !== this.connectionToken) return;
|
|
2448
|
+
// The synthesized frame is self-contained on dirty terminals (phase 3
|
|
2449
|
+
// torture-proven: DECSTR + clear preamble wipes the constructor
|
|
2450
|
+
// screen.log replay and any pre-probe broadcast bytes), so hydrating is
|
|
2451
|
+
// a plain push: the write callback drives receivedOutput/settle/render
|
|
2452
|
+
// exactly like the legacy replay path. No protocol forwarding — the
|
|
2453
|
+
// frame is runner-synthesized grid content, not child passthrough
|
|
2454
|
+
// sequences. Empty baselines (the COMMON initial attach state: the host
|
|
2455
|
+
// publishes alive+childPid before the child's first output) carry no
|
|
2456
|
+
// frame; the loading banner persists until the first live output.
|
|
2457
|
+
// Empty/resnapshot answers ALWAYS wipe the local buffer, attaching or
|
|
2458
|
+
// not: while attaching the banner hides it (zero visual cost), and the
|
|
2459
|
+
// constructor's screen.log warm-start must not outlive the protocol
|
|
2460
|
+
// answer — a gated reset let the dead session's tail render as a
|
|
2461
|
+
// cold-start ghost once the banner lifted (final-review F1).
|
|
2462
|
+
if (!this.replaceParser()) {
|
|
2463
|
+
this.handleParserReplacementFailure();
|
|
2464
|
+
return; // fail-closed: no write to the old parser
|
|
2465
|
+
}
|
|
2466
|
+
const records = Array.isArray(snap.viewProofRecords) ? snap.viewProofRecords.slice(0, MAX_VIEW_PROOF_RECORDS) : [];
|
|
2467
|
+
this.viewProofRecords = records;
|
|
2468
|
+
this.viewProofRecordCount = records.length;
|
|
2469
|
+
if (
|
|
2470
|
+
!this.proofResyncRequested &&
|
|
2471
|
+
records.some((record) => (record as { marker?: unknown } | null)?.marker === "F") &&
|
|
2472
|
+
(snap.viewProof as { status?: string } | null | undefined)?.status !== "covered"
|
|
2473
|
+
) {
|
|
2474
|
+
this.proofResyncRequested = true;
|
|
2475
|
+
this.attachClient.requestResync("view-proof-records-without-coverage");
|
|
2476
|
+
}
|
|
2477
|
+
this.viewProofTrailing.reset();
|
|
2478
|
+
this.recentProtocolOutput = [];
|
|
2479
|
+
this.resizeIntentSatisfied = false;
|
|
2480
|
+
if (snap.viewProof !== undefined) {
|
|
2481
|
+
this.latestViewProof = snap.viewProof;
|
|
2482
|
+
this.viewProofIdentity = (snap.viewProof as { status?: string } | null)?.status === "covered" ? this.currentViewIdentity() : null;
|
|
2483
|
+
if ((snap.viewProof as { status?: string } | null)?.status === "covered") this.proofResyncRequested = false;
|
|
2484
|
+
} else if (snap.empty === true) {
|
|
2485
|
+
this.latestViewProof = { status: "unknown", reason: "empty-baseline", proof: null, throughSeq: null, cursorVisible: null };
|
|
2486
|
+
this.viewProofIdentity = null;
|
|
2487
|
+
} else {
|
|
2488
|
+
this.latestViewProof = null;
|
|
2489
|
+
this.viewProofIdentity = null;
|
|
2490
|
+
}
|
|
2491
|
+
if (typeof snap.frame === "string") {
|
|
2492
|
+
this.protocolBaselineState = "valid-content";
|
|
2493
|
+
this.localHydrationState = "hydrating";
|
|
2494
|
+
this.pushOutput(snap.frame, { authoritative: true, scrollbackLines: snap.scrollbackLines });
|
|
2495
|
+
} else if (snap.empty || snap.resnapshot) {
|
|
2496
|
+
if (snap.empty === true) {
|
|
2497
|
+
this.committedView = null;
|
|
2498
|
+
this.invalidateRecoveryView();
|
|
2499
|
+
this.invalidateViewProof("new-empty-baseline");
|
|
2500
|
+
this.emptyResetPending = true;
|
|
2501
|
+
}
|
|
2502
|
+
this.protocolBaselineState = "valid-empty";
|
|
2503
|
+
this.childInputReady = false;
|
|
2504
|
+
this.childInputModeCarry = "";
|
|
2505
|
+
// Issue #205: the replacement parser is empty, so no stale floor/poison
|
|
2506
|
+
// survives. Empty/resnapshot never triggers the first-attach hooks.
|
|
2507
|
+
this.historyFloor = 0;
|
|
2508
|
+
this.scheduleRender();
|
|
2509
|
+
this.evaluateAttachReady();
|
|
2510
|
+
}
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
if (event === "output") {
|
|
2514
|
+
// Replacement failure is a hard fence: protocol bytes cannot be mixed into
|
|
2515
|
+
// the old parser while waiting for a bounded fresh resync/legacy decision.
|
|
2516
|
+
if (this.localHydrationState === "failed") return;
|
|
2517
|
+
// Live/replay protocol output: no jiggle clear-detection — that feed
|
|
2518
|
+
// exists to cancel the shrink-and-hold chain, which never runs in
|
|
2519
|
+
// protocol mode.
|
|
2520
|
+
if (typeof payload === "string") {
|
|
2521
|
+
this.recentProtocolOutput.push({ seq: this.attachClient.getLastSeq(), data: payload });
|
|
2522
|
+
if (this.recentProtocolOutput.length > 16) this.recentProtocolOutput.shift();
|
|
2523
|
+
this.noteViewProofOutput(payload);
|
|
2524
|
+
this.pushOutput(payload, { forwardProtocols: true });
|
|
2525
|
+
}
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
if (event === "viewProof") {
|
|
2529
|
+
this.latestViewProof = payload ?? null;
|
|
2530
|
+
if ((payload as { status?: string } | null)?.status === "covered") {
|
|
2531
|
+
this.viewProofIdentity = this.currentViewIdentity();
|
|
2532
|
+
this.viewProofTrailing.reset({ cursorVisible: typeof (payload as { cursorVisible?: unknown }).cursorVisible === "boolean" ? (payload as { cursorVisible: boolean }).cursorVisible : undefined });
|
|
2533
|
+
} else {
|
|
2534
|
+
this.viewProofIdentity = null;
|
|
2535
|
+
this.viewProofTrailing.reset();
|
|
2536
|
+
if (String((payload as { reason?: string } | null)?.reason ?? "").includes("reporter-replaced")) {
|
|
2537
|
+
this.viewProofRecords = [];
|
|
2538
|
+
this.viewProofRecordCount = 0;
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
this.evaluateAttachReady();
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
if (event === "viewProofRecord") {
|
|
2545
|
+
if (payload !== null && typeof payload === "object") {
|
|
2546
|
+
const record = payload as Record<string, unknown>;
|
|
2547
|
+
this.noteViewProofRecord(record);
|
|
2548
|
+
if (record.marker === "F") {
|
|
2549
|
+
this.lastProofMetadata = record.metadata as { geometryRevision?: unknown; intentId?: unknown } | undefined;
|
|
2550
|
+
this.resizeIntentSatisfied = this.proofMeetsResizeIntent();
|
|
2551
|
+
}
|
|
2552
|
+
this.evaluateAttachReady();
|
|
2553
|
+
}
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
if (event === "cmdAck") {
|
|
2557
|
+
this.noteResizeAck(payload);
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
if (event === "epochReset") {
|
|
2561
|
+
this.childInputReady = false;
|
|
2562
|
+
this.childInputModeCarry = "";
|
|
2563
|
+
this.committedView = null;
|
|
2564
|
+
this.invalidateRecoveryView();
|
|
2565
|
+
this.invalidateViewProof("epoch-reset");
|
|
2566
|
+
}
|
|
2567
|
+
// "viewProof"/"viewProofRecord": independent protocol facts in this stage;
|
|
2568
|
+
// the component has no readiness/publish hook for them yet.
|
|
2569
|
+
// "resubscribing": recovery is client-internal (informational only).
|
|
2570
|
+
// "protocolError": observability only — recovery is automatic until the
|
|
2571
|
+
// legacy fallback, whose mode event the UI acts on above.
|
|
2572
|
+
// "cmdAck"/"reconciled"/"epochReset" (phase 5): correlation/observability
|
|
2573
|
+
// only — applied/superseded bookkeeping, reconcile baselines and the
|
|
2574
|
+
// generation epoch rule live inside the client; the UI's behavioral
|
|
2575
|
+
// surface (hydrate, mode switching, size-sync) is unchanged. host_starting
|
|
2576
|
+
// resize retries are also client-internal (parity with the legacy runner-
|
|
2577
|
+
// side cachedResize).
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
private nudgeGeometry(waitToken: number): void {
|
|
2581
|
+
this.geometryNudges += 1;
|
|
2582
|
+
const { cols, rows } = this;
|
|
2583
|
+
this.sendResize(cols, Math.max(5, rows - 1));
|
|
2584
|
+
const back = setTimeout(() => {
|
|
2585
|
+
if (!this.closed && this.cols === cols && this.rows === rows) this.sendResize(cols, rows);
|
|
2586
|
+
}, GEOMETRY_NUDGE_SETTLE_MS);
|
|
2587
|
+
back.unref?.();
|
|
2588
|
+
this.attachHardTimeout = setTimeout(() => this.handleAttachHardTimeout(waitToken), GEOMETRY_NUDGE_WAIT_MS);
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
private proofMeetsResizeIntent(): boolean {
|
|
2592
|
+
if (!this.resizeIntentRequired) return true;
|
|
2593
|
+
const metadata = this.lastProofMetadata;
|
|
2594
|
+
return metadata?.geometryRevision === this.proofGeometryRevision && metadata?.intentId === this.proofIntentId;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/** Learns the runner's numbers for the resize we're waiting on. Runners from before it sent them
|
|
2598
|
+
* outlive an upgrade, and keep being compared with ours. */
|
|
2599
|
+
private noteResizeAck(ack: { type?: string; stage?: string; cols?: number; rows?: number; resized?: boolean; geometryRevision?: unknown; intentId?: unknown }): void {
|
|
2600
|
+
if (ack?.type !== "resize" || ack.stage !== "applied") return;
|
|
2601
|
+
if (ack.cols !== this.cols || ack.rows !== this.rows) return; // A newer resize owns the gate.
|
|
2602
|
+
if (typeof ack.geometryRevision !== "number" || typeof ack.intentId !== "number") return;
|
|
2603
|
+
if (ack.resized === false) {
|
|
2604
|
+
// Already that size, so the session won't draw a frame for it.
|
|
2605
|
+
this.resizeIntentRequired = false;
|
|
2606
|
+
} else {
|
|
2607
|
+
this.proofGeometryRevision = ack.geometryRevision;
|
|
2608
|
+
this.proofIntentId = ack.intentId;
|
|
2609
|
+
}
|
|
2610
|
+
this.resizeIntentSatisfied = this.proofMeetsResizeIntent();
|
|
2611
|
+
this.evaluateAttachReady();
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
private forwardTerminalProtocols(data: string): void {
|
|
2615
|
+
const toWrite: string[] = [];
|
|
2616
|
+
if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") {
|
|
2617
|
+
const { sequences, carry } = extractOsc52Sequences(this.osc52Carry + data);
|
|
2618
|
+
this.osc52Carry = carry;
|
|
2619
|
+
toWrite.push(...sequences);
|
|
2620
|
+
}
|
|
2621
|
+
if (process.env.AGENT_BOARD_FORWARD_IMAGES !== "0") {
|
|
2622
|
+
const { sequences, carry } = extractTerminalPassthroughSequences(this.passthroughCarry + data);
|
|
2623
|
+
this.passthroughCarry = carry;
|
|
2624
|
+
toWrite.push(...sequences);
|
|
2625
|
+
}
|
|
2626
|
+
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES !== "0") {
|
|
2627
|
+
// Issue #128 D1: forward the child's terminal capability queries
|
|
2628
|
+
// (OSC 11 background probe, 2031 scheme-notify switch) to the real
|
|
2629
|
+
// terminal; set-forms and kitty/DA negotiation stay excluded (see
|
|
2630
|
+
// extractOscQuerySequences).
|
|
2631
|
+
const { sequences, carry } = extractOscQuerySequences(this.oscQueryCarry + data);
|
|
2632
|
+
this.oscQueryCarry = carry;
|
|
2633
|
+
toWrite.push(...sequences);
|
|
2634
|
+
}
|
|
2635
|
+
for (const seq of toWrite) {
|
|
2636
|
+
try {
|
|
2637
|
+
this.tui.terminal.write(seq);
|
|
2638
|
+
} catch {
|
|
2639
|
+
/* best-effort: forwarded sequences are enhancements, never critical */
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
/** LEGACY FALLBACK (issue #91 phase 6 marking): the screen.log tail replay
|
|
2645
|
+
* is kept only for pre-protocol runners and the
|
|
2646
|
+
* AGENT_BOARD_TERMINAL_SNAPSHOT=0 kill switch. It is NOT a correctness
|
|
2647
|
+
* path of the snapshot+subscribe protocol — the protocol hydrates from
|
|
2648
|
+
* runner-owned snapshots and resumes from a sequence cursor (phase 4),
|
|
2649
|
+
* and this replay runs before the probe resolves. Removal condition: the
|
|
2650
|
+
* installed runner fleet is on the snapshot protocol baseline
|
|
2651
|
+
* (wire-detectable via hello protocol fields).
|
|
2652
|
+
*/
|
|
2653
|
+
private replayScreenLog(): void {
|
|
2654
|
+
if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return;
|
|
2655
|
+
try {
|
|
2656
|
+
// Read only the tail of the log instead of the whole file: large logs (tens of MB)
|
|
2657
|
+
// would otherwise block the attach on a full readFileSync + UTF-8 decode before the
|
|
2658
|
+
// first frame. Skip a leading partial line so we don't inject a half escape sequence.
|
|
2659
|
+
const { size } = statSync(this.opts.screenLogPath);
|
|
2660
|
+
const start = Math.max(0, size - ATTACH_REPLAY_BYTES);
|
|
2661
|
+
const length = size - start;
|
|
2662
|
+
if (length <= 0) return;
|
|
2663
|
+
const fd = openSync(this.opts.screenLogPath, "r");
|
|
2664
|
+
try {
|
|
2665
|
+
const buf = Buffer.alloc(length);
|
|
2666
|
+
readSync(fd, buf, 0, length, start);
|
|
2667
|
+
let tail = buf.toString("utf8");
|
|
2668
|
+
if (start > 0) {
|
|
2669
|
+
const nl = tail.indexOf("\n");
|
|
2670
|
+
tail = nl >= 0 ? tail.slice(nl + 1) : tail;
|
|
2671
|
+
}
|
|
2672
|
+
this.pushOutput(tail);
|
|
2673
|
+
} finally {
|
|
2674
|
+
closeSync(fd);
|
|
2675
|
+
}
|
|
2676
|
+
} catch {
|
|
2677
|
+
/* best-effort: a missing or racing screen.log must not block attach */
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
private pushOutput(data: string, opts: { forwardProtocols?: boolean; authoritative?: boolean; scrollbackLines?: number } = {}): void {
|
|
2682
|
+
if (data.length === 0) return;
|
|
2683
|
+
// Recorded synchronously BEFORE term.write (whose callback is async): the
|
|
2684
|
+
// desync quiet-window must measure when output ARRIVED, not when parsing finished.
|
|
2685
|
+
this.lastOutputAt = this.nowFn();
|
|
2686
|
+
// Current snapshots explicitly restore all modes, including paste mode.
|
|
2687
|
+
// Read their readiness synchronously so an older parse callback cannot
|
|
2688
|
+
// overwrite a newer live shutdown/reload signal.
|
|
2689
|
+
if (opts.authoritative) {
|
|
2690
|
+
this.childInputReady = false;
|
|
2691
|
+
this.childInputModeCarry = "";
|
|
2692
|
+
}
|
|
2693
|
+
if (opts.forwardProtocols || opts.authoritative) this.updateChildInputReadiness(data);
|
|
2694
|
+
if (opts.forwardProtocols) this.forwardTerminalProtocols(data);
|
|
2695
|
+
const term = this.term;
|
|
2696
|
+
const connectionToken = this.connectionToken;
|
|
2697
|
+
const baselineToken = this.baselineToken;
|
|
2698
|
+
const parserEpoch = this.parserEpoch;
|
|
2699
|
+
const generation = this.attachClient.getIdentity().generation ?? "local";
|
|
2700
|
+
const capturedIdentity = { connectionToken, baselineToken, generation, parser: term, geometryRevision: this.geometryRevision, targetCols: this.cols, targetRows: this.rows, closed: false };
|
|
2701
|
+
// @xterm/headless parses asynchronously; the buffer is only populated once this
|
|
2702
|
+
// callback fires. Mark the buffer as ready (so the project path can paint it), but
|
|
2703
|
+
// stay on the loading banner while `attaching` — otherwise the screen-log replay and
|
|
2704
|
+
// the initial resize-jiggle redraws would flash through the viewport. Each parsed
|
|
2705
|
+
// chunk defers the settle window, so the banner holds until output actually
|
|
2706
|
+
// stops arriving (i.e. the redraw finished), not just until the resize jiggle ends.
|
|
2707
|
+
// Current-epoch invariant: replaceParser() resets this counter to 0 when it
|
|
2708
|
+
// advances parserEpoch. A stale callback returns below WITHOUT decrementing;
|
|
2709
|
+
// decrementing it would cancel a new-epoch write and allow premature publish.
|
|
2710
|
+
this.parserWritesInFlight += 1;
|
|
2711
|
+
try {
|
|
2712
|
+
term.write(data, () => {
|
|
2713
|
+
if (this.parserEpoch !== parserEpoch) return;
|
|
2714
|
+
if (this.parserWritesInFlight > 0) this.parserWritesInFlight -= 1;
|
|
2715
|
+
const currentIdentity = {
|
|
2716
|
+
connectionToken: this.connectionToken,
|
|
2717
|
+
baselineToken: this.baselineToken,
|
|
2718
|
+
generation: this.attachClient.getIdentity().generation ?? "local",
|
|
2719
|
+
parser: this.term,
|
|
2720
|
+
geometryRevision: this.geometryRevision,
|
|
2721
|
+
targetCols: this.cols,
|
|
2722
|
+
targetRows: this.rows,
|
|
2723
|
+
closed: this.closed,
|
|
2724
|
+
};
|
|
2725
|
+
if (!canPublishAttachView(capturedIdentity, currentIdentity)) return;
|
|
2726
|
+
if (opts.forwardProtocols || opts.authoritative) this.flushPendingSchemeReport();
|
|
2727
|
+
// Issue #205: baseY is only final once this chunk parsed, and the callback
|
|
2728
|
+
// has no buffer binding — re-read it. Rows past the runner-vouched
|
|
2729
|
+
// scrollback are overflow the hydration itself scrolled out.
|
|
2730
|
+
if (opts.authoritative) {
|
|
2731
|
+
this.historyFloor = historyFloorAfterHydrate(term.buffer.active.baseY, opts.scrollbackLines);
|
|
2732
|
+
this.localHydrationState = "complete";
|
|
2733
|
+
if (!this.parserSyncOpen) this.commitCommittedView(term, parserEpoch);
|
|
2734
|
+
}
|
|
2735
|
+
this.receivedOutput = true;
|
|
2736
|
+
this.deferAttachSettle();
|
|
2737
|
+
if (this.parserWritesInFlight === 0 && !this.parserSyncOpen && this.nowFn() - this.lastUserInputAt < INTERACTIVE_RENDER_WINDOW_MS) {
|
|
2738
|
+
this.outputRenderScheduler.cancel();
|
|
2739
|
+
this.scheduleRender();
|
|
2740
|
+
} else {
|
|
2741
|
+
this.outputRenderScheduler.request();
|
|
2742
|
+
}
|
|
2743
|
+
this.evaluateAttachReady();
|
|
2744
|
+
});
|
|
2745
|
+
} catch {
|
|
2746
|
+
if (this.parserEpoch === parserEpoch && this.parserWritesInFlight > 0) this.parserWritesInFlight -= 1;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
private projectCommitted(height: number): { lines: string[]; cursor: { row: number; col: number } | null } {
|
|
2751
|
+
const view = this.activeCommittedView();
|
|
2752
|
+
if (view === null) return { lines: [], cursor: null };
|
|
2753
|
+
const selection = normalizeSelection(this.selection);
|
|
2754
|
+
const out: string[] = [];
|
|
2755
|
+
for (let index = 0; index < view.rows.length && out.length < height; index += 1) {
|
|
2756
|
+
const lineIndex = view.start + index;
|
|
2757
|
+
out.push(lineToAnsi(view.rows[index].cells, SnapshotCell.empty(), this.term, lineIndex, selection, view.cursor, view.cursorHidden));
|
|
2758
|
+
}
|
|
2759
|
+
return { lines: out, cursor: view.cursor };
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
private project(height: number): { lines: string[]; cursor: { row: number; col: number } | null } {
|
|
2763
|
+
if (this.closed) return { lines: [], cursor: null };
|
|
2764
|
+
if (this.modernCommittedMode()) return this.projectCommitted(height);
|
|
2765
|
+
const out: string[] = [];
|
|
2766
|
+
const buf = this.term.buffer.active;
|
|
2767
|
+
const selection = normalizeSelection(this.selection);
|
|
2768
|
+
this.clampViewportTop(height);
|
|
2769
|
+
const start = this.viewportTop ?? this.bottomViewportTop(height);
|
|
2770
|
+
const end = Math.min(buf.length, start + height);
|
|
2771
|
+
const reusable = buf.getNullCell();
|
|
2772
|
+
// The PTY cursor: xterm buffer cursorY is relative to baseY (the viewport top of
|
|
2773
|
+
// the child terminal); cursorX may equal cols (one past the last cell). Only render
|
|
2774
|
+
// it when it lands inside the projected viewport.
|
|
2775
|
+
const cursor = projectPtyCursor(buf, start, height);
|
|
2776
|
+
const cursorHidden = isPtyCursorHidden(this.term);
|
|
2777
|
+
for (let i = start; i < end; i++) {
|
|
2778
|
+
out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor, cursorHidden));
|
|
2779
|
+
}
|
|
2780
|
+
if (out.length === 0) out.push("Waiting for PTY output…");
|
|
2781
|
+
return { lines: out.slice(-height), cursor };
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
private close(gracefulSocket = false): void {
|
|
2785
|
+
if (this.closed && !this.socket) {
|
|
2786
|
+
// Already closed (e.g. dispose() after detach()); a pending graceful-close
|
|
2787
|
+
// fallback timer stays armed on purpose — it self-clears on socket close
|
|
2788
|
+
// or destroys the socket after the grace window.
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
this.closed = true;
|
|
2792
|
+
this.attachWaitToken += 1;
|
|
2793
|
+
this.committedView = null;
|
|
2794
|
+
this.recoveryView = null;
|
|
2795
|
+
this.recoveryGeneration = null;
|
|
2796
|
+
this.disposeCurrentParser();
|
|
2797
|
+
this.imeCoalesceUninstall?.();
|
|
2798
|
+
this.releaseInput();
|
|
2799
|
+
this.detachColorSchemeBridge();
|
|
2800
|
+
this.clearReplyGuardTimer();
|
|
2801
|
+
this.replyGuard?.reset();
|
|
2802
|
+
this.attachClient.close();
|
|
2803
|
+
this.jiggleRetry.restoreAndStop();
|
|
2804
|
+
this.disableMouseScroll();
|
|
2805
|
+
this.clearMouseRefreshTimers();
|
|
2806
|
+
this.clearPendingClick();
|
|
2807
|
+
this.clearSelectionAutoScroll();
|
|
2808
|
+
this.clearRetry();
|
|
2809
|
+
this.stopLoadingTicker();
|
|
2810
|
+
this.outputRenderScheduler.dispose();
|
|
2811
|
+
// Narrow feature-detected host seam (repo pi-tui 1.1.0, host pi 1.4.0):
|
|
2812
|
+
// cancel the already-queued TUI frame without stopping the TUI. A closed
|
|
2813
|
+
// flag inside render cannot cancel a render queued before dispose.
|
|
2814
|
+
const renderSeam = this.tui as unknown as {
|
|
2815
|
+
cancelRenderTimer?: () => void;
|
|
2816
|
+
renderRequested?: boolean;
|
|
2817
|
+
};
|
|
2818
|
+
if (typeof renderSeam.cancelRenderTimer === "function") {
|
|
2819
|
+
try { renderSeam.cancelRenderTimer(); } catch { /* host seam is best-effort */ }
|
|
2820
|
+
}
|
|
2821
|
+
// TuiBase.requestImmediateRender() queues a process.nextTick that checks
|
|
2822
|
+
// renderRequested; clearing it cancels an already queued immediate paint
|
|
2823
|
+
// without stopping the TUI. Verified host pi 1.4.0 shape.
|
|
2824
|
+
if (typeof renderSeam.renderRequested === "boolean") renderSeam.renderRequested = false;
|
|
2825
|
+
this.stopDesyncProbe();
|
|
2826
|
+
if (this.attachSettleTimer) {
|
|
2827
|
+
clearTimeout(this.attachSettleTimer);
|
|
2828
|
+
this.attachSettleTimer = null;
|
|
2829
|
+
}
|
|
2830
|
+
if (this.attachHardTimeout) {
|
|
2831
|
+
clearTimeout(this.attachHardTimeout);
|
|
2832
|
+
this.attachHardTimeout = null;
|
|
2833
|
+
}
|
|
2834
|
+
this.clearGracefulSocketCloseTimer();
|
|
2835
|
+
const socket = this.socket;
|
|
2836
|
+
this.socket = null;
|
|
2837
|
+
this.connected = false;
|
|
2838
|
+
if (!socket) return;
|
|
2839
|
+
if (!gracefulSocket) {
|
|
2840
|
+
try { socket.destroy(); } catch { /* best-effort teardown */ }
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
// socket.end() preserves the ordering of the already-buffered restore and
|
|
2844
|
+
// detach packets. destroy() would discard buffered writes under backpressure.
|
|
2845
|
+
socket.once("close", () => this.clearGracefulSocketCloseTimer());
|
|
2846
|
+
try { socket.end(); } catch { /* fallback timer below forces cleanup */ }
|
|
2847
|
+
this.gracefulSocketCloseTimer = setTimeout(() => {
|
|
2848
|
+
this.gracefulSocketCloseTimer = null;
|
|
2849
|
+
try { socket.destroy(); } catch { /* best-effort teardown */ }
|
|
2850
|
+
}, GRACEFUL_SOCKET_CLOSE_MS);
|
|
2851
|
+
this.gracefulSocketCloseTimer.unref?.();
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
function asciiCellsForBufferLine(line: BufferLineLike, reusable: BufferCellLike): string[] {
|
|
2856
|
+
const cells: string[] = [];
|
|
2857
|
+
for (let x = 0; x < line.length; x++) {
|
|
2858
|
+
const cell = line.getCell(x, reusable);
|
|
2859
|
+
if (!cell || cell.getWidth() === 0) {
|
|
2860
|
+
cells.push(" ");
|
|
2861
|
+
continue;
|
|
2862
|
+
}
|
|
2863
|
+
const chars = cell.getChars() || " ";
|
|
2864
|
+
cells.push(chars.length === 1 && chars >= " " && chars <= "~" ? chars : " ");
|
|
2865
|
+
}
|
|
2866
|
+
return cells;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
function openExternalTarget(target: string): boolean {
|
|
2870
|
+
const sanitized = sanitizeOscPayload(target).trim();
|
|
2871
|
+
if (!isOpenableLink(sanitized)) return false;
|
|
2872
|
+
// Opening the target is best-effort: an async spawn failure (e.g. xdg-open
|
|
2873
|
+
// missing on a minimal server) must be swallowed, not crash the host (issue #86).
|
|
2874
|
+
try {
|
|
2875
|
+
if (process.platform === "darwin") {
|
|
2876
|
+
const child = spawn("open", [sanitized], { detached: true, stdio: "ignore" });
|
|
2877
|
+
child.on("error", () => {});
|
|
2878
|
+
child.unref();
|
|
2879
|
+
return true;
|
|
2880
|
+
}
|
|
2881
|
+
if (process.platform === "win32") {
|
|
2882
|
+
const child = spawn("cmd", ["/c", "start", "", sanitized], { detached: true, stdio: "ignore" });
|
|
2883
|
+
child.on("error", () => {});
|
|
2884
|
+
child.unref();
|
|
2885
|
+
return true;
|
|
2886
|
+
}
|
|
2887
|
+
const child = spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" });
|
|
2888
|
+
child.on("error", () => {});
|
|
2889
|
+
child.unref();
|
|
2890
|
+
return true;
|
|
2891
|
+
} catch {
|
|
2892
|
+
return false;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
function compareMousePoints(a: MousePoint, b: MousePoint): number {
|
|
2897
|
+
return a.line === b.line ? a.col - b.col : a.line - b.line;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
function normalizeSelection(selection: MouseSelection | null): NormalizedSelection | null {
|
|
2901
|
+
if (!selection) return null;
|
|
2902
|
+
return compareMousePoints(selection.anchor, selection.focus) <= 0
|
|
2903
|
+
? { start: selection.anchor, end: selection.focus }
|
|
2904
|
+
: { start: selection.focus, end: selection.anchor };
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
function pointWithinSelection(line: number, col: number, selection: NormalizedSelection | null): boolean {
|
|
2908
|
+
if (!selection) return false;
|
|
2909
|
+
if (line < selection.start.line || line > selection.end.line) return false;
|
|
2910
|
+
if (selection.start.line === selection.end.line) return col >= selection.start.col && col <= selection.end.col;
|
|
2911
|
+
if (line === selection.start.line) return col >= selection.start.col;
|
|
2912
|
+
if (line === selection.end.line) return col <= selection.end.col;
|
|
2913
|
+
return true;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
function osc52CopySequence(text: string): string {
|
|
2917
|
+
if (!text) return "";
|
|
2918
|
+
const data = Buffer.from(text, "utf8").toString("base64");
|
|
2919
|
+
const seq = `\x1b]52;c;${data}\x07`;
|
|
2920
|
+
return Buffer.byteLength(seq, "utf8") <= OSC52_MAX_BYTES ? seq : "";
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
function lineToAnsi(
|
|
2924
|
+
line: BufferLineLike | undefined,
|
|
2925
|
+
reusable: BufferCellLike,
|
|
2926
|
+
term: XtermLike,
|
|
2927
|
+
lineIndex: number,
|
|
2928
|
+
selection: NormalizedSelection | null,
|
|
2929
|
+
cursor: { row: number; col: number } | null,
|
|
2930
|
+
cursorHidden = false,
|
|
2931
|
+
): string {
|
|
2932
|
+
const isCursorRow = cursor !== null && cursor.row === lineIndex;
|
|
2933
|
+
let last = -1;
|
|
2934
|
+
if (!line) {
|
|
2935
|
+
// No buffer line: keep the marker (IME positioning) and paint the inverse block
|
|
2936
|
+
// only while the child reports a visible cursor.
|
|
2937
|
+
if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
2938
|
+
return "";
|
|
2939
|
+
}
|
|
2940
|
+
let lastBg = -1;
|
|
2941
|
+
for (let x = 0; x < line.length; x++) {
|
|
2942
|
+
const cell = line.getCell(x, reusable);
|
|
2943
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
2944
|
+
if (cell.getChars()) last = x;
|
|
2945
|
+
if (!cell.isBgDefault()) lastBg = x;
|
|
2946
|
+
}
|
|
2947
|
+
// Issue #232: full-screen TUIs paint an explicit theme bg over otherwise empty
|
|
2948
|
+
// regions. Trimming at the last character drops that paint and lets the host
|
|
2949
|
+
// terminal's default bg show through, so the render bound covers the last
|
|
2950
|
+
// painted cell too. Default-bg lines keep the old bound, byte for byte.
|
|
2951
|
+
const renderEnd = Math.max(last, lastBg);
|
|
2952
|
+
if (renderEnd < 0) {
|
|
2953
|
+
// Empty line: same split as above.
|
|
2954
|
+
if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
2955
|
+
return "";
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
let out = "";
|
|
2959
|
+
let prevAttr = "";
|
|
2960
|
+
let prevUri = "";
|
|
2961
|
+
for (let x = 0; x <= renderEnd; x++) {
|
|
2962
|
+
const cell = line.getCell(x, reusable);
|
|
2963
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
2964
|
+
const uri = osc8UriForCell(term, cell);
|
|
2965
|
+
if (uri !== prevUri) {
|
|
2966
|
+
if (prevUri) out += closeOsc8();
|
|
2967
|
+
if (uri) out += openOsc8(uri);
|
|
2968
|
+
prevUri = uri;
|
|
2969
|
+
}
|
|
2970
|
+
const selected = pointWithinSelection(lineIndex, x, selection);
|
|
2971
|
+
// Position and visibility are separate concerns: the zero-width CURSOR_MARKER
|
|
2972
|
+
// (stripped by the TUI) always marks where the hardware cursor belongs for IME
|
|
2973
|
+
// and PI_HARDWARE_CURSOR=1 terminals, while the solid inverse block is only
|
|
2974
|
+
// painted when the child terminal itself reports the cursor as visible. pi-tui
|
|
2975
|
+
// parks a hidden cursor at a diff-write byproduct position, so painting it
|
|
2976
|
+
// unconditionally showed a ghost block (issue #102).
|
|
2977
|
+
const isCursor = isCursorRow && x === cursor!.col;
|
|
2978
|
+
if (isCursor) out += CURSOR_MARKER;
|
|
2979
|
+
const paintCursor = isCursor && !cursorHidden;
|
|
2980
|
+
const key = attrKey(cell, selected, paintCursor);
|
|
2981
|
+
if (key !== prevAttr) {
|
|
2982
|
+
out += attrsToAnsi(cell, selected, paintCursor);
|
|
2983
|
+
prevAttr = key;
|
|
2984
|
+
}
|
|
2985
|
+
out += cell.getChars() || " ";
|
|
2986
|
+
}
|
|
2987
|
+
if (prevUri) out += closeOsc8();
|
|
2988
|
+
// Cursor past the end of the painted region (cursorX == cols or beyond the last
|
|
2989
|
+
// rendered cell): append an inverse space so a VISIBLE position shows, keeping the
|
|
2990
|
+
// marker either way.
|
|
2991
|
+
if (isCursorRow && cursor!.col > renderEnd) {
|
|
2992
|
+
out += CURSOR_MARKER + (cursorHidden ? "" : "\x1b[7m \x1b[0m");
|
|
2993
|
+
}
|
|
2994
|
+
return out + "\x1b[0m";
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
function attrKey(cell: BufferCellLike, selected = false, isCursor = false): string {
|
|
2998
|
+
// Runs for every cell of every frame, so no array to build and join.
|
|
2999
|
+
const flags =
|
|
3000
|
+
(selected ? 1 : 0) |
|
|
3001
|
+
(isCursor ? 2 : 0) |
|
|
3002
|
+
(cell.isBold() ? 4 : 0) |
|
|
3003
|
+
(cell.isDim() ? 8 : 0) |
|
|
3004
|
+
(cell.isItalic() ? 16 : 0) |
|
|
3005
|
+
(cell.isUnderline() ? 32 : 0) |
|
|
3006
|
+
(cell.isBlink() ? 64 : 0) |
|
|
3007
|
+
(cell.isInverse() ? 128 : 0) |
|
|
3008
|
+
(cell.isInvisible() ? 256 : 0) |
|
|
3009
|
+
(cell.isStrikethrough() ? 512 : 0) |
|
|
3010
|
+
(cell.isOverline() ? 1024 : 0) |
|
|
3011
|
+
(cell.isFgRGB() ? 2048 : 0) |
|
|
3012
|
+
(cell.isFgPalette() ? 4096 : 0) |
|
|
3013
|
+
(cell.isBgRGB() ? 8192 : 0) |
|
|
3014
|
+
(cell.isBgPalette() ? 16384 : 0);
|
|
3015
|
+
return `${flags};${cell.getFgColor()};${cell.getBgColor()}`;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
function attrsToAnsi(cell: BufferCellLike, selected = false, isCursor = false): string {
|
|
3019
|
+
const codes: string[] = ["0"];
|
|
3020
|
+
if (cell.isBold()) codes.push("1");
|
|
3021
|
+
if (cell.isDim()) codes.push("2");
|
|
3022
|
+
if (cell.isItalic()) codes.push("3");
|
|
3023
|
+
if (cell.isUnderline()) codes.push("4");
|
|
3024
|
+
if (cell.isBlink()) codes.push("5");
|
|
3025
|
+
if (cell.isInverse() || selected || isCursor) codes.push("7");
|
|
3026
|
+
if (cell.isInvisible()) codes.push("8");
|
|
3027
|
+
if (cell.isStrikethrough()) codes.push("9");
|
|
3028
|
+
if (cell.isOverline()) codes.push("53");
|
|
3029
|
+
codes.push(...colorCodes(cell, "fg"));
|
|
3030
|
+
codes.push(...colorCodes(cell, "bg"));
|
|
3031
|
+
return `\x1b[${codes.join(";")}m`;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
function colorCodes(cell: BufferCellLike, kind: "fg" | "bg"): string[] {
|
|
3035
|
+
const isFg = kind === "fg";
|
|
3036
|
+
const color = isFg ? cell.getFgColor() : cell.getBgColor();
|
|
3037
|
+
if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {
|
|
3038
|
+
return [isFg ? "38" : "48", "2", String((color >> 16) & 255), String((color >> 8) & 255), String(color & 255)];
|
|
3039
|
+
}
|
|
3040
|
+
if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
|
|
3041
|
+
if (color >= 0 && color <= 7) return [String((isFg ? 30 : 40) + color)];
|
|
3042
|
+
if (color >= 8 && color <= 15) return [String((isFg ? 90 : 100) + color - 8)];
|
|
3043
|
+
return [isFg ? "38" : "48", "5", String(color)];
|
|
3044
|
+
}
|
|
3045
|
+
return [isFg ? "39" : "49"];
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function clip(line: string, width: number): string {
|
|
3049
|
+
return truncateToWidth(line, width, "");
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
function clipTerminalLine(line: string, width: number): string {
|
|
3053
|
+
// pi-tui's width helpers are ANSI-aware, but OSC sequences are terminal
|
|
3054
|
+
// protocols rather than SGR styling. Avoid truncating inside OSC 8 hyperlinks;
|
|
3055
|
+
// these lines are already projected from an xterm buffer sized to the viewport.
|
|
3056
|
+
return line.includes("\x1b]") ? line : clip(line, width);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
function osc8UriForCell(term: XtermLike, cell: BufferCellLike): string {
|
|
3060
|
+
const committed = (cell as { osc8Uri?: () => string }).osc8Uri?.();
|
|
3061
|
+
if (typeof committed === "string" && committed.length > 0) return committed;
|
|
3062
|
+
// xterm leaves cached extended data on reused cells when this flag is unset.
|
|
3063
|
+
if (!cell.hasExtendedAttrs()) return "";
|
|
3064
|
+
const id = cell.extended?.urlId ?? cell.extended?._urlId ?? 0;
|
|
3065
|
+
if (!id) return "";
|
|
3066
|
+
const service = term._core?._oscLinkService;
|
|
3067
|
+
const uri = service?.getLinkData?.(id)?.uri ?? service?._dataByLinkId?.get(id)?.data?.uri;
|
|
3068
|
+
return sanitizeOscPayload(uri ?? "");
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
function openOsc8(uri: string): string {
|
|
3072
|
+
return uri ? `\x1b]8;;${uri}\x07` : "";
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
function closeOsc8(): string {
|
|
3076
|
+
return "\x1b]8;;\x07";
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
function extractOsc52Sequences(input: string): { sequences: string[]; carry: string } {
|
|
3080
|
+
const sequences: string[] = [];
|
|
3081
|
+
let scanFrom = 0;
|
|
3082
|
+
let carryStart = -1;
|
|
3083
|
+
while (scanFrom < input.length) {
|
|
3084
|
+
const start = input.indexOf(OSC52_PREFIX, scanFrom);
|
|
3085
|
+
if (start < 0) break;
|
|
3086
|
+
const bel = input.indexOf("\x07", start + OSC52_PREFIX.length);
|
|
3087
|
+
const st = input.indexOf("\x1b\\", start + OSC52_PREFIX.length);
|
|
3088
|
+
const end = firstTerminator(bel, st);
|
|
3089
|
+
if (!end) {
|
|
3090
|
+
carryStart = start;
|
|
3091
|
+
break;
|
|
3092
|
+
}
|
|
3093
|
+
const [endIndex, terminatorLength] = end;
|
|
3094
|
+
const seq = input.slice(start, endIndex + terminatorLength);
|
|
3095
|
+
if (isForwardableOsc52(seq)) sequences.push(seq);
|
|
3096
|
+
scanFrom = endIndex + terminatorLength;
|
|
3097
|
+
}
|
|
3098
|
+
const carry = carryStart >= 0 ? input.slice(carryStart).slice(-OSC52_CARRY_MAX_BYTES) : osc52PrefixSuffix(input);
|
|
3099
|
+
return { sequences, carry };
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
function osc52PrefixSuffix(input: string): string {
|
|
3103
|
+
const max = Math.min(input.length, OSC52_PREFIX.length - 1);
|
|
3104
|
+
for (let len = max; len > 0; len--) {
|
|
3105
|
+
const suffix = input.slice(-len);
|
|
3106
|
+
if (OSC52_PREFIX.startsWith(suffix)) return suffix;
|
|
3107
|
+
}
|
|
3108
|
+
return "";
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
function firstTerminator(bel: number, st: number): [number, number] | null {
|
|
3112
|
+
if (bel < 0 && st < 0) return null;
|
|
3113
|
+
if (bel >= 0 && (st < 0 || bel < st)) return [bel, 1];
|
|
3114
|
+
return [st, 2];
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
function extractTerminalPassthroughSequences(input: string): { sequences: string[]; carry: string } {
|
|
3118
|
+
const sequences: string[] = [];
|
|
3119
|
+
let scanFrom = 0;
|
|
3120
|
+
let carryStart = -1;
|
|
3121
|
+
while (scanFrom < input.length) {
|
|
3122
|
+
const kitty = input.indexOf(KITTY_IMAGE_PREFIX, scanFrom);
|
|
3123
|
+
const iterm = input.indexOf(ITERM2_FILE_PREFIX, scanFrom);
|
|
3124
|
+
const start = firstIndex(kitty, iterm);
|
|
3125
|
+
if (start < 0) break;
|
|
3126
|
+
const prefix = start === kitty ? KITTY_IMAGE_PREFIX : ITERM2_FILE_PREFIX;
|
|
3127
|
+
const bel = prefix === ITERM2_FILE_PREFIX ? input.indexOf("\x07", start + prefix.length) : -1;
|
|
3128
|
+
const st = input.indexOf("\x1b\\", start + prefix.length);
|
|
3129
|
+
const end = firstTerminator(bel, st);
|
|
3130
|
+
if (!end) {
|
|
3131
|
+
carryStart = start;
|
|
3132
|
+
break;
|
|
3133
|
+
}
|
|
3134
|
+
const [endIndex, terminatorLength] = end;
|
|
3135
|
+
const seq = input.slice(start, endIndex + terminatorLength);
|
|
3136
|
+
// Without inline=1, iTerm2 takes this as a file download rather than an image.
|
|
3137
|
+
const isInlineImage = prefix !== ITERM2_FILE_PREFIX || /^[^:]*\binline=1\b/.test(seq.slice(prefix.length));
|
|
3138
|
+
if (seq.length <= TERMINAL_PASSTHROUGH_MAX_BYTES && isInlineImage) sequences.push(seq);
|
|
3139
|
+
scanFrom = endIndex + terminatorLength;
|
|
3140
|
+
}
|
|
3141
|
+
const carry = carryStart >= 0 ? input.slice(carryStart).slice(-TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES) : terminalPassthroughPrefixSuffix(input);
|
|
3142
|
+
return { sequences, carry };
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
function firstIndex(a: number, b: number): number {
|
|
3146
|
+
if (a < 0) return b;
|
|
3147
|
+
if (b < 0) return a;
|
|
3148
|
+
return Math.min(a, b);
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
function terminalPassthroughPrefixSuffix(input: string): string {
|
|
3152
|
+
let best = "";
|
|
3153
|
+
for (const prefix of [KITTY_IMAGE_PREFIX, ITERM2_FILE_PREFIX]) {
|
|
3154
|
+
const max = Math.min(input.length, prefix.length - 1);
|
|
3155
|
+
for (let len = max; len > best.length; len--) {
|
|
3156
|
+
const suffix = input.slice(-len);
|
|
3157
|
+
if (prefix.startsWith(suffix)) best = suffix;
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
return best;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function isForwardableOsc52(seq: string): boolean {
|
|
3164
|
+
if (seq.length > OSC52_MAX_BYTES) return false;
|
|
3165
|
+
const terminatorLength = seq.endsWith("\x1b\\") ? 2 : 1;
|
|
3166
|
+
const body = seq.slice(2, -terminatorLength); // strip ESC] and BEL/ST
|
|
3167
|
+
const firstSemi = body.indexOf(";");
|
|
3168
|
+
const secondSemi = body.indexOf(";", firstSemi + 1);
|
|
3169
|
+
if (!body.startsWith("52;") || secondSemi < 0) return false;
|
|
3170
|
+
const payload = body.slice(secondSemi + 1).replace(/[\r\n]/g, "");
|
|
3171
|
+
// Do not forward clipboard-read requests (OSC 52 ; ... ; ?) to the outer terminal.
|
|
3172
|
+
if (payload === "?") return false;
|
|
3173
|
+
return /^[A-Za-z0-9+/=]*$/.test(payload);
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
function sanitizeOscPayload(value: string): string {
|
|
3177
|
+
return value.replace(/[\x00-\x1f\x7f]/g, "");
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
function center(text: string, width: number): string {
|
|
3181
|
+
const w = visibleWidth(text);
|
|
3182
|
+
if (w >= width) return clip(text, width);
|
|
3183
|
+
return " ".repeat(Math.floor((width - w) / 2)) + text;
|
|
3184
|
+
}
|