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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user