@zhuxixi/pi-agent-board 0.10.1 → 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);
@@ -225,6 +236,10 @@ export class PtyAttachComponent implements Component {
225
236
  private attaching = true;
226
237
  private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
227
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;
228
243
  // Issue #128 D2: unsubscriber for the color-scheme bridge registered at
229
244
  // attach settle; nulled on every terminal path so the TUI never keeps a
230
245
  // listener pointing at a dead attach surface.
@@ -353,6 +368,22 @@ export class PtyAttachComponent implements Component {
353
368
  this.send({ type: "input", data });
354
369
  }
355
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
+
356
387
  render(width: number): string[] {
357
388
  this.resizeIfNeeded(width);
358
389
  const height = this.tui.terminal?.rows ?? 24;
@@ -598,7 +629,10 @@ export class PtyAttachComponent implements Component {
598
629
  // Issue #128: the settle transition is the single point where `attaching`
599
630
  // flips false (never re-armed), so these hooks run at most once per attach.
600
631
  this.attachColorSchemeBridge();
601
- 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.
602
636
  this.reportRealTerminalColorScheme();
603
637
  }
604
638
 
@@ -634,52 +668,89 @@ export class PtyAttachComponent implements Component {
634
668
  }
635
669
  }
636
670
 
637
- /** Issue #128 D3: the child probed OSC 11 at spawn, before any attach
638
- * client existed, so its background-color answer was lost. Re-ask the REAL
639
- * terminal once now; the reply travels back through the existing input
640
- * passthrough (handleInput fallback) to the child. Silent on failure — a
641
- * missing answer keeps the pre-#128 fallback theme. */
642
- private replayBackgroundQuery(): void {
643
- if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
644
- try {
645
- this.tui.terminal.write("\x1b]11;?\x07");
646
- } catch {
647
- /* best-effort: enhancement, never critical */
648
- }
649
- }
650
-
651
- /** Issue #148: #128's replay only helps while the child still has a PENDING
652
- * OSC 11 query. That precondition does not hold here: the child's probe fires
653
- * at spawn (long before attach), and Pi's leaked pending state — a timeout
654
- * settles the query but leaves it queued and keeps the reply counter up —
655
- * makes the next arriving reply get consumed by that stale entry and
656
- * discarded, resolving nothing. So the client stops relying on the child's
657
- * probe entirely: it asks the REAL terminal itself (pi-tui's public query API)
658
- * and hands the child a 997 color-scheme report, which Pi consumes
659
- * unconditionally (no pending-query precondition — see
660
- * consumeTerminalColorSchemeReport). Same kill switch as #128 D1-D3.
661
- *
662
- * One probe only (CR round-1 advisory): the local pi-tui has the same leak, so
663
- * when a stale entry sits in ITS queue the reply is swallowed before this
664
- * promise can see it, and retrying would append another stale entry per
665
- * attempt — failing identically while poisoning the host's own detection. The
666
- * 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. */
667
676
  private reportRealTerminalColorScheme(): void {
668
- 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
+ }
669
681
  void this.probeAndReportRealTerminalColorScheme();
670
682
  }
671
683
 
672
684
  private async probeAndReportRealTerminalColorScheme(): Promise<void> {
673
- 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 };
674
691
  try {
675
- 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
+ );
676
697
  } catch {
677
- /* 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;
678
728
  }
679
- 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 {
680
733
  if (!scheme || this.closed) return;
681
734
  const data = toColorSchemeReport(scheme);
682
- 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));
683
754
  }
684
755
 
685
756
  private startDesyncProbe(): void {
@@ -779,7 +850,7 @@ export class PtyAttachComponent implements Component {
779
850
  }
780
851
 
781
852
  private enableMouseScroll(): void {
782
- if (!this.mouseScrollEnabled()) return;
853
+ if (!this.ownsOuterMouseMode()) return;
783
854
  try {
784
855
  this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
785
856
  this.tui.terminal.write(MOUSE_ENABLE);
@@ -788,6 +859,15 @@ export class PtyAttachComponent implements Component {
788
859
  }
789
860
  }
790
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
+
791
871
  private mouseScrollEnabled(): boolean {
792
872
  const mode = (process.env.AGENT_BOARD_ATTACH_MOUSE ?? "").trim().toLowerCase();
793
873
  if (mode === "0" || mode === "off" || mode === "false") return false;
@@ -809,6 +889,7 @@ export class PtyAttachComponent implements Component {
809
889
  }
810
890
 
811
891
  private disableMouseScroll(): void {
892
+ if (!this.ownsOuterMouseMode()) return;
812
893
  try {
813
894
  this.tui.terminal.write(MOUSE_DISABLE);
814
895
  } catch {