feat(agent-mode): inline tool permission cards in chat (#2493)

Replace the modal-based permission prompter with inline ToolPermissionCard
rendered at the tail of the chat scroll, matching how plan proposals already
work. Refactor AgentSession status to be derived from underlying primitives
(resolver maps, abortController, etc.) so it cannot drift from reality, and
add tests covering the awaiting_permission lifecycle and turn-error reset.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-20 18:27:33 -07:00 committed by Logan Yang
parent f53672d091
commit c4b92fcb16
No known key found for this signature in database
13 changed files with 523 additions and 211 deletions

View file

@ -354,7 +354,7 @@ UI side:
- `ui/permissionPrompter.ts` — routing layer; special-cases
`ExitPlanMode` switch_mode
- `ui/AcpPermissionModal.tsx` — generic permission modal
- `ui/ToolPermissionCard.tsx` — generic inline permission card
- `ui/PlanProposalCard.tsx` — inline plan card
- `ui/PlanPreviewView.tsx` — full-screen plan preview (custom Obsidian
view registered in `main.ts`)

View file

@ -25,6 +25,7 @@
- [ ] P0: Auto-save chat history controls
- [ ] P0: Support image context
- [ ] P1: [BUG] Check active note path. Sometimes the agent will start from a path that does not exist
- [ ] P1: Make askuserquestion tool show questions inline in the chat.
- [ ] P1: MCP
- Basic functionality is ready
- [ ] P1: Surface externally-managed MCP servers (claude.ai remote, plugin-provided) — see [MCP_EXTERNALLY_MANAGED_SERVERS.md](./MCP_EXTERNALLY_MANAGED_SERVERS.md)

View file

@ -1,11 +1,7 @@
import { type App, Platform } from "obsidian";
import type CopilotPlugin from "@/main";
import { logError } from "@/logger";
import {
type CopilotSettings,
getSettings,
useSettingsValue,
} from "@/settings/model";
import { type CopilotSettings, getSettings, useSettingsValue } from "@/settings/model";
import { backendRegistry, listBackendDescriptors } from "./backends/registry";
import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager";
import { AgentModelPreloader } from "./session/AgentModelPreloader";
@ -103,7 +99,6 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen
// happen before assignment below.
let managerRef: AgentSessionManager | null = null;
const prompter = createDefaultPermissionPrompter(
app,
(id) => managerRef?.getSessionByBackendId(id) ?? null
);
const manager = new AgentSessionManager(app, plugin, {

View file

@ -3,6 +3,7 @@ import type {
AgentChatMessage,
BackendState,
CurrentPlan,
PermissionPrompt,
PlanDecisionAction,
PromptContent,
} from "./types";
@ -79,4 +80,18 @@ export interface AgentChatBackend {
* proposal card's actions.
*/
hasPendingPlanPermission(): boolean;
/**
* Snapshot of every non-plan tool-permission request currently waiting on
* the user. Rendered as inline `ToolPermissionCard`s at the tail of the
* chat scroll container. Empty list when nothing is pending.
*/
getPendingToolPermissions(): PermissionPrompt[];
/**
* Resolve a pending tool permission with the option the user picked. The
* card is removed from `getPendingToolPermissions()` synchronously and the
* SDK turn unblocks. No-op when no permission is pending for the given id.
*/
resolveToolPermission(toolCallId: string, optionId: string): void;
}

View file

@ -5,6 +5,7 @@ import type {
AgentChatMessage,
BackendState,
CurrentPlan,
PermissionPrompt,
PlanDecisionAction,
PromptContent,
} from "@/agentMode/session/types";
@ -120,6 +121,15 @@ export class AgentChatUIState implements AgentChatBackend {
return this.session.hasPendingPlanPermission();
}
getPendingToolPermissions(): PermissionPrompt[] {
return this.session.getPendingToolPermissions();
}
resolveToolPermission(toolCallId: string, optionId: string): void {
this.session.resolveToolPermission(toolCallId, optionId);
this.notifyListeners();
}
getCurrentPlan(): CurrentPlan | null {
return this.session.getCurrentPlan();
}

View file

@ -1363,6 +1363,98 @@ describe("AgentSession plan proposal lifecycle", () => {
await decisionPromise;
});
it("marks an active turn as awaiting permission while an inline tool card is pending", async () => {
const mock = makeMockBackend();
let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null;
mock.prompt.mockImplementation(
() => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt))
);
const session = new AgentSession({
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
});
const statusChanges: string[] = [];
session.subscribe({
onMessagesChanged: () => {},
onStatusChanged: (status) => statusChanges.push(status),
});
const { turn } = session.sendPrompt("edit a file");
expect(session.getStatus()).toBe("running");
const decisionPromise = session.handleToolPermission({
sessionId: "acp-1",
toolCall: {
toolCallId: "tc-write",
kind: "edit",
status: "pending",
title: "Write",
rawInput: { file_path: "note.md", content: "updated" },
},
options: [
{ optionId: "allow_once", name: "Allow once", kind: "allow_once" },
{ optionId: "reject_once", name: "Deny once", kind: "reject_once" },
],
});
expect(session.getStatus()).toBe("awaiting_permission");
expect(session.getPendingToolPermissions()).toHaveLength(1);
expect(statusChanges).toContain("awaiting_permission");
session.resolveToolPermission("tc-write", "allow_once");
await expect(decisionPromise).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow_once" },
});
expect(session.getPendingToolPermissions()).toHaveLength(0);
expect(session.getStatus()).toBe("running");
resolvePrompt!({ stopReason: "end_turn" });
await turn;
});
it("rejects a pending inline tool permission before awaiting backend cancel", async () => {
const mock = makeMockBackend();
let resolvePrompt: ((v: { stopReason: "cancelled" }) => void) | null = null;
let decisionPromise: Promise<unknown> = Promise.resolve();
mock.prompt.mockImplementation(
() => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt))
);
mock.cancel.mockImplementation(() => decisionPromise.then(() => undefined));
const session = new AgentSession({
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
});
const { turn } = session.sendPrompt("edit a file");
decisionPromise = session.handleToolPermission({
sessionId: "acp-1",
toolCall: {
toolCallId: "tc-write",
kind: "edit",
status: "pending",
title: "Write",
rawInput: { file_path: "note.md", content: "updated" },
},
options: [
{ optionId: "allow_once", name: "Allow once", kind: "allow_once" },
{ optionId: "reject_once", name: "Deny once", kind: "reject_once" },
],
});
const cancelPromise = session.cancel();
await expect(decisionPromise).resolves.toEqual({
outcome: { outcome: "selected", optionId: "reject_once" },
});
expect(session.getPendingToolPermissions()).toHaveLength(0);
await cancelPromise;
resolvePrompt!({ stopReason: "cancelled" });
await turn;
});
it("forwards the optional denyMessage on resolvePlanProposalPermission to the resolved decision", async () => {
const mock = makeMockBackend();
let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null;
@ -1446,6 +1538,70 @@ describe("AgentSession plan proposal lifecycle", () => {
});
});
describe("AgentSession status derivation", () => {
it("reports 'error' after a failed turn and resets to 'running' on the next sendPrompt", async () => {
const mock = makeMockBackend();
mock.prompt.mockRejectedValueOnce(new Error("boom"));
const session = new AgentSession({
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "opencode",
});
await expect(session.sendPrompt("hi").turn).rejects.toThrow("boom");
expect(session.getStatus()).toBe("error");
// A fresh prompt should clear the prior turn error and report running.
const { turn } = session.sendPrompt("retry");
expect(session.getStatus()).toBe("running");
await turn;
expect(session.getStatus()).toBe("idle");
});
it("fires onStatusChanged exactly once per distinct transition through the permission lifecycle", async () => {
const mock = makeMockBackend();
let resolvePrompt: ((v: { stopReason: "end_turn" }) => void) | null = null;
mock.prompt.mockImplementation(
() => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt))
);
const session = new AgentSession({
backend: mock.asBackend,
backendSessionId: "acp-1",
internalId: "internal-1",
backendId: "claude-code",
});
const statusChanges: string[] = [];
session.subscribe({
onMessagesChanged: () => {},
onStatusChanged: (status) => statusChanges.push(status),
});
const { turn } = session.sendPrompt("edit a file");
const decisionPromise = session.handleToolPermission({
sessionId: "acp-1",
toolCall: {
toolCallId: "tc-write",
kind: "edit",
status: "pending",
title: "Write",
rawInput: { file_path: "note.md", content: "updated" },
},
options: [
{ optionId: "allow_once", name: "Allow once", kind: "allow_once" },
{ optionId: "reject_once", name: "Deny once", kind: "reject_once" },
],
});
session.resolveToolPermission("tc-write", "allow_once");
await decisionPromise;
resolvePrompt!({ stopReason: "end_turn" });
await turn;
// running → awaiting_permission → running → idle, each fired once.
expect(statusChanges).toEqual(["running", "awaiting_permission", "running", "idle"]);
});
});
describe("tryReadExitPlanModeCall", () => {
it("returns the plan body when isPlanProposal is true", () => {
const out = tryReadExitPlanModeCall({

View file

@ -38,6 +38,10 @@ import { getSettings } from "@/settings/model";
*/
const DEFAULT_TITLE_PREFIX = "New session";
const MAX_TOOL_OUTPUT_TEXT_CHARS = 12_000;
// Shared sentinel so `getPendingToolPermissions()` returns a stable reference
// when nothing is pending — preserves React `useState` setter bail-out
// behavior on idle subscription ticks.
const EMPTY_PERMISSIONS: PermissionPrompt[] = [];
export type AgentSessionStatus =
| "starting"
@ -134,7 +138,20 @@ export class AgentSession {
private readonly backend: BackendProcess;
private readonly cwd: string | null;
private readonly getDescriptor: (() => BackendDescriptor | undefined) | null;
private status: AgentSessionStatus = "starting";
// `status` is derived from the primitives below — see `getStatus()`.
// `cachedStatus` is a memo of the last value we fired through
// `onStatusChanged`, used purely for change detection. It is not the
// source of truth; never read it from anywhere except
// `recomputeStatusIfChanged()`.
private cachedStatus: AgentSessionStatus = "starting";
// Set in `initialize`'s catch when the backend's `newSession` rejects.
// Combined with `backendSessionId === null` this yields the startup
// `"error"` status.
private startupFailed = false;
// Set in `runTurn`'s catch; cleared at the top of `sendPrompt` once
// preconditions pass. Yields the per-turn `"error"` status while the
// session sits idle between a failed turn and the next prompt.
private lastTurnError = false;
private placeholderId: string | null = null;
private abortController: AbortController | null = null;
private listeners = new Set<AgentSessionListener>();
@ -160,6 +177,17 @@ export class AgentSession {
resolve: (resp: PermissionDecision) => void;
}
>();
// Pending permission resolvers for general (non-plan) tool calls. Populated
// by `handleToolPermission` when the wrapped prompter routes a request to
// this session; the inline `ToolPermissionCard` resolves them through
// `resolveToolPermission`.
private pendingToolResolvers = new Map<
string,
{
request: PermissionPrompt;
resolve: (resp: PermissionDecision) => void;
}
>();
// Singleton "current plan" for the floating card. At most one per session
// while in canonical plan mode and a plan has been proposed; cleared on a
// terminal user decision or when the canonical mode flips out of plan.
@ -196,10 +224,14 @@ export class AgentSession {
opts.backendSessionId,
(event) => this.handleSessionEvent(event)
);
this.status = "idle";
// backendSessionId is non-null → getStatus() yields "idle" without
// any further bookkeeping. Sync the cache so the first
// `recomputeStatusIfChanged` call doesn't fire a spurious
// "starting → idle" transition that no one observed.
this.cachedStatus = this.getStatus();
this.ready = Promise.resolve();
} else {
this.status = "starting";
// Default cachedStatus ("starting") already matches getStatus().
this.ready = this.initialize(opts);
}
}
@ -244,7 +276,7 @@ export class AgentSession {
this.unregisterSessionHandler = null;
return;
}
this.setStatus("idle");
this.recomputeStatusIfChanged();
this.notifyModelChanged();
// Apply sticky preference. Best-effort — failures leave the session
@ -259,7 +291,8 @@ export class AgentSession {
} catch (err) {
if (this.disposed) return;
logWarn(`[AgentMode] session/new failed for ${this.internalId}`, err);
this.setStatus("error");
this.startupFailed = true;
this.recomputeStatusIfChanged();
throw err instanceof Error ? err : new Error(err2String(err));
}
}
@ -283,7 +316,7 @@ export class AgentSession {
* available" and degrade the UI accordingly.
*/
async setModel(modelId: string): Promise<void> {
if (this.status === "closed") throw new Error("Session is closed");
if (this.getStatus() === "closed") throw new Error("Session is closed");
if (!this.backendSessionId) throw new Error("Session is still starting");
const next = await this.backend.setSessionModel({
sessionId: this.backendSessionId,
@ -299,7 +332,7 @@ export class AgentSession {
* changes as one channel.
*/
async setConfigOption(configId: string, value: string): Promise<void> {
if (this.status === "closed") throw new Error("Session is closed");
if (this.getStatus() === "closed") throw new Error("Session is closed");
if (!this.backendSessionId) throw new Error("Session is still starting");
const next = await this.backend.setSessionConfigOption({
sessionId: this.backendSessionId,
@ -320,7 +353,7 @@ export class AgentSession {
* switching.
*/
async setMode(modeId: string): Promise<void> {
if (this.status === "closed") throw new Error("Session is closed");
if (this.getStatus() === "closed") throw new Error("Session is closed");
if (!this.backendSessionId) throw new Error("Session is still starting");
const next = await this.backend.setSessionMode({
sessionId: this.backendSessionId,
@ -333,7 +366,7 @@ export class AgentSession {
/** Whether the user can swap the active model on this session. */
canSwitchModel(): boolean | null {
if (this.status === "starting") return false;
if (this.getStatus() === "starting") return false;
return this.backend.isSetSessionModelSupported();
}
@ -343,7 +376,7 @@ export class AgentSession {
* routing is encapsulated here UI consumers ask intent only.
*/
canSwitchEffort(): boolean | null {
if (this.status === "starting") return false;
if (this.getStatus() === "starting") return false;
const descriptor = this.getDescriptor?.();
if (!descriptor) return null;
return descriptor.wire.effortConfigFor
@ -357,7 +390,7 @@ export class AgentSession {
* the dispatch path is consistent, so we sample the first option.
*/
canSwitchMode(): boolean | null {
if (this.status === "starting") return false;
if (this.getStatus() === "starting") return false;
const mode = this.currentState?.mode;
if (!mode) return null;
const sample = mode.options[0];
@ -369,8 +402,25 @@ export class AgentSession {
: this.backend.isSetSessionModeSupported();
}
/**
* The status is derived from underlying primitives so it cannot drift
* from reality: any combination of `disposed`, `backendSessionId`,
* resolver-map sizes, `abortController`, `startupFailed`, and
* `lastTurnError` maps to exactly one status. Mutating any of those
* primitives implicitly transitions the status; consumers observe the
* change via `onStatusChanged` after `recomputeStatusIfChanged()` runs.
*/
getStatus(): AgentSessionStatus {
return this.status;
if (this.disposed) return "closed";
if (this.backendSessionId === null) {
return this.startupFailed ? "error" : "starting";
}
if (this.pendingPlanResolvers.size + this.pendingToolResolvers.size > 0) {
return "awaiting_permission";
}
if (this.abortController !== null) return "running";
if (this.lastTurnError) return "error";
return "idle";
}
getNeedsAttention(): boolean {
@ -467,13 +517,14 @@ export class AgentSession {
context?: MessageContext,
promptContent?: PromptContent[]
): { userMessageId: string; turn: Promise<StopReason> } {
if (this.status === "starting") {
const status = this.getStatus();
if (status === "starting") {
throw new Error("Session is still starting");
}
if (this.status === "running" || this.status === "awaiting_permission") {
if (status === "running" || status === "awaiting_permission") {
throw new Error("Session already has a turn in flight");
}
if (this.status === "closed") {
if (status === "closed") {
throw new Error("Session is closed");
}
@ -497,7 +548,11 @@ export class AgentSession {
this.notifyMessages();
this.abortController = new AbortController();
this.setStatus("running");
// Clear any prior terminal error before the new turn starts so the
// derived status reflects the fresh `"running"` state. Both flips
// are recomputed together so listeners see one transition.
this.lastTurnError = false;
this.recomputeStatusIfChanged();
const turn = this.runTurn(displayText, context, promptContent);
return { userMessageId, turn };
@ -535,7 +590,6 @@ export class AgentSession {
) {
this.notifyMessages();
}
this.setStatus("idle");
if (this.placeholderId === placeholderId) this.placeholderId = null;
if (resp.stopReason === "end_turn") void this.pollSessionTitle();
return resp.stopReason;
@ -545,11 +599,16 @@ export class AgentSession {
this.store.markMessageError(placeholderId, formatPromptFailure(err));
this.notifyMessages();
}
this.setStatus("error");
this.lastTurnError = true;
if (this.placeholderId === placeholderId) this.placeholderId = null;
throw err;
} finally {
// Clearing `abortController` flips the derived status off `"running"`
// (to `"error"` if `lastTurnError`, else `"idle"`). Recompute after
// both the success path (no error) and the catch path (error set) so
// listeners see the single transition out of the in-flight state.
this.abortController = null;
this.recomputeStatusIfChanged();
}
}
@ -557,10 +616,19 @@ export class AgentSession {
* Cancel any in-flight turn. The backend may still emit a few trailing
* session events before the prompt promise resolves with
* `stopReason: "cancelled"` that's expected.
*
* Flushes any pending tool-permission resolvers as rejects so the inline
* cards disappear immediately (and the SDK sees a deny rather than a
* dangling promise) instead of waiting for the user to click them.
*/
async cancel(): Promise<void> {
if (this.status !== "running" && this.status !== "awaiting_permission") return;
const status = this.getStatus();
if (status !== "running" && status !== "awaiting_permission") return;
if (!this.backendSessionId) return;
if (this.pendingToolResolvers.size > 0) {
this.flushResolvers(this.pendingToolResolvers);
this.notifyMessages();
}
try {
await this.backend.cancel({ sessionId: this.backendSessionId });
} catch (e) {
@ -574,13 +642,14 @@ export class AgentSession {
this.disposed = true;
this.unregisterSessionHandler?.();
this.unregisterSessionHandler = null;
for (const { request, resolve } of this.pendingPlanResolvers.values()) {
resolve(decisionFor(request, PERMISSION_REJECT_KINDS));
}
this.pendingPlanResolvers.clear();
this.flushResolvers(this.pendingPlanResolvers);
this.flushResolvers(this.pendingToolResolvers);
this.decidedPlanToolCallIds.clear();
this.currentPlan = null;
this.setStatus("closed");
// Fire the `"closed"` transition before clearing listeners so
// subscribers still observe it. `disposed = true` above guarantees
// `getStatus()` returns `"closed"` regardless of the other primitives.
this.recomputeStatusIfChanged();
this.cancelScheduledNotify();
this.listeners.clear();
}
@ -602,6 +671,7 @@ export class AgentSession {
}
return new Promise<PermissionDecision>((resolve) => {
this.pendingPlanResolvers.set(toolCallId, { request, resolve });
this.recomputeStatusIfChanged();
this.notifyMessages();
});
}
@ -625,6 +695,7 @@ export class AgentSession {
);
const decision: PermissionDecision = !allow && denyMessage ? { ...base, denyMessage } : base;
entry.resolve(decision);
this.recomputeStatusIfChanged();
this.notifyMessages();
}
@ -709,7 +780,7 @@ export class AgentSession {
*/
waitForIdle(): Promise<void> {
const terminal = (s: AgentSessionStatus) => s === "idle" || s === "error" || s === "closed";
if (this.disposed || terminal(this.status)) return Promise.resolve();
if (this.disposed || terminal(this.getStatus())) return Promise.resolve();
return new Promise((resolve) => {
const unsub = this.subscribe({
onMessagesChanged: () => {},
@ -732,6 +803,60 @@ export class AgentSession {
return this.pendingPlanResolvers.size > 0;
}
/**
* Called by the wrapped permission prompter for any non-plan tool call. The
* returned promise is resolved when the inline `ToolPermissionCard` calls
* `resolveToolPermission` with the user's chosen option.
*/
handleToolPermission(request: PermissionPrompt): Promise<PermissionDecision> {
const toolCallId = request.toolCall.toolCallId;
return new Promise<PermissionDecision>((resolve) => {
this.pendingToolResolvers.set(toolCallId, { request, resolve });
this.recomputeStatusIfChanged();
this.notifyMessages();
});
}
/**
* Resolve a pending tool permission with the option the user selected.
* No-op when no permission is pending for the given id (stale clicks).
*/
resolveToolPermission(toolCallId: string, optionId: string): void {
const entry = this.pendingToolResolvers.get(toolCallId);
if (!entry) return;
this.pendingToolResolvers.delete(toolCallId);
entry.resolve({ outcome: { outcome: "selected", optionId } });
this.recomputeStatusIfChanged();
this.notifyMessages();
}
/**
* Snapshot of every pending tool-permission request, in arrival order so
* the UI renders cards stably (oldest at the top). Returns a shared empty
* array when nothing is pending so React subscribers don't re-render on
* unrelated ticks (a fresh `Array.from` would always change identity).
*/
getPendingToolPermissions(): PermissionPrompt[] {
if (this.pendingToolResolvers.size === 0) return EMPTY_PERMISSIONS;
return Array.from(this.pendingToolResolvers.values(), (e) => e.request);
}
/**
* Reject every pending resolver in `map` with the canonical deny decision
* derived from the request's offered options, then clear the map. Used by
* `cancel()` and `dispose()` so the inline cards disappear and the SDK
* turn unblocks instead of leaving a dangling promise.
*/
private flushResolvers(
map: Map<string, { request: PermissionPrompt; resolve: (resp: PermissionDecision) => void }>
): void {
for (const { request, resolve } of map.values()) {
resolve(decisionFor(request, PERMISSION_REJECT_KINDS));
}
map.clear();
this.recomputeStatusIfChanged();
}
private handleSessionEvent(event: SessionEvent): void {
const update = event.update;
@ -861,9 +986,20 @@ export class AgentSession {
return this.currentState?.mode?.current === "plan";
}
private setStatus(next: AgentSessionStatus): void {
if (this.status === next) return;
this.status = next;
/**
* Recompute the derived status and fire `onStatusChanged` if it changed
* since the last time we fired. The cache (`cachedStatus`) exists purely
* for change detection never read it as truth; always call
* `getStatus()`.
*
* Call this after mutating any primitive that participates in the
* derivation: `disposed`, `backendSessionId`, `startupFailed`,
* `abortController`, `lastTurnError`, or the resolver maps.
*/
private recomputeStatusIfChanged(): void {
const next = this.getStatus();
if (next === this.cachedStatus) return;
this.cachedStatus = next;
for (const l of this.listeners) {
try {
l.onStatusChanged(next);

View file

@ -13,7 +13,12 @@ import { useChatFileDrop } from "@/hooks/useChatFileDrop";
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
import { expandCustomCommandPrefix } from "@/agentMode/session/expandCustomCommandPrefix";
import type { AgentChatMessage, CurrentPlan, PromptContent } from "@/agentMode/session/types";
import type {
AgentChatMessage,
CurrentPlan,
PermissionPrompt,
PromptContent,
} from "@/agentMode/session/types";
import { CustomCommandManager } from "@/commands/customCommandManager";
import { getCachedCustomCommands } from "@/commands/state";
import { logError, logWarn } from "@/logger";
@ -146,6 +151,9 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
const [currentPlan, setCurrentPlan] = useState<CurrentPlan | null>(() =>
backend.getCurrentPlan()
);
const [pendingToolPermissions, setPendingToolPermissions] = useState<PermissionPrompt[]>(() =>
backend.getPendingToolPermissions()
);
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
const [queuedMessages, setQueuedMessages] = useState<QueuedAgentMessage[]>([]);
const [selectedTextContexts] = useSelectedTextContexts();
@ -174,6 +182,7 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
setIsStarting(backend.isStarting());
setHasPendingPlanPermission(backend.hasPendingPlanPermission());
setCurrentPlan(backend.getCurrentPlan());
setPendingToolPermissions(backend.getPendingToolPermissions());
};
sync();
return backend.subscribe(() => {
@ -491,6 +500,7 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
app={app}
onDelete={handleDelete}
currentPlan={currentPlan}
pendingToolPermissions={pendingToolPermissions}
chatBackend={backend}
isLoading={loading}
/>

View file

@ -1,11 +1,12 @@
import { AgentTrail } from "@/agentMode/ui/AgentTrailView";
import { PlanProposalCard } from "@/agentMode/ui/PlanProposalCard";
import { ToolPermissionCard } from "@/agentMode/ui/ToolPermissionCard";
import { BottomLoadingIndicator } from "@/components/chat-components/BottomLoadingIndicator";
import ChatSingleMessage from "@/components/chat-components/ChatSingleMessage";
import { USER_SENDER } from "@/constants";
import { useChatScrolling } from "@/hooks/useChatScrolling";
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
import type { AgentChatMessage, CurrentPlan } from "@/agentMode/session/types";
import type { AgentChatMessage, CurrentPlan, PermissionPrompt } from "@/agentMode/session/types";
import type { ChatMessage } from "@/types/message";
import { App } from "obsidian";
import React, { memo, useMemo } from "react";
@ -15,6 +16,7 @@ interface AgentChatMessagesProps {
app: App;
onDelete: (messageId: string) => void;
currentPlan: CurrentPlan | null;
pendingToolPermissions: PermissionPrompt[];
chatBackend: AgentChatBackend;
/** True while a turn is in flight. The last assistant message in the
* visible list is treated as the streaming placeholder. */
@ -40,7 +42,15 @@ function toChatMessageView(m: AgentChatMessage): ChatMessage {
}
const AgentChatMessages = memo(
({ messages, app, onDelete, currentPlan, chatBackend, isLoading }: AgentChatMessagesProps) => {
({
messages,
app,
onDelete,
currentPlan,
pendingToolPermissions,
chatBackend,
isLoading,
}: AgentChatMessagesProps) => {
const visible = useMemo(() => messages.filter((m) => m.isVisible), [messages]);
const adapted = useMemo(() => visible.map(toChatMessageView), [visible]);
const { containerMinHeight, scrollContainerCallbackRef, getMessageKey } = useChatScrolling({
@ -51,6 +61,14 @@ const AgentChatMessages = memo(
const inlinePlanCard = showPlanCard ? (
<PlanProposalCard plan={currentPlan} app={app} chatBackend={chatBackend} />
) : null;
const inlineToolPermissionCards = pendingToolPermissions.map((req) => (
<ToolPermissionCard
key={req.toolCall.toolCallId}
request={req}
onResolve={chatBackend.resolveToolPermission.bind(chatBackend)}
/>
));
const hasTailCards = showPlanCard || pendingToolPermissions.length > 0;
// The last visible assistant message is the streaming placeholder while
// a turn is in flight — drives the reasoning-block timer/spinner and the
@ -81,6 +99,7 @@ const AgentChatMessages = memo(
<div className="tw-flex tw-size-full tw-flex-col tw-gap-2 tw-overflow-y-auto tw-px-3 tw-pt-2">
{isLoading && <BottomLoadingIndicator />}
{inlinePlanCard}
{inlineToolPermissionCards}
</div>
);
}
@ -95,10 +114,11 @@ const AgentChatMessages = memo(
{visible.map((message, index) => {
const isLastMessage = index === visible.length - 1;
// Reserve scroll headroom only when the last message is the
// assistant AND there's no inline plan card below it — the card
// already provides visible content at the tail of the stream.
// assistant AND there's nothing pinned at the tail (plan card or
// tool-permission card) — those already provide visible content
// at the bottom of the stream.
const shouldApplyMinHeight =
isLastMessage && message.sender !== USER_SENDER && !showPlanCard;
isLastMessage && message.sender !== USER_SENDER && !hasTailCards;
const adaptedMessage = adapted[index];
// When an assistant message has structured parts, the trail owns
// its entire body — `text` parts already cover streamed prose, so
@ -148,6 +168,7 @@ const AgentChatMessages = memo(
);
})}
{inlinePlanCard}
{inlineToolPermissionCards}
</div>
</div>
);

View file

@ -1,158 +0,0 @@
import { Button } from "@/components/ui/button";
import { extractDiffContents, formatAgentInput, renderDiff } from "@/agentMode/ui/diffRender";
import type {
PermissionDecision,
PermissionOption,
PermissionOptionKind,
PermissionPrompt,
} from "@/agentMode/session/types";
import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { App, Modal } from "obsidian";
import React from "react";
import { Root } from "react-dom/client";
interface ContentProps {
request: PermissionPrompt;
onChoose: (response: PermissionDecision) => void;
}
/**
* Map `PermissionOptionKind` to a Button variant. Allow-once is the
* primary action; reject variants are destructive; allow-always is also
* default but with a subtle outline styling so the user has to think.
*/
function variantForKind(kind: PermissionOptionKind): "default" | "secondary" | "destructive" {
switch (kind) {
case "allow_once":
return "default";
case "allow_always":
return "secondary";
case "reject_once":
case "reject_always":
return "destructive";
}
}
const PermissionContent: React.FC<ContentProps> = ({ request, onChoose }) => {
const { toolCall, options } = request;
const orderedOptions = React.useMemo(() => sortOptions(options), [options]);
const diffContents = React.useMemo(
() => extractDiffContents(toolCall.content),
[toolCall.content]
);
const inputJson = React.useMemo(() => formatAgentInput(toolCall.rawInput), [toolCall.rawInput]);
const title = toolCall.title ?? "Tool call";
return (
<div className="tw-flex tw-flex-col tw-gap-3">
<p className="tw-text-sm">
Agent Mode wants to run <strong>{title}</strong>.
</p>
{toolCall.kind ? (
<p className="tw-text-xs tw-text-muted">
Kind: <code>{toolCall.kind}</code>
</p>
) : null}
{diffContents.length > 0 ? (
<div className="tw-flex tw-flex-col tw-gap-2">
{diffContents.map((d, i) => (
<div
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff list is derived once per render from a snapshot; same path can appear multiple times
key={`diff-${i}-${d.path}`}
className="tw-rounded tw-border tw-border-border tw-p-2"
>
<p className="tw-mb-1 tw-font-mono tw-text-xs tw-text-muted">{d.path}</p>
<pre className="tw-max-h-48 tw-overflow-auto tw-whitespace-pre-wrap tw-text-xs">
{renderDiff(d.oldText, d.newText)}
</pre>
</div>
))}
</div>
) : inputJson ? (
<details>
<summary className="tw-cursor-pointer tw-text-xs tw-text-muted">Show inputs</summary>
<pre className="tw-mt-1 tw-max-h-48 tw-overflow-auto tw-rounded tw-bg-secondary tw-p-2 tw-text-xs">
{inputJson}
</pre>
</details>
) : null}
<div className="tw-mt-1 tw-flex tw-flex-wrap tw-justify-end tw-gap-2">
{orderedOptions.map((opt) => (
<Button
key={opt.optionId}
variant={variantForKind(opt.kind)}
onClick={() => onChoose({ outcome: { outcome: "selected", optionId: opt.optionId } })}
>
{opt.name}
</Button>
))}
</div>
</div>
);
};
/**
* Open the permission modal for one `PermissionPrompt`. Resolves with the
* `PermissionDecision` to send back to the backend. Closing the modal
* without choosing resolves with `outcome: "cancelled"`.
*/
export function openPermissionModal(
app: App,
request: PermissionPrompt
): Promise<PermissionDecision> {
return new Promise((resolve) => {
const modal = new PermissionModal(app, request, (response) => resolve(response));
modal.open();
});
}
class PermissionModal extends Modal {
private root: Root | null = null;
private settled = false;
constructor(
app: App,
private readonly request: PermissionPrompt,
private readonly onSettle: (response: PermissionDecision) => void
) {
super(app);
this.titleEl.setText("Agent Mode — Permission required");
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
this.root = createPluginRoot(contentEl, this.app);
this.root.render(
<PermissionContent
request={this.request}
onChoose={(response) => {
this.settled = true;
this.onSettle(response);
this.close();
}}
/>
);
}
onClose(): void {
this.root?.unmount();
this.root = null;
this.contentEl.empty();
if (!this.settled) {
this.settled = true;
this.onSettle({ outcome: { outcome: "cancelled" } });
}
}
}
function sortOptions(options: PermissionOption[]): PermissionOption[] {
// Show allow_once first (the safe default), then allow_always, then reject
// variants. Keeps the most-used action under the user's mouse.
return [...options].sort(
(a, b) => PERMISSION_OPTION_KINDS.indexOf(a.kind) - PERMISSION_OPTION_KINDS.indexOf(b.kind)
);
}

View file

@ -0,0 +1,129 @@
import { Button } from "@/components/ui/button";
import { extractDiffContents, formatAgentInput, renderDiff } from "@/agentMode/ui/diffRender";
import type {
PermissionOption,
PermissionOptionKind,
PermissionPrompt,
} from "@/agentMode/session/types";
import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types";
import { ShieldQuestion } from "lucide-react";
import React, { useMemo, useState } from "react";
interface ToolPermissionCardProps {
request: PermissionPrompt;
onResolve: (toolCallId: string, optionId: string) => void;
}
/**
* Inline permission card rendered at the tail of the chat scroll container
* while a tool call is awaiting the user's decision. Replaces the modal that
* used to sit on top of every chat modals are easy to dismiss by accident
* (click-outside resolves as deny) and they steal focus across concurrent
* sessions. The card stays in-place until the user picks an option or the
* turn is cancelled.
*
* The actual SDK permission update (allow_once / allow_always /
* reject_once / reject_always semantics, including the
* `updatedPermissions` payload for "always" choices) is handled in
* `mapDecisionToSdk` this component just forwards the chosen `optionId`.
*/
export const ToolPermissionCard: React.FC<ToolPermissionCardProps> = ({ request, onResolve }) => {
const { toolCall, options } = request;
const [busy, setBusy] = useState(false);
const orderedOptions = useMemo(() => sortOptions(options), [options]);
const diffContents = useMemo(() => extractDiffContents(toolCall.content), [toolCall.content]);
const inputJson = useMemo(() => formatAgentInput(toolCall.rawInput), [toolCall.rawInput]);
const title = toolCall.title ?? "Tool call";
const choose = (optionId: string) => {
if (busy) return;
setBusy(true);
onResolve(toolCall.toolCallId, optionId);
};
return (
<div className="tw-mx-3 tw-my-2 tw-w-[calc(100%-1.5rem)] tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-secondary">
<div className="copilot-divider-b tw-flex tw-items-center tw-gap-2 tw-px-3 tw-py-2">
<ShieldQuestion className="tw-size-4 tw-shrink-0 tw-text-accent" />
<div className="tw-truncate tw-text-sm tw-font-medium">Permission required</div>
</div>
<div className="tw-flex tw-flex-col tw-gap-2 tw-px-3 tw-py-2">
<p className="tw-m-0 tw-text-sm">
Agent Mode wants to run <strong>{title}</strong>.
</p>
{toolCall.kind ? (
<p className="tw-m-0 tw-text-xs tw-text-muted">
Kind: <code>{toolCall.kind}</code>
</p>
) : null}
{diffContents.length > 0 ? (
<div className="tw-flex tw-flex-col tw-gap-2">
{diffContents.map((d, i) => (
<div
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff list is derived once per render from a snapshot; same path can appear multiple times
key={`diff-${i}-${d.path}`}
className="tw-rounded tw-border tw-border-solid tw-border-border tw-p-2"
>
<p className="tw-mb-1 tw-font-mono tw-text-xs tw-text-muted">{d.path}</p>
<pre className="tw-max-h-48 tw-overflow-auto tw-whitespace-pre-wrap tw-text-xs">
{renderDiff(d.oldText, d.newText)}
</pre>
</div>
))}
</div>
) : inputJson ? (
<details>
<summary className="tw-cursor-pointer tw-text-xs tw-text-muted">Show inputs</summary>
<pre className="tw-mt-1 tw-max-h-48 tw-overflow-auto tw-rounded tw-bg-primary tw-p-2 tw-text-xs">
{inputJson}
</pre>
</details>
) : null}
</div>
<div className="tw-flex tw-flex-wrap tw-items-center tw-justify-end tw-gap-2 tw-border-t tw-border-solid tw-border-border tw-px-3 tw-py-2">
{orderedOptions.map((opt) => (
<Button
key={opt.optionId}
variant={variantForKind(opt.kind)}
size="sm"
disabled={busy}
onClick={() => choose(opt.optionId)}
>
{opt.name}
</Button>
))}
</div>
</div>
);
};
/**
* Map `PermissionOptionKind` to a Button variant. "Once" actions stay neutral
* so neither answer feels pre-selected. "Always" actions get visual weight
* (accent for allow, red for deny) those are the choices the user should
* think harder about, since they persist beyond this turn.
*/
function variantForKind(kind: PermissionOptionKind): "default" | "secondary" | "destructive" {
switch (kind) {
case "allow_once":
case "reject_once":
return "secondary";
case "allow_always":
return "default";
case "reject_always":
return "destructive";
}
}
/**
* Show allow_once first (the safe default), then allow_always, then reject
* variants. Keeps the most-used action under the user's mouse.
*/
function sortOptions(options: PermissionOption[]): PermissionOption[] {
return [...options].sort(
(a, b) => PERMISSION_OPTION_KINDS.indexOf(a.kind) - PERMISSION_OPTION_KINDS.indexOf(b.kind)
);
}

View file

@ -1,25 +1,23 @@
import { openPermissionModal } from "@/agentMode/ui/PermissionModal";
import type { AgentSession } from "@/agentMode/session/AgentSession";
import type { PermissionPrompter } from "@/agentMode/session/AgentSessionManager";
import type { SessionId } from "@/agentMode/session/types";
import type { App } from "obsidian";
/**
* Plan-finalization prompts route into the owning session's plan-card flow
* (so the user sees the plan body in chat instead of a generic permission
* modal); everything else opens the modal. Returns `cancelled` when no
* session owns the plan request, otherwise the SDK turn would hang.
* Permission prompts route into the owning session so the user sees an inline
* card in the chat instead of a modal. Plan proposals flow through
* `handlePlanProposalPermission` (which also publishes the plan body); every
* other tool call flows through `handleToolPermission`. Returns `cancelled`
* when no session owns the request without that the SDK turn would hang.
*/
export function createDefaultPermissionPrompter(
app: App,
resolveSession: (backendSessionId: SessionId) => AgentSession | null
): PermissionPrompter {
return (req) => {
const session = resolveSession(req.sessionId);
if (!session) return Promise.resolve({ outcome: { outcome: "cancelled" } });
if (req.toolCall.isPlanProposal) {
const session = resolveSession(req.sessionId);
if (!session) return Promise.resolve({ outcome: { outcome: "cancelled" } });
return session.handlePlanProposalPermission(req);
}
return openPermissionModal(app, req);
return session.handleToolPermission(req);
};
}

View file

@ -804,8 +804,7 @@ function sanitizeAgentMode(raw: unknown): CopilotSettings["agentMode"] {
return { ...DEFAULT_SETTINGS.agentMode };
}
const r = raw as Record<string, unknown>;
const enabled =
typeof r.enabled === "boolean" ? r.enabled : DEFAULT_SETTINGS.agentMode.enabled;
const enabled = typeof r.enabled === "boolean" ? r.enabled : DEFAULT_SETTINGS.agentMode.enabled;
const byok =
r.byok && typeof r.byok === "object"
? (r.byok as { anthropic?: string; openai?: string; google?: string })