@zhuxixi/pi-agent-board 0.5.0 → 0.5.2
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/PROGRESS.md +18 -3
- package/README.md +298 -76
- package/VERIFY.md +3 -3
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -3
- package/docs/superpowers/plans/2026-08-30-circular-navigation.md +308 -0
- package/docs/superpowers/plans/2026-08-30-pty-attach-quality-debt.md +311 -0
- package/docs/superpowers/plans/2026-08-30-readme-v2.md +294 -0
- package/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +284 -0
- package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +722 -0
- package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +146 -0
- package/docs/superpowers/specs/2026-08-21-attach-coldstart-jiggle-rearm-design.md +4 -0
- package/docs/superpowers/specs/2026-08-22-jiggle-shrink-and-hold-design.md +4 -0
- package/docs/superpowers/specs/2026-08-30-circular-navigation-design.md +47 -0
- package/docs/superpowers/specs/2026-08-30-pty-attach-quality-debt-design.md +105 -0
- package/docs/superpowers/specs/2026-08-30-readme-v2-design.md +115 -0
- package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +78 -0
- package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +120 -0
- package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +99 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +64 -17
- package/src/core/code-refs-store.mjs +3 -0
- package/src/core/editor-state-reporter.mjs +102 -0
- package/src/core/launch.mjs +6 -0
- package/src/core/pty-attach-jiggle-controller.mjs +71 -17
- package/src/core/pty-input.mjs +32 -0
- package/src/core/pty-scroll.mjs +4 -3
- package/src/core/repo.mjs +3 -0
- package/src/core/worktree.mjs +1 -0
- package/src/index.ts +12 -1
- package/src/ui/dashboard.ts +6 -2
- package/src/ui/pty-attach.ts +148 -32
package/src/index.ts
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* the count of sessions needing attention. See docs/EXPLORATION.md for the design.
|
|
7
7
|
*/
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { createConnection } from "node:net";
|
|
9
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { resolvePiInvocation } from "./core/invocation.mjs";
|
|
11
|
-
import { defaultRoot } from "./core/paths.mjs";
|
|
12
|
+
import { controlSocketPathFor, defaultRoot } from "./core/paths.mjs";
|
|
12
13
|
import { listRows } from "./core/store.mjs";
|
|
13
14
|
import { createService } from "./runtime/service.mjs";
|
|
14
15
|
import { openDashboard, registerAgentBoardCommand } from "./commands/agent-board.js";
|
|
@@ -19,6 +20,8 @@ const PTY_RUNNER_SCRIPT = fileURLToPath(new URL("../runner/pty-runner.mjs", impo
|
|
|
19
20
|
const TITLE_RUNNER_SCRIPT = fileURLToPath(new URL("../runner/title-runner.mjs", import.meta.url));
|
|
20
21
|
const AUTO_STATE_RUNNER_SCRIPT = fileURLToPath(new URL("../runner/state-runner.mjs", import.meta.url));
|
|
21
22
|
|
|
23
|
+
let hostedEditorReporter: { start(): void; stop(): void } | null = null;
|
|
24
|
+
|
|
22
25
|
export default function piAgentBoard(pi: ExtensionAPI): void {
|
|
23
26
|
const root = defaultRoot();
|
|
24
27
|
const { piCommand, piArgsPrefix } = resolvePiInvocation();
|
|
@@ -71,6 +74,14 @@ export default function piAgentBoard(pi: ExtensionAPI): void {
|
|
|
71
74
|
};
|
|
72
75
|
|
|
73
76
|
pi.on("session_start", async (event, ctx) => {
|
|
77
|
+
if (isHostedChild && !hostedEditorReporter && typeof ctx.ui?.getEditorText === "function" && hostedViewId) {
|
|
78
|
+
const { createEditorStateReporter } = await import("./core/editor-state-reporter.mjs");
|
|
79
|
+
hostedEditorReporter = createEditorStateReporter({
|
|
80
|
+
getEditorText: () => ctx.ui.getEditorText(),
|
|
81
|
+
connect: () => createConnection(controlSocketPathFor(process.platform as "win32" | "linux" | "darwin", root, hostedViewId)),
|
|
82
|
+
});
|
|
83
|
+
hostedEditorReporter.start();
|
|
84
|
+
}
|
|
74
85
|
updateStatus(ctx);
|
|
75
86
|
if (event.reason === "startup" && !isHostedChild && pi.getFlag("agent-board") === true && ctx.hasUI) {
|
|
76
87
|
const service = createService({ root, runnerScript: RUNNER_SCRIPT, ptyRunnerScript: PTY_RUNNER_SCRIPT, titleRunnerScript: TITLE_RUNNER_SCRIPT, autoStateRunnerScript: AUTO_STATE_RUNNER_SCRIPT, piCommand, piArgsPrefix, defaultCwd: ctx.cwd });
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -250,7 +250,9 @@ export class DashboardComponent implements Component {
|
|
|
250
250
|
private moveSelection(delta: number): void {
|
|
251
251
|
if (this.orderedIds.length === 0) return;
|
|
252
252
|
const cur = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
|
|
253
|
-
const
|
|
253
|
+
const len = this.orderedIds.length;
|
|
254
|
+
// Wrap around both ends: down past last -> first, up past first -> last.
|
|
255
|
+
const next = (((cur < 0 ? 0 : cur) + delta) % len + len) % len;
|
|
254
256
|
const nextId = this.orderedIds[next];
|
|
255
257
|
if (nextId === this.selectedId) return;
|
|
256
258
|
this.selectedId = nextId;
|
|
@@ -1095,7 +1097,9 @@ export class DashboardComponent implements Component {
|
|
|
1095
1097
|
if (!this.peekId) return;
|
|
1096
1098
|
const idx = this.orderedIds.indexOf(this.peekId);
|
|
1097
1099
|
if (idx < 0) return;
|
|
1098
|
-
const
|
|
1100
|
+
const len = this.orderedIds.length;
|
|
1101
|
+
// Wrap around both ends, same as moveSelection().
|
|
1102
|
+
const next = ((idx + delta) % len + len) % len;
|
|
1099
1103
|
this.peekId = this.orderedIds[next];
|
|
1100
1104
|
this.selectedId = this.peekId;
|
|
1101
1105
|
}
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
|
5
5
|
import { createConnection, type Socket } from "node:net";
|
|
6
6
|
import type { Component, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
7
7
|
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
-
import { isProbablyEmptyPiInputLine } from "../core/pty-input.mjs";
|
|
8
|
+
import { isProbablyEmptyPiInputLine, isProbablyPiInputLine, resolveEditorEmpty } from "../core/pty-input.mjs";
|
|
9
9
|
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
10
10
|
import { createAttachOutputRenderScheduler, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
11
11
|
import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
|
|
@@ -29,7 +29,7 @@ export interface PtyAttachOptions {
|
|
|
29
29
|
const require = createRequire(import.meta.url);
|
|
30
30
|
const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record<string, unknown>) => XtermLike };
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
|
|
33
33
|
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
|
|
34
34
|
const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
|
|
35
35
|
const XTSHIFTESCAPE_SELECT = "\x1b[>0s";
|
|
@@ -40,6 +40,8 @@ const LOADING_TICK_MS = 120;
|
|
|
40
40
|
const ATTACH_SETTLE_MS = 250;
|
|
41
41
|
/** Hard cap on the attach transition so a silent session can't stall the banner. */
|
|
42
42
|
const ATTACH_HARD_TIMEOUT_MS = 2500;
|
|
43
|
+
/** Give ordered detach packets time to flush before using destroy as a fallback. */
|
|
44
|
+
const GRACEFUL_SOCKET_CLOSE_MS = 1000;
|
|
43
45
|
/** How many tail bytes of the screen log to replay on attach. Read from the file tail
|
|
44
46
|
* (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
|
|
45
47
|
* handful of screens, which is all a fresh attach needs. */
|
|
@@ -156,6 +158,9 @@ export class PtyAttachComponent implements Component {
|
|
|
156
158
|
private pendingClickTimer: ReturnType<typeof setTimeout> | null = null;
|
|
157
159
|
private lastClickPoint: MousePoint | null = null;
|
|
158
160
|
private lastClickAt = 0;
|
|
161
|
+
/** Authoritative editor emptiness pushed by the child Pi extension via the
|
|
162
|
+
* control socket (issue #68). null = unknown — fall back to the heuristic. */
|
|
163
|
+
private editorEmpty: boolean | null = null;
|
|
159
164
|
// Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
|
|
160
165
|
// loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
|
|
161
166
|
// the previous screen visible.
|
|
@@ -166,6 +171,7 @@ export class PtyAttachComponent implements Component {
|
|
|
166
171
|
private attaching = true;
|
|
167
172
|
private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
168
173
|
private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
174
|
+
private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
169
175
|
// Force a single full-clear on the first paint so the prior session/dashboard can't
|
|
170
176
|
// ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
|
|
171
177
|
// differential renderer so wheel/output bursts don't each trigger a full repaint.
|
|
@@ -238,15 +244,12 @@ export class PtyAttachComponent implements Component {
|
|
|
238
244
|
this.send({ type: "input", data });
|
|
239
245
|
return;
|
|
240
246
|
}
|
|
241
|
-
if (
|
|
242
|
-
DETACH_KEYS.has(data) ||
|
|
243
|
-
matchesKey(data, Key.left) ||
|
|
244
|
-
matchesKey(data, Key.ctrl("]"))
|
|
245
|
-
) {
|
|
247
|
+
if (matchesKey(data, Key.left)) {
|
|
246
248
|
// While the socket is down the key can never reach the child, so
|
|
247
249
|
// escape unconditionally — the view must always be exitable, even
|
|
248
|
-
// after the host crashes mid-output (issue #48).
|
|
249
|
-
|
|
250
|
+
// after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
|
|
251
|
+
// a detach key — it passes through to Pi (tui.editor.jumpForward).
|
|
252
|
+
if (shouldEscapeAttach(this.connected, resolveEditorEmpty(this.editorEmpty, this.childInputLooksEmpty()))) {
|
|
250
253
|
this.detach();
|
|
251
254
|
return;
|
|
252
255
|
}
|
|
@@ -268,7 +271,7 @@ export class PtyAttachComponent implements Component {
|
|
|
268
271
|
const bodyHeight = Math.max(1, height - 2);
|
|
269
272
|
let body: string[];
|
|
270
273
|
if (!this.attaching && this.receivedOutput) {
|
|
271
|
-
const projected = this.project(bodyHeight
|
|
274
|
+
const projected = this.project(bodyHeight);
|
|
272
275
|
body = projected.lines;
|
|
273
276
|
while (body.length < bodyHeight) body.unshift("");
|
|
274
277
|
} else {
|
|
@@ -276,7 +279,7 @@ export class PtyAttachComponent implements Component {
|
|
|
276
279
|
}
|
|
277
280
|
const header =
|
|
278
281
|
this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
|
|
279
|
-
this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach
|
|
282
|
+
this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · ← detach`);
|
|
280
283
|
return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
|
|
281
284
|
}
|
|
282
285
|
|
|
@@ -292,7 +295,7 @@ export class PtyAttachComponent implements Component {
|
|
|
292
295
|
out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
|
|
293
296
|
out.push(center(this.theme.fg("muted", detail), width));
|
|
294
297
|
out.push("");
|
|
295
|
-
out.push(center(this.theme.fg("dim", "←
|
|
298
|
+
out.push(center(this.theme.fg("dim", "← to detach"), width));
|
|
296
299
|
while (out.length < height) out.push("");
|
|
297
300
|
return out.slice(0, height);
|
|
298
301
|
}
|
|
@@ -311,17 +314,62 @@ export class PtyAttachComponent implements Component {
|
|
|
311
314
|
}
|
|
312
315
|
|
|
313
316
|
private detach(): void {
|
|
317
|
+
// Restore a held PTY before ending the control socket. The runner closes
|
|
318
|
+
// the socket immediately after receiving detach, so sending detach first
|
|
319
|
+
// can drop the G3 restore resize (issue #42).
|
|
320
|
+
this.jiggleRetry.restoreAndStop();
|
|
314
321
|
this.send({ type: "detach" });
|
|
315
|
-
this.close();
|
|
322
|
+
this.close(true);
|
|
316
323
|
this.done({ action: "detached" });
|
|
317
324
|
}
|
|
318
325
|
|
|
326
|
+
/** Bottom-most line whose cells include an inverse-video cell — Pi renders
|
|
327
|
+
* its editor cursor as an inverse "fake cursor" (`ESC[7m`), and the cell
|
|
328
|
+
* persists in the buffer even while streaming differential frames skip
|
|
329
|
+
* repainting the editor line. */
|
|
330
|
+
private findLastInverseCellLine(active: {
|
|
331
|
+
baseY: number;
|
|
332
|
+
length: number;
|
|
333
|
+
getLine(index: number): BufferLineLike | undefined;
|
|
334
|
+
}): number | null {
|
|
335
|
+
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
336
|
+
const line = active.getLine(y);
|
|
337
|
+
if (!line) continue;
|
|
338
|
+
for (let x = 0; x < line.length; x++) {
|
|
339
|
+
if (line.getCell(x)?.isInverse()) return y;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
319
345
|
private childInputLooksEmpty(): boolean {
|
|
320
346
|
if (!this.receivedOutput) return true;
|
|
321
347
|
const active = this.term.buffer.active;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
348
|
+
// The terminal cursor is not a reliable anchor for the editor line:
|
|
349
|
+
// while Pi streams output (or right after attach) the cursor rests on
|
|
350
|
+
// working/output lines, never the input line, so a genuinely empty
|
|
351
|
+
// editor was misread as non-empty and ← stopped detaching (issue #66).
|
|
352
|
+
// Pi's editor line always carries an inverse-video fake-cursor cell,
|
|
353
|
+
// so anchor on that instead.
|
|
354
|
+
const fakeCursorLine = this.findLastInverseCellLine(active);
|
|
355
|
+
if (fakeCursorLine !== null) {
|
|
356
|
+
const line = active.getLine(fakeCursorLine)?.translateToString(true) ?? "";
|
|
357
|
+
return isProbablyEmptyPiInputLine(line);
|
|
358
|
+
}
|
|
359
|
+
// Fallback: Pi variants that render no fake cursor — look for an EMPTY
|
|
360
|
+
// prompt-glyph line. Only an empty glyph line proves an empty editor:
|
|
361
|
+
// content glyph lines (markdown table rows `│ … │`, quotes `> …`, or a
|
|
362
|
+
// real draft in a no-fake-cursor Pi variant) cannot be told apart, and
|
|
363
|
+
// trapping the user is worse than a spurious detach (issue #69) — skip
|
|
364
|
+
// them and keep scanning; the loop-end escape below stays authoritative.
|
|
365
|
+
for (let y = active.baseY + active.length - 1; y >= active.baseY; y--) {
|
|
366
|
+
const line = active.getLine(y)?.translateToString(true) ?? "";
|
|
367
|
+
if (isProbablyPiInputLine(line) && isProbablyEmptyPiInputLine(line)) return true;
|
|
368
|
+
}
|
|
369
|
+
// No editor line recoverable (e.g. a garbled replay buffer): treat the
|
|
370
|
+
// input as empty — ← is the only detach key left on the attach surface,
|
|
371
|
+
// so it must always escape rather than trap the user.
|
|
372
|
+
return true;
|
|
325
373
|
}
|
|
326
374
|
|
|
327
375
|
private connect(): void {
|
|
@@ -336,7 +384,14 @@ export class PtyAttachComponent implements Component {
|
|
|
336
384
|
}
|
|
337
385
|
const socket = createConnection(this.opts.socketPath);
|
|
338
386
|
this.socket = socket;
|
|
387
|
+
// A partial protocol line left over from a failed socket would prefix the
|
|
388
|
+
// replacement connection's first line; start each socket with a clean buffer.
|
|
389
|
+
this.parserBuffer = "";
|
|
339
390
|
socket.on("connect", () => {
|
|
391
|
+
if (this.socket !== socket) {
|
|
392
|
+
try { socket.destroy(); } catch {}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
340
395
|
this.clearRetry();
|
|
341
396
|
this.connected = true;
|
|
342
397
|
this.everConnected = true;
|
|
@@ -348,8 +403,14 @@ export class PtyAttachComponent implements Component {
|
|
|
348
403
|
this.scheduleRender();
|
|
349
404
|
this.startAttachSettle();
|
|
350
405
|
});
|
|
351
|
-
socket.on("data", (chunk) =>
|
|
406
|
+
socket.on("data", (chunk) => {
|
|
407
|
+
if (this.socket !== socket) return;
|
|
408
|
+
this.onSocketData(chunk.toString("utf8"));
|
|
409
|
+
});
|
|
352
410
|
socket.on("close", () => {
|
|
411
|
+
// A failed socket can close after a replacement connection has already
|
|
412
|
+
// succeeded. Never let that stale event clear the replacement state.
|
|
413
|
+
if (this.socket !== socket) return;
|
|
353
414
|
this.socket = null;
|
|
354
415
|
this.connected = false;
|
|
355
416
|
this.disconnectedAt ??= Date.now();
|
|
@@ -360,8 +421,15 @@ export class PtyAttachComponent implements Component {
|
|
|
360
421
|
if (!this.closed) this.scheduleRender();
|
|
361
422
|
});
|
|
362
423
|
socket.on("error", (err) => {
|
|
424
|
+
// The error may belong to an old socket after reconnect. Dispose that
|
|
425
|
+
// socket, but do not alter the state of the current connection.
|
|
426
|
+
if (this.socket !== socket) {
|
|
427
|
+
try { socket.destroy(); } catch {}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
363
430
|
this.socket = null;
|
|
364
431
|
this.connected = false;
|
|
432
|
+
try { socket.destroy(); } catch {}
|
|
365
433
|
this.disconnectedAt ??= Date.now();
|
|
366
434
|
if (this.closed) return;
|
|
367
435
|
this.status = `waiting for host… ${err.message}`;
|
|
@@ -476,12 +544,20 @@ export class PtyAttachComponent implements Component {
|
|
|
476
544
|
this.retryTimer = null;
|
|
477
545
|
}
|
|
478
546
|
|
|
547
|
+
private clearGracefulSocketCloseTimer(): void {
|
|
548
|
+
if (!this.gracefulSocketCloseTimer) return;
|
|
549
|
+
clearTimeout(this.gracefulSocketCloseTimer);
|
|
550
|
+
this.gracefulSocketCloseTimer = null;
|
|
551
|
+
}
|
|
552
|
+
|
|
479
553
|
private enableMouseScroll(): void {
|
|
480
554
|
if (!this.mouseScrollEnabled()) return;
|
|
481
555
|
try {
|
|
482
556
|
this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
|
|
483
557
|
this.tui.terminal.write(MOUSE_ENABLE);
|
|
484
|
-
} catch {
|
|
558
|
+
} catch {
|
|
559
|
+
/* best-effort: some terminals reject these sequences; mouse reporting is optional */
|
|
560
|
+
}
|
|
485
561
|
}
|
|
486
562
|
|
|
487
563
|
private mouseScrollEnabled(): boolean {
|
|
@@ -507,7 +583,9 @@ export class PtyAttachComponent implements Component {
|
|
|
507
583
|
private disableMouseScroll(): void {
|
|
508
584
|
try {
|
|
509
585
|
this.tui.terminal.write(MOUSE_DISABLE);
|
|
510
|
-
} catch {
|
|
586
|
+
} catch {
|
|
587
|
+
/* best-effort: terminal may already be gone at teardown */
|
|
588
|
+
}
|
|
511
589
|
}
|
|
512
590
|
|
|
513
591
|
private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean {
|
|
@@ -721,7 +799,9 @@ export class PtyAttachComponent implements Component {
|
|
|
721
799
|
if (seq) {
|
|
722
800
|
try {
|
|
723
801
|
this.tui.terminal.write(seq);
|
|
724
|
-
} catch {
|
|
802
|
+
} catch {
|
|
803
|
+
/* best-effort: OSC52 clipboard support is optional */
|
|
804
|
+
}
|
|
725
805
|
}
|
|
726
806
|
// Also mirror the selection into the X11 PRIMARY selection so the rest of the
|
|
727
807
|
// desktop can middle-click-paste it — closes the loop with pastePrimarySelection().
|
|
@@ -742,7 +822,9 @@ export class PtyAttachComponent implements Component {
|
|
|
742
822
|
const timer = setTimeout(() => {
|
|
743
823
|
try {
|
|
744
824
|
child.kill("SIGKILL");
|
|
745
|
-
} catch {
|
|
825
|
+
} catch {
|
|
826
|
+
/* the child may have already exited before the timeout fired */
|
|
827
|
+
}
|
|
746
828
|
}, 800);
|
|
747
829
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
748
830
|
out += chunk.toString("utf8");
|
|
@@ -752,7 +834,9 @@ export class PtyAttachComponent implements Component {
|
|
|
752
834
|
clearTimeout(timer);
|
|
753
835
|
if (!this.closed && out) this.send({ type: "input", data: out });
|
|
754
836
|
});
|
|
755
|
-
} catch {
|
|
837
|
+
} catch {
|
|
838
|
+
/* silent no-op when xclip is absent — documented contract of this helper */
|
|
839
|
+
}
|
|
756
840
|
}
|
|
757
841
|
|
|
758
842
|
/** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
|
|
@@ -763,7 +847,9 @@ export class PtyAttachComponent implements Component {
|
|
|
763
847
|
child.stdin?.on("error", () => {});
|
|
764
848
|
child.on("error", () => {});
|
|
765
849
|
child.stdin?.end(text);
|
|
766
|
-
} catch {
|
|
850
|
+
} catch {
|
|
851
|
+
/* silent no-op when xclip is absent */
|
|
852
|
+
}
|
|
767
853
|
}
|
|
768
854
|
|
|
769
855
|
private selectionText(): string {
|
|
@@ -792,6 +878,9 @@ export class PtyAttachComponent implements Component {
|
|
|
792
878
|
}
|
|
793
879
|
|
|
794
880
|
private currentSize(): { cols: number; rows: number } {
|
|
881
|
+
// SAFETY: duck-typed read — Pi TUI's Terminal type does not consistently expose
|
|
882
|
+
// cols/columns/rows across versions (see resizeIfNeeded below). Runtime
|
|
883
|
+
// fallbacks (120/24) keep this safe when the fields are absent.
|
|
795
884
|
const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined;
|
|
796
885
|
return {
|
|
797
886
|
cols: Math.max(20, term?.cols ?? term?.columns ?? 120),
|
|
@@ -893,8 +982,12 @@ export class PtyAttachComponent implements Component {
|
|
|
893
982
|
this.checkClearSequence(msg.data);
|
|
894
983
|
continue;
|
|
895
984
|
}
|
|
896
|
-
if (msg.type === "hello" || msg.type === "status")
|
|
897
|
-
|
|
985
|
+
if (msg.type === "hello" || msg.type === "status") {
|
|
986
|
+
this.status = "attached";
|
|
987
|
+
if (msg.type === "hello") this.editorEmpty = typeof msg.editorEmpty === "boolean" ? msg.editorEmpty : null;
|
|
988
|
+
} else if (msg.type === "editor_state") {
|
|
989
|
+
this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
|
|
990
|
+
} else if (msg.type === "exit") {
|
|
898
991
|
this.status = "host exited";
|
|
899
992
|
this.done({ action: "closed", exitCode: msg.exitCode ?? null });
|
|
900
993
|
} else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
|
|
@@ -921,7 +1014,9 @@ export class PtyAttachComponent implements Component {
|
|
|
921
1014
|
for (const seq of toWrite) {
|
|
922
1015
|
try {
|
|
923
1016
|
this.tui.terminal.write(seq);
|
|
924
|
-
} catch {
|
|
1017
|
+
} catch {
|
|
1018
|
+
/* best-effort: forwarded sequences are enhancements, never critical */
|
|
1019
|
+
}
|
|
925
1020
|
}
|
|
926
1021
|
}
|
|
927
1022
|
|
|
@@ -948,7 +1043,9 @@ export class PtyAttachComponent implements Component {
|
|
|
948
1043
|
} finally {
|
|
949
1044
|
closeSync(fd);
|
|
950
1045
|
}
|
|
951
|
-
} catch {
|
|
1046
|
+
} catch {
|
|
1047
|
+
/* best-effort: a missing or racing screen.log must not block attach */
|
|
1048
|
+
}
|
|
952
1049
|
}
|
|
953
1050
|
|
|
954
1051
|
private pushOutput(data: string, opts: { forwardProtocols?: boolean } = {}): void {
|
|
@@ -967,7 +1064,7 @@ export class PtyAttachComponent implements Component {
|
|
|
967
1064
|
});
|
|
968
1065
|
}
|
|
969
1066
|
|
|
970
|
-
private project(height: number
|
|
1067
|
+
private project(height: number): { lines: string[]; cursor: { row: number; col: number } | null } {
|
|
971
1068
|
const out: string[] = [];
|
|
972
1069
|
const buf = this.term.buffer.active;
|
|
973
1070
|
const selection = normalizeSelection(this.selection);
|
|
@@ -986,7 +1083,13 @@ export class PtyAttachComponent implements Component {
|
|
|
986
1083
|
return { lines: out.slice(-height), cursor };
|
|
987
1084
|
}
|
|
988
1085
|
|
|
989
|
-
private close(): void {
|
|
1086
|
+
private close(gracefulSocket = false): void {
|
|
1087
|
+
if (this.closed && !this.socket) {
|
|
1088
|
+
// Already closed (e.g. dispose() after detach()); a pending graceful-close
|
|
1089
|
+
// fallback timer stays armed on purpose — it self-clears on socket close
|
|
1090
|
+
// or destroys the socket after the grace window.
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
990
1093
|
this.closed = true;
|
|
991
1094
|
this.imeCoalesceUninstall?.();
|
|
992
1095
|
this.jiggleRetry.restoreAndStop();
|
|
@@ -1005,11 +1108,24 @@ export class PtyAttachComponent implements Component {
|
|
|
1005
1108
|
clearTimeout(this.attachHardTimeout);
|
|
1006
1109
|
this.attachHardTimeout = null;
|
|
1007
1110
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
} catch {}
|
|
1111
|
+
this.clearGracefulSocketCloseTimer();
|
|
1112
|
+
const socket = this.socket;
|
|
1011
1113
|
this.socket = null;
|
|
1012
1114
|
this.connected = false;
|
|
1115
|
+
if (!socket) return;
|
|
1116
|
+
if (!gracefulSocket) {
|
|
1117
|
+
try { socket.destroy(); } catch {}
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
// socket.end() preserves the ordering of the already-buffered restore and
|
|
1121
|
+
// detach packets. destroy() would discard buffered writes under backpressure.
|
|
1122
|
+
socket.once("close", () => this.clearGracefulSocketCloseTimer());
|
|
1123
|
+
try { socket.end(); } catch {}
|
|
1124
|
+
this.gracefulSocketCloseTimer = setTimeout(() => {
|
|
1125
|
+
this.gracefulSocketCloseTimer = null;
|
|
1126
|
+
try { socket.destroy(); } catch {}
|
|
1127
|
+
}, GRACEFUL_SOCKET_CLOSE_MS);
|
|
1128
|
+
this.gracefulSocketCloseTimer.unref?.();
|
|
1013
1129
|
}
|
|
1014
1130
|
}
|
|
1015
1131
|
|