@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.
@@ -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
- this.replayBackgroundQuery();
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 #128 D3: the child probed OSC 11 at spawn, before any attach
637
- * client existed, so its background-color answer was lost. Re-ask the REAL
638
- * terminal once now; the reply travels back through the existing input
639
- * passthrough (handleInput fallback) to the child. Silent on failure — a
640
- * missing answer keeps the pre-#128 fallback theme. */
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") return;
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
- let rgb: RgbColor | undefined;
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
- rgb = await this.tui.queryTerminalBackgroundColor({ timeoutMs: REAL_TERMINAL_SCHEME_PROBE_TIMEOUT_MS });
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
- const scheme: TerminalColorScheme | undefined = colorSchemeForBackgroundRgb(rgb) ?? undefined;
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) this.send({ type: "input", 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.mouseScrollEnabled()) return;
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;