@zhuxixi/pi-agent-board 0.5.1 → 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/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 });
@@ -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";
@@ -158,6 +158,9 @@ export class PtyAttachComponent implements Component {
158
158
  private pendingClickTimer: ReturnType<typeof setTimeout> | null = null;
159
159
  private lastClickPoint: MousePoint | null = null;
160
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;
161
164
  // Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
162
165
  // loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
163
166
  // the previous screen visible.
@@ -246,7 +249,7 @@ export class PtyAttachComponent implements Component {
246
249
  // escape unconditionally — the view must always be exitable, even
247
250
  // after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
248
251
  // a detach key — it passes through to Pi (tui.editor.jumpForward).
249
- if (shouldEscapeAttach(this.connected, this.childInputLooksEmpty())) {
252
+ if (shouldEscapeAttach(this.connected, resolveEditorEmpty(this.editorEmpty, this.childInputLooksEmpty()))) {
250
253
  this.detach();
251
254
  return;
252
255
  }
@@ -320,12 +323,53 @@ export class PtyAttachComponent implements Component {
320
323
  this.done({ action: "detached" });
321
324
  }
322
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
+
323
345
  private childInputLooksEmpty(): boolean {
324
346
  if (!this.receivedOutput) return true;
325
347
  const active = this.term.buffer.active;
326
- if (typeof active.cursorY !== "number") return false;
327
- const line = active.getLine(active.baseY + active.cursorY)?.translateToString(true) ?? "";
328
- return isProbablyEmptyPiInputLine(line);
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;
329
373
  }
330
374
 
331
375
  private connect(): void {
@@ -938,8 +982,12 @@ export class PtyAttachComponent implements Component {
938
982
  this.checkClearSequence(msg.data);
939
983
  continue;
940
984
  }
941
- if (msg.type === "hello" || msg.type === "status") this.status = "attached";
942
- else if (msg.type === "exit") {
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") {
943
991
  this.status = "host exited";
944
992
  this.done({ action: "closed", exitCode: msg.exitCode ?? null });
945
993
  } else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;