@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/docs/superpowers/plans/2026-09-01-attach-detach-gate-cursor-anchor.md +284 -0
- package/docs/superpowers/plans/2026-09-02-attach-detach-editor-state.md +722 -0
- package/docs/superpowers/plans/2026-09-03-detach-gate-glyph-fallback.md +146 -0
- package/docs/superpowers/specs/2026-09-01-attach-detach-gate-cursor-anchor-design.md +78 -0
- package/docs/superpowers/specs/2026-09-02-attach-detach-editor-state-design.md +120 -0
- package/docs/superpowers/specs/2026-09-03-detach-gate-glyph-fallback-design.md +99 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +12 -2
- package/src/core/editor-state-reporter.mjs +102 -0
- package/src/core/pty-input.mjs +32 -0
- package/src/index.ts +12 -1
- package/src/ui/pty-attach.ts +55 -7
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 });
|
package/src/ui/pty-attach.ts
CHANGED
|
@@ -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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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")
|
|
942
|
-
|
|
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"}`;
|