939 lines
31 KiB
JavaScript
939 lines
31 KiB
JavaScript
//
|
||
// permissions.js — answering the CLI's `can_use_tool` requests.
|
||
//
|
||
// Every prompt is registered in the sidebar first and only then surfaced as a
|
||
// panel. Dismissing the panel therefore does not strand the session: the
|
||
// request stays in "Needs Approval" until it is answered from the context
|
||
// menu, or the CLI withdraws it.
|
||
//
|
||
// Two tools are their own interaction surface rather than a yes/no gate, and
|
||
// get purpose-built handling:
|
||
//
|
||
// AskUserQuestion — the host collects the answers and returns them by
|
||
// rewriting the tool input (`updatedInput.answers`).
|
||
// ExitPlanMode — the host shows the plan and either approves it (moving
|
||
// the session out of plan mode) or sends back feedback.
|
||
//
|
||
|
||
const { log, oneLine, stripAnsi, warn, conf, basename } = require("./util.js");
|
||
const { summarizeTool, friendlyToolName, planTitle } = require("./session.js");
|
||
const diff = require("./diff.js");
|
||
|
||
const ALLOW = "Allow";
|
||
const ALLOW_ALWAYS = "Allow for Session";
|
||
const DENY = "Deny";
|
||
const REVIEW = "Review Change…";
|
||
const OTHER = "Other…";
|
||
|
||
const FEEDBACK_HEADER =
|
||
"<!-- Feedback on Claude's plan.\n" +
|
||
" Write below, then choose “Send Feedback” in the Claude sidebar.\n" +
|
||
" Editing the plan itself works too — approving sends your version. -->\n\n";
|
||
|
||
/**
|
||
* The actions a pending request offers, rendered as rows under it in the
|
||
* sidebar. Ids are handed straight back to `decide()`.
|
||
*/
|
||
function actionsFor(approval) {
|
||
const name = approval.request.tool_name;
|
||
|
||
if (name === "ExitPlanMode") {
|
||
return [
|
||
{ id: "approve-auto", label: "Approve — auto-accept edits" },
|
||
{ id: "approve-manual", label: "Approve — ask before each edit" },
|
||
approval.feedbackEditor
|
||
? { id: "send-feedback", label: "Send Feedback" }
|
||
: { id: "write-feedback", label: "Write Feedback…" },
|
||
{ id: "show", label: "Open Plan" },
|
||
];
|
||
}
|
||
|
||
if (name === "AskUserQuestion") {
|
||
return [
|
||
{ id: "answer", label: "Answer…" },
|
||
{ id: "deny", label: "Dismiss" },
|
||
];
|
||
}
|
||
|
||
if (approval.proposalEditor) {
|
||
const actions = [
|
||
{ id: "apply", label: "Apply Change" },
|
||
{ id: "deny", label: "Reject" },
|
||
{ id: "open-proposal", label: "Open Proposal" },
|
||
{ id: "review", label: "Show Diff" },
|
||
];
|
||
if (!approval.request.suppress_always_allow_rule) {
|
||
actions.push({ id: "always", label: "Allow for Session" });
|
||
}
|
||
return actions;
|
||
}
|
||
|
||
const actions = [{ id: "allow", label: "Allow" }];
|
||
if (!approval.request.suppress_always_allow_rule) {
|
||
actions.push({ id: "always", label: "Allow for Session" });
|
||
}
|
||
if (approval.preview) {
|
||
actions.push({ id: "review", label: "Review Change…" });
|
||
}
|
||
actions.push({ id: "deny", label: "Deny" });
|
||
return actions;
|
||
}
|
||
|
||
/** Quote a passage so it reads as a citation in the message back to Claude. */
|
||
function quote(text) {
|
||
return String(text)
|
||
.split("\n")
|
||
.map((line) => "> " + line)
|
||
.join("\n");
|
||
}
|
||
|
||
/** Promise wrappers around Nova's callback-style panels. */
|
||
function choicePalette(choices, placeholder) {
|
||
return new Promise((resolve) => {
|
||
nova.workspace.showChoicePalette(choices, { placeholder: placeholder }, (choice, index) => {
|
||
resolve(index === null || index === undefined ? null : index);
|
||
});
|
||
});
|
||
}
|
||
|
||
function inputPanel(message, options) {
|
||
return new Promise((resolve) => {
|
||
nova.workspace.showInputPanel(message, options || {}, (value) => resolve(value));
|
||
});
|
||
}
|
||
|
||
function actionPanel(message, buttons) {
|
||
return new Promise((resolve) => {
|
||
nova.workspace.showActionPanel(message, { buttons: buttons }, (index) => {
|
||
resolve(index === null || index === undefined ? null : index);
|
||
});
|
||
});
|
||
}
|
||
|
||
class PermissionBroker {
|
||
constructor(session, transcript) {
|
||
this.session = session;
|
||
this.transcript = transcript;
|
||
}
|
||
|
||
/**
|
||
* Handle one `can_use_tool` request.
|
||
* Resolves with a PermissionResult once the user decides.
|
||
*/
|
||
handle(request, requestId) {
|
||
return new Promise((resolve) => {
|
||
const summary = summarizeTool(request.tool_name, request.input);
|
||
const preview = diff.previewForTool(request.tool_name, request.input);
|
||
|
||
const approval = {
|
||
requestId: requestId,
|
||
request: request,
|
||
summary: summary,
|
||
preview: preview,
|
||
settled: false,
|
||
resolve: (result) => {
|
||
if (approval.settled) return;
|
||
approval.settled = true;
|
||
this.session.removeApproval(requestId);
|
||
resolve(result);
|
||
},
|
||
};
|
||
|
||
this.session.addApproval(approval);
|
||
this.transcript.update();
|
||
|
||
this._present(approval);
|
||
});
|
||
}
|
||
|
||
/** Route a request to the surface that fits it. */
|
||
_present(approval) {
|
||
const name = approval.request.tool_name;
|
||
|
||
if (name === "AskUserQuestion") {
|
||
this._askQuestions(approval);
|
||
return;
|
||
}
|
||
|
||
if (name === "ExitPlanMode") {
|
||
this._reviewPlan(approval);
|
||
return;
|
||
}
|
||
|
||
// A file change gets an editable proposal rather than a yes/no panel,
|
||
// so it can be amended before it lands.
|
||
if (approval.preview && conf("claudenova.reviewEdits", "editor") === "editor") {
|
||
this._reviewChange(approval);
|
||
return;
|
||
}
|
||
|
||
if (approval.request.requires_user_interaction) {
|
||
approval.resolve({
|
||
behavior: "deny",
|
||
message:
|
||
`${name} needs its own interactive prompt, which Nova cannot display. ` +
|
||
"Run it from `claude` in a terminal, or take a different approach.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
this._prompt(approval);
|
||
}
|
||
|
||
/** Withdraw a prompt the CLI no longer needs an answer for. */
|
||
cancel(requestId) {
|
||
const approval = this.session.findApproval(requestId);
|
||
if (!approval) return;
|
||
approval.settled = true;
|
||
this.session.removeApproval(requestId);
|
||
log("permission request withdrawn:", requestId);
|
||
}
|
||
|
||
/** Answer from the sidebar: "allow" | "always" | "deny" | "review". */
|
||
async decide(approval, choice) {
|
||
if (!approval || approval.settled) return;
|
||
|
||
// Plan actions, chosen from the sidebar rather than a panel.
|
||
switch (choice) {
|
||
case "approve-auto":
|
||
await this._approvePlan(approval, "acceptEdits");
|
||
return;
|
||
case "approve-manual":
|
||
await this._approvePlan(approval, "default");
|
||
return;
|
||
case "write-feedback":
|
||
await this.writeFeedback(approval);
|
||
return;
|
||
case "send-feedback":
|
||
await this.sendFeedback(approval);
|
||
return;
|
||
case "show":
|
||
await this._showPlan(approval.request.input || {});
|
||
return;
|
||
case "answer":
|
||
await this._askQuestions(approval);
|
||
return;
|
||
case "apply":
|
||
await this.applyChange(approval);
|
||
return;
|
||
case "open-proposal":
|
||
if (approval.proposalPath) await nova.workspace.openFile(approval.proposalPath);
|
||
return;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
// These two carry their own decision surface; a bare allow would skip
|
||
// the mode change a plan needs, and a bare deny would drop an answer.
|
||
const name = approval.request.tool_name;
|
||
if (name === "AskUserQuestion" || name === "ExitPlanMode") {
|
||
if (choice === "deny") {
|
||
approval.resolve({
|
||
behavior: "deny",
|
||
message: "The user declined this in Nova.",
|
||
});
|
||
return;
|
||
}
|
||
this._present(approval);
|
||
return;
|
||
}
|
||
|
||
switch (choice) {
|
||
case "allow":
|
||
approval.resolve({ behavior: "allow" });
|
||
return;
|
||
case "always":
|
||
approval.resolve(this._allowAlways(approval.request));
|
||
return;
|
||
case "deny":
|
||
approval.resolve({
|
||
behavior: "deny",
|
||
message: "The user declined this action in Nova.",
|
||
});
|
||
return;
|
||
case "review":
|
||
await this.review(approval);
|
||
return;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-open whatever surface this request needs. For a plain tool that is the
|
||
* change preview; for questions and plans it is the flow itself.
|
||
*/
|
||
async review(approval) {
|
||
if (approval.settled) return;
|
||
|
||
const name = approval.request.tool_name;
|
||
if (name === "AskUserQuestion" || name === "ExitPlanMode") {
|
||
this._present(approval);
|
||
return;
|
||
}
|
||
|
||
if (approval.preview) {
|
||
await diff.showPreview(approval.preview);
|
||
} else if (approval.request.input && approval.request.input.file_path) {
|
||
await nova.workspace.openFile(approval.request.input.file_path);
|
||
} else {
|
||
nova.workspace.showInformativeMessage(
|
||
"There is nothing to preview for this request:\n\n" +
|
||
oneLine(JSON.stringify(approval.request.input || {}), 400)
|
||
);
|
||
}
|
||
|
||
if (!approval.settled) this._prompt(approval);
|
||
}
|
||
|
||
// -- AskUserQuestion ---------------------------------------------------
|
||
|
||
/**
|
||
* Ask each question in turn and hand the answers back as `updatedInput`.
|
||
* The CLI feeds them to the tool, which reports them to Claude.
|
||
*/
|
||
async _askQuestions(approval) {
|
||
const input = approval.request.input || {};
|
||
const questions = Array.isArray(input.questions) ? input.questions : [];
|
||
|
||
if (questions.length === 0) {
|
||
approval.resolve({ behavior: "allow" });
|
||
return;
|
||
}
|
||
|
||
const answers = {};
|
||
|
||
for (const question of questions) {
|
||
const answer = question.multiSelect
|
||
? await this._askMultiple(question)
|
||
: await this._askOne(question);
|
||
|
||
if (answer === null) {
|
||
approval.resolve({
|
||
behavior: "deny",
|
||
message: "The user dismissed the question without answering.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
answers[question.question] = answer;
|
||
}
|
||
|
||
approval.resolve({
|
||
behavior: "allow",
|
||
updatedInput: Object.assign({}, input, { answers: answers }),
|
||
});
|
||
}
|
||
|
||
_optionLabels(question) {
|
||
return (question.options || []).map((option) => {
|
||
const description = option.description ? ` — ${oneLine(option.description, 70)}` : "";
|
||
return `${option.label}${description}`;
|
||
});
|
||
}
|
||
|
||
async _askOne(question) {
|
||
const choices = this._optionLabels(question).concat([OTHER]);
|
||
const index = await choicePalette(choices, oneLine(question.question, 90));
|
||
|
||
if (index === null) return null;
|
||
|
||
if (index === choices.length - 1) {
|
||
const custom = await inputPanel(question.question, {
|
||
label: question.header || "Answer",
|
||
placeholder: "Your answer",
|
||
prompt: "Answer",
|
||
});
|
||
if (custom === null || !custom.trim()) return null;
|
||
return custom.trim();
|
||
}
|
||
|
||
return question.options[index].label;
|
||
}
|
||
|
||
async _askMultiple(question) {
|
||
const chosen = new Set();
|
||
|
||
for (;;) {
|
||
const choices = (question.options || []).map((option, i) => {
|
||
const mark = chosen.has(i) ? "✓ " : " ";
|
||
const description = option.description ? ` — ${oneLine(option.description, 60)}` : "";
|
||
return `${mark}${option.label}${description}`;
|
||
});
|
||
choices.push(chosen.size > 0 ? `Done — ${chosen.size} selected` : OTHER);
|
||
|
||
const index = await choicePalette(
|
||
choices,
|
||
`${oneLine(question.question, 70)} (choose any)`
|
||
);
|
||
|
||
if (index === null) return null;
|
||
|
||
if (index === choices.length - 1) {
|
||
if (chosen.size > 0) break;
|
||
|
||
const custom = await inputPanel(question.question, {
|
||
label: question.header || "Answer",
|
||
placeholder: "Your answer",
|
||
prompt: "Answer",
|
||
});
|
||
if (custom === null || !custom.trim()) return null;
|
||
return custom.trim();
|
||
}
|
||
|
||
if (chosen.has(index)) chosen.delete(index);
|
||
else chosen.add(index);
|
||
}
|
||
|
||
return Array.from(chosen)
|
||
.sort((a, b) => a - b)
|
||
.map((i) => question.options[i].label)
|
||
.join(", ");
|
||
}
|
||
|
||
// -- ExitPlanMode ------------------------------------------------------
|
||
|
||
/**
|
||
* Show the plan, then either approve it — which also chooses how the rest
|
||
* of the session handles edits — or send Claude back to planning.
|
||
*
|
||
* The plan opens as an ordinary editable document, and edits made there are
|
||
* the primary way to give feedback: an approved plan is handed back through
|
||
* `updatedInput.plan`, which the CLI writes to the plan file, so the plan of
|
||
* record becomes the user's version rather than Claude's draft.
|
||
*/
|
||
async _reviewPlan(approval) {
|
||
const input = approval.request.input || {};
|
||
await this._showPlan(input);
|
||
|
||
// A notification rather than a panel: it does not block the window, so
|
||
// the plan stays scrollable and editable while the decision is open,
|
||
// and the same choices sit under the request in the sidebar.
|
||
const request = new NotificationRequest("claudenova-plan-" + approval.requestId);
|
||
request.title = "Claude finished planning";
|
||
request.body =
|
||
(planTitle(input.plan) || "The plan is ready to review.") +
|
||
"\n\nEdit the plan in its tab to change it — approving sends your version back.";
|
||
request.actions = ["Approve", "Auto-accept edits", "Write Feedback…"];
|
||
|
||
let response = null;
|
||
try {
|
||
response = await nova.notifications.add(request);
|
||
} catch (err) {
|
||
warn("could not post the plan notification:", err);
|
||
await this._reviewPlanFallback(approval);
|
||
return;
|
||
}
|
||
|
||
if (!response || response.actionIdx === null || response.actionIdx === undefined) {
|
||
// Dismissed — the request stays in the sidebar with its actions.
|
||
this.transcript.update();
|
||
return;
|
||
}
|
||
|
||
if (response.actionIdx === 2) {
|
||
await this.writeFeedback(approval);
|
||
return;
|
||
}
|
||
|
||
await this._approvePlan(approval, response.actionIdx === 1 ? "acceptEdits" : "default");
|
||
}
|
||
|
||
/** Panel version, for when notifications are unavailable. */
|
||
async _reviewPlanFallback(approval) {
|
||
const buttons = [
|
||
"Approve — ask before each edit",
|
||
"Approve — auto-accept edits",
|
||
"Write Feedback…",
|
||
];
|
||
const index = await actionPanel(
|
||
"Claude finished planning.\n\n" +
|
||
(planTitle((approval.request.input || {}).plan) || "The plan is ready to review."),
|
||
buttons
|
||
);
|
||
|
||
if (index === null) {
|
||
this.transcript.update();
|
||
return;
|
||
}
|
||
if (index === 2) {
|
||
await this.writeFeedback(approval);
|
||
return;
|
||
}
|
||
await this._approvePlan(approval, index === 1 ? "acceptEdits" : "default");
|
||
}
|
||
|
||
/**
|
||
* Approve the plan, carrying any edits the user made in its tab, and set
|
||
* how the rest of the session handles edits.
|
||
*/
|
||
async _approvePlan(approval, mode) {
|
||
if (approval.settled) return;
|
||
|
||
const input = approval.request.input || {};
|
||
const proposed = String(input.plan || "");
|
||
const current = this._currentPlanText(input);
|
||
const edited = current.trim() !== proposed.trim() && current.trim().length > 0;
|
||
|
||
this.session.permissionMode = mode;
|
||
this.session.changed();
|
||
|
||
const result = {
|
||
behavior: "allow",
|
||
updatedPermissions: [{ type: "setMode", mode: mode, destination: "session" }],
|
||
};
|
||
|
||
if (edited) {
|
||
result.updatedInput = Object.assign({}, input, { plan: current });
|
||
this.session.addNotice("Approved your edited version of the plan.", "info");
|
||
}
|
||
|
||
approval.resolve(result);
|
||
}
|
||
|
||
/**
|
||
* Open a scratch document for feedback. Nova has no multi-line field an
|
||
* extension can put in a sidebar, so the editor itself is the text box —
|
||
* write as much as you like, then choose Send Feedback.
|
||
*/
|
||
async writeFeedback(approval) {
|
||
if (approval.settled) return;
|
||
|
||
if (approval.feedbackEditor && !approval.feedbackEditor.document.isClosed) {
|
||
await nova.workspace.openFile(approval.feedbackEditor.document.uri);
|
||
this.session.changed();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const editor = await nova.workspace.openNewTextDocument({
|
||
content: FEEDBACK_HEADER,
|
||
syntax: "markdown",
|
||
line: 5,
|
||
});
|
||
|
||
if (editor) {
|
||
approval.feedbackEditor = editor;
|
||
this.session.addNotice(
|
||
"Write your feedback in the new tab, then choose Send Feedback in the Claude sidebar.",
|
||
"info"
|
||
);
|
||
this.session.changed();
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
warn("could not open a feedback document:", err);
|
||
}
|
||
|
||
// No scratch tab available — fall back to the one-line panel.
|
||
const notes = await inputPanel("What should Claude change about the plan?", {
|
||
label: "Notes",
|
||
placeholder: "Your feedback",
|
||
prompt: "Send",
|
||
});
|
||
if (notes === null) return;
|
||
this._sendFeedbackText(approval, notes);
|
||
}
|
||
|
||
/** Read the feedback document and send it back to Claude. */
|
||
async sendFeedback(approval) {
|
||
if (approval.settled) return;
|
||
|
||
let text = "";
|
||
const editor = approval.feedbackEditor;
|
||
|
||
if (editor && editor.document && !editor.document.isClosed) {
|
||
const document = editor.document;
|
||
text = document.getTextInRange(new Range(0, document.length));
|
||
const end = text.indexOf("-->");
|
||
if (text.trimStart().startsWith("<!--") && end >= 0) {
|
||
text = text.slice(end + 3);
|
||
}
|
||
}
|
||
|
||
this._sendFeedbackText(approval, text);
|
||
}
|
||
|
||
/** Combine notes, a highlighted passage, and plan edits into one reply. */
|
||
_sendFeedbackText(approval, notes) {
|
||
const input = approval.request.input || {};
|
||
const proposed = String(input.plan || "");
|
||
const current = this._currentPlanText(input);
|
||
const edited = current.trim() !== proposed.trim() && current.trim().length > 0;
|
||
const highlighted = this._planSelection(input);
|
||
const trimmed = String(notes || "").trim();
|
||
|
||
const parts = ["The user is not ready to proceed and wants to keep planning."];
|
||
|
||
if (highlighted) {
|
||
parts.push("", "They highlighted this part of the plan:", "", quote(highlighted));
|
||
}
|
||
|
||
if (trimmed) {
|
||
parts.push("", "Their feedback:", "", trimmed);
|
||
}
|
||
|
||
if (edited) {
|
||
parts.push(
|
||
"",
|
||
"They also edited the plan directly. This is their version — treat it as authoritative:",
|
||
"",
|
||
current.trim()
|
||
);
|
||
}
|
||
|
||
if (!highlighted && !trimmed && !edited) {
|
||
parts.push("Revise the plan before proceeding.");
|
||
}
|
||
|
||
approval.resolve({ behavior: "deny", message: parts.join("\n") });
|
||
}
|
||
|
||
/**
|
||
* The plan as it stands: the open document (which may hold unsaved edits),
|
||
* else the file on disk, else what Claude proposed.
|
||
*/
|
||
_currentPlanText(input) {
|
||
const path = input.planFilePath;
|
||
|
||
if (path) {
|
||
const editor = (nova.workspace.textEditors || []).find(
|
||
(candidate) => candidate.document && candidate.document.path === path
|
||
);
|
||
|
||
if (editor) {
|
||
return editor.document.getTextInRange(new Range(0, editor.document.length));
|
||
}
|
||
|
||
const onDisk = diff.readFile(path);
|
||
if (onDisk !== null) return onDisk;
|
||
}
|
||
|
||
return String(input.plan || "");
|
||
}
|
||
|
||
/** Text the user highlighted in the plan document, if any. */
|
||
_planSelection(input) {
|
||
const editor = nova.workspace.activeTextEditor;
|
||
if (!editor || !editor.document) return null;
|
||
|
||
const path = input.planFilePath;
|
||
if (path && editor.document.path !== path) return null;
|
||
|
||
const range = editor.selectedRange;
|
||
if (!range || range.empty) return null;
|
||
|
||
const text = editor.document.getTextInRange(range).trim();
|
||
return text || null;
|
||
}
|
||
|
||
async _showPlan(input) {
|
||
const path = input.planFilePath;
|
||
|
||
if (path && nova.fs.access(path, nova.fs.R_OK)) {
|
||
try {
|
||
await nova.workspace.openFile(path);
|
||
return;
|
||
} catch (err) {
|
||
warn("could not open the plan file:", err);
|
||
}
|
||
}
|
||
|
||
if (!input.plan) return;
|
||
|
||
try {
|
||
await nova.workspace.openNewTextDocument({
|
||
content: String(input.plan),
|
||
syntax: "markdown",
|
||
});
|
||
} catch (err) {
|
||
warn("could not show the plan:", err);
|
||
nova.workspace.showInformativeMessage(oneLine(input.plan, 800));
|
||
}
|
||
}
|
||
|
||
// -- editable change proposals -----------------------------------------
|
||
|
||
/**
|
||
* Open the file as it would be after the change, and let ordinary editor
|
||
* gestures settle it: Save applies, Close rejects. Anything the user edits
|
||
* in that tab is what gets written — the change can be amended, not just
|
||
* accepted or refused.
|
||
*/
|
||
async _reviewChange(approval) {
|
||
const preview = approval.preview;
|
||
|
||
const path = await this._writeProposal(approval, preview);
|
||
if (!path) {
|
||
// No scratch file — fall back to the panel with a diff.
|
||
this._prompt(approval);
|
||
return;
|
||
}
|
||
|
||
let editor = null;
|
||
try {
|
||
editor = await nova.workspace.openFile(path);
|
||
} catch (err) {
|
||
warn("could not open the proposed change:", err);
|
||
}
|
||
|
||
if (!editor) {
|
||
this._prompt(approval);
|
||
return;
|
||
}
|
||
|
||
approval.proposalEditor = editor;
|
||
this.session.changed();
|
||
|
||
// Save applies, close rejects — the same gestures the editor already
|
||
// trains, and the reason the tab is a real file rather than a scratch
|
||
// document (an untitled document cannot be saved without a dialog).
|
||
const disposables = [];
|
||
const cleanup = () => {
|
||
for (const d of disposables) {
|
||
try {
|
||
d.dispose();
|
||
} catch (_) {
|
||
/* already disposed */
|
||
}
|
||
}
|
||
};
|
||
|
||
disposables.push(
|
||
editor.onDidSave(() => {
|
||
if (approval.settled) return;
|
||
cleanup();
|
||
this.applyChange(approval);
|
||
})
|
||
);
|
||
|
||
disposables.push(
|
||
editor.onDidDestroy(() => {
|
||
if (approval.settled) return;
|
||
cleanup();
|
||
approval.resolve({
|
||
behavior: "deny",
|
||
message: "The user closed the proposed change without applying it.",
|
||
});
|
||
this.session.addNotice(`Rejected the change to ${basename(preview.path)}.`, "info");
|
||
})
|
||
);
|
||
|
||
const request = new NotificationRequest("claudenova-change-" + approval.requestId);
|
||
request.title = preview.title;
|
||
request.body =
|
||
`+${preview.additions} −${preview.deletions}\n\n` +
|
||
"Edit the proposal if you want changes, then save to apply it. " +
|
||
"Closing the tab rejects it.";
|
||
request.actions = ["Apply", "Reject", "Show Diff"];
|
||
|
||
let response = null;
|
||
try {
|
||
response = await nova.notifications.add(request);
|
||
} catch (err) {
|
||
warn("could not post the change notification:", err);
|
||
return; // the tab and its gestures are still live
|
||
}
|
||
|
||
if (approval.settled) return;
|
||
|
||
if (response && response.actionIdx === 0) {
|
||
cleanup();
|
||
await this.applyChange(approval);
|
||
} else if (response && response.actionIdx === 1) {
|
||
cleanup();
|
||
approval.resolve({
|
||
behavior: "deny",
|
||
message: "The user rejected the proposed change in Nova.",
|
||
});
|
||
} else if (response && response.actionIdx === 2) {
|
||
await diff.showPreview(preview);
|
||
}
|
||
}
|
||
|
||
/** Write the proposed file contents somewhere Nova can open and save. */
|
||
async _writeProposal(approval, preview) {
|
||
try {
|
||
// One directory per request: two files with the same name can be
|
||
// under review at once, and the tab should still read `index.js`.
|
||
const root = nova.path.join(
|
||
nova.workspace.path
|
||
? nova.extension.workspaceStoragePath
|
||
: nova.extension.globalStoragePath,
|
||
"proposed"
|
||
);
|
||
nova.fs.mkdir(root);
|
||
|
||
const directory = nova.path.join(root, String(approval.requestId).slice(-8));
|
||
nova.fs.mkdir(directory);
|
||
|
||
// Keep the extension so the proposal is syntax-highlighted.
|
||
const path = nova.path.join(directory, basename(preview.path));
|
||
const file = nova.fs.open(path, "w");
|
||
file.write(preview.after);
|
||
file.close();
|
||
|
||
approval.proposalPath = path;
|
||
return path;
|
||
} catch (err) {
|
||
warn("could not stage the proposed change:", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Apply the proposal, carrying any edits the user made to it. */
|
||
async applyChange(approval) {
|
||
if (approval.settled) return;
|
||
|
||
const preview = approval.preview;
|
||
const current = this._proposalText(approval);
|
||
const amended = current !== null && current !== preview.after;
|
||
|
||
const result = { behavior: "allow" };
|
||
|
||
if (amended) {
|
||
const updated = this._inputForContent(approval.request, preview, current);
|
||
if (updated) {
|
||
result.updatedInput = updated;
|
||
this.session.addNotice(
|
||
`Applied your amended version of ${basename(preview.path)}.`,
|
||
"info"
|
||
);
|
||
} else {
|
||
warn("could not express the amended change; applying Claude's version");
|
||
}
|
||
}
|
||
|
||
approval.resolve(result);
|
||
}
|
||
|
||
/**
|
||
* Rewrite the tool input so it produces `content`.
|
||
*
|
||
* A whole-file replacement is used rather than trying to recover a minimal
|
||
* edit: the old text is unique in the file by construction, so it always
|
||
* matches, and the result is exactly what the user saw.
|
||
*/
|
||
_inputForContent(request, preview, content) {
|
||
const input = request.input || {};
|
||
|
||
if (request.tool_name === "Write") {
|
||
return Object.assign({}, input, { content: content });
|
||
}
|
||
|
||
if (!preview.exists || typeof preview.before !== "string") return null;
|
||
|
||
if (request.tool_name === "Edit" || request.tool_name === "Update") {
|
||
return Object.assign({}, input, {
|
||
old_string: preview.before,
|
||
new_string: content,
|
||
replace_all: false,
|
||
});
|
||
}
|
||
|
||
if (request.tool_name === "MultiEdit") {
|
||
return Object.assign({}, input, {
|
||
edits: [{ old_string: preview.before, new_string: content, replace_all: false }],
|
||
});
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** The proposal as it stands, preferring unsaved editor content. */
|
||
_proposalText(approval) {
|
||
const editor = approval.proposalEditor;
|
||
|
||
if (editor && editor.document && !editor.document.isClosed) {
|
||
const document = editor.document;
|
||
return document.getTextInRange(new Range(0, document.length));
|
||
}
|
||
|
||
if (approval.proposalPath) {
|
||
return diff.readFile(approval.proposalPath);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// -- plain tool approvals ----------------------------------------------
|
||
|
||
_allowAlways(request) {
|
||
return {
|
||
behavior: "allow",
|
||
updatedPermissions: [
|
||
{
|
||
type: "addRules",
|
||
rules: [{ toolName: request.tool_name }],
|
||
behavior: "allow",
|
||
destination: "session",
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
_message(approval) {
|
||
const request = approval.request;
|
||
const lines = [];
|
||
|
||
const title = request.title || `Claude wants to use ${friendlyToolName(request.tool_name)}`;
|
||
lines.push(title);
|
||
|
||
if (approval.summary) {
|
||
lines.push("");
|
||
lines.push(approval.summary);
|
||
}
|
||
|
||
if (approval.preview) {
|
||
const { additions, deletions } = approval.preview;
|
||
lines.push("");
|
||
lines.push(`${approval.preview.title} · +${additions} −${deletions}`);
|
||
}
|
||
|
||
const reason = stripAnsi(request.decision_reason || request.description || "");
|
||
if (reason) {
|
||
lines.push("");
|
||
lines.push(oneLine(reason, 300));
|
||
}
|
||
|
||
if (request.blocked_path) {
|
||
lines.push("");
|
||
lines.push(`Outside the workspace: ${request.blocked_path}`);
|
||
}
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
_prompt(approval) {
|
||
const buttons = [ALLOW];
|
||
|
||
if (!approval.request.suppress_always_allow_rule) {
|
||
buttons.push(ALLOW_ALWAYS);
|
||
}
|
||
if (approval.preview) {
|
||
buttons.push(REVIEW);
|
||
}
|
||
buttons.push(DENY);
|
||
|
||
nova.workspace.showActionPanel(
|
||
this._message(approval),
|
||
{ buttons: buttons },
|
||
(index) => {
|
||
if (index === null || index === undefined) {
|
||
// Dismissed — leave it pending in the sidebar.
|
||
this.transcript.update();
|
||
return;
|
||
}
|
||
|
||
const choice = buttons[index];
|
||
if (choice === ALLOW) this.decide(approval, "allow");
|
||
else if (choice === ALLOW_ALWAYS) this.decide(approval, "always");
|
||
else if (choice === REVIEW) this.decide(approval, "review");
|
||
else this.decide(approval, "deny");
|
||
}
|
||
);
|
||
}
|
||
}
|
||
|
||
module.exports = { PermissionBroker, actionsFor };
|