// PermissionPopup -- raised above the input bar when the model wants to run a
// command or change a file, OR when the model is asking a question (the ask
// tool). Two modes:
// • permission: Allow / Always allow / Deny only — a permission is a yes/no
// verdict, so there is no "type your answer" row.
// • question: the model's question + its provided options, plus a "Type your
// answer…" row — questions may legitimately need an answer that is not one
// of the offered options. Typing opens the inline editor in this popup —
// no separate overlay ever opens for typing.
import { createSignal, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { palette } from "../theme.ts";
import {
permission, getSession,
questionOpen, questionText, setQuestionText, openQuestion, closeQuestion,
setAutoPerm,
} from "../store.ts";
const ui = palette("loom");
const FIT = 54;
function fit(s: string, n = FIT) {
const flat = String(s || "").replace(/\s+/g, " ").trim();
return flat.length <= n ? flat : flat.slice(0, Math.max(1, n - 1)) + "\u2026";
}
export function PermissionPopup() {
// Keyed Show: every request is a NEW object, so the inner prompt remounts
// with fresh selection/custom state (a plain Show kept the previous answer).
return (
{() => }
);
}
function PermissionPrompt() {
const pr = permission()!;
const isQ = !!pr.isQuestion;
const isStart = !!pr.sessionStart;
const opts = isQ && pr.options && pr.options.length ? pr.options : [];
// Row index: 0..opts.length-1 are the question options; the last row is
// "Type your answer…" (question mode) — or 0 Allow · 1 Always · 2 Deny ·
// 3 Allow-all-in-session (bash only). The session-start prompt is a
// question with exactly its two options and no free-answer row.
const maxSel = isStart ? opts.length - 1 : isQ ? opts.length : (pr.tool === "bash" ? 3 : 2);
const [sel, setSel] = createSignal(0);
const custom = () => questionOpen();
const customText = () => questionText();
// Note: the question state lives in the STORE (not in this component) on
// purpose — the reconciler remounts this subtree on signal changes, and a
// mount-time reset would wipe a typed answer mid-keystroke.
const answerWith = (text: string) => {
const p = permission();
if (!p) return;
closeQuestion();
const t = String(text || "").trim();
if (!t) return;
p.resolve(true, t); // questions: any text is the answer
};
const submitCustom = () => answerWith(customText());
// Mouse: rows are click-to-select + click-to-act (same action as Enter on
// the highlighted row), so the popup is fully reachable with the mouse.
const execSel = (i: number) => {
const p = permission()!;
if (isQ) {
if (isStart) {
if (i === 0) {
// "Allow all commands" — session-wide auto-approve (Shift+Tab
// toggles it off/on too).
try { getSession().permissions.setAuto(true); } catch {}
setAutoPerm(true);
p.resolve(true, "allow");
return;
}
p.resolve(false, "ask");
return;
}
if (i >= opts.length) { openQuestion(""); return; }
answerWith(opts[i]);
return;
}
if (i === 0) { p.resolve(true); return; }
if (i === 1) {
try { getSession().permissions.setRule(p.command, "allow", true); } catch {}
p.resolve(true);
return;
}
if (i === 3) {
// Session-wide auto-approve (bash only): the rest of THIS turn (and any
// later turn in this session) stops asking for command approval.
try { getSession().permissions.setAuto(true); } catch {}
p.resolve(true);
return;
}
p.resolve(false);
};
const row = (i: number) => ({
onMouseDown: () => setSel(i),
onMouseUp: () => execSel(i),
});
useKeyboard(function(key) {
const p = permission();
if (!p) return;
const k = key.name;
if (k === "escape") {
if (custom()) { closeQuestion(); return; }
p.resolve(false, "(esc)");
return;
}
if (custom()) {
if (k === "return") { submitCustom(); return; }
if (k === "backspace" || k === "delete") { setQuestionText(v => v.slice(0, -1)); return; }
const s = key.sequence;
if (!key.ctrl && !key.meta && s && s.length <= 10 && s !== "\r" && s !== "\n" && s !== "\t") {
setQuestionText(v => v + s);
}
return;
}
// Session-start prompt: only up/down + enter + esc, never the answer
// editor (there is no "type your own answer" for this one).
if (isStart) {
if (k === "up" || k === "down") {
setSel(i => (k === "up" ? Math.max(0, i - 1) : Math.min(maxSel, i + 1)));
return;
}
if (k === "return") { execSel(sel()); return; }
return;
}
if (k === "up" || k === "down") {
setSel(i => (k === "up" ? Math.max(0, i - 1) : Math.min(maxSel, i + 1)));
return;
}
// Question mode: typing opens the inline editor (the user's answer may
// not be one of the offered options). Permission mode: ignored — a
// permission is Allow / Always allow / Deny, nothing else.
if (isQ && !key.ctrl && !key.meta && key.sequence && key.sequence.length <= 10 && key.sequence !== "\r" && key.sequence !== "\n" && key.sequence !== "\t") {
openQuestion(key.sequence);
return;
}
if (k === "return") {
if (isQ) {
if (sel() >= opts.length) { openQuestion(""); return; }
answerWith(opts[sel()]);
return;
}
if (sel() === 0) { p.resolve(true); return; }
if (sel() === 1) {
try { getSession().permissions.setRule(p.command, "allow", true); } catch {}
p.resolve(true);
return;
}
if (sel() === 3) {
try { getSession().permissions.setAuto(true); } catch {}
p.resolve(true);
return;
}
p.resolve(false);
}
});
return (
{custom() ? "\u2753 Answer" : isStart ? "Session permissions" : isQ ? "\u2753 Question" : "\u26A0 Permission needed"}
{isStart
? "New session \u00B7 you can toggle anytime with Shift+Tab:"
: isQ
? "The model wants to know:"
: "Model wants to " + (pr.tool === "bash" ? "run a command" : "change a file") + ":"}
{isQ ? pr.command : pr.tool + ": " + fit(pr.command)}
{"\u26A0 " + pr.label}
{isQ ? (
opts.map((o, i) => (
{(sel() === i ? "\u25B6 " : " ") + fit(o, 40)}
))
) : (
<>
{(sel() === 0 ? "\u25B6 " : " ") + "Allow"}
{"(recommended)"}
{(sel() === 1 ? "\u25B6 " : " ") + "Always allow"}
{"remember for this command"}
{(sel() === 2 ? "\u25B6 " : " ") + "Deny"}
{pr.tool === "bash" ? (
{(sel() === 3 ? "\u25B6 " : " ") + "Allow all commands in this session"}
) : null}
>
)}
{isQ && !isStart ? (
{(sel() === opts.length ? "\u25B6 " : " ") + "Type your answer\u2026"}
) : null}
{customText() || " "}
{custom()
? "Enter send \u00B7 Esc back"
: isStart
? "\u2191\u2193 choose \u00B7 Enter confirm \u00B7 ESC skip"
: isQ
? "\u2191\u2193 choose \u00B7 Enter confirm \u00B7 type = your own answer \u00B7 ESC skip"
: "\u2191\u2193 choose \u00B7 Enter confirm \u00B7 ESC deny"}
);
}