feat: pet chat window replaces the Sagi panel; Windows pane attach no longer crashes the server

- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo
  status strip, Needs you, Mute / Clear alerts); SagiPanel removed.
- Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu).
- pty-attach: resize/write after a Windows attach exited, and node-pty's pipe
  errors, no longer throw out of the WebSocket handler.
- restart-helper: the server started by an update logs to
  data/agenthub-server.log instead of discarding its output.
This commit is contained in:
2026-10-16 10:27:22 +07:00
parent a58e95a957
commit f460fff406
16 changed files with 734 additions and 555 deletions
+53 -1
View File
@@ -207,6 +207,35 @@ describe("panePaste", () => {
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
});
it("reads Claude's unnumbered trust prompt and picks by arrows", () => {
vi.useFakeTimers();
const m = parsePaneMenu(TRUST)!;
expect(m.options.map((o) => o.label)).toEqual(["No, exit", "Yes, I trust this folder"]);
expect(m.options.every((o) => o.n === null)).toBe(true);
expect(m.cursor).toBe(0);
expect(m.question.split("\n")[0]).toBe("Accessing workspace:");
expect(m.question).toContain("Security guide");
const keys: string[] = [];
const off = registerPane(
"agenthub-lane-12",
() => {},
() => true,
(k) => keys.push(k)
);
expect(chooseRunOption("agenthub-lane-12", m, 1)).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\r"]);
off();
vi.useRealTimers();
});
it("finds no unnumbered menu in Claude's input box or a wrapped prompt above it", () => {
expect(parsePaneMenu(CLAUDE)).toBeNull();
expect(
parsePaneMenu(["❯ a long prompt that", " wrapped onto two rows", "", RULE, "❯\u00a0", RULE])
).toBeNull();
});
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
// No cursor on the list.
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
@@ -313,12 +342,35 @@ const CODEX_APPROVAL = [
" enteresc",
];
// Claude Code's folder-trust prompt: no numbers, its footer dim (left out).
const TRUST = [
RULE,
" Accessing workspace:",
"",
" /home/me",
"",
" Quick safety check: Is this a project you created or one you trust?",
"",
" Security guide",
"",
" ❯ No, exit",
" Yes, I trust this folder",
"",
"",
];
describe("MENU_CURSOR_ROW", () => {
// The chat's 700 ms poll reads the screen cell by cell only when a row
// matches it, so every menu parsePaneMenu finds must have such a row.
it("matches a row of every menu, and no row of an idle prompt", () => {
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) {
for (const menu of [
ASK,
MULTI,
PERMISSION,
CODEX_APPROVAL,
[...TRUST, " Enter to confirm · Esc to cancel"],
]) {
expect(parsePaneMenu(menu)).not.toBeNull();
expect(has(menu)).toBe(true);
}
+38 -3
View File
@@ -176,8 +176,9 @@ export type PaneMenu = {
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
@@ -224,7 +225,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
}
}
const numbered = options.filter((o) => o.n !== null);
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? ""))
return parseBareMenu(r.slice(0, end + 1));
if (numbered.some((o, i) => o.n !== i + 1)) return null;
// The question: the block of text right above the first option.
let q = top - 1;
@@ -241,6 +243,39 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
};
}
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
* `❯ No, exit` over ` Yes, I trust this folder`), or null. Its rows share the
* cursor row's text column, and nothing under them is a rule — Claude's input
* box draws one above and below its `❯` row, so neither the box nor a prompt
* in the history above it reads as a menu. Picked by arrows and Enter. */
function parseBareMenu(r: string[]): PaneMenu | null {
let at = -1;
for (let i = r.length - 1; i >= 0 && at < 0; i--) if (BARE_CURSOR.test(r[i] ?? "")) at = i;
if (at < 0 || RULE_ROW.test(r[at - 1] ?? "") || r.slice(at + 1).some((l) => RULE_ROW.test(l)))
return null;
const m = BARE_CURSOR.exec(r[at]!)!;
const indent = " ".repeat((m[1] ?? "").length + 2);
const sibling = (l = "") => l.startsWith(indent) && /\S/.test(l[indent.length] ?? " ");
let top = at;
while (top > 0 && sibling(r[top - 1])) top--;
let last = at;
while (last < r.length - 1 && sibling(r[last + 1])) last++;
if (last === top) return null;
const options = r.slice(top, last + 1).map((l, i) => ({
n: null,
label: top + i === at ? (m[2] ?? "").trim() : l.trim(),
hint: "",
checked: null,
}));
// The question: the paragraphs above, up to the dialog's top rule.
const lines: string[] = [];
for (let q = top - 1; q >= 0 && !RULE_ROW.test(r[q]!) && lines.length < 8; q--)
if (r[q]) lines.unshift(r[q]!.trim());
return { question: lines.join("\n"), options, cursor: at - top, multi: false, typeAt: -1 };
}
/** The "Type something" row. Text typed into it replaces its label, and it is
* still the last numbered row above "Chat about this". */
function typeRow(options: PaneMenuOption[]): number {