fix(maestro): align TUI and web shortcuts

Spawn was `s`/`S` in the TUI and `n`/`N` in the web client; the TUI now
uses `n`/`N` too. The web sidebar gains `c`, `r` and `x` — collapse,
rename, kill — which the TUI already bound.

docs/SIDEBAR.md now carries the shared table, so a new binding has one
place to land on both clients.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-09-03 08:25:35 +01:00
co-authored by Claude Opus 5
parent b36d571b21
commit b1527f5fc1
8 changed files with 1744 additions and 12 deletions
+1645
View File
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -566,6 +566,25 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
// keyboard stays here while j/k runs.
treeHost.tabIndex = -1;
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
const row = rowFor(navKey);
const id = row?.dataset.sessionId;
if (!row || !id) return undefined;
const session = store.snapshot().sessions.get(id);
return session ? { row, session } : undefined;
};
/** `c`: collapse or expand the highlighted folder, the TUI's
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
* the key does nothing rather than toggling some other folder. */
const toggleHighlightedFolder = () => {
const row = rowFor(navKey);
if (!row || !row.classList.contains("tree-folder-header")) return;
row.click();
applyNav();
};
treeHost.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.altKey || e.metaKey) return;
// The inline rename field lives inside a row and owns its own keys.
@@ -574,7 +593,14 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
else if (e.key === "k" || e.key === "ArrowUp") moveNav(-1);
else if (e.key === "Enter") activateNav();
else if (e.key === "Tab") document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT));
else return;
else if (e.key === "c") toggleHighlightedFolder();
else if (e.key === "x") {
const hit = highlightedSession();
if (hit) void requestKill(hit.session, sessionName(hit.session));
} else if (e.key === "r") {
const hit = highlightedSession();
if (hit) beginRename(hit.row, hit.session);
} else return;
e.preventDefault();
});
+3
View File
@@ -31,6 +31,9 @@ export const SHORTCUTS: ShortcutGroup[] = [
{ keys: ["j"], description: "Highlight the next session" },
{ keys: ["k"], description: "Highlight the previous session" },
{ keys: ["Enter"], description: "Open the highlighted session, or expand a folder" },
{ keys: ["c"], description: "Collapse or expand the highlighted folder" },
{ keys: ["r"], description: "Rename the highlighted session" },
{ keys: ["x"], description: "Kill the highlighted session" },
{ keys: ["Tab"], description: "Send the keyboard to the pane" },
{ keys: ["Esc"], description: "Hand the keyboard back to the session list, from the pane" },
],
+37
View File
@@ -117,6 +117,43 @@ describe("sidebar keyboard navigation", () => {
expect(asked).toBe(1);
});
it("c collapses and expands the highlighted folder", () => {
const { el } = twoFolders();
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
header.click(); // collapse /one, so the walk stops on its header
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "c");
// Expanded: the card it was hiding is back in the walk.
expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull();
});
it("r renames the highlighted session in place", () => {
const { el } = twoFolders();
press(tree(el), "j");
press(tree(el), "r");
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
expect(field).not.toBeNull();
// Escape settles the rename, so the module-level "a row is being renamed"
// flag doesn't leak into the next test.
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
it("x kills the highlighted session", async () => {
const { el } = twoFolders();
const killed: string[] = [];
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
killed.push(`${init?.method ?? "GET"} ${String(input)}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof window.fetch;
press(tree(el), "j");
press(tree(el), "x");
await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
});
it("leaves the keyboard alone while a row is being renamed", () => {
const { el } = twoFolders();
press(tree(el), "j");