@zhuxixi/pi-agent-board 0.8.0 → 0.9.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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Terminal query sequence extraction and color-scheme report mapping for the
3
+ * PTY attach client (issue #128).
4
+ *
5
+ * The attach client rebuilds the child screen in a headless terminal model, so
6
+ * the child's terminal capability queries (OSC 11 background color, color
7
+ * scheme change notifications) are consumed locally and never reach the real
8
+ * terminal. extractOscQuerySequences pulls exactly those query/notify forms
9
+ * out of the child output stream so the attach client can forward them to the
10
+ * real terminal; the replies travel back through the existing input
11
+ * passthrough (handleInput fallback). Setting forms (e.g. `\x1b]11;rgb:...`)
12
+ * are deliberately NOT extracted: forwarding those would repaint the local
13
+ * terminal's own colors.
14
+ *
15
+ * Kitty keyboard protocol / DA1 negotiation (`\x1b[>7u`, `\x1b[?u`, `\x1b[c`)
16
+ * is intentionally out of scope: the child negotiates once at spawn, before
17
+ * any attach client exists, and pushing kitty flags would retune the shared
18
+ * real-terminal keyboard stack under the local Pi's feet.
19
+ *
20
+ * Pure functions only — no I/O, no pi-tui import (the report format below is
21
+ * pinned by unit tests that round-trip it through pi-tui's parser).
22
+ */
23
+
24
+ // OSC 11 background-color QUERY. The `?` payload is what makes it a query;
25
+ // the SET form `\x1b]11;rgb:...` never matches this prefix (`;r` vs `;?`).
26
+ const OSC11_QUERY_PREFIX = "\x1b]11;?";
27
+ const OSC11_QUERY_BEL = OSC11_QUERY_PREFIX + "\x07";
28
+ const OSC11_QUERY_ST = OSC11_QUERY_PREFIX + "\x1b\\";
29
+
30
+ // Color-scheme change notification switch (host Pi toggles it at startup and
31
+ // when its own setting changes; the real terminal then reports scheme changes
32
+ // as `\x1b[?997;{1|2}n` reports). Fixed-form CSI, no variadic fields.
33
+ const COLOR_SCHEME_NOTIFY_ON = "\x1b[?2031h";
34
+ const COLOR_SCHEME_NOTIFY_OFF = "\x1b[?2031l";
35
+
36
+ // Carry slack aligned with the extractor family in pty-attach.ts (payload cap
37
+ // + 4096). Our longest target is 8 bytes, so 4096 is ample even for a
38
+ // malformed no-terminator tail, and slice(-N) keeps the carry bounded.
39
+ export const OSC_QUERY_CARRY_MAX_BYTES = 4096;
40
+
41
+ const TARGETS = [OSC11_QUERY_PREFIX, COLOR_SCHEME_NOTIFY_ON, COLOR_SCHEME_NOTIFY_OFF];
42
+ const MAX_TARGET_LENGTH = Math.max(...TARGETS.map((t) => t.length));
43
+
44
+ /**
45
+ * Extract forwardable terminal query / notify sequences from a child output
46
+ * chunk. Returns the sequences (in order) and the carry: the unmatched tail
47
+ * to prepend to the next chunk. The carry is either a target prefix suffix
48
+ * (sequence split across chunks) or an unterminated OSC 11 query waiting for
49
+ * its BEL/ST terminator.
50
+ */
51
+ export function extractOscQuerySequences(input) {
52
+ const sequences = [];
53
+ let scanFrom = 0;
54
+ let carryStart = -1;
55
+ while (scanFrom < input.length) {
56
+ const osc = input.indexOf(OSC11_QUERY_PREFIX, scanFrom);
57
+ const notifyOn = input.indexOf(COLOR_SCHEME_NOTIFY_ON, scanFrom);
58
+ const notifyOff = input.indexOf(COLOR_SCHEME_NOTIFY_OFF, scanFrom);
59
+ const start = firstIndex(firstIndex(osc, notifyOn), notifyOff);
60
+ if (start < 0) break;
61
+ if (start === osc) {
62
+ const after = start + OSC11_QUERY_PREFIX.length;
63
+ const bel = input.indexOf("\x07", after);
64
+ const st = input.indexOf("\x1b\\", after);
65
+ const end = firstTerminator(bel, st);
66
+ if (!end) {
67
+ carryStart = start;
68
+ break;
69
+ }
70
+ const [endIndex, terminatorLength] = end;
71
+ const seq = input.slice(start, endIndex + terminatorLength);
72
+ // Strict form: an OSC 11 query has no payload between `?` and the
73
+ // terminator. Anything else (e.g. `\x1b]11;?<junk>\x07`) is not a
74
+ // query we forward.
75
+ if (seq === OSC11_QUERY_BEL || seq === OSC11_QUERY_ST) sequences.push(seq);
76
+ scanFrom = endIndex + terminatorLength;
77
+ } else {
78
+ // Fixed-form CSI notify switch: an indexOf hit is always complete.
79
+ const seq = start === notifyOn ? COLOR_SCHEME_NOTIFY_ON : COLOR_SCHEME_NOTIFY_OFF;
80
+ sequences.push(seq);
81
+ scanFrom = start + seq.length;
82
+ }
83
+ }
84
+ const carry = carryStart >= 0
85
+ ? input.slice(carryStart).slice(-OSC_QUERY_CARRY_MAX_BYTES)
86
+ : queryPrefixSuffix(input);
87
+ return { sequences, carry };
88
+ }
89
+
90
+ /**
91
+ * Longest suffix of `input` that is a strict prefix of one of our target
92
+ * sequences (empty when none matches). Used as the carry when every target
93
+ * occurrence in this chunk was complete.
94
+ */
95
+ function queryPrefixSuffix(input) {
96
+ const max = Math.min(input.length, MAX_TARGET_LENGTH - 1);
97
+ for (let len = max; len > 0; len--) {
98
+ const suffix = input.slice(-len);
99
+ if (TARGETS.some((t) => t.startsWith(suffix))) return suffix;
100
+ }
101
+ return "";
102
+ }
103
+
104
+ function firstIndex(a, b) {
105
+ if (a < 0) return b;
106
+ if (b < 0) return a;
107
+ return Math.min(a, b);
108
+ }
109
+
110
+ function firstTerminator(bel, st) {
111
+ if (bel < 0 && st < 0) return null;
112
+ if (bel >= 0 && (st < 0 || bel < st)) return [bel, 1];
113
+ return [st, 2];
114
+ }
115
+
116
+ // Color-scheme report expected by pi-tui's parseTerminalColorSchemeReport
117
+ // (node_modules/@earendil-works/pi-tui/dist/terminal-colors.js:19):
118
+ // /^\x1b\[\?997;(1|2)n$/ with "2" => light, "1" => dark (line 57). Note the
119
+ // report code point is 997 — the QUERY the host sends is `\x1b[?996n`.
120
+ const COLOR_SCHEME_REPORTS = {
121
+ light: "\x1b[?997;2n",
122
+ dark: "\x1b[?997;1n",
123
+ };
124
+
125
+ /**
126
+ * Map a color scheme ("light" | "dark") to the report sequence the host Pi's
127
+ * pi-tui parser consumes. Returns "" for anything else; callers skip sending
128
+ * on empty.
129
+ */
130
+ export function toColorSchemeReport(scheme) {
131
+ return Object.hasOwn(COLOR_SCHEME_REPORTS, scheme) ? COLOR_SCHEME_REPORTS[scheme] : "";
132
+ }
@@ -14,6 +14,7 @@ import { requestDashboardRender } from "../core/dashboard-render.mjs";
14
14
  import { normalizeGenericStatusText } from "../core/derive.mjs";
15
15
  import { firstSentence, truncate } from "../core/heuristics.mjs";
16
16
  import {
17
+ browseTabCompletion,
17
18
  canonicalModelRef,
18
19
  clampThinkingLevel,
19
20
  listDirectorySuggestions,
@@ -596,13 +597,24 @@ export class DashboardComponent implements Component {
596
597
  return;
597
598
  }
598
599
  if (launch.picker === "cwd" && matchesKey(data, Key.tab)) {
599
- if (launch.cwdPickerMode === "favorites" && launch.cwdSuggestions.length > 0) {
600
- const completed = launch.cwdSuggestions[launch.cwdSuggestionIndex] ?? launch.cwdSuggestions[0];
601
- launch.cwdQuery = completed;
602
- const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
603
- launch.cwdPickerMode = state.mode;
604
- launch.cwdSuggestions = state.suggestions;
605
- launch.cwdSuggestionIndex = Math.max(0, state.suggestions.indexOf(completed));
600
+ if (launch.cwdSuggestions.length > 0) {
601
+ if (launch.cwdPickerMode === "browse") {
602
+ const completion = browseTabCompletion(launch.cwdQuery, launch.cwdSuggestions, launch.cwdSuggestionIndex);
603
+ if (completion) {
604
+ launch.cwdQuery = completion.query;
605
+ const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
606
+ launch.cwdPickerMode = state.mode;
607
+ launch.cwdSuggestions = state.suggestions;
608
+ launch.cwdSuggestionIndex = Math.max(0, state.suggestions.indexOf(completion.completed));
609
+ }
610
+ } else {
611
+ const completed = launch.cwdSuggestions[launch.cwdSuggestionIndex] ?? launch.cwdSuggestions[0];
612
+ launch.cwdQuery = completed;
613
+ const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
614
+ launch.cwdPickerMode = state.mode;
615
+ launch.cwdSuggestions = state.suggestions;
616
+ launch.cwdSuggestionIndex = Math.max(0, state.suggestions.indexOf(completed));
617
+ }
606
618
  }
607
619
  return;
608
620
  }
@@ -1583,7 +1595,7 @@ export class DashboardComponent implements Component {
1583
1595
  } else {
1584
1596
  lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
1585
1597
  lines.push("");
1586
- lines.push(t.fg("dim", "type to filter folders · enter choose · esc back"));
1598
+ lines.push(t.fg("dim", "type to filter folders · tab complete · enter choose · esc back"));
1587
1599
  }
1588
1600
  } else if (launch.picker === "model") {
1589
1601
  lines.push(t.fg("warning", `model› ${singleLineInput(launch.modelQuery)}${cursor()}`));
@@ -11,6 +11,7 @@ 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 { extractOscQuerySequences, toColorSchemeReport } from "../core/terminal-query-sequences.mjs";
14
15
  import { clampInt, parseMouseInputChunk, resolveWheelLines, scrollViewportTop, selectionDragScrollLines } from "../core/pty-scroll.mjs";
15
16
 
16
17
  export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null };
@@ -170,6 +171,7 @@ export class PtyAttachComponent implements Component {
170
171
  private jiggleStartTimer: ReturnType<typeof setTimeout> | null = null;
171
172
  private osc52Carry = "";
172
173
  private passthroughCarry = "";
174
+ private oscQueryCarry = "";
173
175
  private readonly connectStartedAt = Date.now();
174
176
  // Set once the first socket connect succeeds; drives the reconnect give-up
175
177
  // policy (short window after a live session dies, long window during cold
@@ -214,6 +216,10 @@ export class PtyAttachComponent implements Component {
214
216
  private attaching = true;
215
217
  private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
216
218
  private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
219
+ // Issue #128 D2: unsubscriber for the color-scheme bridge registered at
220
+ // attach settle; nulled on every terminal path so the TUI never keeps a
221
+ // listener pointing at a dead attach surface.
222
+ private colorSchemeBridgeUnsubscribe: (() => void) | null = null;
217
223
  private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
218
224
  // Force a single full-clear on the first paint so the prior session/dashboard can't
219
225
  // ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
@@ -287,6 +293,18 @@ export class PtyAttachComponent implements Component {
287
293
  this.send({ type: "input", data });
288
294
  return;
289
295
  }
296
+ if (matchesKey(data, "ctrl+\\")) {
297
+ // Unconditional, terminal-independent escape (issue #126): 0x1c is a
298
+ // single raw ASCII byte every terminal forwards unchanged in raw
299
+ // mode — no modifier-encoding dependency like Ctrl+Left (macOS
300
+ // Terminal.app never emits it, WezTerm consumes it for tab
301
+ // switching). Only caveat (documented in README): layouts where
302
+ // `\` sits on AltGr may never produce this byte; the escape ladder
303
+ // still covers those. matchesKey also covers the kitty CSI-u and
304
+ // modifyOtherKeys encodings of the same chord.
305
+ this.detach();
306
+ return;
307
+ }
290
308
  if (matchesKey(data, Key.ctrl("left"))) {
291
309
  // Explicit detach chord (issue #89): single ← is gated on editor state
292
310
  // (it doubles as cursor-left inside a non-empty draft), so a user with
@@ -340,7 +358,7 @@ export class PtyAttachComponent implements Component {
340
358
  }
341
359
  const header =
342
360
  this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
343
- this.theme.fg("muted", `${this.status} · click opens links · dblclick/drag selects+copies · Ctrl+← detach`);
361
+ this.theme.fg("muted", `${this.status} · opens links · drag selects+copies · Ctrl+←/Ctrl+\\ detach`);
344
362
  return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
345
363
  }
346
364
 
@@ -356,7 +374,7 @@ export class PtyAttachComponent implements Component {
356
374
  out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
357
375
  out.push(center(this.theme.fg("muted", detail), width));
358
376
  out.push("");
359
- out.push(center(this.theme.fg("dim", "Ctrl+← to detach"), width));
377
+ out.push(center(this.theme.fg("dim", "Ctrl+←/Ctrl+\\ to detach"), width));
360
378
  while (out.length < height) out.push("");
361
379
  return out.slice(0, height);
362
380
  }
@@ -568,6 +586,56 @@ export class PtyAttachComponent implements Component {
568
586
  // buffer, instead of diffing banner lines into buffer lines.
569
587
  this.scheduleRender(true);
570
588
  this.startDesyncProbe();
589
+ // Issue #128: the settle transition is the single point where `attaching`
590
+ // flips false (never re-armed), so both hooks run at most once per attach.
591
+ this.attachColorSchemeBridge();
592
+ this.replayBackgroundQuery();
593
+ }
594
+
595
+ /** Issue #128 D2: the local pi-tui consumes the child's color-scheme
596
+ * traffic (996 queries / 2031 notifications forwarded by D1) — reports
597
+ * never reach the child. onTerminalColorSchemeChange fires from that same
598
+ * consumption point, so the bridge repackages each event as a 997 report
599
+ * on the child pty, where the child's pi-tui parser consumes it. Once per
600
+ * attach (settle is single-shot + explicit guard); kill switch skips it. */
601
+ private attachColorSchemeBridge(): void {
602
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
603
+ if (this.colorSchemeBridgeUnsubscribe) return;
604
+ try {
605
+ this.colorSchemeBridgeUnsubscribe = this.tui.onTerminalColorSchemeChange((scheme) => {
606
+ const data = toColorSchemeReport(scheme);
607
+ if (data) this.send({ type: "input", data });
608
+ });
609
+ } catch {
610
+ /* best-effort: an unbridgeable TUI keeps pre-#128 behavior */
611
+ }
612
+ }
613
+
614
+ /** Called on every terminal path (close() covers detach/dispose; the exit
615
+ * branch calls done() without close(), so it unsubscribes explicitly). */
616
+ private detachColorSchemeBridge(): void {
617
+ const unsubscribe = this.colorSchemeBridgeUnsubscribe;
618
+ this.colorSchemeBridgeUnsubscribe = null;
619
+ if (!unsubscribe) return;
620
+ try {
621
+ unsubscribe();
622
+ } catch {
623
+ /* best-effort: a stale listener must not block teardown */
624
+ }
625
+ }
626
+
627
+ /** Issue #128 D3: the child probed OSC 11 at spawn, before any attach
628
+ * client existed, so its background-color answer was lost. Re-ask the REAL
629
+ * terminal once now; the reply travels back through the existing input
630
+ * passthrough (handleInput fallback) to the child. Silent on failure — a
631
+ * missing answer keeps the pre-#128 fallback theme. */
632
+ private replayBackgroundQuery(): void {
633
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
634
+ try {
635
+ this.tui.terminal.write("\x1b]11;?\x07");
636
+ } catch {
637
+ /* best-effort: enhancement, never critical */
638
+ }
571
639
  }
572
640
 
573
641
  private startDesyncProbe(): void {
@@ -1117,6 +1185,10 @@ export class PtyAttachComponent implements Component {
1117
1185
  this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
1118
1186
  } else if (msg.type === "exit") {
1119
1187
  this.status = "host exited";
1188
+ // The exit path calls done() directly (host disposes later);
1189
+ // drop the #128 bridge now so A7 symmetry doesn't depend on
1190
+ // the host honoring dispose().
1191
+ this.detachColorSchemeBridge();
1120
1192
  this.done({ action: "closed", exitCode: msg.exitCode ?? null });
1121
1193
  } else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
1122
1194
  if (shouldScheduleAttachRenderForMessage(msg.type)) needsRender = true;
@@ -1228,6 +1300,15 @@ export class PtyAttachComponent implements Component {
1228
1300
  this.passthroughCarry = carry;
1229
1301
  toWrite.push(...sequences);
1230
1302
  }
1303
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES !== "0") {
1304
+ // Issue #128 D1: forward the child's terminal capability queries
1305
+ // (OSC 11 background probe, 2031 scheme-notify switch) to the real
1306
+ // terminal; set-forms and kitty/DA negotiation stay excluded (see
1307
+ // extractOscQuerySequences).
1308
+ const { sequences, carry } = extractOscQuerySequences(this.oscQueryCarry + data);
1309
+ this.oscQueryCarry = carry;
1310
+ toWrite.push(...sequences);
1311
+ }
1231
1312
  for (const seq of toWrite) {
1232
1313
  try {
1233
1314
  this.tui.terminal.write(seq);
@@ -1322,6 +1403,7 @@ export class PtyAttachComponent implements Component {
1322
1403
  }
1323
1404
  this.closed = true;
1324
1405
  this.imeCoalesceUninstall?.();
1406
+ this.detachColorSchemeBridge();
1325
1407
  this.attachClient.close();
1326
1408
  this.jiggleRetry.restoreAndStop();
1327
1409
  this.disableMouseScroll();