@zhuxixi/pi-agent-board 0.10.0 → 0.10.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/CHANGELOG.md +19 -0
- package/docs/superpowers/plans/2026-10-06-attach-settle-scheme-delivery.md +751 -0
- package/docs/superpowers/plans/2026-10-09-issue-167-attach-middle-paste.md +509 -0
- package/docs/superpowers/plans/2026-10-09-issue-169-fullscreen-mouse-ownership.md +346 -0
- package/docs/superpowers/specs/2026-10-06-attach-settle-scheme-delivery-design.md +196 -0
- package/docs/superpowers/specs/2026-10-09-issue-167-attach-middle-paste-design.md +125 -0
- package/docs/superpowers/specs/2026-10-09-issue-169-fullscreen-mouse-ownership-design.md +130 -0
- package/package.json +3 -3
- package/src/commands/agent-board.ts +11 -0
- package/src/commands/attach-flow.ts +11 -0
- package/src/core/pty-scroll.mjs +25 -0
- package/src/core/terminal-query-sequences.mjs +81 -0
- package/src/ui/pty-attach.ts +125 -41
package/src/ui/pty-attach.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
5
5
|
import { createConnection, type Socket } from "node:net";
|
|
6
|
-
import type { Component, KeybindingsManager, RgbColor, TUI, TerminalColorScheme } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { Component, KeybindingsManager, RgbColor, TUI, TerminalColorScheme, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
7
7
|
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import { findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
|
|
9
9
|
import { createAttachOutputRenderScheduler, detectCursorDesync, isPtyCursorHidden, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
|
|
@@ -11,8 +11,8 @@ import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-
|
|
|
11
11
|
import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
|
|
12
12
|
import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
|
|
13
13
|
import { createTerminalAttachClient } from "../core/terminal-attach-client.mjs";
|
|
14
|
-
import { colorSchemeForBackgroundRgb, extractOscQuerySequences, toColorSchemeReport } from "../core/terminal-query-sequences.mjs";
|
|
15
|
-
import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
|
|
14
|
+
import { backgroundRgbFromTerminalColors, buildSettleSchemePatch, colorSchemeForBackgroundRgb, extractOscQuerySequences, resolveProbeApi, toColorSchemeReport } from "../core/terminal-query-sequences.mjs";
|
|
15
|
+
import { clampInt, parseMouseInputChunk, resolveAttachMouseAction, resolveWheelLines, scrollViewportTop, selectionDragScrollLines, shouldOwnOuterMouseMode } from "../core/pty-scroll.mjs";
|
|
16
16
|
|
|
17
17
|
export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };
|
|
18
18
|
|
|
@@ -21,10 +21,21 @@ type ThemeLike = {
|
|
|
21
21
|
bold(text: string): string;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
/** Diagnostic patch emitted at attach settle (DiagnosticEvent-compatible; built by buildSettleSchemePatch). */
|
|
25
|
+
export interface AttachSettleDiagnosticPatch {
|
|
26
|
+
source: string;
|
|
27
|
+
level: "info" | "warn";
|
|
28
|
+
code: string;
|
|
29
|
+
message: string;
|
|
30
|
+
details: { probeApi?: string; outcome: string; report?: string; late?: boolean };
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
export interface PtyAttachOptions {
|
|
25
34
|
socketPath: string;
|
|
26
35
|
screenLogPath?: string;
|
|
27
36
|
title: string;
|
|
37
|
+
/** Issue #161: receives settle-scheme diagnostic patches. Default: no-op. */
|
|
38
|
+
onDiagnostic?: (event: AttachSettleDiagnosticPatch) => void;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
const require = createRequire(import.meta.url);
|
|
@@ -113,6 +124,7 @@ interface BufferLineLike {
|
|
|
113
124
|
interface BufferCellLike {
|
|
114
125
|
getWidth(): number;
|
|
115
126
|
getChars(): string;
|
|
127
|
+
hasExtendedAttrs(): number;
|
|
116
128
|
extended?: { urlId?: number; _urlId?: number };
|
|
117
129
|
getFgColor(): number;
|
|
118
130
|
getBgColor(): number;
|
|
@@ -224,6 +236,10 @@ export class PtyAttachComponent implements Component {
|
|
|
224
236
|
private attaching = true;
|
|
225
237
|
private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
226
238
|
private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
239
|
+
// Last scheme delivered to the child at settle (issue #161). Starts null:
|
|
240
|
+
// null never matches the duplicate check, so a timeout-then-late sequence
|
|
241
|
+
// still delivers. Single-shot per attach — settle runs at most once.
|
|
242
|
+
private sentScheme: TerminalColorScheme | null = null;
|
|
227
243
|
// Issue #128 D2: unsubscriber for the color-scheme bridge registered at
|
|
228
244
|
// attach settle; nulled on every terminal path so the TUI never keeps a
|
|
229
245
|
// listener pointing at a dead attach surface.
|
|
@@ -352,6 +368,22 @@ export class PtyAttachComponent implements Component {
|
|
|
352
368
|
this.send({ type: "input", data });
|
|
353
369
|
}
|
|
354
370
|
|
|
371
|
+
/** Issue #167: fullscreen TUI (pi ≥1.0) consumes raw SGR mouse sequences in
|
|
372
|
+
* TuiAltScreen and delivers normalized events to overlays here instead — the
|
|
373
|
+
* legacy handleInput mouse path never runs in that mode. Only the
|
|
374
|
+
* middle-click paste is ours; every other event returns undefined so pi's
|
|
375
|
+
* own fullscreen selection and the wheel defer path keep working. */
|
|
376
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
377
|
+
const action = resolveAttachMouseAction(event, {
|
|
378
|
+
nativePasteEnabled: process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE !== "0",
|
|
379
|
+
});
|
|
380
|
+
if (action !== "paste-primary") return undefined;
|
|
381
|
+
this.clearPendingClick();
|
|
382
|
+
this.clearSelection();
|
|
383
|
+
this.pastePrimarySelection();
|
|
384
|
+
return { handled: true, render: false };
|
|
385
|
+
}
|
|
386
|
+
|
|
355
387
|
render(width: number): string[] {
|
|
356
388
|
this.resizeIfNeeded(width);
|
|
357
389
|
const height = this.tui.terminal?.rows ?? 24;
|
|
@@ -597,7 +629,10 @@ export class PtyAttachComponent implements Component {
|
|
|
597
629
|
// Issue #128: the settle transition is the single point where `attaching`
|
|
598
630
|
// flips false (never re-armed), so these hooks run at most once per attach.
|
|
599
631
|
this.attachColorSchemeBridge();
|
|
600
|
-
|
|
632
|
+
// Use only the tracked TUI probe. An extra terminal.write(OSC 11) has
|
|
633
|
+
// no local query owner, so its reply falls through handleInput into the
|
|
634
|
+
// child PTY. Settle can precede the child's raw-mode startup, making
|
|
635
|
+
// the line discipline echo that reply as visible ^[]11;rgb:... text.
|
|
601
636
|
this.reportRealTerminalColorScheme();
|
|
602
637
|
}
|
|
603
638
|
|
|
@@ -633,52 +668,89 @@ export class PtyAttachComponent implements Component {
|
|
|
633
668
|
}
|
|
634
669
|
}
|
|
635
670
|
|
|
636
|
-
/** Issue #
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
private replayBackgroundQuery(): void {
|
|
642
|
-
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
|
|
643
|
-
try {
|
|
644
|
-
this.tui.terminal.write("\x1b]11;?\x07");
|
|
645
|
-
} catch {
|
|
646
|
-
/* best-effort: enhancement, never critical */
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/** Issue #148: #128's replay only helps while the child still has a PENDING
|
|
651
|
-
* OSC 11 query. That precondition does not hold here: the child's probe fires
|
|
652
|
-
* at spawn (long before attach), and Pi's leaked pending state — a timeout
|
|
653
|
-
* settles the query but leaves it queued and keeps the reply counter up —
|
|
654
|
-
* makes the next arriving reply get consumed by that stale entry and
|
|
655
|
-
* discarded, resolving nothing. So the client stops relying on the child's
|
|
656
|
-
* probe entirely: it asks the REAL terminal itself (pi-tui's public query API)
|
|
657
|
-
* and hands the child a 997 color-scheme report, which Pi consumes
|
|
658
|
-
* unconditionally (no pending-query precondition — see
|
|
659
|
-
* consumeTerminalColorSchemeReport). Same kill switch as #128 D1-D3.
|
|
660
|
-
*
|
|
661
|
-
* One probe only (CR round-1 advisory): the local pi-tui has the same leak, so
|
|
662
|
-
* when a stale entry sits in ITS queue the reply is swallowed before this
|
|
663
|
-
* promise can see it, and retrying would append another stale entry per
|
|
664
|
-
* attempt — failing identically while poisoning the host's own detection. The
|
|
665
|
-
* degraded case stays the documented pre-#148 silence. */
|
|
671
|
+
/** Issue #148/#161: at settle, ask the REAL terminal itself (feature-detected
|
|
672
|
+
* public query API — pi-tui >= 0.99 renamed it to queryTerminalColors) and
|
|
673
|
+
* hand the child an unconditional 997 color-scheme report. One probe per
|
|
674
|
+
* attach; the pre-#148 silence on failure is now observable via
|
|
675
|
+
* onDiagnostic. Same kill switch as #128 D1-D3. */
|
|
666
676
|
private reportRealTerminalColorScheme(): void {
|
|
667
|
-
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0")
|
|
677
|
+
if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") {
|
|
678
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ outcome: "suppressed" }));
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
668
681
|
void this.probeAndReportRealTerminalColorScheme();
|
|
669
682
|
}
|
|
670
683
|
|
|
671
684
|
private async probeAndReportRealTerminalColorScheme(): Promise<void> {
|
|
672
|
-
|
|
685
|
+
const probe = resolveProbeApi(this.tui as never);
|
|
686
|
+
if (probe.api === "none") {
|
|
687
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: "none", outcome: "no_probe_api" }));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
let result: { rgb?: RgbColor; anyColors: boolean };
|
|
673
691
|
try {
|
|
674
|
-
|
|
692
|
+
result = await probe.invoke(
|
|
693
|
+
this.tui as never,
|
|
694
|
+
REAL_TERMINAL_SCHEME_PROBE_TIMEOUT_MS,
|
|
695
|
+
(colors) => this.handleLateSettleColors(probe.api, colors),
|
|
696
|
+
);
|
|
675
697
|
} catch {
|
|
676
|
-
/* best-effort: a failed probe keeps the pre-#148 behavior */
|
|
698
|
+
/* best-effort: a failed probe keeps the pre-#148 behavior, but recorded */
|
|
699
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: probe.api, outcome: "error" }));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const rgb = result?.rgb;
|
|
703
|
+
if (!rgb) {
|
|
704
|
+
// Outcome rule (spec §2.3): any color answered but no background =>
|
|
705
|
+
// no_background; nothing answered at all => timeout. The old API only
|
|
706
|
+
// queries OSC 11, so its undefined is always a timeout.
|
|
707
|
+
const outcome = result && result.anyColors ? "no_background" : "timeout";
|
|
708
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: probe.api, outcome }));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
this.deliverSettleScheme(colorSchemeForBackgroundRgb(rgb) ?? undefined, probe.api, false);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** pi-tui >= 0.99 late replies: deliver only when the scheme differs from
|
|
715
|
+
* what was already sent (sentScheme starts null and never matches, so the
|
|
716
|
+
* timeout-then-late path delivers). Closed => dropped, but recorded. */
|
|
717
|
+
private handleLateSettleColors(api: "colors" | "background" | "none", colors: unknown): void {
|
|
718
|
+
if (this.closed) {
|
|
719
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "dropped_closed" }));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const rgb = backgroundRgbFromTerminalColors(colors as never);
|
|
723
|
+
const scheme = rgb ? colorSchemeForBackgroundRgb(rgb) ?? undefined : undefined;
|
|
724
|
+
if (!scheme) return;
|
|
725
|
+
if (this.sentScheme === scheme) {
|
|
726
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "duplicate_skipped" }));
|
|
727
|
+
return;
|
|
677
728
|
}
|
|
678
|
-
|
|
729
|
+
this.deliverSettleScheme(scheme, api, true);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
private deliverSettleScheme(scheme: TerminalColorScheme | undefined, api: "colors" | "background" | "none", late: boolean): void {
|
|
679
733
|
if (!scheme || this.closed) return;
|
|
680
734
|
const data = toColorSchemeReport(scheme);
|
|
681
|
-
if (data)
|
|
735
|
+
if (!data) return;
|
|
736
|
+
// CR round-1 advisory: send() silently no-ops while the control socket is
|
|
737
|
+
// disconnected (hard-timeout settle before the host finished connecting).
|
|
738
|
+
// Claiming "reported" there would be a lie the dedup then acts on, so
|
|
739
|
+
// record the drop and leave sentScheme unset — a later same-scheme late
|
|
740
|
+
// reply re-attempts instead of being swallowed as duplicate_skipped.
|
|
741
|
+
if (!this.connected) {
|
|
742
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "dropped_disconnected" }));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
this.sentScheme = scheme;
|
|
746
|
+
this.send({ type: "input", data });
|
|
747
|
+
const details: { probeApi: "colors" | "background" | "none"; outcome: "reported"; report: string; late?: boolean } = {
|
|
748
|
+
probeApi: api,
|
|
749
|
+
outcome: "reported",
|
|
750
|
+
report: scheme === "light" ? "997;2" : "997;1",
|
|
751
|
+
};
|
|
752
|
+
if (late) details.late = true;
|
|
753
|
+
this.opts.onDiagnostic?.(buildSettleSchemePatch(details));
|
|
682
754
|
}
|
|
683
755
|
|
|
684
756
|
private startDesyncProbe(): void {
|
|
@@ -778,7 +850,7 @@ export class PtyAttachComponent implements Component {
|
|
|
778
850
|
}
|
|
779
851
|
|
|
780
852
|
private enableMouseScroll(): void {
|
|
781
|
-
if (!this.
|
|
853
|
+
if (!this.ownsOuterMouseMode()) return;
|
|
782
854
|
try {
|
|
783
855
|
this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
|
|
784
856
|
this.tui.terminal.write(MOUSE_ENABLE);
|
|
@@ -787,6 +859,15 @@ export class PtyAttachComponent implements Component {
|
|
|
787
859
|
}
|
|
788
860
|
}
|
|
789
861
|
|
|
862
|
+
/** Issue #169: in fullscreen mode TuiAltScreen owns outer-terminal mouse
|
|
863
|
+
* reporting (asserted once at startup, never re-asserted) — writing any
|
|
864
|
+
* mouse-mode sequence from here can only downgrade or kill it. tui.mode is
|
|
865
|
+
* typed non-optional but old pi runtimes lack it; the strict inequality
|
|
866
|
+
* then keeps today's (regular) behavior. */
|
|
867
|
+
private ownsOuterMouseMode(): boolean {
|
|
868
|
+
return shouldOwnOuterMouseMode(this.tui.mode, this.mouseScrollEnabled());
|
|
869
|
+
}
|
|
870
|
+
|
|
790
871
|
private mouseScrollEnabled(): boolean {
|
|
791
872
|
const mode = (process.env.AGENT_BOARD_ATTACH_MOUSE ?? "").trim().toLowerCase();
|
|
792
873
|
if (mode === "0" || mode === "off" || mode === "false") return false;
|
|
@@ -808,6 +889,7 @@ export class PtyAttachComponent implements Component {
|
|
|
808
889
|
}
|
|
809
890
|
|
|
810
891
|
private disableMouseScroll(): void {
|
|
892
|
+
if (!this.ownsOuterMouseMode()) return;
|
|
811
893
|
try {
|
|
812
894
|
this.tui.terminal.write(MOUSE_DISABLE);
|
|
813
895
|
} catch {
|
|
@@ -1688,6 +1770,8 @@ function clipTerminalLine(line: string, width: number): string {
|
|
|
1688
1770
|
}
|
|
1689
1771
|
|
|
1690
1772
|
function osc8UriForCell(term: XtermLike, cell: BufferCellLike): string {
|
|
1773
|
+
// xterm leaves cached extended data on reused cells when this flag is unset.
|
|
1774
|
+
if (!cell.hasExtendedAttrs()) return "";
|
|
1691
1775
|
const id = cell.extended?.urlId ?? cell.extended?._urlId ?? 0;
|
|
1692
1776
|
if (!id) return "";
|
|
1693
1777
|
const service = term._core?._oscLinkService;
|