mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
* Clarify model-facing message when user rejects a tool call * Include the rejected tool's name in denial reasons * Move user-rejected tool reason into @cline/shared * Route new user-rejection approval paths through shared reason builder Since the original PR, several new approval surfaces landed on main with their own terse denial strings (CLI connectors, ACP permissions, Cline Hub webview, desktop webview, example VS Code extension). Route all of them through buildUserRejectedToolReason so the model sees a consistent, non-error rejection message. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Add buildUserRejectedToolReason to the @cline/shared integration-test stub The VS Code integration tests run the tsc-built CJS tree and stub the ESM-only @cline/shared package in test-setup.js; the stub was missing the new export, so tool-approval-denial.js threw at module load in CI. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Trim scope back to the minimal rejection-copy fix Restore the connector deniedReason plumbing, ACP permission strings, desktop webview reason, example extension reason, and hub server fallback to their main versions. Those surfaces already attribute the denial to a user and are outside ENG-2329. Keep the Cline Hub webview change since that path emits its own rejection string the model sees. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * Move rejection guidance suffix into agent runtime per review * Apply review suggestions: neutral fallback reason and -- separator before rejection suffix --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
173 lines
4.8 KiB
TypeScript
173 lines
4.8 KiB
TypeScript
import { createInterface } from "node:readline";
|
|
import {
|
|
type ToolApprovalRequest,
|
|
type ToolApprovalResult,
|
|
USER_REJECTED_TOOL_REASON,
|
|
} from "@cline/shared";
|
|
import { truncate } from "./helpers";
|
|
import { c, getActiveCliSession, write } from "./output";
|
|
|
|
const SHOW_TERMINAL_CURSOR = "\x1b[?25h";
|
|
|
|
// =============================================================================
|
|
// Desktop tool approval
|
|
// =============================================================================
|
|
|
|
let cachedDesktopApprovalRequester:
|
|
| Promise<
|
|
(
|
|
request: ToolApprovalRequest,
|
|
options?: {
|
|
approvalDir?: string;
|
|
sessionId?: string;
|
|
},
|
|
) => Promise<ToolApprovalResult>
|
|
>
|
|
| undefined;
|
|
|
|
async function requestDesktopToolApprovalFromCore(
|
|
request: ToolApprovalRequest,
|
|
): Promise<ToolApprovalResult> {
|
|
if (!cachedDesktopApprovalRequester) {
|
|
cachedDesktopApprovalRequester = import("@cline/core")
|
|
.then((module) => {
|
|
const fn = (
|
|
module as {
|
|
requestDesktopToolApproval?: (
|
|
request: ToolApprovalRequest,
|
|
options?: {
|
|
approvalDir?: string;
|
|
sessionId?: string;
|
|
},
|
|
) => Promise<ToolApprovalResult>;
|
|
}
|
|
).requestDesktopToolApproval;
|
|
if (typeof fn !== "function") {
|
|
throw new Error(
|
|
"Installed @cline/core does not expose requestDesktopToolApproval",
|
|
);
|
|
}
|
|
return fn;
|
|
})
|
|
.catch(() => {
|
|
return async () => ({
|
|
approved: false,
|
|
reason: "Desktop tool approval IPC is not available",
|
|
});
|
|
});
|
|
}
|
|
const requester = await cachedDesktopApprovalRequester;
|
|
const sessionId = getActiveCliSession()?.manifest.session_id;
|
|
const approvalDir = process.env.CLINE_TOOL_APPROVAL_DIR?.trim();
|
|
return requester(request, { approvalDir, sessionId });
|
|
}
|
|
|
|
// =============================================================================
|
|
// Terminal tool approval
|
|
// =============================================================================
|
|
|
|
async function requestTerminalToolApproval(
|
|
request: ToolApprovalRequest,
|
|
): Promise<ToolApprovalResult> {
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
return {
|
|
approved: false,
|
|
reason: `Tool "${request.toolName}" requires approval in a TTY session`,
|
|
};
|
|
}
|
|
const preview = truncate(JSON.stringify(request.input), 160);
|
|
const answer = await new Promise<string>((resolve) => {
|
|
const rl = createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
rl.question(
|
|
`\n${c.yellow}Approve ${c.green}"${request.toolName}" ${c.dim}${preview} ${c.reset}[y/N] `,
|
|
(value) => {
|
|
rl.close();
|
|
resolve(value);
|
|
},
|
|
);
|
|
});
|
|
const normalized = answer.trim().toLowerCase();
|
|
if (normalized === "y" || normalized === "yes") {
|
|
return { approved: true };
|
|
}
|
|
return {
|
|
approved: false,
|
|
reason: USER_REJECTED_TOOL_REASON,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Unified approval entry point
|
|
// =============================================================================
|
|
|
|
export async function requestToolApproval(
|
|
request: ToolApprovalRequest,
|
|
): Promise<ToolApprovalResult> {
|
|
const mode = process.env.CLINE_TOOL_APPROVAL_MODE?.trim().toLowerCase();
|
|
if (mode === "desktop") {
|
|
return requestDesktopToolApprovalFromCore(request);
|
|
}
|
|
return requestTerminalToolApproval(request);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Interactive question
|
|
// =============================================================================
|
|
|
|
export async function askQuestionInTerminal(
|
|
question: string,
|
|
options: string[],
|
|
): Promise<string> {
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
return options[0] ?? "";
|
|
}
|
|
|
|
return new Promise<string>((resolve) => {
|
|
const rl = createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
|
|
write(`\n${c.dim}[follow-up]${c.reset} ${question}\n`);
|
|
for (const [index, option] of options.entries()) {
|
|
write(`${c.dim} ${index + 1}.${c.reset} ${option}\n`);
|
|
}
|
|
// Terminal renderers can hide the cursor; restore it so readline shows a
|
|
// normal blinking insertion point for the follow-up.
|
|
write(SHOW_TERMINAL_CURSOR);
|
|
write(
|
|
`${c.dim}Choose 1-${options.length} or type a custom answer:${c.reset}\n${c.green}>${c.reset} `,
|
|
);
|
|
|
|
rl.question("", (value) => {
|
|
rl.close();
|
|
const trimmed = value.trim();
|
|
const numeric = Number.parseInt(trimmed, 10);
|
|
if (
|
|
Number.isInteger(numeric) &&
|
|
numeric >= 1 &&
|
|
numeric <= options.length
|
|
) {
|
|
resolve(options[numeric - 1] ?? "");
|
|
return;
|
|
}
|
|
if (trimmed.length > 0) {
|
|
resolve(trimmed);
|
|
return;
|
|
}
|
|
resolve(options[0] ?? "");
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function submitAndExitInTerminal(
|
|
summary: string,
|
|
verified: boolean,
|
|
): Promise<string> {
|
|
const status = verified ? "verified" : "unverified";
|
|
return `Submission recorded (${status}): ${summary}`;
|
|
}
|