*
* Every surface (CLI TUI, Desktop, Dashboard, ACP) converts a PauseGate
* PauseRequest into this structure for rendering, then maps the user's
* selection back through resolveApprovalPrompt() into the verdict shape
* PauseGate expects.
*
* This is the single source of truth for:
* - title / subtitle / preview / meta assembly
* - option list ordering and IDs
* - tone selection
* - derivePrefix computation for "always allow"
*/
import { derivePrefix } from "./derive-prefix.js";
import type {
CheckpointVerdict,
ChoiceVerdict,
ConfirmationChoice,
PlanVerdict,
RevisionVerdict,
} from "./permission-types.js";
export type ApprovalPromptKind =
| "shell"
| "path"
| "plan"
| "checkpoint"
| "revision"
| "choice";
export type ApprovalTone = "warn" | "error" | "info" | "accent";
export interface ApprovalAction {
id: string;
label: string;
kind: "allow_once" | "allow_always" | "reject" | "custom";
secondaryInput?: { hint: string; required: boolean };
}
export interface ApprovalPrompt {
id: number;
kind: ApprovalPromptKind;
tone: ApprovalTone;
title: string;
subtitle?: string;
preview?: string;
meta?: Record<string, string>;
actions: ApprovalAction[];
data?: Record<string, unknown>;
}
* Labels are English fallbacks so the ACP surface (which has no i18n layer)
* can render immediately. CLI and Desktop may override labels with their
* own translation systems if desired.
*/
export function toApprovalPrompt(req: {
id: number;
kind: string;
payload: unknown;
}): ApprovalPrompt {
const payload = req.payload as Record<string, unknown>;
switch (req.kind) {
case "run_command":
return shellPrompt(req.id, payload, false);
case "run_background":
return shellPrompt(req.id, payload, true);
case "path_access":
return pathPrompt(req.id, payload);
case "plan_proposed":
return planPrompt(req.id, payload);
case "plan_checkpoint":
return checkpointPrompt(req.id, payload);
case "plan_revision":
return revisionPrompt(req.id, payload);
case "choice":
return choicePrompt(req.id, payload);
default:
return {
id: req.id,
kind: "shell",
tone: "warn",
title: "Unrecognized request",
subtitle: String(req.kind),
actions: [{ id: "deny", label: "Dismiss", kind: "reject" }],
};
}
}
function shellPrompt(
id: number,
payload: Record<string, unknown>,
isBackground: boolean,
): ApprovalPrompt {
const command = String(payload.command ?? "");
const cwd = payload.cwd ? String(payload.cwd) : undefined;
const timeoutSec =
!isBackground && typeof payload.timeoutSec === "number"
? payload.timeoutSec
: undefined;
const waitSec =
isBackground && typeof payload.waitSec === "number"
? payload.waitSec
: undefined;
const prefix = derivePrefix(command);
const meta: Record<string, string> = {};
if (cwd) meta.cwd = cwd;
if (timeoutSec !== undefined) meta.timeout = `${timeoutSec}s`;
if (waitSec !== undefined) meta.wait = `${waitSec}s`;
return {
id,
kind: "shell",
tone: "warn",
title: isBackground ? "Run background command" : "Run command",
subtitle: command,
preview: command,
meta: Object.keys(meta).length > 0 ? meta : undefined,
actions: [
{
id: "run_once",
label: "Run once",
kind: "allow_once",
},
{
id: "always_allow",
label: `Always allow — ${prefix}`,
kind: "allow_always",
},
{
id: "deny",
label: "Deny",
kind: "reject",
secondaryInput: {
hint: "Reason for denial (optional)",
required: false,
},
},
],
data: { prefix },
};
}
function pathPrompt(
id: number,
payload: Record<string, unknown>,
): ApprovalPrompt {
const path = String(payload.path ?? "");
const intent = payload.intent === "write" ? "write" : "read";
const toolName = String(payload.toolName ?? "");
const sandboxRoot = String(payload.sandboxRoot ?? "");
const allowPrefix = String(payload.allowPrefix ?? "");
const meta: Record<string, string> = {};
if (sandboxRoot) meta.sandboxRoot = sandboxRoot;
return {
id,
kind: "path",
tone: "warn",
title: `Access path — ${intent}`,
subtitle: path,
preview: `${toolName} → ${path}`,
meta: Object.keys(meta).length > 0 ? meta : undefined,
actions: [
{
id: "run_once",
label: intent === "write" ? "Allow write" : "Allow read",
kind: "allow_once",
},
{
id: "always_allow",
label: `Always allow — ${allowPrefix}`,
kind: "allow_always",
},
{
id: "deny",
label: "Deny",
kind: "reject",
secondaryInput: {
hint: "Reason for denial (optional)",
required: false,
},
},
],
data: { prefix: allowPrefix, intent },
};
}
function planPrompt(
id: number,
payload: Record<string, unknown>,
): ApprovalPrompt {
const plan = String(payload.plan ?? "");
const summary = payload.summary ? String(payload.summary) : undefined;
const steps = Array.isArray(payload.steps) ? payload.steps : [];
const subtitle = summary ?? (plan.length > 80 ? `${plan.slice(0, 80)}…` : plan);
const meta: Record<string, string> = {};
if (steps.length > 0) meta.steps = String(steps.length);
return {
id,
kind: "plan",
tone: "accent",
title: "Approve plan",
subtitle,
preview: plan,
meta: Object.keys(meta).length > 0 ? meta : undefined,
actions: [
{ id: "approve", label: "Approve", kind: "allow_once" },
{ id: "refine", label: "Refine", kind: "custom" },
{ id: "cancel", label: "Cancel", kind: "reject" },
],
};
}
function checkpointPrompt(
id: number,
payload: Record<string, unknown>,
): ApprovalPrompt {
const titleText = String(payload.title ?? "step complete");
const result = String(payload.result ?? "");
const notes = payload.notes ? String(payload.notes) : undefined;
const completed =
typeof payload.completed === "number" ? payload.completed : 0;
const total = typeof payload.total === "number" ? payload.total : 0;
const meta: Record<string, string> = {};
if (total > 0) meta.progress = `${completed}/${total}`;
let preview = result;
if (notes) preview += `\n${notes}`;
return {
id,
kind: "checkpoint",
tone: "info",
title: `Checkpoint — ${titleText}`,
subtitle: result,
preview: preview || undefined,
meta: Object.keys(meta).length > 0 ? meta : undefined,
actions: [
{ id: "continue", label: "Continue", kind: "allow_once" },
{ id: "revise", label: "Revise", kind: "custom" },
{ id: "stop", label: "Stop", kind: "reject" },
],
};
}
function revisionPrompt(
id: number,
payload: Record<string, unknown>,
): ApprovalPrompt {
const reason = String(payload.reason ?? "");
const remainingSteps = Array.isArray(payload.remainingSteps)
? payload.remainingSteps
: [];
const summary = payload.summary ? String(payload.summary) : undefined;
const subtitle = summary ?? (reason.length > 80 ? `${reason.slice(0, 80)}…` : reason);
const meta: Record<string, string> = {};
if (remainingSteps.length > 0) meta.steps = String(remainingSteps.length);
return {
id,
kind: "revision",
tone: "warn",
title: "Approve plan revision",
subtitle,
preview: reason,
meta: Object.keys(meta).length > 0 ? meta : undefined,
actions: [
{ id: "accept", label: "Accept", kind: "allow_once" },
{ id: "reject", label: "Keep original", kind: "reject" },
],
};
}
function choicePrompt(
id: number,
payload: Record<string, unknown>,
): ApprovalPrompt {
const question = String(payload.question ?? "Choose an option");
const rawOptions = Array.isArray(payload.options) ? payload.options : [];
const allowCustom = payload.allowCustom === true;
const actions: ApprovalAction[] = rawOptions.map((o: unknown) => {
const opt = o as Record<string, unknown>;
const optId = String(opt.id ?? "");
const optTitle = String(opt.title ?? optId);
return { id: optId, label: optTitle, kind: "custom" };
});
actions.push({ id: "cancel", label: "Cancel", kind: "reject" });
return {
id,
kind: "choice",
tone: "info",
title: question,
actions,
data: allowCustom ? { allowCustom: true } : undefined,
};
}
* The returned shape is a union; callers should narrow by prompt.kind.
*/
export function resolveApprovalPrompt(
prompt: ApprovalPrompt,
actionId: string,
secondaryInput?: string,
):
| ConfirmationChoice
| PlanVerdict
| CheckpointVerdict
| RevisionVerdict
| ChoiceVerdict {
const action = prompt.actions.find((a) => a.id === actionId);
if (!action) {
return safeDefaultForKind(prompt.kind);
}
switch (prompt.kind) {
case "shell":
case "path": {
if (action.kind === "reject") {
return { type: "deny", denyContext: secondaryInput };
}
if (action.kind === "allow_always") {
return {
type: "always_allow",
prefix: String(prompt.data?.prefix ?? ""),
};
}
return { type: "run_once" };
}
case "plan": {
if (action.id === "approve") return { type: "approve" };
if (action.id === "refine") return { type: "refine" };
return { type: "cancel" };
}
case "checkpoint": {
if (action.id === "continue") return { type: "continue" };
if (action.id === "revise") return { type: "revise" };
return { type: "stop" };
}
case "revision": {
if (action.id === "accept") return { type: "accepted" };
return { type: "rejected" };
}
case "choice": {
if (action.kind === "reject") return { type: "cancel" };
return { type: "pick", optionId: action.id };
}
}
}
function safeDefaultForKind(
kind: ApprovalPromptKind,
):
| ConfirmationChoice
| PlanVerdict
| CheckpointVerdict
| RevisionVerdict
| ChoiceVerdict {
switch (kind) {
case "shell":
case "path":
return { type: "deny" };
case "plan":
return { type: "cancel" };
case "checkpoint":
return { type: "stop" };
case "revision":
return { type: "rejected" };
case "choice":
return { type: "cancel" };
}
}