@zhuxixi/pi-agent-board 0.3.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/IMPLEMENTATION_PLAN.md +920 -0
- package/LICENSE +21 -0
- package/PRD.md +484 -0
- package/PROGRESS.md +127 -0
- package/README.md +131 -0
- package/VERIFY.md +113 -0
- package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
- package/docs/EXPLORATION.md +187 -0
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
- package/index.ts +6 -0
- package/package.json +81 -0
- package/runner/job-runner.mjs +420 -0
- package/runner/pty-runner.mjs +310 -0
- package/runner/state-runner.mjs +120 -0
- package/runner/title-runner.mjs +80 -0
- package/scripts/patch-vulns.mjs +59 -0
- package/src/commands/agent-board.ts +318 -0
- package/src/commands/attach-flow.ts +231 -0
- package/src/commands/bg.ts +70 -0
- package/src/core/atomic.mjs +145 -0
- package/src/core/auto-state.mjs +320 -0
- package/src/core/dashboard-render.mjs +10 -0
- package/src/core/derive.mjs +114 -0
- package/src/core/diagnostics.mjs +109 -0
- package/src/core/events.mjs +268 -0
- package/src/core/evidence.mjs +242 -0
- package/src/core/follow-up-queue.mjs +193 -0
- package/src/core/heuristics.mjs +240 -0
- package/src/core/ids.mjs +35 -0
- package/src/core/invocation.mjs +43 -0
- package/src/core/launch-options.mjs +317 -0
- package/src/core/launch.mjs +116 -0
- package/src/core/locks.mjs +80 -0
- package/src/core/paths.mjs +86 -0
- package/src/core/pid.mjs +42 -0
- package/src/core/prewarm-schedule.mjs +41 -0
- package/src/core/prompt-transport.mjs +13 -0
- package/src/core/pty-attach-jiggle-retry.mjs +90 -0
- package/src/core/pty-attach-render.mjs +51 -0
- package/src/core/pty-input.mjs +15 -0
- package/src/core/pty-links.mjs +71 -0
- package/src/core/pty-scroll.mjs +155 -0
- package/src/core/pty-support.mjs +327 -0
- package/src/core/repo.mjs +47 -0
- package/src/core/rows.mjs +290 -0
- package/src/core/screen-log-gc.mjs +198 -0
- package/src/core/screen-log.mjs +160 -0
- package/src/core/session-view.mjs +174 -0
- package/src/core/steering-prompts.mjs +34 -0
- package/src/core/steering.mjs +133 -0
- package/src/core/store.mjs +308 -0
- package/src/core/title.mjs +43 -0
- package/src/core/types.mjs +380 -0
- package/src/core/worktree.mjs +64 -0
- package/src/index.ts +109 -0
- package/src/runtime/service.mjs +1194 -0
- package/src/ui/dashboard-evidence.mjs +85 -0
- package/src/ui/dashboard.ts +1952 -0
- package/src/ui/pty-attach.ts +1378 -0
|
@@ -0,0 +1,1378 @@
|
|
|
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 type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
7
|
+
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { isProbablyEmptyPiInputLine } from "../core/pty-input.mjs";
|
|
9
|
+
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
10
|
+
import { createAttachOutputRenderScheduler, nextAttachRender, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
11
|
+
import {
|
|
12
|
+
createJiggleRetryState,
|
|
13
|
+
feedOutput as feedJiggleRetry,
|
|
14
|
+
nextRetryDelay,
|
|
15
|
+
advanceRetry,
|
|
16
|
+
stopRetry,
|
|
17
|
+
} from "../core/pty-attach-jiggle-retry.mjs";
|
|
18
|
+
import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
|
|
19
|
+
|
|
20
|
+
export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };
|
|
21
|
+
|
|
22
|
+
type ThemeLike = {
|
|
23
|
+
fg(color: string, text: string): string;
|
|
24
|
+
bold(text: string): string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface PtyAttachOptions {
|
|
28
|
+
socketPath: string;
|
|
29
|
+
screenLogPath?: string;
|
|
30
|
+
title: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record<string, unknown>) => XtermLike };
|
|
35
|
+
|
|
36
|
+
const DETACH_KEYS = new Set(["\x1d"]); // ctrl+]
|
|
37
|
+
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
38
|
+
const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
|
|
39
|
+
const XTSHIFTESCAPE_SELECT = "\x1b[>0s";
|
|
40
|
+
const DOUBLE_CLICK_MS = 260;
|
|
41
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
42
|
+
const LOADING_TICK_MS = 120;
|
|
43
|
+
/** How long to keep the loading banner after the last resize-jiggle during attach. */
|
|
44
|
+
const ATTACH_SETTLE_MS = 250;
|
|
45
|
+
/** Hard cap on the attach transition so a silent session can't stall the banner. */
|
|
46
|
+
const ATTACH_HARD_TIMEOUT_MS = 2500;
|
|
47
|
+
/** How many tail bytes of the screen log to replay on attach. Read from the file tail
|
|
48
|
+
* (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
|
|
49
|
+
* handful of screens, which is all a fresh attach needs. */
|
|
50
|
+
const ATTACH_REPLAY_BYTES = 60_000;
|
|
51
|
+
const OSC52_PREFIX = "\x1b]52;";
|
|
52
|
+
const OSC52_MAX_BYTES = 1_000_000;
|
|
53
|
+
const OSC52_CARRY_MAX_BYTES = OSC52_MAX_BYTES + 4096;
|
|
54
|
+
const TERMINAL_PASSTHROUGH_MAX_BYTES = 5_000_000;
|
|
55
|
+
const TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES = TERMINAL_PASSTHROUGH_MAX_BYTES + 4096;
|
|
56
|
+
const KITTY_IMAGE_PREFIX = "\x1b_G";
|
|
57
|
+
const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
|
|
58
|
+
|
|
59
|
+
interface XtermLike {
|
|
60
|
+
write(data: string, cb?: () => void): void;
|
|
61
|
+
resize(cols: number, rows: number): void;
|
|
62
|
+
buffer: {
|
|
63
|
+
active: {
|
|
64
|
+
baseY: number;
|
|
65
|
+
cursorX?: number;
|
|
66
|
+
cursorY?: number;
|
|
67
|
+
length: number;
|
|
68
|
+
getLine(index: number): BufferLineLike | undefined;
|
|
69
|
+
getNullCell(): BufferCellLike;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
_core?: {
|
|
73
|
+
_oscLinkService?: {
|
|
74
|
+
getLinkData?: (id: number) => { uri?: string } | undefined;
|
|
75
|
+
_dataByLinkId?: Map<number, { data?: { uri?: string } }>;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface BufferLineLike {
|
|
81
|
+
length: number;
|
|
82
|
+
getCell(x: number, cell?: BufferCellLike): BufferCellLike | undefined;
|
|
83
|
+
translateToString(trimRight?: boolean): string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface BufferCellLike {
|
|
87
|
+
getWidth(): number;
|
|
88
|
+
getChars(): string;
|
|
89
|
+
extended?: { urlId?: number; _urlId?: number };
|
|
90
|
+
getFgColor(): number;
|
|
91
|
+
getBgColor(): number;
|
|
92
|
+
isFgRGB(): boolean;
|
|
93
|
+
isBgRGB(): boolean;
|
|
94
|
+
isFgPalette(): boolean;
|
|
95
|
+
isBgPalette(): boolean;
|
|
96
|
+
isFgDefault(): boolean;
|
|
97
|
+
isBgDefault(): boolean;
|
|
98
|
+
isBold(): number;
|
|
99
|
+
isItalic(): number;
|
|
100
|
+
isDim(): number;
|
|
101
|
+
isUnderline(): number;
|
|
102
|
+
isBlink(): number;
|
|
103
|
+
isInverse(): number;
|
|
104
|
+
isInvisible(): number;
|
|
105
|
+
isStrikethrough(): number;
|
|
106
|
+
isOverline(): number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type MousePoint = { line: number; col: number };
|
|
110
|
+
type MouseSelection = { anchor: MousePoint; focus: MousePoint };
|
|
111
|
+
type NormalizedSelection = { start: MousePoint; end: MousePoint };
|
|
112
|
+
|
|
113
|
+
export class PtyAttachComponent implements Component {
|
|
114
|
+
private socket: Socket | null = null;
|
|
115
|
+
private connected = false;
|
|
116
|
+
private closed = false;
|
|
117
|
+
private status = "connecting";
|
|
118
|
+
private parserBuffer = "";
|
|
119
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
120
|
+
private loadingTimer: ReturnType<typeof setInterval> | null = null;
|
|
121
|
+
private redrawTimer: ReturnType<typeof setTimeout> | null = null;
|
|
122
|
+
private mouseRefreshTimers: Array<ReturnType<typeof setTimeout>> = [];
|
|
123
|
+
// Jiggle retry chain: re-send resize jiggle until we see a full-clear sequence
|
|
124
|
+
// in the PTY output, proving the child pi-tui did a fullRender and the replay
|
|
125
|
+
// garbage has been flushed. Replaces the one-shot forceChildRedrawAfterLiveOutput.
|
|
126
|
+
private jiggleRetryState = createJiggleRetryState();
|
|
127
|
+
private jiggleRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
128
|
+
private clearCarry = "";
|
|
129
|
+
private osc52Carry = "";
|
|
130
|
+
private passthroughCarry = "";
|
|
131
|
+
private readonly connectStartedAt = Date.now();
|
|
132
|
+
// Lines scrolled per mouse-wheel event; configurable via $AGENT_BOARD_WHEEL_LINES.
|
|
133
|
+
private readonly mouseWheelLines = resolveWheelLines();
|
|
134
|
+
private readonly term: XtermLike;
|
|
135
|
+
private cols = 120;
|
|
136
|
+
private rows = 24;
|
|
137
|
+
// Absolute buffer line shown at the top of the viewport. null means follow bottom.
|
|
138
|
+
private viewportTop: number | null = null;
|
|
139
|
+
private selection: MouseSelection | null = null;
|
|
140
|
+
private selectionDragging = false;
|
|
141
|
+
private selectionAutoScrollTimer: ReturnType<typeof setInterval> | null = null;
|
|
142
|
+
private selectionAutoScrollLines = 0;
|
|
143
|
+
private selectionAutoScrollMouse: { row: number; col: number } | null = null;
|
|
144
|
+
private pendingClickTimer: ReturnType<typeof setTimeout> | null = null;
|
|
145
|
+
private lastClickPoint: MousePoint | null = null;
|
|
146
|
+
private lastClickAt = 0;
|
|
147
|
+
// Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
|
|
148
|
+
// loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
|
|
149
|
+
// the previous screen visible.
|
|
150
|
+
private receivedOutput = false;
|
|
151
|
+
// Attach transition: hold the loading banner until the screen-log replay and the
|
|
152
|
+
// initial resize-jiggle redraws settle, so attach never visibly scrolls/flashes the
|
|
153
|
+
// buffer. `receivedOutput` tracks buffer content; `attaching` gates whether we paint it.
|
|
154
|
+
private attaching = true;
|
|
155
|
+
private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
156
|
+
private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
157
|
+
// Force a single full-clear on the first paint so the prior session/dashboard can't
|
|
158
|
+
// ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
|
|
159
|
+
// differential renderer so wheel/output bursts don't each trigger a full repaint.
|
|
160
|
+
private firstPaint = true;
|
|
161
|
+
// Coalesce live PTY output repaints to ~25fps. node-pty splits one child-TUI update
|
|
162
|
+
// into many small chunks; painting after each chunk would drive the outer TUI to its
|
|
163
|
+
// frame cap and expose intermediate frames (visible as flicker on a busy session).
|
|
164
|
+
private readonly outputRenderScheduler = createAttachOutputRenderScheduler(() => this.scheduleRender());
|
|
165
|
+
|
|
166
|
+
constructor(
|
|
167
|
+
private readonly tui: TUI,
|
|
168
|
+
private readonly theme: ThemeLike,
|
|
169
|
+
_keybindings: KeybindingsManager,
|
|
170
|
+
private readonly done: (result: PtyAttachResult) => void,
|
|
171
|
+
private readonly opts: PtyAttachOptions,
|
|
172
|
+
) {
|
|
173
|
+
const size = this.currentSize();
|
|
174
|
+
this.cols = size.cols;
|
|
175
|
+
this.rows = size.rows;
|
|
176
|
+
this.term = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true });
|
|
177
|
+
// Keep mouse reporting enabled by default so wheel scrolling and local drag-to-copy
|
|
178
|
+
// selection can coexist inside the attach surface. Set AGENT_BOARD_ATTACH_MOUSE=0
|
|
179
|
+
// to fall back to terminal-native selection only.
|
|
180
|
+
this.disableMouseScroll();
|
|
181
|
+
this.enableMouseScroll();
|
|
182
|
+
this.refreshMouseScrollMode();
|
|
183
|
+
this.replayScreenLog();
|
|
184
|
+
this.connect();
|
|
185
|
+
this.startLoadingTicker();
|
|
186
|
+
// Paint immediately (forced once) so the loading banner replaces the previous
|
|
187
|
+
// surface the instant we attach, rather than after the first reconnect tick.
|
|
188
|
+
this.scheduleRender();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
handleInput(data: string): void {
|
|
192
|
+
const mouseInput = parseMouseInputChunk(data);
|
|
193
|
+
if (mouseInput && this.handleMouseInputChunk(mouseInput)) return;
|
|
194
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
195
|
+
this.clearPendingClick();
|
|
196
|
+
this.clearSelection();
|
|
197
|
+
if (this.tryScrollBy(this.pageSize())) return;
|
|
198
|
+
this.send({ type: "input", data });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
202
|
+
this.clearPendingClick();
|
|
203
|
+
this.clearSelection();
|
|
204
|
+
if (this.tryScrollBy(-this.pageSize())) return;
|
|
205
|
+
this.send({ type: "input", data });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (matchesKey(data, Key.home)) {
|
|
209
|
+
this.clearPendingClick();
|
|
210
|
+
this.clearSelection();
|
|
211
|
+
if (this.tryScrollToTop()) return;
|
|
212
|
+
this.send({ type: "input", data });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (matchesKey(data, Key.end)) {
|
|
216
|
+
this.clearPendingClick();
|
|
217
|
+
this.clearSelection();
|
|
218
|
+
if (this.tryScrollToBottom()) return;
|
|
219
|
+
this.send({ type: "input", data });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (
|
|
223
|
+
DETACH_KEYS.has(data) ||
|
|
224
|
+
matchesKey(data, Key.left) ||
|
|
225
|
+
matchesKey(data, Key.ctrl("]"))
|
|
226
|
+
) {
|
|
227
|
+
if (this.childInputLooksEmpty()) {
|
|
228
|
+
this.detach();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.clearPendingClick();
|
|
232
|
+
this.clearSelection();
|
|
233
|
+
this.scrollToBottom();
|
|
234
|
+
this.send({ type: "input", data });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.clearPendingClick();
|
|
238
|
+
this.clearSelection();
|
|
239
|
+
this.scrollToBottom();
|
|
240
|
+
this.send({ type: "input", data });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
render(width: number): string[] {
|
|
244
|
+
this.resizeIfNeeded(width);
|
|
245
|
+
const height = this.tui.terminal?.rows ?? 24;
|
|
246
|
+
const bodyHeight = Math.max(1, height - 2);
|
|
247
|
+
let body: string[];
|
|
248
|
+
if (!this.attaching && this.receivedOutput) {
|
|
249
|
+
const projected = this.project(bodyHeight, width);
|
|
250
|
+
body = projected.lines;
|
|
251
|
+
while (body.length < bodyHeight) body.unshift("");
|
|
252
|
+
} else {
|
|
253
|
+
body = this.renderLoading(bodyHeight, width);
|
|
254
|
+
}
|
|
255
|
+
const header =
|
|
256
|
+
this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
|
|
257
|
+
this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach · ctrl+] detach`);
|
|
258
|
+
return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Centered "loading" surface shown until the first PTY output paints the session. */
|
|
262
|
+
private renderLoading(height: number, width: number): string[] {
|
|
263
|
+
const elapsedMs = Date.now() - this.connectStartedAt;
|
|
264
|
+
const spinner = this.closed ? "·" : SPINNER[Math.floor(elapsedMs / LOADING_TICK_MS) % SPINNER.length];
|
|
265
|
+
const title = `${spinner} Loading "${this.opts.title}"…`;
|
|
266
|
+
const detail = this.loadingDetail(Math.max(0, Math.round(elapsedMs / 1000)));
|
|
267
|
+
const out: string[] = [];
|
|
268
|
+
const top = Math.max(0, Math.floor((height - 3) / 2));
|
|
269
|
+
for (let i = 0; i < top; i++) out.push("");
|
|
270
|
+
out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
|
|
271
|
+
out.push(center(this.theme.fg("muted", detail), width));
|
|
272
|
+
out.push("");
|
|
273
|
+
out.push(center(this.theme.fg("dim", "← or ctrl+] to detach"), width));
|
|
274
|
+
while (out.length < height) out.push("");
|
|
275
|
+
return out.slice(0, height);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private loadingDetail(elapsedSeconds: number): string {
|
|
279
|
+
if (this.status === "attached") return "Attached · waiting for the session to render…";
|
|
280
|
+
if (this.status.startsWith("error") || this.status === "host exited") return this.status;
|
|
281
|
+
if (this.status === "disconnected") return `Reconnecting to the session host… ${elapsedSeconds}s`;
|
|
282
|
+
return `Starting the session host… ${elapsedSeconds}s`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
invalidate(): void {}
|
|
286
|
+
|
|
287
|
+
dispose(): void {
|
|
288
|
+
this.close();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private detach(): void {
|
|
292
|
+
this.send({ type: "detach" });
|
|
293
|
+
this.close();
|
|
294
|
+
this.done({ action: "detached" });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private childInputLooksEmpty(): boolean {
|
|
298
|
+
if (!this.receivedOutput) return true;
|
|
299
|
+
const active = this.term.buffer.active;
|
|
300
|
+
if (typeof active.cursorY !== "number") return false;
|
|
301
|
+
const line = active.getLine(active.baseY + active.cursorY)?.translateToString(true) ?? "";
|
|
302
|
+
return isProbablyEmptyPiInputLine(line);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private connect(): void {
|
|
306
|
+
if (this.closed || this.connected || this.socket) return;
|
|
307
|
+
if (!existsSync(this.opts.socketPath)) {
|
|
308
|
+
this.status = `starting host… ${Math.ceil((Date.now() - this.connectStartedAt) / 1000)}s`;
|
|
309
|
+
this.scheduleReconnect();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const socket = createConnection(this.opts.socketPath);
|
|
313
|
+
this.socket = socket;
|
|
314
|
+
socket.on("connect", () => {
|
|
315
|
+
this.clearRetry();
|
|
316
|
+
this.connected = true;
|
|
317
|
+
this.status = "attached";
|
|
318
|
+
this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
|
|
319
|
+
this.sendResize();
|
|
320
|
+
this.forceChildRedraw();
|
|
321
|
+
this.startJiggleRetry();
|
|
322
|
+
this.enableMouseScroll();
|
|
323
|
+
this.scheduleRender();
|
|
324
|
+
this.startAttachSettle();
|
|
325
|
+
});
|
|
326
|
+
socket.on("data", (chunk) => this.onSocketData(chunk.toString("utf8")));
|
|
327
|
+
socket.on("close", () => {
|
|
328
|
+
this.socket = null;
|
|
329
|
+
this.connected = false;
|
|
330
|
+
if (!this.closed && this.status !== "host exited") {
|
|
331
|
+
this.status = "disconnected";
|
|
332
|
+
this.scheduleReconnect();
|
|
333
|
+
}
|
|
334
|
+
if (!this.closed) this.scheduleRender();
|
|
335
|
+
});
|
|
336
|
+
socket.on("error", (err) => {
|
|
337
|
+
this.socket = null;
|
|
338
|
+
this.connected = false;
|
|
339
|
+
if (this.closed) return;
|
|
340
|
+
this.status = `waiting for host… ${err.message}`;
|
|
341
|
+
this.scheduleReconnect();
|
|
342
|
+
this.scheduleRender();
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Request a repaint. The first paint is forced (clears any prior screen so the previous
|
|
348
|
+
* session can't ghost behind us); all later paints are coalesced + throttled + differential
|
|
349
|
+
* by the TUI, so bursts of wheel/output events don't each clear-and-repaint the whole screen.
|
|
350
|
+
*/
|
|
351
|
+
private scheduleRender(force = false): void {
|
|
352
|
+
const next = nextAttachRender(this.firstPaint, force);
|
|
353
|
+
this.firstPaint = next.firstPaint;
|
|
354
|
+
this.tui.requestRender(next.force);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private scheduleReconnect(): void {
|
|
358
|
+
if (this.closed || this.retryTimer) return;
|
|
359
|
+
this.retryTimer = setTimeout(() => {
|
|
360
|
+
this.retryTimer = null;
|
|
361
|
+
this.connect();
|
|
362
|
+
this.scheduleRender();
|
|
363
|
+
}, 150);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Animate the loading banner until the first PTY output arrives (or we close). */
|
|
367
|
+
private startLoadingTicker(): void {
|
|
368
|
+
if (this.loadingTimer || !this.attaching || this.closed) return;
|
|
369
|
+
this.loadingTimer = setInterval(() => {
|
|
370
|
+
if (this.closed || !this.attaching) return this.stopLoadingTicker();
|
|
371
|
+
this.tui.requestRender();
|
|
372
|
+
}, LOADING_TICK_MS);
|
|
373
|
+
this.loadingTimer.unref?.();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private stopLoadingTicker(): void {
|
|
377
|
+
if (!this.loadingTimer) return;
|
|
378
|
+
clearInterval(this.loadingTimer);
|
|
379
|
+
this.loadingTimer = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Attach transition lifecycle. Keep the loading banner up while the screen-log replay
|
|
384
|
+
* and the initial resize-jiggle redraws settle, so the buffer doesn't visibly scroll or
|
|
385
|
+
* flash through the viewport on attach. Each `forceChildRedraw` defers the settle
|
|
386
|
+
* window; a hard timeout guards against a session that never produces output.
|
|
387
|
+
*/
|
|
388
|
+
private startAttachSettle(): void {
|
|
389
|
+
if (!this.attaching) return;
|
|
390
|
+
this.deferAttachSettle();
|
|
391
|
+
if (!this.attachHardTimeout) {
|
|
392
|
+
this.attachHardTimeout = setTimeout(() => this.finishAttachTransition(), ATTACH_HARD_TIMEOUT_MS);
|
|
393
|
+
this.attachHardTimeout.unref?.();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private deferAttachSettle(): void {
|
|
398
|
+
if (!this.attaching) return;
|
|
399
|
+
if (this.attachSettleTimer) clearTimeout(this.attachSettleTimer);
|
|
400
|
+
this.attachSettleTimer = setTimeout(() => this.finishAttachTransition(), ATTACH_SETTLE_MS);
|
|
401
|
+
this.attachSettleTimer.unref?.();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private finishAttachTransition(): void {
|
|
405
|
+
if (!this.attaching) return;
|
|
406
|
+
// Note: jiggle retry chain is NOT cancelled here — it survives the settle
|
|
407
|
+
// transition because any successful jiggle triggers a fullRender which emits
|
|
408
|
+
// \x1b[2J, self-cancelling the chain. Post-settle jiggles only occur when the
|
|
409
|
+
// child consumed none of the earlier ones (the exact failure mode this
|
|
410
|
+
// feature fixes); in that case the screen is already stale, so the trade-off
|
|
411
|
+
// of a brief full-render flicker (which also clears any in-progress text
|
|
412
|
+
// selection) is acceptable in exchange for self-healing. When the child is
|
|
413
|
+
// healthy, the very first jiggle's fullRender is detected and the chain
|
|
414
|
+
// stops before settle ends, so no post-settle jiggle fires at all.
|
|
415
|
+
this.attaching = false;
|
|
416
|
+
if (this.attachSettleTimer) {
|
|
417
|
+
clearTimeout(this.attachSettleTimer);
|
|
418
|
+
this.attachSettleTimer = null;
|
|
419
|
+
}
|
|
420
|
+
if (this.attachHardTimeout) {
|
|
421
|
+
clearTimeout(this.attachHardTimeout);
|
|
422
|
+
this.attachHardTimeout = null;
|
|
423
|
+
}
|
|
424
|
+
this.stopLoadingTicker();
|
|
425
|
+
// Force a full clear so the loading banner is replaced atomically by the settled
|
|
426
|
+
// buffer, instead of diffing banner lines into buffer lines.
|
|
427
|
+
this.scheduleRender(true);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private clearRedrawTimer(): void {
|
|
431
|
+
if (this.redrawTimer) {
|
|
432
|
+
clearTimeout(this.redrawTimer);
|
|
433
|
+
this.redrawTimer = null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private clearMouseRefreshTimers(): void {
|
|
438
|
+
for (const timer of this.mouseRefreshTimers) clearTimeout(timer);
|
|
439
|
+
this.mouseRefreshTimers = [];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private clearRetry(): void {
|
|
443
|
+
if (!this.retryTimer) return;
|
|
444
|
+
clearTimeout(this.retryTimer);
|
|
445
|
+
this.retryTimer = null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private enableMouseScroll(): void {
|
|
449
|
+
if (!this.mouseScrollEnabled()) return;
|
|
450
|
+
try {
|
|
451
|
+
this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
|
|
452
|
+
this.tui.terminal.write(MOUSE_ENABLE);
|
|
453
|
+
} catch {}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private mouseScrollEnabled(): boolean {
|
|
457
|
+
const mode = (process.env.AGENT_BOARD_ATTACH_MOUSE ?? "").trim().toLowerCase();
|
|
458
|
+
if (mode === "0" || mode === "off" || mode === "false") return false;
|
|
459
|
+
if (mode === "1" || mode === "on" || mode === "true" || mode === "classic") return true;
|
|
460
|
+
if ((process.env.AGENT_BOARD_ENABLE_MOUSE_SCROLL ?? "").trim() === "0") return false;
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private refreshMouseScrollMode(): void {
|
|
465
|
+
this.clearMouseRefreshTimers();
|
|
466
|
+
if (!this.mouseScrollEnabled()) return;
|
|
467
|
+
for (const delay of [0, 50, 250]) {
|
|
468
|
+
const timer = setTimeout(() => {
|
|
469
|
+
if (!this.closed) this.enableMouseScroll();
|
|
470
|
+
}, delay);
|
|
471
|
+
timer.unref?.();
|
|
472
|
+
this.mouseRefreshTimers.push(timer);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private disableMouseScroll(): void {
|
|
477
|
+
try {
|
|
478
|
+
this.tui.terminal.write(MOUSE_DISABLE);
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean {
|
|
483
|
+
let handled = false;
|
|
484
|
+
for (const entry of events) {
|
|
485
|
+
const { mouse, raw } = entry;
|
|
486
|
+
if ((mouse.button & 64) !== 0) {
|
|
487
|
+
handled = true;
|
|
488
|
+
const wheelButton = mouse.button & 3;
|
|
489
|
+
const wheel = wheelButton === 0 ? 1 : wheelButton === 1 ? -1 : 0;
|
|
490
|
+
if (wheel === 0) continue;
|
|
491
|
+
this.clearPendingClick();
|
|
492
|
+
this.clearSelection();
|
|
493
|
+
if (!this.tryScrollBy(wheel * this.mouseWheelLines)) this.send({ type: "input", data: raw });
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
handled = true;
|
|
497
|
+
this.handleLocalMouseEvent(mouse);
|
|
498
|
+
}
|
|
499
|
+
return handled;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private updateSelectionAutoScroll(row: number, col: number): void {
|
|
503
|
+
this.selectionAutoScrollMouse = { row, col };
|
|
504
|
+
if (!this.selection || !this.selectionDragging) return this.clearSelectionAutoScroll(false);
|
|
505
|
+
const lines = selectionDragScrollLines(row, this.bodyHeight());
|
|
506
|
+
if (lines === 0) return this.clearSelectionAutoScroll(false);
|
|
507
|
+
if (this.selectionAutoScrollTimer && this.selectionAutoScrollLines === lines) return;
|
|
508
|
+
this.clearSelectionAutoScroll(false);
|
|
509
|
+
this.selectionAutoScrollLines = lines;
|
|
510
|
+
this.selectionAutoScrollTimer = setInterval(() => this.tickSelectionAutoScroll(), 50);
|
|
511
|
+
this.selectionAutoScrollTimer.unref?.();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private tickSelectionAutoScroll(): void {
|
|
515
|
+
if (this.closed || !this.selection || !this.selectionDragging || !this.selectionAutoScrollMouse || !this.selectionAutoScrollLines) {
|
|
516
|
+
this.clearSelectionAutoScroll();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const changed = this.tryScrollBy(this.selectionAutoScrollLines);
|
|
520
|
+
const point = this.mousePointForEvent(this.selectionAutoScrollMouse.row, this.selectionAutoScrollMouse.col, true);
|
|
521
|
+
if (point) this.selection.focus = point;
|
|
522
|
+
if (!changed) this.clearSelectionAutoScroll(false);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private clearSelectionAutoScroll(clearPointer = true): void {
|
|
526
|
+
if (this.selectionAutoScrollTimer) {
|
|
527
|
+
clearInterval(this.selectionAutoScrollTimer);
|
|
528
|
+
this.selectionAutoScrollTimer = null;
|
|
529
|
+
}
|
|
530
|
+
this.selectionAutoScrollLines = 0;
|
|
531
|
+
if (clearPointer) this.selectionAutoScrollMouse = null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private handleLocalMouseEvent(mouse: { button: number; row: number; col: number; action: string }): void {
|
|
535
|
+
const primary = (mouse.button & 3) === 0;
|
|
536
|
+
const middleButton = (mouse.button & 3) === 1;
|
|
537
|
+
if (mouse.action === "press") {
|
|
538
|
+
this.clearSelectionAutoScroll();
|
|
539
|
+
if (middleButton) {
|
|
540
|
+
// Middle-click paste: forward the X11 PRIMARY selection to the hosted
|
|
541
|
+
// session as input, mimicking the terminal-native middle-click paste
|
|
542
|
+
// that mouse reporting (kept on for wheel scrolling) would swallow.
|
|
543
|
+
this.pastePrimarySelection();
|
|
544
|
+
this.clearPendingClick();
|
|
545
|
+
this.clearSelection();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (!primary) {
|
|
549
|
+
this.clearPendingClick();
|
|
550
|
+
this.clearSelection();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const point = this.mousePointForEvent(mouse.row, mouse.col, false);
|
|
554
|
+
if (!point) {
|
|
555
|
+
this.clearPendingClick();
|
|
556
|
+
this.clearSelection();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (this.isDoubleClickCandidate(point)) this.clearPendingClick();
|
|
560
|
+
this.selection = { anchor: point, focus: point };
|
|
561
|
+
this.selectionDragging = false;
|
|
562
|
+
this.scheduleRender();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (!this.selection) return;
|
|
566
|
+
if (mouse.action === "move") {
|
|
567
|
+
if (!primary) return;
|
|
568
|
+
const point = this.mousePointForEvent(mouse.row, mouse.col, true);
|
|
569
|
+
if (!point) return;
|
|
570
|
+
this.selection.focus = point;
|
|
571
|
+
this.selectionDragging = true;
|
|
572
|
+
this.updateSelectionAutoScroll(mouse.row, mouse.col);
|
|
573
|
+
this.scheduleRender();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (mouse.action === "release") {
|
|
577
|
+
this.clearSelectionAutoScroll();
|
|
578
|
+
const point = this.mousePointForEvent(mouse.row, mouse.col, true);
|
|
579
|
+
if (point) this.selection.focus = point;
|
|
580
|
+
const shouldCopy = this.selectionDragging;
|
|
581
|
+
this.selectionDragging = false;
|
|
582
|
+
if (shouldCopy) {
|
|
583
|
+
this.clearPendingClick();
|
|
584
|
+
this.lastClickPoint = null;
|
|
585
|
+
this.lastClickAt = 0;
|
|
586
|
+
this.copySelectionToClipboard();
|
|
587
|
+
this.scheduleRender();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (!point) {
|
|
591
|
+
this.clearPendingClick();
|
|
592
|
+
this.clearSelection();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (this.isDoubleClickCandidate(point)) {
|
|
596
|
+
this.clearPendingClick();
|
|
597
|
+
this.lastClickPoint = null;
|
|
598
|
+
this.lastClickAt = 0;
|
|
599
|
+
if (!this.selectWordAtPoint(point)) this.selection = null;
|
|
600
|
+
this.scheduleRender();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
this.lastClickPoint = point;
|
|
604
|
+
this.lastClickAt = Date.now();
|
|
605
|
+
this.schedulePendingClick(point);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private mousePointForEvent(row: number, col: number, clampToBody: boolean): MousePoint | null {
|
|
610
|
+
const height = this.bodyHeight();
|
|
611
|
+
const bodyRow = row - 2;
|
|
612
|
+
if (!clampToBody && (bodyRow < 0 || bodyRow >= height)) return null;
|
|
613
|
+
const clampedRow = clampInt(bodyRow, 0, Math.max(0, height - 1));
|
|
614
|
+
this.clampViewportTop(height);
|
|
615
|
+
const start = this.viewportTop ?? this.bottomViewportTop(height);
|
|
616
|
+
const line = clampInt(start + clampedRow, 0, Math.max(0, this.term.buffer.active.length - 1));
|
|
617
|
+
const cellCol = clampInt(col - 1, 0, Math.max(0, this.cols - 1));
|
|
618
|
+
return { line, col: cellCol };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private isDoubleClickCandidate(point: MousePoint): boolean {
|
|
622
|
+
return !!this.lastClickPoint && Date.now() - this.lastClickAt <= DOUBLE_CLICK_MS && sameMousePoint(this.lastClickPoint, point);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private schedulePendingClick(point: MousePoint): void {
|
|
626
|
+
this.clearPendingClick();
|
|
627
|
+
this.pendingClickTimer = setTimeout(() => {
|
|
628
|
+
this.pendingClickTimer = null;
|
|
629
|
+
this.lastClickPoint = null;
|
|
630
|
+
this.lastClickAt = 0;
|
|
631
|
+
if (this.closed) return;
|
|
632
|
+
this.openLinkAtPoint(point);
|
|
633
|
+
this.selection = null;
|
|
634
|
+
this.selectionDragging = false;
|
|
635
|
+
this.tui.requestRender();
|
|
636
|
+
}, DOUBLE_CLICK_MS);
|
|
637
|
+
this.pendingClickTimer.unref?.();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private clearPendingClick(): void {
|
|
641
|
+
if (!this.pendingClickTimer) return;
|
|
642
|
+
clearTimeout(this.pendingClickTimer);
|
|
643
|
+
this.pendingClickTimer = null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private selectWordAtPoint(point: MousePoint): boolean {
|
|
647
|
+
const buf = this.term.buffer.active;
|
|
648
|
+
const line = buf.getLine(point.line);
|
|
649
|
+
if (!line) return false;
|
|
650
|
+
const reusable = buf.getNullCell();
|
|
651
|
+
const range = findWordRangeAtCells(asciiCellsForBufferLine(line, reusable), point.col);
|
|
652
|
+
if (!range) return false;
|
|
653
|
+
this.selection = {
|
|
654
|
+
anchor: { line: point.line, col: range.start },
|
|
655
|
+
focus: { line: point.line, col: range.end },
|
|
656
|
+
};
|
|
657
|
+
this.selectionDragging = false;
|
|
658
|
+
this.copySelectionToClipboard();
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private openLinkAtPoint(point: MousePoint): boolean {
|
|
663
|
+
const target = this.linkAtPoint(point);
|
|
664
|
+
return target ? openExternalTarget(target) : false;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private linkAtPoint(point: MousePoint): string | null {
|
|
668
|
+
const buf = this.term.buffer.active;
|
|
669
|
+
const line = buf.getLine(point.line);
|
|
670
|
+
if (!line) return null;
|
|
671
|
+
const reusable = buf.getNullCell();
|
|
672
|
+
const cell = line.getCell(point.col, reusable);
|
|
673
|
+
const osc8 = cell ? osc8UriForCell(this.term, cell) : "";
|
|
674
|
+
if (osc8) return osc8;
|
|
675
|
+
return findHttpUrlAtCells(asciiCellsForBufferLine(line, reusable), point.col);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private clearSelection(): void {
|
|
679
|
+
this.clearSelectionAutoScroll();
|
|
680
|
+
if (!this.selection && !this.selectionDragging) return;
|
|
681
|
+
this.selection = null;
|
|
682
|
+
this.selectionDragging = false;
|
|
683
|
+
this.scheduleRender();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private copySelectionToClipboard(): void {
|
|
687
|
+
const text = this.selectionText();
|
|
688
|
+
if (!text) return;
|
|
689
|
+
const seq = osc52CopySequence(text);
|
|
690
|
+
if (seq) {
|
|
691
|
+
try {
|
|
692
|
+
this.tui.terminal.write(seq);
|
|
693
|
+
} catch {}
|
|
694
|
+
}
|
|
695
|
+
// Also mirror the selection into the X11 PRIMARY selection so the rest of the
|
|
696
|
+
// desktop can middle-click-paste it — closes the loop with pastePrimarySelection().
|
|
697
|
+
this.writePrimarySelection(text);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Middle-click paste. Reads the X11 PRIMARY selection via xclip and forwards it to
|
|
702
|
+
* the hosted session as input, mimicking the terminal-native middle-click paste that
|
|
703
|
+
* mouse reporting (kept on for wheel scrolling) would otherwise swallow. Silent no-op
|
|
704
|
+
* when xclip is absent or AGENT_BOARD_ATTACH_NATIVE_PASTE=0.
|
|
705
|
+
*/
|
|
706
|
+
private pastePrimarySelection(): void {
|
|
707
|
+
if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
|
|
708
|
+
try {
|
|
709
|
+
const child = spawn("xclip", ["-o", "-selection", "primary"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
710
|
+
let out = "";
|
|
711
|
+
const timer = setTimeout(() => {
|
|
712
|
+
try {
|
|
713
|
+
child.kill("SIGKILL");
|
|
714
|
+
} catch {}
|
|
715
|
+
}, 800);
|
|
716
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
717
|
+
out += chunk.toString("utf8");
|
|
718
|
+
});
|
|
719
|
+
child.on("error", () => clearTimeout(timer));
|
|
720
|
+
child.on("close", () => {
|
|
721
|
+
clearTimeout(timer);
|
|
722
|
+
if (!this.closed && out) this.send({ type: "input", data: out });
|
|
723
|
+
});
|
|
724
|
+
} catch {}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
|
|
728
|
+
private writePrimarySelection(text: string): void {
|
|
729
|
+
if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
|
|
730
|
+
try {
|
|
731
|
+
const child = spawn("xclip", ["-selection", "primary"], { stdio: ["pipe", "ignore", "ignore"] });
|
|
732
|
+
child.stdin?.on("error", () => {});
|
|
733
|
+
child.on("error", () => {});
|
|
734
|
+
child.stdin?.end(text);
|
|
735
|
+
} catch {}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private selectionText(): string {
|
|
739
|
+
const range = normalizeSelection(this.selection);
|
|
740
|
+
if (!range) return "";
|
|
741
|
+
const buf = this.term.buffer.active;
|
|
742
|
+
const reusable = buf.getNullCell();
|
|
743
|
+
const parts: string[] = [];
|
|
744
|
+
for (let lineIndex = range.start.line; lineIndex <= range.end.line; lineIndex++) {
|
|
745
|
+
const line = buf.getLine(lineIndex);
|
|
746
|
+
if (!line) {
|
|
747
|
+
parts.push("");
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const from = lineIndex === range.start.line ? range.start.col : 0;
|
|
751
|
+
const to = lineIndex === range.end.line ? range.end.col : line.length - 1;
|
|
752
|
+
let text = "";
|
|
753
|
+
for (let x = Math.max(0, from); x <= Math.max(from, to); x++) {
|
|
754
|
+
const cell = line.getCell(x, reusable);
|
|
755
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
756
|
+
text += cell.getChars() || " ";
|
|
757
|
+
}
|
|
758
|
+
parts.push(text.replace(/\s+$/u, ""));
|
|
759
|
+
}
|
|
760
|
+
return parts.join("\n").replace(/^\n+|\n+$/gu, "");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private currentSize(): { cols: number; rows: number } {
|
|
764
|
+
const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined;
|
|
765
|
+
return {
|
|
766
|
+
cols: Math.max(20, term?.cols ?? term?.columns ?? 120),
|
|
767
|
+
rows: Math.max(5, (term?.rows ?? 24) - 2),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private resizeIfNeeded(width: number): void {
|
|
772
|
+
const size = this.currentSize();
|
|
773
|
+
// Render width is authoritative inside ctx.ui.custom; terminal.cols is not
|
|
774
|
+
// consistently exposed by all Pi TUI versions.
|
|
775
|
+
size.cols = Math.max(20, width);
|
|
776
|
+
if (size.cols === this.cols && size.rows === this.rows) return;
|
|
777
|
+
this.cols = size.cols;
|
|
778
|
+
this.rows = size.rows;
|
|
779
|
+
this.term.resize(this.cols, this.rows);
|
|
780
|
+
this.sendResize();
|
|
781
|
+
this.enableMouseScroll();
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private sendResize(cols = this.cols, rows = this.rows): void {
|
|
785
|
+
this.send({ type: "resize", cols, rows });
|
|
786
|
+
this.clampViewportTop(this.bodyHeight());
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private forceChildRedraw(): void {
|
|
790
|
+
this.clearRedrawTimer();
|
|
791
|
+
if (!this.connected) return;
|
|
792
|
+
const cols = this.cols;
|
|
793
|
+
const rows = this.rows;
|
|
794
|
+
const jiggle = localResizeJiggleSize(cols, rows);
|
|
795
|
+
if (!jiggle) return;
|
|
796
|
+
// A completed-session reattach often starts from an old screen.log recorded at
|
|
797
|
+
// a different terminal size. Real terminal zoom fixes that by causing SIGWINCH;
|
|
798
|
+
// do the same proactively so the child Pi redraws for the attach viewport.
|
|
799
|
+
this.sendResize(jiggle.cols, jiggle.rows);
|
|
800
|
+
this.deferAttachSettle();
|
|
801
|
+
this.redrawTimer = setTimeout(() => {
|
|
802
|
+
this.redrawTimer = null;
|
|
803
|
+
if (!this.closed && this.connected) {
|
|
804
|
+
this.sendResize(cols, rows);
|
|
805
|
+
this.deferAttachSettle();
|
|
806
|
+
}
|
|
807
|
+
}, 40);
|
|
808
|
+
this.redrawTimer.unref?.();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Start the jiggle retry chain after initial jiggle. */
|
|
812
|
+
private startJiggleRetry(): void {
|
|
813
|
+
if (this.jiggleRetryTimer) {
|
|
814
|
+
clearTimeout(this.jiggleRetryTimer);
|
|
815
|
+
this.jiggleRetryTimer = null;
|
|
816
|
+
}
|
|
817
|
+
this.jiggleRetryState = createJiggleRetryState();
|
|
818
|
+
this.clearCarry = "";
|
|
819
|
+
this.scheduleNextJiggleRetry();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Check socket output for full-clear sequence; cancel retry if found. */
|
|
823
|
+
private checkClearSequence(data: string): void {
|
|
824
|
+
if (this.jiggleRetryState.stopped) return;
|
|
825
|
+
const result = feedJiggleRetry(this.jiggleRetryState, data, this.clearCarry);
|
|
826
|
+
this.jiggleRetryState = result.state;
|
|
827
|
+
this.clearCarry = result.carry;
|
|
828
|
+
if (result.clearFound) this.cancelJiggleRetry();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Schedule the next jiggle retry with backoff. */
|
|
832
|
+
private scheduleNextJiggleRetry(): void {
|
|
833
|
+
const delay = nextRetryDelay(this.jiggleRetryState);
|
|
834
|
+
if (delay === null) {
|
|
835
|
+
// Chain exhausted (max retries) — mark stopped so checkClearSequence
|
|
836
|
+
// short-circuits on subsequent output chunks.
|
|
837
|
+
this.jiggleRetryState = stopRetry(this.jiggleRetryState);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
this.jiggleRetryTimer = setTimeout(() => {
|
|
841
|
+
this.jiggleRetryTimer = null;
|
|
842
|
+
if (this.closed || !this.connected) return;
|
|
843
|
+
this.forceChildRedraw();
|
|
844
|
+
this.jiggleRetryState = advanceRetry(this.jiggleRetryState);
|
|
845
|
+
this.scheduleNextJiggleRetry();
|
|
846
|
+
}, delay);
|
|
847
|
+
this.jiggleRetryTimer.unref?.();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Cancel the jiggle retry chain. */
|
|
851
|
+
private cancelJiggleRetry(): void {
|
|
852
|
+
if (this.jiggleRetryTimer) {
|
|
853
|
+
clearTimeout(this.jiggleRetryTimer);
|
|
854
|
+
this.jiggleRetryTimer = null;
|
|
855
|
+
}
|
|
856
|
+
this.jiggleRetryState = stopRetry(this.jiggleRetryState);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private tryScrollBy(linesUp: number): boolean {
|
|
860
|
+
const result = scrollViewportTop(this.viewportTop, this.bottomViewportTop(this.bodyHeight()), linesUp);
|
|
861
|
+
this.viewportTop = result.viewportTop;
|
|
862
|
+
if (result.changed) this.requestScrollRender();
|
|
863
|
+
return result.changed;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private tryScrollToTop(): boolean {
|
|
867
|
+
if (this.bottomViewportTop(this.bodyHeight()) <= 0 || this.viewportTop === 0) return false;
|
|
868
|
+
this.viewportTop = 0;
|
|
869
|
+
this.requestScrollRender();
|
|
870
|
+
return true;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
private tryScrollToBottom(): boolean {
|
|
874
|
+
if (this.viewportTop === null) return false;
|
|
875
|
+
this.viewportTop = null;
|
|
876
|
+
this.requestScrollRender();
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private scrollToBottom(): void {
|
|
881
|
+
this.viewportTop = null;
|
|
882
|
+
this.requestScrollRender();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
private requestScrollRender(): void {
|
|
886
|
+
this.scheduleRender();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private bodyHeight(): number {
|
|
890
|
+
return Math.max(1, (this.tui.terminal?.rows ?? 24) - 2);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private pageSize(): number {
|
|
894
|
+
return Math.max(1, this.bodyHeight() - 2);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private bottomViewportTop(height: number): number {
|
|
898
|
+
const buf = this.term.buffer.active;
|
|
899
|
+
const bottom = Math.min(buf.length, buf.baseY + this.rows);
|
|
900
|
+
return Math.max(0, bottom - height);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private clampViewportTop(height: number): void {
|
|
904
|
+
if (this.viewportTop === null) return;
|
|
905
|
+
this.viewportTop = clampInt(this.viewportTop, 0, this.bottomViewportTop(height));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private send(msg: Record<string, unknown>): void {
|
|
909
|
+
if (!this.socket || !this.connected) return;
|
|
910
|
+
this.socket.write(JSON.stringify(msg) + "\n");
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private onSocketData(text: string): void {
|
|
914
|
+
this.parserBuffer += text;
|
|
915
|
+
const lines = this.parserBuffer.split("\n");
|
|
916
|
+
this.parserBuffer = lines.pop() ?? "";
|
|
917
|
+
let needsRender = false;
|
|
918
|
+
for (const line of lines) {
|
|
919
|
+
if (!line.trim()) continue;
|
|
920
|
+
try {
|
|
921
|
+
const msg = JSON.parse(line);
|
|
922
|
+
if (msg.type === "output" && typeof msg.data === "string") {
|
|
923
|
+
this.pushOutput(msg.data, { forwardProtocols: true });
|
|
924
|
+
this.checkClearSequence(msg.data);
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (msg.type === "hello" || msg.type === "status") this.status = "attached";
|
|
928
|
+
else if (msg.type === "exit") {
|
|
929
|
+
this.status = "host exited";
|
|
930
|
+
this.done({ action: "closed", exitCode: msg.exitCode ?? null });
|
|
931
|
+
} else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
|
|
932
|
+
if (shouldScheduleAttachRenderForMessage(msg.type)) needsRender = true;
|
|
933
|
+
} catch {
|
|
934
|
+
// Ignore malformed protocol lines; raw PTY data is only legal inside output.data.
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (needsRender) this.scheduleRender();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private forwardTerminalProtocols(data: string): void {
|
|
941
|
+
const toWrite: string[] = [];
|
|
942
|
+
if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") {
|
|
943
|
+
const { sequences, carry } = extractOsc52Sequences(this.osc52Carry + data);
|
|
944
|
+
this.osc52Carry = carry;
|
|
945
|
+
toWrite.push(...sequences);
|
|
946
|
+
}
|
|
947
|
+
if (process.env.AGENT_BOARD_FORWARD_IMAGES !== "0") {
|
|
948
|
+
const { sequences, carry } = extractTerminalPassthroughSequences(this.passthroughCarry + data);
|
|
949
|
+
this.passthroughCarry = carry;
|
|
950
|
+
toWrite.push(...sequences);
|
|
951
|
+
}
|
|
952
|
+
for (const seq of toWrite) {
|
|
953
|
+
try {
|
|
954
|
+
this.tui.terminal.write(seq);
|
|
955
|
+
} catch {}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
private replayScreenLog(): void {
|
|
960
|
+
if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return;
|
|
961
|
+
try {
|
|
962
|
+
// Read only the tail of the log instead of the whole file: large logs (tens of MB)
|
|
963
|
+
// would otherwise block the attach on a full readFileSync + UTF-8 decode before the
|
|
964
|
+
// first frame. Skip a leading partial line so we don't inject a half escape sequence.
|
|
965
|
+
const { size } = statSync(this.opts.screenLogPath);
|
|
966
|
+
const start = Math.max(0, size - ATTACH_REPLAY_BYTES);
|
|
967
|
+
const length = size - start;
|
|
968
|
+
if (length <= 0) return;
|
|
969
|
+
const fd = openSync(this.opts.screenLogPath, "r");
|
|
970
|
+
try {
|
|
971
|
+
const buf = Buffer.alloc(length);
|
|
972
|
+
readSync(fd, buf, 0, length, start);
|
|
973
|
+
let tail = buf.toString("utf8");
|
|
974
|
+
if (start > 0) {
|
|
975
|
+
const nl = tail.indexOf("\n");
|
|
976
|
+
tail = nl >= 0 ? tail.slice(nl + 1) : tail;
|
|
977
|
+
}
|
|
978
|
+
this.pushOutput(tail);
|
|
979
|
+
} finally {
|
|
980
|
+
closeSync(fd);
|
|
981
|
+
}
|
|
982
|
+
} catch {}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
|
|
986
|
+
if (data.length === 0) return;
|
|
987
|
+
if (opts.forwardProtocols) this.forwardTerminalProtocols(data);
|
|
988
|
+
// @xterm/headless parses asynchronously; the buffer is only populated once this
|
|
989
|
+
// callback fires. Mark the buffer as ready (so the project path can paint it), but
|
|
990
|
+
// stay on the loading banner while `attaching` — otherwise the screen-log replay and
|
|
991
|
+
// the initial resize-jiggle redraws would flash through the viewport. Each parsed
|
|
992
|
+
// chunk also defers the settle window, so the banner holds until output actually
|
|
993
|
+
// stops arriving (i.e. the redraw finished), not just until the resize jiggle ends.
|
|
994
|
+
this.term.write(data, () => {
|
|
995
|
+
this.receivedOutput = true;
|
|
996
|
+
this.deferAttachSettle();
|
|
997
|
+
this.outputRenderScheduler.request();
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private project(height: number, width: number): { lines: string[]; cursor: { row: number; col: number } | null } {
|
|
1002
|
+
const out: string[] = [];
|
|
1003
|
+
const buf = this.term.buffer.active;
|
|
1004
|
+
const selection = normalizeSelection(this.selection);
|
|
1005
|
+
this.clampViewportTop(height);
|
|
1006
|
+
const start = this.viewportTop ?? this.bottomViewportTop(height);
|
|
1007
|
+
const end = Math.min(buf.length, start + height);
|
|
1008
|
+
const reusable = buf.getNullCell();
|
|
1009
|
+
// The PTY cursor: xterm buffer cursorY is relative to baseY (the viewport top of
|
|
1010
|
+
// the child terminal); cursorX may equal cols (one past the last cell). Only render
|
|
1011
|
+
// it when it lands inside the projected viewport.
|
|
1012
|
+
const cursor = cursorInViewport(buf, start, height);
|
|
1013
|
+
for (let i = start; i < end; i++) {
|
|
1014
|
+
out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor));
|
|
1015
|
+
}
|
|
1016
|
+
if (out.length === 0) out.push("Waiting for PTY output…");
|
|
1017
|
+
return { lines: out.slice(-height), cursor };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private close(): void {
|
|
1021
|
+
this.closed = true;
|
|
1022
|
+
this.cancelJiggleRetry();
|
|
1023
|
+
this.disableMouseScroll();
|
|
1024
|
+
this.clearMouseRefreshTimers();
|
|
1025
|
+
this.clearPendingClick();
|
|
1026
|
+
this.clearSelectionAutoScroll();
|
|
1027
|
+
this.clearRetry();
|
|
1028
|
+
this.clearRedrawTimer();
|
|
1029
|
+
this.stopLoadingTicker();
|
|
1030
|
+
this.outputRenderScheduler.dispose();
|
|
1031
|
+
if (this.attachSettleTimer) {
|
|
1032
|
+
clearTimeout(this.attachSettleTimer);
|
|
1033
|
+
this.attachSettleTimer = null;
|
|
1034
|
+
}
|
|
1035
|
+
if (this.attachHardTimeout) {
|
|
1036
|
+
clearTimeout(this.attachHardTimeout);
|
|
1037
|
+
this.attachHardTimeout = null;
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
this.socket?.destroy();
|
|
1041
|
+
} catch {}
|
|
1042
|
+
this.socket = null;
|
|
1043
|
+
this.connected = false;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function localResizeJiggleSize(cols: number, rows: number): { cols: number; rows: number } | null {
|
|
1048
|
+
if (cols > 21 && rows > 6) return { cols: cols - 1, rows: rows - 1 };
|
|
1049
|
+
if (rows > 6) return { cols, rows: rows - 1 };
|
|
1050
|
+
if (cols > 21) return { cols: cols - 1, rows };
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function sameMousePoint(a: MousePoint, b: MousePoint): boolean {
|
|
1055
|
+
return a.line === b.line && Math.abs(a.col - b.col) <= 1;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function asciiCellsForBufferLine(line: BufferLineLike, reusable: BufferCellLike): string[] {
|
|
1059
|
+
const cells: string[] = [];
|
|
1060
|
+
for (let x = 0; x < line.length; x++) {
|
|
1061
|
+
const cell = line.getCell(x, reusable);
|
|
1062
|
+
if (!cell || cell.getWidth() === 0) {
|
|
1063
|
+
cells.push(" ");
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
const chars = cell.getChars() || " ";
|
|
1067
|
+
cells.push(chars.length === 1 && chars >= " " && chars <= "~" ? chars : " ");
|
|
1068
|
+
}
|
|
1069
|
+
return cells;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function openExternalTarget(target: string): boolean {
|
|
1073
|
+
const sanitized = sanitizeOscPayload(target).trim();
|
|
1074
|
+
if (!sanitized) return false;
|
|
1075
|
+
try {
|
|
1076
|
+
if (process.platform === "darwin") {
|
|
1077
|
+
spawn("open", [sanitized], { detached: true, stdio: "ignore" }).unref();
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
if (process.platform === "win32") {
|
|
1081
|
+
spawn("cmd", ["/c", "start", "", sanitized], { detached: true, stdio: "ignore" }).unref();
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" }).unref();
|
|
1085
|
+
return true;
|
|
1086
|
+
} catch {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function compareMousePoints(a: MousePoint, b: MousePoint): number {
|
|
1092
|
+
return a.line === b.line ? a.col - b.col : a.line - b.line;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function normalizeSelection(selection: MouseSelection | null): NormalizedSelection | null {
|
|
1096
|
+
if (!selection) return null;
|
|
1097
|
+
return compareMousePoints(selection.anchor, selection.focus) <= 0
|
|
1098
|
+
? { start: selection.anchor, end: selection.focus }
|
|
1099
|
+
: { start: selection.focus, end: selection.anchor };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function pointWithinSelection(line: number, col: number, selection: NormalizedSelection | null): boolean {
|
|
1103
|
+
if (!selection) return false;
|
|
1104
|
+
if (line < selection.start.line || line > selection.end.line) return false;
|
|
1105
|
+
if (selection.start.line === selection.end.line) return col >= selection.start.col && col <= selection.end.col;
|
|
1106
|
+
if (line === selection.start.line) return col >= selection.start.col;
|
|
1107
|
+
if (line === selection.end.line) return col <= selection.end.col;
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function osc52CopySequence(text: string): string {
|
|
1112
|
+
if (!text) return "";
|
|
1113
|
+
const data = Buffer.from(text, "utf8").toString("base64");
|
|
1114
|
+
const seq = `\x1b]52;c;${data}\x07`;
|
|
1115
|
+
return Buffer.byteLength(seq, "utf8") <= OSC52_MAX_BYTES ? seq : "";
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function lineToAnsi(
|
|
1119
|
+
line: BufferLineLike | undefined,
|
|
1120
|
+
reusable: BufferCellLike,
|
|
1121
|
+
term: XtermLike,
|
|
1122
|
+
lineIndex: number,
|
|
1123
|
+
selection: NormalizedSelection | null,
|
|
1124
|
+
cursor: { row: number; col: number } | null,
|
|
1125
|
+
): string {
|
|
1126
|
+
const isCursorRow = cursor !== null && cursor.row === lineIndex;
|
|
1127
|
+
let last = -1;
|
|
1128
|
+
if (!line) {
|
|
1129
|
+
// No buffer line: show the cursor as an inverse block at the start of the line.
|
|
1130
|
+
if (isCursorRow && cursor!.col >= 0) return CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1131
|
+
return "";
|
|
1132
|
+
}
|
|
1133
|
+
for (let x = 0; x < line.length; x++) {
|
|
1134
|
+
const cell = line.getCell(x, reusable);
|
|
1135
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
1136
|
+
if (cell.getChars()) last = x;
|
|
1137
|
+
}
|
|
1138
|
+
if (last < 0) {
|
|
1139
|
+
// Empty line: show the cursor as a full inverse block at the start of the line
|
|
1140
|
+
// (or as an inverse space when it sits past the end of the content).
|
|
1141
|
+
if (isCursorRow && cursor!.col >= 0) return CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1142
|
+
return "";
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
let out = "";
|
|
1146
|
+
let prevAttr = "";
|
|
1147
|
+
let prevUri = "";
|
|
1148
|
+
for (let x = 0; x <= last; x++) {
|
|
1149
|
+
const cell = line.getCell(x, reusable);
|
|
1150
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
1151
|
+
const uri = osc8UriForCell(term, cell);
|
|
1152
|
+
if (uri !== prevUri) {
|
|
1153
|
+
if (prevUri) out += closeOsc8();
|
|
1154
|
+
if (uri) out += openOsc8(uri);
|
|
1155
|
+
prevUri = uri;
|
|
1156
|
+
}
|
|
1157
|
+
const selected = pointWithinSelection(lineIndex, x, selection);
|
|
1158
|
+
// The PTY cursor renders as a solid inverse block so it stays visible even though
|
|
1159
|
+
// the outer TUI hides the hardware cursor by default. The zero-width CURSOR_MARKER
|
|
1160
|
+
// (stripped by the TUI) additionally positions the hardware cursor for IME and
|
|
1161
|
+
// PI_HARDWARE_CURSOR=1 terminals; truncateToWidth keeps or drops it with the cell.
|
|
1162
|
+
const isCursor = isCursorRow && x === cursor!.col;
|
|
1163
|
+
if (isCursor) out += CURSOR_MARKER;
|
|
1164
|
+
const key = attrKey(cell, selected, isCursor);
|
|
1165
|
+
if (key !== prevAttr) {
|
|
1166
|
+
out += attrsToAnsi(cell, selected, isCursor);
|
|
1167
|
+
prevAttr = key;
|
|
1168
|
+
}
|
|
1169
|
+
out += cell.getChars() || " ";
|
|
1170
|
+
}
|
|
1171
|
+
if (prevUri) out += closeOsc8();
|
|
1172
|
+
// Cursor past the end of the line content (cursorX == cols or beyond last cell):
|
|
1173
|
+
// append an inverse space so the position is still visible.
|
|
1174
|
+
if (isCursorRow && cursor!.col > last) {
|
|
1175
|
+
out += CURSOR_MARKER + "\x1b[7m \x1b[0m";
|
|
1176
|
+
}
|
|
1177
|
+
return out + "\x1b[0m";
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Resolve the PTY cursor into viewport coordinates. xterm's buffer cursorY is relative
|
|
1182
|
+
* to baseY (the child terminal's viewport top); returns null when the cursor is outside
|
|
1183
|
+
* the projected window (e.g. the user scrolled up into history).
|
|
1184
|
+
*/
|
|
1185
|
+
function cursorInViewport(
|
|
1186
|
+
buf: XtermLike["buffer"]["active"],
|
|
1187
|
+
start: number,
|
|
1188
|
+
height: number,
|
|
1189
|
+
): { row: number; col: number } | null {
|
|
1190
|
+
if (typeof buf.cursorX !== "number" || typeof buf.cursorY !== "number" || buf.cursorX < 0 || buf.cursorY < 0) return null;
|
|
1191
|
+
const row = buf.baseY + buf.cursorY - start;
|
|
1192
|
+
if (row < 0 || row >= height) return null;
|
|
1193
|
+
return { row, col: buf.cursorX };
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function attrKey(cell: BufferCellLike, selected = false, isCursor = false): string {
|
|
1197
|
+
return [
|
|
1198
|
+
selected ? 1 : 0,
|
|
1199
|
+
isCursor ? 1 : 0,
|
|
1200
|
+
cell.isBold(),
|
|
1201
|
+
cell.isDim(),
|
|
1202
|
+
cell.isItalic(),
|
|
1203
|
+
cell.isUnderline(),
|
|
1204
|
+
cell.isBlink(),
|
|
1205
|
+
cell.isInverse(),
|
|
1206
|
+
cell.isInvisible(),
|
|
1207
|
+
cell.isStrikethrough(),
|
|
1208
|
+
cell.isOverline(),
|
|
1209
|
+
cell.isFgRGB(),
|
|
1210
|
+
cell.isFgPalette(),
|
|
1211
|
+
cell.getFgColor(),
|
|
1212
|
+
cell.isBgRGB(),
|
|
1213
|
+
cell.isBgPalette(),
|
|
1214
|
+
cell.getBgColor(),
|
|
1215
|
+
].join(";");
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function attrsToAnsi(cell: BufferCellLike, selected = false, isCursor = false): string {
|
|
1219
|
+
const codes: string[] = ["0"];
|
|
1220
|
+
if (cell.isBold()) codes.push("1");
|
|
1221
|
+
if (cell.isDim()) codes.push("2");
|
|
1222
|
+
if (cell.isItalic()) codes.push("3");
|
|
1223
|
+
if (cell.isUnderline()) codes.push("4");
|
|
1224
|
+
if (cell.isBlink()) codes.push("5");
|
|
1225
|
+
if (cell.isInverse() || selected || isCursor) codes.push("7");
|
|
1226
|
+
if (cell.isInvisible()) codes.push("8");
|
|
1227
|
+
if (cell.isStrikethrough()) codes.push("9");
|
|
1228
|
+
if (cell.isOverline()) codes.push("53");
|
|
1229
|
+
codes.push(...colorCodes(cell, "fg"));
|
|
1230
|
+
codes.push(...colorCodes(cell, "bg"));
|
|
1231
|
+
return `\x1b[${codes.join(";")}m`;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function colorCodes(cell: BufferCellLike, kind: "fg" | "bg"): string[] {
|
|
1235
|
+
const isFg = kind === "fg";
|
|
1236
|
+
const color = isFg ? cell.getFgColor() : cell.getBgColor();
|
|
1237
|
+
if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {
|
|
1238
|
+
return [isFg ? "38" : "48", "2", String((color >> 16) & 255), String((color >> 8) & 255), String(color & 255)];
|
|
1239
|
+
}
|
|
1240
|
+
if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
|
|
1241
|
+
if (color >= 0 && color <= 7) return [String((isFg ? 30 : 40) + color)];
|
|
1242
|
+
if (color >= 8 && color <= 15) return [String((isFg ? 90 : 100) + color - 8)];
|
|
1243
|
+
return [isFg ? "38" : "48", "5", String(color)];
|
|
1244
|
+
}
|
|
1245
|
+
return [isFg ? "39" : "49"];
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function clip(line: string, width: number): string {
|
|
1249
|
+
return truncateToWidth(line, width, "");
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function clipTerminalLine(line: string, width: number): string {
|
|
1253
|
+
// pi-tui's width helpers are ANSI-aware, but OSC sequences are terminal
|
|
1254
|
+
// protocols rather than SGR styling. Avoid truncating inside OSC 8 hyperlinks;
|
|
1255
|
+
// these lines are already projected from an xterm buffer sized to the viewport.
|
|
1256
|
+
return line.includes("\x1b]") ? line : clip(line, width);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function osc8UriForCell(term: XtermLike, cell: BufferCellLike): string {
|
|
1260
|
+
const id = cell.extended?.urlId ?? cell.extended?._urlId ?? 0;
|
|
1261
|
+
if (!id) return "";
|
|
1262
|
+
const service = term._core?._oscLinkService;
|
|
1263
|
+
const uri = service?.getLinkData?.(id)?.uri ?? service?._dataByLinkId?.get(id)?.data?.uri;
|
|
1264
|
+
return sanitizeOscPayload(uri ?? "");
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function openOsc8(uri: string): string {
|
|
1268
|
+
return uri ? `\x1b]8;;${uri}\x07` : "";
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function closeOsc8(): string {
|
|
1272
|
+
return "\x1b]8;;\x07";
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function extractOsc52Sequences(input: string): { sequences: string[]; carry: string } {
|
|
1276
|
+
const sequences: string[] = [];
|
|
1277
|
+
let scanFrom = 0;
|
|
1278
|
+
let carryStart = -1;
|
|
1279
|
+
while (scanFrom < input.length) {
|
|
1280
|
+
const start = input.indexOf(OSC52_PREFIX, scanFrom);
|
|
1281
|
+
if (start < 0) break;
|
|
1282
|
+
const bel = input.indexOf("\x07", start + OSC52_PREFIX.length);
|
|
1283
|
+
const st = input.indexOf("\x1b\\", start + OSC52_PREFIX.length);
|
|
1284
|
+
const end = firstTerminator(bel, st);
|
|
1285
|
+
if (!end) {
|
|
1286
|
+
carryStart = start;
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
const [endIndex, terminatorLength] = end;
|
|
1290
|
+
const seq = input.slice(start, endIndex + terminatorLength);
|
|
1291
|
+
if (isForwardableOsc52(seq)) sequences.push(seq);
|
|
1292
|
+
scanFrom = endIndex + terminatorLength;
|
|
1293
|
+
}
|
|
1294
|
+
const carry = carryStart >= 0 ? input.slice(carryStart).slice(-OSC52_CARRY_MAX_BYTES) : osc52PrefixSuffix(input);
|
|
1295
|
+
return { sequences, carry };
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function osc52PrefixSuffix(input: string): string {
|
|
1299
|
+
const max = Math.min(input.length, OSC52_PREFIX.length - 1);
|
|
1300
|
+
for (let len = max; len > 0; len--) {
|
|
1301
|
+
const suffix = input.slice(-len);
|
|
1302
|
+
if (OSC52_PREFIX.startsWith(suffix)) return suffix;
|
|
1303
|
+
}
|
|
1304
|
+
return "";
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function firstTerminator(bel: number, st: number): [number, number] | null {
|
|
1308
|
+
if (bel < 0 && st < 0) return null;
|
|
1309
|
+
if (bel >= 0 && (st < 0 || bel < st)) return [bel, 1];
|
|
1310
|
+
return [st, 2];
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function extractTerminalPassthroughSequences(input: string): { sequences: string[]; carry: string } {
|
|
1314
|
+
const sequences: string[] = [];
|
|
1315
|
+
let scanFrom = 0;
|
|
1316
|
+
let carryStart = -1;
|
|
1317
|
+
while (scanFrom < input.length) {
|
|
1318
|
+
const kitty = input.indexOf(KITTY_IMAGE_PREFIX, scanFrom);
|
|
1319
|
+
const iterm = input.indexOf(ITERM2_FILE_PREFIX, scanFrom);
|
|
1320
|
+
const start = firstIndex(kitty, iterm);
|
|
1321
|
+
if (start < 0) break;
|
|
1322
|
+
const prefix = start === kitty ? KITTY_IMAGE_PREFIX : ITERM2_FILE_PREFIX;
|
|
1323
|
+
const bel = prefix === ITERM2_FILE_PREFIX ? input.indexOf("\x07", start + prefix.length) : -1;
|
|
1324
|
+
const st = input.indexOf("\x1b\\", start + prefix.length);
|
|
1325
|
+
const end = firstTerminator(bel, st);
|
|
1326
|
+
if (!end) {
|
|
1327
|
+
carryStart = start;
|
|
1328
|
+
break;
|
|
1329
|
+
}
|
|
1330
|
+
const [endIndex, terminatorLength] = end;
|
|
1331
|
+
const seq = input.slice(start, endIndex + terminatorLength);
|
|
1332
|
+
if (seq.length <= TERMINAL_PASSTHROUGH_MAX_BYTES) sequences.push(seq);
|
|
1333
|
+
scanFrom = endIndex + terminatorLength;
|
|
1334
|
+
}
|
|
1335
|
+
const carry = carryStart >= 0 ? input.slice(carryStart).slice(-TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES) : terminalPassthroughPrefixSuffix(input);
|
|
1336
|
+
return { sequences, carry };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function firstIndex(a: number, b: number): number {
|
|
1340
|
+
if (a < 0) return b;
|
|
1341
|
+
if (b < 0) return a;
|
|
1342
|
+
return Math.min(a, b);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function terminalPassthroughPrefixSuffix(input: string): string {
|
|
1346
|
+
let best = "";
|
|
1347
|
+
for (const prefix of [KITTY_IMAGE_PREFIX, ITERM2_FILE_PREFIX]) {
|
|
1348
|
+
const max = Math.min(input.length, prefix.length - 1);
|
|
1349
|
+
for (let len = max; len > best.length; len--) {
|
|
1350
|
+
const suffix = input.slice(-len);
|
|
1351
|
+
if (prefix.startsWith(suffix)) best = suffix;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return best;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function isForwardableOsc52(seq: string): boolean {
|
|
1358
|
+
if (seq.length > OSC52_MAX_BYTES) return false;
|
|
1359
|
+
const terminatorLength = seq.endsWith("\x1b\\") ? 2 : 1;
|
|
1360
|
+
const body = seq.slice(2, -terminatorLength); // strip ESC] and BEL/ST
|
|
1361
|
+
const firstSemi = body.indexOf(";");
|
|
1362
|
+
const secondSemi = body.indexOf(";", firstSemi + 1);
|
|
1363
|
+
if (!body.startsWith("52;") || secondSemi < 0) return false;
|
|
1364
|
+
const payload = body.slice(secondSemi + 1).replace(/[\r\n]/g, "");
|
|
1365
|
+
// Do not forward clipboard-read requests (OSC 52 ; ... ; ?) to the outer terminal.
|
|
1366
|
+
if (payload === "?") return false;
|
|
1367
|
+
return /^[A-Za-z0-9+/=]*$/.test(payload);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function sanitizeOscPayload(value: string): string {
|
|
1371
|
+
return value.replace(/[\x00-\x1f\x7f]/g, "");
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function center(text: string, width: number): string {
|
|
1375
|
+
const w = visibleWidth(text);
|
|
1376
|
+
if (w >= width) return clip(text, width);
|
|
1377
|
+
return " ".repeat(Math.floor((width - w) / 2)) + text;
|
|
1378
|
+
}
|