import { visibleWidth } from "@earendil-works/pi-tui"; import { describe, expect, it, vi } from "vitest"; import type { RequestPermissionOptions, UnattributedDecision, } from "#src/authority/permission-dialog"; import { type PermissionPromptUi, type PermissionPromptView, presentInlinePermissionPrompt, requestPermissionDecision, } from "#src/authority/permission-prompt-component"; import { DEFAULT_RENDER_BUDGET } from "#src/presentation/dialog-renderer"; import type { PromptPayload } from "#src/presentation/prompt-payload"; import { makePromptPayload } from "#test/helpers/prompt-details-fixtures"; import { makePromptPreferences } from "#test/helpers/prompt-view-fixtures"; // ── Fake TUI view harness ──────────────────────────────────────────────────── function plainTheme() { return { fg(_color: string, text: string) { return text; }, bg(_color: string, text: string) { return text; }, }; } interface CapturedComponent { render(width: number): string[]; handleInput(data: string): void; } type PromptFactory = ( tui: { requestRender: () => void }, theme: ReturnType, keybindings: { matches(data: string, action: string): boolean }, done: (decision: UnattributedDecision) => void, ) => CapturedComponent; /** Pi's default binding for the `app.tools.expand` action. */ const CTRL_O = "\u000f"; function makeFakeView( doublePressToConfirm: boolean, expandKey = CTRL_O, budget = DEFAULT_RENDER_BUDGET, ) { const captured: { component?: CapturedComponent; options?: unknown; } = {}; let toolsExpanded = false; const getToolsExpanded = vi.fn(() => toolsExpanded); const setToolsExpanded = vi.fn((expanded: boolean) => { toolsExpanded = expanded; }); const custom = ( factory: PromptFactory, options: unknown, ): Promise => { captured.options = options; return new Promise((resolve) => { captured.component = factory( { requestRender: vi.fn() }, plainTheme(), { matches: (data, action) => action === "app.tools.expand" && data === expandKey, }, resolve, ); }); }; const view = makeView( "tui", doublePressToConfirm, { select: vi.fn(), input: vi.fn(), custom, getToolsExpanded, setToolsExpanded, }, budget, ); return { view, captured, getToolsExpanded, setToolsExpanded }; } /** * The view the dispatcher and the inline component take. * * Typed as `PermissionPromptView` so a field added to it is a compile error * here; the cast is confined to the `ui` double, whose generic `custom` a * plain `vi.fn()` cannot satisfy. */ function makeView( mode: PermissionPromptView["mode"], doublePressToConfirm: boolean, ui: unknown, budget = DEFAULT_RENDER_BUDGET, ): PermissionPromptView { return { mode, ui: ui as PermissionPromptUi, ...makePromptPreferences({ doublePressToConfirm, budget }), }; } const ARROW_DOWN = "\u001b[B"; const ENTER = "\r"; const ESCAPE = "\u001b"; /** How the terminal delivers a paste: one chunk, markers included. */ function paste(content: string): string { return `\u001b[200~${content}\u001b[201~`; } /** A path ask; `path : /repo/secret.txt` is its decision-relevant line. */ function makeAsk(value = "/repo/secret.txt"): PromptPayload { return makePromptPayload({ kind: "path", request: { ...makePromptPayload().request, surface: "path", toolName: "read", value, matchedPattern: null, }, }); } const ASK = makeAsk(); /** Title, blank separator, four decision options, blank, hint. */ const DECISION_CHROME_ROWS = 8; async function runPrompt( doublePressToConfirm: boolean, keys: string[], options?: RequestPermissionOptions, ): Promise { const { view, captured } = makeFakeView(doublePressToConfirm); const promise = presentInlinePermissionPrompt( view, "Permission Required", ASK, options, ); for (const key of keys) { captured.component?.handleInput(key); } return promise; } // ── Tests ───────────────────────────────────────────────────────────────── describe("presentInlinePermissionPrompt", () => { it("renders inline (not as an overlay) with the request facts and hotkey labels", () => { const { view, captured } = makeFakeView(true); void presentInlinePermissionPrompt(view, "Permission Required", ASK); expect(captured.options).toEqual({ overlay: false }); const text = captured.component?.render(80).join("\n") ?? ""; expect(text).toContain("tool : read"); expect(text).toContain("path : /repo/secret.txt"); expect(text).toContain("Yes"); expect(text).toContain("No, provide reason"); expect(text).toContain("y"); expect(text).toContain("r"); }); it("clips every rendered line to the terminal width", () => { const { view, captured } = makeFakeView(true); void presentInlinePermissionPrompt( view, "Permission Required", makeAsk(`~/.pi/agent/sessions/${"a".repeat(300)}`), ); const width = 40; const lines = captured.component?.render(width) ?? []; expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(visibleWidth(line)).toBeLessThanOrEqual(width); } }); describe("double-press to confirm (enabled)", () => { it("resolves approved on y, y", async () => { expect(await runPrompt(true, ["y", "y"])).toEqual({ approved: true, state: "approved", }); }); it("does not resolve on a single armed press", async () => { const { view, captured } = makeFakeView(true); const promise = presentInlinePermissionPrompt( view, "Permission Required", ASK, ); let settled = false; void promise.then(() => { settled = true; }); captured.component?.handleInput("y"); await Promise.resolve(); expect(settled).toBe(false); const text = captured.component?.render(80).join("\n") ?? ""; expect(text).toContain("Press y again to approve."); }); it("resolves denied on n, n", async () => { expect(await runPrompt(true, ["n", "n"])).toEqual({ approved: false, state: "denied", }); }); }); describe("double-press to confirm (disabled)", () => { it("resolves approved on a single y", async () => { expect(await runPrompt(false, ["y"])).toEqual({ approved: true, state: "approved", }); }); }); describe("navigation and escape", () => { it("resolves the highlighted option on enter", async () => { // y -> s -> n, then enter expect(await runPrompt(true, [ARROW_DOWN, ARROW_DOWN, ENTER])).toEqual({ approved: false, state: "denied", }); }); it("denies on escape at the decision step", async () => { expect(await runPrompt(true, [ESCAPE])).toEqual({ approved: false, state: "denied", }); }); it("never decides on a stray paste at the decision step", async () => { const { view, captured } = makeFakeView(false); const promise = presentInlinePermissionPrompt(view, "Title", ASK); let settled = false; void promise.then(() => { settled = true; }); captured.component?.handleInput(paste("y")); captured.component?.handleInput(paste("some copied text")); await Promise.resolve(); expect(settled).toBe(false); captured.component?.handleInput("n"); expect(await promise).toEqual({ approved: false, state: "denied" }); }); }); describe("deny with reason", () => { it("collects a typed reason and resolves denied_with_reason", async () => { const decision = await runPrompt(false, ["r", "n", "o", "p", "e", ENTER]); expect(decision).toEqual({ approved: false, state: "denied_with_reason", denialReason: "nope", }); }); it("rejects an empty reason and shows an error, then accepts a real one", async () => { const { view, captured } = makeFakeView(false); const promise = presentInlinePermissionPrompt(view, "T", ASK); captured.component?.handleInput("r"); // opens reason step captured.component?.handleInput(ENTER); // empty submit -> rejected const text = captured.component?.render(80).join("\n") ?? ""; expect(text).toContain("A reason is required."); captured.component?.handleInput("x"); captured.component?.handleInput(ENTER); expect(await promise).toEqual({ approved: false, state: "denied_with_reason", denialReason: "x", }); }); it("supports backspace while editing the reason", async () => { const decision = await runPrompt(false, [ "r", "a", "b", "\u007f", // backspace removes "b" ENTER, ]); expect(decision).toEqual({ approved: false, state: "denied_with_reason", denialReason: "a", }); }); it("accepts pasted text into the reason", async () => { expect( await runPrompt(false, ["r", paste("pasted text"), ENTER]), ).toEqual({ approved: false, state: "denied_with_reason", denialReason: "pasted text", }); }); it("flattens a multi-line paste into one readable line", async () => { expect( await runPrompt(false, [ "r", paste("denied because it touches\n~/.ssh"), ENTER, ]), ).toEqual({ approved: false, state: "denied_with_reason", denialReason: "denied because it touches ~/.ssh", }); }); it("keeps a pasted reason on one row, however long it is", () => { const { view, captured } = makeFakeView(false); void presentInlinePermissionPrompt(view, "Title", ASK); captured.component?.handleInput("r"); const before = captured.component?.render(40) ?? []; // "q" appears nowhere else in this render; "x" would match `secret.txt`. captured.component?.handleInput(paste("q".repeat(500))); const after = captured.component?.render(40) ?? []; expect(after).toHaveLength(before.length); expect(after.join("\n")).toContain("qqq"); for (const line of after) { expect(visibleWidth(line)).toBeLessThanOrEqual(40); } }); it("drops the expand key instead of typing it into the reason", async () => { const { view, captured, setToolsExpanded } = makeFakeView(false); const promise = presentInlinePermissionPrompt(view, "Title", ASK); captured.component?.handleInput("r"); captured.component?.handleInput("a"); captured.component?.handleInput(CTRL_O); captured.component?.handleInput(ENTER); expect(await promise).toEqual({ approved: false, state: "denied_with_reason", denialReason: "a", }); expect(setToolsExpanded).not.toHaveBeenCalled(); }); it("navigates back to the decision step on escape from the reason step", async () => { // r opens reason, esc returns to decision, then n deny expect(await runPrompt(false, ["r", ESCAPE, "n"])).toEqual({ approved: false, state: "denied", }); }); }); describe("requestPermissionDecision dispatch", () => { it("renders the inline dialog in TUI mode", async () => { const { view, captured } = makeFakeView(true); const promise = requestPermissionDecision(view, "Title", ASK); expect(captured.component).toBeDefined(); captured.component?.handleInput("y"); captured.component?.handleInput("y"); expect(await promise).toEqual({ approved: true, state: "approved", decidedBy: { kind: "user", via: "dialog" }, }); }); it("bounds a pathological forwarded ask instead of filling the viewport", () => { const { view, captured } = makeFakeView(true); const body = Array.from( { length: 200 }, () => "- a finding line about some module in the codebase", ).join("\n"); const command = `@'\n${body}\n'@ | Out-File -FilePath report.md`; void presentInlinePermissionPrompt( view, "Permission Required (Subagent)", makePromptPayload({ kind: "forwarded", request: { ...makePromptPayload().request, requester: { agentName: "scout", forwarded: true, sessionId: "abc123", }, surface: "bash", toolName: null, value: command, matchedPattern: null, }, evidence: [{ label: "requested", text: command, detail: null }], }), ); const lines = captured.component?.render(120) ?? []; // The same ask renders 205 rows through the unbounded flat message. expect(lines.length).toBeLessThanOrEqual( DEFAULT_RENDER_BUDGET.maxRows + DECISION_CHROME_ROWS, ); expect(lines).toContain("subagent : scout · session abc123"); }); it("falls back to the select flow outside TUI mode", async () => { const custom = vi.fn(); const select = vi.fn().mockResolvedValue("Yes"); const view = makeView("rpc", true, { select, input: vi.fn(), custom, }); const decision = await requestPermissionDecision(view, "Title", ASK); expect(custom).not.toHaveBeenCalled(); expect(select).toHaveBeenCalledWith( "Title\ntool : read\npath : /repo/secret.txt", expect.any(Array), ); expect(decision).toEqual({ approved: true, state: "approved", decidedBy: { kind: "user", via: "select" }, }); }); it("attributes a denial to the surface the human answered on", async () => { const select = vi.fn().mockResolvedValue("No"); const view = makeView("rpc", true, { select, input: vi.fn(), custom: vi.fn(), }); const decision = await requestPermissionDecision(view, "Title", ASK); // The denial is the human's, and which surface they used is what // separates "the operator declined" from "a prompt they never saw". expect(decision).toEqual({ approved: false, state: "denied", decidedBy: { kind: "user", via: "select" }, }); }); }); describe("approve-for-session scope (forwarded asks)", () => { const options: RequestPermissionOptions = { sessionScope: { subagentLabel: "This subagent only", servingSessionLabel: "The whole session", }, }; it("commits the subagent scope by default", async () => { expect(await runPrompt(false, ["s", ENTER], options)).toEqual({ approved: true, state: "approved_for_session", }); }); it("commits the serving-session scope when the second option is chosen", async () => { expect(await runPrompt(false, ["s", ARROW_DOWN, ENTER], options)).toEqual( { approved: true, state: "approved_for_serving_session" }, ); }); }); describe("tool expansion", () => { const scopeOptions: RequestPermissionOptions = { sessionScope: { subagentLabel: "This subagent only", servingSessionLabel: "The whole session", }, }; it("toggles tool expansion without settling the decision", async () => { const { view, captured, getToolsExpanded, setToolsExpanded } = makeFakeView(true); const promise = presentInlinePermissionPrompt(view, "Title", ASK); let settled = false; void promise.then(() => { settled = true; }); captured.component?.handleInput(CTRL_O); await Promise.resolve(); expect(setToolsExpanded).toHaveBeenNthCalledWith(1, true); expect(settled).toBe(false); captured.component?.handleInput(CTRL_O); await Promise.resolve(); expect(setToolsExpanded).toHaveBeenNthCalledWith(2, false); expect(settled).toBe(false); expect(getToolsExpanded).toHaveBeenCalledTimes(2); captured.component?.handleInput("y"); captured.component?.handleInput("y"); // Unattributed: the inline component states the outcome, and the // dispatcher above it names the surface the human answered on. expect(await promise).toEqual({ approved: true, state: "approved" }); }); it("toggles during the scope step without committing the grant", async () => { const { view, captured, setToolsExpanded } = makeFakeView(false); const promise = presentInlinePermissionPrompt( view, "Title", ASK, scopeOptions, ); captured.component?.handleInput("s"); // decision -> scope captured.component?.handleInput(CTRL_O); expect(setToolsExpanded).toHaveBeenNthCalledWith(1, true); captured.component?.handleInput(ENTER); expect(await promise).toEqual({ approved: true, state: "approved_for_session", }); }); it("expands the dialog to the complete request and back", () => { const { view, captured, setToolsExpanded } = makeFakeView(true, CTRL_O, { maxRows: 24, fieldMaxWidth: 10, }); void presentInlinePermissionPrompt( view, "Title", makeAsk("/repo/a/very/long/secret.txt"), ); const bounded = captured.component?.render(120) ?? []; expect(bounded).toContain("path : /repo/a/ve…"); expect(bounded.at(-1)).toContain("ctrl+o full request"); captured.component?.handleInput(CTRL_O); const expanded = captured.component?.render(120) ?? []; expect(expanded).toContain("path : /repo/a/very/long/secret.txt"); expect(expanded.at(-1)).toContain("ctrl+o collapse"); // The host's own tool expansion still follows the same keystroke (#642). expect(setToolsExpanded).toHaveBeenCalledWith(true); captured.component?.handleInput(CTRL_O); expect(captured.component?.render(120)).toEqual(bounded); }); it("advertises the affordance only when the render left something out", () => { const { view, captured } = makeFakeView(true); void presentInlinePermissionPrompt(view, "Title", ASK); expect(captured.component?.render(120).at(-1)).not.toContain("ctrl+o"); }); it("does not intercept the expand key while a denial reason is typed", async () => { // Bound to a printable key on purpose: the default Ctrl+O is a control // character the reason editor rejects anyway, so it cannot discriminate. const { view, captured, setToolsExpanded } = makeFakeView(false, "e"); const promise = presentInlinePermissionPrompt(view, "Title", ASK); captured.component?.handleInput("r"); // decision -> reason captured.component?.handleInput("e"); // typed literally, not an app action captured.component?.handleInput(ENTER); expect(await promise).toEqual({ approved: false, state: "denied_with_reason", denialReason: "e", }); expect(setToolsExpanded).not.toHaveBeenCalled(); }); }); });