mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Inline Claude ask-user questions (#2530)
This commit is contained in:
parent
38f6e42bcc
commit
d3f8f99e1b
20 changed files with 677 additions and 215 deletions
|
|
@ -48,7 +48,14 @@ carry the **why** — the things a reader cannot recover by reading the code.
|
|||
- **No arbitrary font-size values**: Never use Tailwind's arbitrary-value syntax for typography (e.g. `tw-text-[10.5px]`, `tw-text-[13px]`). Stick to the configured `fontSize` tokens (`tw-text-ui-smaller`, `tw-text-ui-small`, `tw-text-xs`, `tw-text-smallest`, etc.) so type stays consistent with Obsidian's CSS variables. If none of the existing tokens fit, extend the `fontSize` scale in `tailwind.config.js` rather than hard-coding a pixel value at the call site.
|
||||
- **No inline `style={{ ... }}`**: Reserve the `style` prop for values that must change dynamically at runtime (computed positions, animated transforms). Static visual changes belong in Tailwind classes or the shared component (e.g. `Button` variants/sizes).
|
||||
- **Always wrap Tailwind class strings with `cn()`** (from `@/lib/utils`) whenever the classes live anywhere other than a literal `className=` attribute on a JSX element — variable assignments, ternaries, function returns, props passed to other components, etc. `eslint-plugin-tailwindcss` only lints classes it can statically see inside JSX `className` literals or inside calls to its registered callees (`cn`, `clsx`, `classnames`, `ctl`, `cva`). Use `cn()` for composition too — instead of a ternary between two whole class strings, merge a shared base with conditional fragments: `cn("tw-flex tw-text-sm", expandable && "tw-cursor-pointer")`.
|
||||
- **Never use a raw `<button>`** — use the `Button` design-system component (`@/components/ui/button`) with the matching `variant`; if no variant fits, fall back to a `<div role="button">`, never a bare `<button>`.
|
||||
- **Raw `<button>` elements must be explicitly reset/styled**: Prefer the
|
||||
design-system `Button` component (`@/components/ui/button`) for ordinary
|
||||
command buttons. When the native element is a better semantic fit (for
|
||||
example tabs, segmented controls, icon-only controls, or compact custom
|
||||
widgets), a raw `<button>` is fine, but account for Preflight being off:
|
||||
explicitly set the visual reset/interaction styles you rely on (background,
|
||||
border, radius, padding, text color, hover/focus/disabled states, etc.) so
|
||||
Obsidian/browser defaults do not leak into the UI.
|
||||
|
||||
## Writing testable code (dependency injection)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
||||
import { App, Modal } from "obsidian";
|
||||
import React from "react";
|
||||
import { Root } from "react-dom/client";
|
||||
import type { AskUserQuestionInput } from "@/agentMode/sdk/permissionBridge";
|
||||
|
||||
type Questions = AskUserQuestionInput["questions"];
|
||||
type Answers = { [questionText: string]: string };
|
||||
|
||||
interface ContentProps {
|
||||
questions: Questions;
|
||||
onSubmit: (answers: Answers) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal that renders the SDK's `AskUserQuestion` payload as a series of
|
||||
* single- or multi-choice question blocks. Resolves with `{ questionText:
|
||||
* "label" }` (single-select) or `{ questionText: "label1, label2" }`
|
||||
* (multi-select). Closing without submitting resolves with `{}` — the
|
||||
* permission bridge treats an empty answer map as a cancellation.
|
||||
*/
|
||||
const AskUserQuestionContent: React.FC<ContentProps> = ({ questions, onSubmit, onCancel }) => {
|
||||
// Per-question selection: a single label for radio, a Set of labels for checkbox.
|
||||
const [selections, setSelections] = React.useState<Record<number, string | Set<string>>>({});
|
||||
|
||||
const canSubmit = questions.every((q, idx) => {
|
||||
if (q.multiSelect) return true;
|
||||
return typeof selections[idx] === "string" && selections[idx] !== "";
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
const answers: Answers = {};
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const sel = selections[i];
|
||||
if (q.multiSelect) {
|
||||
answers[q.question] = sel instanceof Set ? Array.from(sel).join(", ") : "";
|
||||
} else {
|
||||
answers[q.question] = typeof sel === "string" ? sel : "";
|
||||
}
|
||||
}
|
||||
onSubmit(answers);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-4">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.question} className="tw-flex tw-flex-col tw-gap-2">
|
||||
{q.header && (
|
||||
<div className="tw-text-xs tw-font-semibold tw-uppercase tw-text-muted">{q.header}</div>
|
||||
)}
|
||||
<div className="tw-text-sm">{q.question}</div>
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
{q.options.map((opt) => {
|
||||
const sel = selections[idx];
|
||||
const checked = q.multiSelect
|
||||
? sel instanceof Set && sel.has(opt.label)
|
||||
: sel === opt.label;
|
||||
return (
|
||||
<label
|
||||
key={opt.label}
|
||||
className="tw-flex tw-cursor-pointer tw-items-start tw-gap-2 tw-rounded tw-px-2 tw-py-1 hover:tw-bg-modifier-hover"
|
||||
>
|
||||
<input
|
||||
type={q.multiSelect ? "checkbox" : "radio"}
|
||||
name={`askq-${idx}`}
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelections((prev) => {
|
||||
if (q.multiSelect) {
|
||||
const cur = prev[idx];
|
||||
const next = new Set(cur instanceof Set ? cur : []);
|
||||
if (next.has(opt.label)) next.delete(opt.label);
|
||||
else next.add(opt.label);
|
||||
return { ...prev, [idx]: next };
|
||||
}
|
||||
return { ...prev, [idx]: opt.label };
|
||||
});
|
||||
}}
|
||||
className="tw-mt-0.5"
|
||||
/>
|
||||
<div className="tw-min-w-0">
|
||||
<div className="tw-text-sm">{opt.label}</div>
|
||||
{opt.description && (
|
||||
<div className="tw-text-xs tw-text-muted">{opt.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="tw-mt-2 tw-flex tw-justify-end tw-gap-2">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={submit} disabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the AskUserQuestion modal for one Claude SDK `canUseTool` invocation.
|
||||
* Resolves with the answers map (or `{}` on cancel — the bridge maps empty
|
||||
* to a deny-with-cancelled message).
|
||||
*/
|
||||
export function openAskUserQuestionModal(app: App, questions: Questions): Promise<Answers> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new AskUserQuestionModal(app, questions, resolve);
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
class AskUserQuestionModal extends Modal {
|
||||
private root: Root | null = null;
|
||||
private settled = false;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private readonly questions: Questions,
|
||||
private readonly onSettle: (answers: Answers) => void
|
||||
) {
|
||||
super(app);
|
||||
this.titleEl.setText("Agent Mode — Question from Claude");
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.root = createPluginRoot(contentEl, this.app);
|
||||
this.root.render(
|
||||
<AskUserQuestionContent
|
||||
questions={this.questions}
|
||||
onSubmit={(answers) => {
|
||||
this.settled = true;
|
||||
this.onSettle(answers);
|
||||
this.close();
|
||||
}}
|
||||
onCancel={() => {
|
||||
this.settled = true;
|
||||
this.onSettle({});
|
||||
this.close();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
this.contentEl.empty();
|
||||
if (!this.settled) {
|
||||
this.settled = true;
|
||||
this.onSettle({});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,6 @@ import type { BackendDescriptor, BackendProcess, InstallState } from "@/agentMod
|
|||
import { ClaudeInstallModal } from "./ClaudeInstallModal";
|
||||
import ClaudeLogo from "./logo.svg";
|
||||
import { ClaudeSettingsPanel } from "./ClaudeSettingsPanel";
|
||||
import { openAskUserQuestionModal } from "./AskUserQuestionModal";
|
||||
|
||||
export const CLAUDE_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
|
||||
|
||||
|
|
@ -201,7 +200,6 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
|
|||
app: args.app,
|
||||
clientVersion: args.clientVersion,
|
||||
descriptor: args.descriptor,
|
||||
askUserQuestion: (questions) => openAskUserQuestionModal(args.app, questions),
|
||||
getEnableThinking: () => Boolean(getSettings().agentMode?.backends?.claude?.enableThinking),
|
||||
getEnvOverrides: () => getSettings().agentMode?.backends?.claude?.envOverrides,
|
||||
isPlanModePlanFilePath: isClaudePlanModePlanFilePath,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManag
|
|||
import { AgentModelPreloader } from "./session/AgentModelPreloader";
|
||||
import { AgentSessionManager } from "./session/AgentSessionManager";
|
||||
import { SkillManager } from "./skills";
|
||||
import { createDefaultPermissionPrompter } from "./ui/permissionPrompter";
|
||||
import {
|
||||
createDefaultAskUserQuestionPrompter,
|
||||
createDefaultPermissionPrompter,
|
||||
} from "./ui/permissionPrompter";
|
||||
|
||||
export { AGENT_CHAT_MODE } from "./session/AgentChatPersistenceManager";
|
||||
export { AgentModeChat } from "./ui/AgentModeChat";
|
||||
|
|
@ -126,8 +129,12 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen
|
|||
const prompter = createDefaultPermissionPrompter(
|
||||
(id) => managerRef?.getSessionByBackendId(id) ?? null
|
||||
);
|
||||
const askUserQuestionPrompter = createDefaultAskUserQuestionPrompter(
|
||||
(id) => managerRef?.getSessionByBackendId(id) ?? null
|
||||
);
|
||||
const manager = new AgentSessionManager(app, plugin, {
|
||||
permissionPrompter: prompter,
|
||||
askUserQuestionPrompter,
|
||||
resolveDescriptor: (id) => backendRegistry[id],
|
||||
modelPreloader: preloader,
|
||||
persistenceManager,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import type {
|
|||
} from "@/agentMode/session/types";
|
||||
import { MethodUnsupportedError } from "@/agentMode/session/errors";
|
||||
import { createTranslatorState, mapStopReason, translateSdkMessage } from "./sdkMessageTranslator";
|
||||
import { PermissionBridge, type AskUserQuestionHandler } from "./permissionBridge";
|
||||
import { PermissionBridge, type AskUserQuestionPrompter } from "./permissionBridge";
|
||||
import {
|
||||
getCachedSdkCatalog,
|
||||
probeClaudeSdkCatalog,
|
||||
|
|
@ -101,7 +101,6 @@ export interface ClaudeSdkBackendProcessOptions {
|
|||
app: App;
|
||||
clientVersion: string;
|
||||
descriptor: BackendDescriptor;
|
||||
askUserQuestion?: AskUserQuestionHandler;
|
||||
/**
|
||||
* Read at the start of every `prompt()` so a settings change live-applies on
|
||||
* the next turn.
|
||||
|
|
@ -155,6 +154,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
|
|||
private readonly sessions = new Map<SessionId, SessionState>();
|
||||
private permissionPrompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null =
|
||||
null;
|
||||
private askUserQuestionPrompter: AskUserQuestionPrompter | null = null;
|
||||
private exitListeners = new Set<() => void>();
|
||||
private shuttingDown = false;
|
||||
private readonly bridge: PermissionBridge;
|
||||
|
|
@ -169,7 +169,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
|
|||
constructor(private readonly opts: ClaudeSdkBackendProcessOptions) {
|
||||
this.bridge = new PermissionBridge({
|
||||
getPrompter: () => this.permissionPrompter,
|
||||
askUserQuestion: opts.askUserQuestion,
|
||||
getAskUserQuestionPrompter: () => this.askUserQuestionPrompter,
|
||||
isPlanModePlanFilePath: opts.isPlanModePlanFilePath,
|
||||
});
|
||||
logInfo(
|
||||
|
|
@ -190,6 +190,10 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
|
|||
this.permissionPrompter = fn;
|
||||
}
|
||||
|
||||
setAskUserQuestionPrompter(fn: AskUserQuestionPrompter): void {
|
||||
this.askUserQuestionPrompter = fn;
|
||||
}
|
||||
|
||||
registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void {
|
||||
this.sessionHandlers.set(sessionId, handler);
|
||||
const buffered = this.pendingUpdates.get(sessionId);
|
||||
|
|
|
|||
|
|
@ -1,22 +1,45 @@
|
|||
import type { PermissionDecision, PermissionPrompt } from "@/agentMode/session/types";
|
||||
import { PermissionBridge } from "./permissionBridge";
|
||||
import type {
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
PermissionDecision,
|
||||
PermissionPrompt,
|
||||
} from "@/agentMode/session/types";
|
||||
import { PermissionBridge, type AskUserQuestionPrompter } from "./permissionBridge";
|
||||
|
||||
describe("PermissionBridge.canUseTool", () => {
|
||||
function makeBridge(
|
||||
prompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null,
|
||||
askUserQuestion?: (
|
||||
questions: Array<{ question: string; options: Array<{ label: string }> }>
|
||||
) => Promise<{ [q: string]: string }>
|
||||
askUserQuestionPrompter?: AskUserQuestionPrompter
|
||||
) {
|
||||
const bridge = new PermissionBridge({
|
||||
getPrompter: () => prompter,
|
||||
|
||||
askUserQuestion: askUserQuestion,
|
||||
getAskUserQuestionPrompter: askUserQuestionPrompter
|
||||
? () => askUserQuestionPrompter
|
||||
: undefined,
|
||||
});
|
||||
bridge.setSessionContext("session-1");
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal stand-in for an `AgentSession`'s ask-question resolver path: holds
|
||||
* the in-flight request and a `resolve` handle so a test can drive the
|
||||
* inline-card "submit" / "cancel" transitions the bridge awaits.
|
||||
*/
|
||||
class FakeQuestionSession {
|
||||
pending: AskUserQuestionPrompt | null = null;
|
||||
private resolver: ((answers: AgentQuestionAnswers) => void) | null = null;
|
||||
readonly handle: AskUserQuestionPrompter = (req) => {
|
||||
this.pending = req;
|
||||
return new Promise<AgentQuestionAnswers>((resolve) => {
|
||||
this.resolver = resolve;
|
||||
});
|
||||
};
|
||||
resolve(answers: AgentQuestionAnswers): void {
|
||||
this.resolver?.(answers);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "toolu_test_id",
|
||||
|
|
@ -133,8 +156,10 @@ describe("PermissionBridge.canUseTool", () => {
|
|||
expect(result.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
it("routes AskUserQuestion to the dedicated handler with answers", async () => {
|
||||
const handler = jest.fn(async () => ({ "What's your favorite color?": "Blue" }));
|
||||
it("routes AskUserQuestion to the ask-question prompter with a session-domain request", async () => {
|
||||
const handler = jest.fn<Promise<AgentQuestionAnswers>, [AskUserQuestionPrompt]>(async () => ({
|
||||
"What's your favorite color?": "Blue",
|
||||
}));
|
||||
const bridge = makeBridge(null, handler);
|
||||
const result = await bridge.canUseTool(
|
||||
"AskUserQuestion",
|
||||
|
|
@ -143,7 +168,11 @@ describe("PermissionBridge.canUseTool", () => {
|
|||
},
|
||||
ctx
|
||||
);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(handler).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
requestId: "toolu_test_id",
|
||||
questions: [{ question: "What's your favorite color?", options: [{ label: "Blue" }] }],
|
||||
});
|
||||
expect(result.behavior).toBe("allow");
|
||||
if (result.behavior === "allow") {
|
||||
expect(result.updatedInput).toMatchObject({
|
||||
|
|
@ -152,7 +181,7 @@ describe("PermissionBridge.canUseTool", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("denies AskUserQuestion when no handler is configured", async () => {
|
||||
it("denies AskUserQuestion when no ask-question prompter is configured", async () => {
|
||||
const bridge = makeBridge(async () => ({ outcome: { outcome: "cancelled" } }));
|
||||
const result = await bridge.canUseTool(
|
||||
"AskUserQuestion",
|
||||
|
|
@ -162,15 +191,48 @@ describe("PermissionBridge.canUseTool", () => {
|
|||
expect(result.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
it("treats empty AskUserQuestion answers as cancelled", async () => {
|
||||
const handler = jest.fn(async () => ({}));
|
||||
const bridge = makeBridge(null, handler);
|
||||
const result = await bridge.canUseTool(
|
||||
it("submitting answers resolves AskUserQuestion with allow + the { questions, answers } payload", async () => {
|
||||
// End-to-end inline-card resolver path: the bridge awaits the session's
|
||||
// pending question, then maps the submitted answers back to a SDK allow —
|
||||
// the same payload the old modal produced.
|
||||
const fake = new FakeQuestionSession();
|
||||
const bridge = makeBridge(null, fake.handle);
|
||||
const questions = [
|
||||
{ question: "Pick a fruit", options: [{ label: "Apple" }, { label: "Pear" }] },
|
||||
];
|
||||
const resultPromise = bridge.canUseTool("AskUserQuestion", { questions }, ctx);
|
||||
// The card is pending until the user submits — the prompter saw the
|
||||
// session-domain request keyed by the SDK tool_use_id.
|
||||
expect(fake.pending).toEqual({
|
||||
sessionId: "session-1",
|
||||
requestId: "toolu_test_id",
|
||||
questions,
|
||||
});
|
||||
|
||||
fake.resolve({ "Pick a fruit": "Pear" });
|
||||
const result = await resultPromise;
|
||||
expect(result).toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: { questions, answers: { "Pick a fruit": "Pear" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("cancelling AskUserQuestion (empty answers) resolves with deny + the cancellation message", async () => {
|
||||
const fake = new FakeQuestionSession();
|
||||
const bridge = makeBridge(null, fake.handle);
|
||||
const resultPromise = bridge.canUseTool(
|
||||
"AskUserQuestion",
|
||||
{ questions: [{ question: "Q", options: [{ label: "A" }] }] },
|
||||
ctx
|
||||
);
|
||||
|
||||
// Dismissing the card resolves the resolver with `{}`.
|
||||
fake.resolve({});
|
||||
const result = await resultPromise;
|
||||
expect(result.behavior).toBe("deny");
|
||||
if (result.behavior === "deny") {
|
||||
expect(result.message).toBe("User cancelled the question");
|
||||
}
|
||||
});
|
||||
|
||||
describe("Write tool gating", () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
/**
|
||||
* Bridge between the Claude SDK's `canUseTool` callback and Agent Mode's
|
||||
* session-domain permission prompter. Each `canUseTool` invocation is
|
||||
* translated to a `PermissionPrompt`, dispatched through the prompter, then
|
||||
* translated back to a SDK `PermissionResult`. AskUserQuestion gets a
|
||||
* separate branch that opens a dedicated multi-choice modal.
|
||||
* session-domain prompters. Each `canUseTool` invocation is translated to a
|
||||
* `PermissionPrompt`, dispatched through the permission prompter, then
|
||||
* translated back to a SDK `PermissionResult`. AskUserQuestion gets a separate
|
||||
* branch that dispatches through the ask-question prompter — the session
|
||||
* surfaces an inline card and returns the answers map.
|
||||
*/
|
||||
import type {
|
||||
CanUseTool,
|
||||
|
|
@ -11,6 +12,9 @@ import type {
|
|||
PermissionUpdate,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AgentQuestion,
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
PermissionDecision,
|
||||
PermissionOption,
|
||||
PermissionOptionKind,
|
||||
|
|
@ -25,22 +29,26 @@ import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta";
|
|||
|
||||
export type Prompter = (req: PermissionPrompt) => Promise<PermissionDecision>;
|
||||
|
||||
export type AskUserQuestionHandler = (
|
||||
questions: AskUserQuestionInput["questions"]
|
||||
) => Promise<{ [questionText: string]: string }>;
|
||||
/**
|
||||
* Session-domain handler for the SDK's `AskUserQuestion` tool. Mirrors the
|
||||
* permission `Prompter`: the bridge fetches it lazily via
|
||||
* `getAskUserQuestionPrompter` so it can be registered after construction.
|
||||
*/
|
||||
export type AskUserQuestionPrompter = (req: AskUserQuestionPrompt) => Promise<AgentQuestionAnswers>;
|
||||
|
||||
/** SDK-side shape of the `AskUserQuestion` tool input. */
|
||||
export interface AskUserQuestionInput {
|
||||
questions: Array<{
|
||||
question: string;
|
||||
header?: string;
|
||||
options: Array<{ label: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
}>;
|
||||
questions: AgentQuestion[];
|
||||
}
|
||||
|
||||
export interface PermissionBridgeOptions {
|
||||
getPrompter: () => Prompter | null;
|
||||
askUserQuestion?: AskUserQuestionHandler;
|
||||
/**
|
||||
* Lazily fetch the session-domain ask-question prompter. Absent / returning
|
||||
* `null` makes AskUserQuestion deny with "not yet supported", matching the
|
||||
* pre-inline behavior when no handler was wired.
|
||||
*/
|
||||
getAskUserQuestionPrompter?: () => AskUserQuestionPrompter | null;
|
||||
/**
|
||||
* Predicate identifying plan-mode plan files. When provided, the bridge
|
||||
* auto-allows `Write` calls whose `file_path` satisfies the predicate so
|
||||
|
|
@ -71,7 +79,7 @@ export class PermissionBridge {
|
|||
|
||||
canUseTool: CanUseTool = async (toolName, input, ctx) => {
|
||||
if (toolName === "AskUserQuestion") {
|
||||
return this.handleAskUserQuestion(input as unknown as AskUserQuestionInput);
|
||||
return this.handleAskUserQuestion(input as unknown as AskUserQuestionInput, ctx);
|
||||
}
|
||||
|
||||
const sessionId = this.currentSessionId;
|
||||
|
|
@ -105,10 +113,14 @@ export class PermissionBridge {
|
|||
return result;
|
||||
};
|
||||
|
||||
private async handleAskUserQuestion(input: AskUserQuestionInput): Promise<PermissionResult> {
|
||||
private async handleAskUserQuestion(
|
||||
input: AskUserQuestionInput,
|
||||
ctx: Parameters<CanUseTool>[2]
|
||||
): Promise<PermissionResult> {
|
||||
const sessionId = this.currentSessionId;
|
||||
logSdkInbound("askUserQuestion:request", input, sessionId);
|
||||
if (!this.opts.askUserQuestion) {
|
||||
const prompter = this.opts.getAskUserQuestionPrompter?.() ?? null;
|
||||
if (!prompter || !sessionId) {
|
||||
return this.deny(
|
||||
"askUserQuestion:response",
|
||||
"AskUserQuestion is not yet supported",
|
||||
|
|
@ -116,7 +128,14 @@ export class PermissionBridge {
|
|||
);
|
||||
}
|
||||
try {
|
||||
const answers = await this.opts.askUserQuestion(input.questions);
|
||||
// Reuse the SDK's `tool_use_id` as the requestId so the inline card's
|
||||
// resolver pairs the answer with this call, mirroring the permission
|
||||
// prompt's `toolCallId`.
|
||||
const answers = await prompter({
|
||||
sessionId,
|
||||
requestId: ctx.toolUseID,
|
||||
questions: input.questions,
|
||||
});
|
||||
if (Object.keys(answers).length === 0) {
|
||||
return this.deny("askUserQuestion:response", "User cancelled the question", sessionId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { MessageContext } from "@/types/message";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
BackendState,
|
||||
CurrentPlan,
|
||||
PermissionPrompt,
|
||||
|
|
@ -94,4 +96,20 @@ export interface AgentChatBackend {
|
|||
* SDK turn unblocks. No-op when no permission is pending for the given id.
|
||||
*/
|
||||
resolveToolPermission(toolCallId: string, optionId: string): void;
|
||||
|
||||
/**
|
||||
* Snapshot of every pending AskUserQuestion request waiting on the user.
|
||||
* Rendered as inline `AskUserQuestionCard`s at the tail of the chat scroll
|
||||
* container, alongside any `ToolPermissionCard`s. Empty list when none.
|
||||
*/
|
||||
getPendingAskUserQuestions(): AskUserQuestionPrompt[];
|
||||
|
||||
/**
|
||||
* Resolve a pending AskUserQuestion with the user's answers. The card is
|
||||
* removed from `getPendingAskUserQuestions()` synchronously and the SDK turn
|
||||
* unblocks. An empty map signals cancellation (the backend produces the
|
||||
* "User cancelled the question" deny). No-op when no question is pending for
|
||||
* the given id.
|
||||
*/
|
||||
resolveAskUserQuestion(requestId: string, answers: AgentQuestionAnswers): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
|
|||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
BackendState,
|
||||
CurrentPlan,
|
||||
PermissionPrompt,
|
||||
|
|
@ -130,6 +132,15 @@ export class AgentChatUIState implements AgentChatBackend {
|
|||
this.notifyListeners();
|
||||
}
|
||||
|
||||
getPendingAskUserQuestions(): AskUserQuestionPrompt[] {
|
||||
return this.session.getPendingAskUserQuestions();
|
||||
}
|
||||
|
||||
resolveAskUserQuestion(requestId: string, answers: AgentQuestionAnswers): void {
|
||||
this.session.resolveAskUserQuestion(requestId, answers);
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
getCurrentPlan(): CurrentPlan | null {
|
||||
return this.session.getCurrentPlan();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1725,6 +1725,92 @@ describe("AgentSession plan proposal lifecycle", () => {
|
|||
await turn;
|
||||
});
|
||||
|
||||
it("surfaces a pending AskUserQuestion and resolves it with the submitted answers", 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",
|
||||
});
|
||||
const statusChanges: string[] = [];
|
||||
session.subscribe({
|
||||
onMessagesChanged: () => {},
|
||||
onStatusChanged: (status) => statusChanges.push(status),
|
||||
});
|
||||
const { turn } = session.sendPrompt("ask me something");
|
||||
expect(session.getStatus()).toBe("running");
|
||||
|
||||
const answersPromise = session.handleAskUserQuestion({
|
||||
sessionId: "acp-1",
|
||||
requestId: "tc-ask",
|
||||
questions: [{ question: "Pick a fruit", options: [{ label: "Apple" }, { label: "Pear" }] }],
|
||||
});
|
||||
|
||||
expect(session.getStatus()).toBe("awaiting_permission");
|
||||
expect(session.getPendingAskUserQuestions()).toHaveLength(1);
|
||||
expect(statusChanges).toContain("awaiting_permission");
|
||||
|
||||
session.resolveAskUserQuestion("tc-ask", { "Pick a fruit": "Pear" });
|
||||
await expect(answersPromise).resolves.toEqual({ "Pick a fruit": "Pear" });
|
||||
expect(session.getPendingAskUserQuestions()).toHaveLength(0);
|
||||
expect(session.getStatus()).toBe("running");
|
||||
|
||||
resolvePrompt!({ stopReason: "end_turn" });
|
||||
await turn;
|
||||
});
|
||||
|
||||
it("returns the shared empty array when no AskUserQuestion is pending", () => {
|
||||
const mock = makeMockBackend();
|
||||
const session = new AgentSession({
|
||||
backend: mock.asBackend,
|
||||
backendSessionId: "acp-1",
|
||||
internalId: "internal-1",
|
||||
backendId: "claude",
|
||||
});
|
||||
expect(session.getPendingAskUserQuestions()).toHaveLength(0);
|
||||
// Stable reference across idle ticks so React subscribers don't re-render.
|
||||
expect(session.getPendingAskUserQuestions()).toBe(session.getPendingAskUserQuestions());
|
||||
});
|
||||
|
||||
it("flushes a pending AskUserQuestion with empty answers when the turn is cancelled", async () => {
|
||||
const mock = makeMockBackend();
|
||||
let resolvePrompt: ((v: { stopReason: "cancelled" }) => void) | null = null;
|
||||
let answersPromise: Promise<unknown> = Promise.resolve();
|
||||
mock.prompt.mockImplementation(
|
||||
() => new Promise((resolve) => (resolvePrompt = resolve as typeof resolvePrompt))
|
||||
);
|
||||
mock.cancel.mockImplementation(() => answersPromise.then(() => undefined));
|
||||
const session = new AgentSession({
|
||||
backend: mock.asBackend,
|
||||
backendSessionId: "acp-1",
|
||||
internalId: "internal-1",
|
||||
backendId: "claude",
|
||||
});
|
||||
const { turn } = session.sendPrompt("ask me something");
|
||||
|
||||
answersPromise = session.handleAskUserQuestion({
|
||||
sessionId: "acp-1",
|
||||
requestId: "tc-ask",
|
||||
questions: [{ question: "Pick a fruit", options: [{ label: "Apple" }] }],
|
||||
});
|
||||
expect(session.getStatus()).toBe("awaiting_permission");
|
||||
|
||||
const cancelPromise = session.cancel();
|
||||
// Cancellation resolves the card's resolver with `{}` (the cancel signal)
|
||||
// so the SDK turn unblocks instead of dangling.
|
||||
await expect(answersPromise).resolves.toEqual({});
|
||||
expect(session.getPendingAskUserQuestions()).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;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { logInfo, logWarn } from "@/logger";
|
|||
import { AgentMessageStore } from "@/agentMode/session/AgentMessageStore";
|
||||
import {
|
||||
AgentMessagePart,
|
||||
AgentQuestionAnswers,
|
||||
AgentToolCallOutput,
|
||||
AskUserQuestionPrompt,
|
||||
BackendDescriptor,
|
||||
BackendId,
|
||||
BackendProcess,
|
||||
|
|
@ -43,6 +45,11 @@ const MAX_TOOL_OUTPUT_TEXT_CHARS = 12_000;
|
|||
// when nothing is pending — preserves React `useState` setter bail-out
|
||||
// behavior on idle subscription ticks.
|
||||
const EMPTY_PERMISSIONS: PermissionPrompt[] = [];
|
||||
// Same idea for `getPendingAskUserQuestions()`.
|
||||
const EMPTY_QUESTIONS: AskUserQuestionPrompt[] = [];
|
||||
// Canonical "no answers" map. Resolving an in-flight question with this on
|
||||
// cancel/dispose makes the bridge treat it as a user cancellation.
|
||||
const EMPTY_ANSWERS: AgentQuestionAnswers = Object.freeze({});
|
||||
|
||||
/**
|
||||
* Optimistically swap `state.model.current.baseModelId` for the persisted
|
||||
|
|
@ -241,6 +248,17 @@ export class AgentSession {
|
|||
resolve: (resp: PermissionDecision) => void;
|
||||
}
|
||||
>();
|
||||
// Pending AskUserQuestion resolvers keyed by requestId. Populated by
|
||||
// `handleAskUserQuestion` when the wrapped ask-question prompter routes a
|
||||
// request to this session; the inline `AskUserQuestionCard` resolves them
|
||||
// through `resolveAskUserQuestion`.
|
||||
private pendingQuestionResolvers = new Map<
|
||||
string,
|
||||
{
|
||||
request: AskUserQuestionPrompt;
|
||||
resolve: (answers: AgentQuestionAnswers) => 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.
|
||||
|
|
@ -509,7 +527,12 @@ export class AgentSession {
|
|||
if (this.backendSessionId === null) {
|
||||
return this.startupFailed ? "error" : "starting";
|
||||
}
|
||||
if (this.pendingPlanResolvers.size + this.pendingToolResolvers.size > 0) {
|
||||
if (
|
||||
this.pendingPlanResolvers.size +
|
||||
this.pendingToolResolvers.size +
|
||||
this.pendingQuestionResolvers.size >
|
||||
0
|
||||
) {
|
||||
return "awaiting_permission";
|
||||
}
|
||||
if (this.abortController !== null) return "running";
|
||||
|
|
@ -711,9 +734,10 @@ export class AgentSession {
|
|||
* 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.
|
||||
* Flushes any pending tool-permission and AskUserQuestion resolvers (rejects
|
||||
* / empty answers respectively) 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> {
|
||||
const status = this.getStatus();
|
||||
|
|
@ -723,6 +747,10 @@ export class AgentSession {
|
|||
this.flushResolvers(this.pendingToolResolvers);
|
||||
this.notifyMessages();
|
||||
}
|
||||
if (this.pendingQuestionResolvers.size > 0) {
|
||||
this.flushQuestionResolvers();
|
||||
this.notifyMessages();
|
||||
}
|
||||
try {
|
||||
await this.backend.cancel({ sessionId: this.backendSessionId });
|
||||
} catch (e) {
|
||||
|
|
@ -738,6 +766,7 @@ export class AgentSession {
|
|||
this.unregisterSessionHandler = null;
|
||||
this.flushResolvers(this.pendingPlanResolvers);
|
||||
this.flushResolvers(this.pendingToolResolvers);
|
||||
this.flushQuestionResolvers();
|
||||
this.decidedPlanToolCallIds.clear();
|
||||
this.currentPlan = null;
|
||||
// Fire the `"closed"` transition before clearing listeners so
|
||||
|
|
@ -935,6 +964,46 @@ export class AgentSession {
|
|||
return Array.from(this.pendingToolResolvers.values(), (e) => e.request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the wrapped ask-question prompter when the backend invokes its
|
||||
* inline-question surface (Claude SDK's `AskUserQuestion`). The returned
|
||||
* promise resolves when the inline `AskUserQuestionCard` calls
|
||||
* `resolveAskUserQuestion` with the user's answers — or with `EMPTY_ANSWERS`
|
||||
* if the turn is cancelled/disposed, which the bridge maps to a deny.
|
||||
*/
|
||||
handleAskUserQuestion(request: AskUserQuestionPrompt): Promise<AgentQuestionAnswers> {
|
||||
const requestId = request.requestId;
|
||||
return new Promise<AgentQuestionAnswers>((resolve) => {
|
||||
this.pendingQuestionResolvers.set(requestId, { request, resolve });
|
||||
this.recomputeStatusIfChanged();
|
||||
this.notifyMessages();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending AskUserQuestion with the user's answers. An empty map
|
||||
* signals cancellation (the bridge turns it into the "User cancelled the
|
||||
* question" deny). No-op when no question is pending for the given id.
|
||||
*/
|
||||
resolveAskUserQuestion(requestId: string, answers: AgentQuestionAnswers): void {
|
||||
const entry = this.pendingQuestionResolvers.get(requestId);
|
||||
if (!entry) return;
|
||||
this.pendingQuestionResolvers.delete(requestId);
|
||||
entry.resolve(answers);
|
||||
this.recomputeStatusIfChanged();
|
||||
this.notifyMessages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of every pending AskUserQuestion request, in arrival order so the
|
||||
* UI renders cards stably. Returns a shared empty array when nothing is
|
||||
* pending so React subscribers don't re-render on unrelated ticks.
|
||||
*/
|
||||
getPendingAskUserQuestions(): AskUserQuestionPrompt[] {
|
||||
if (this.pendingQuestionResolvers.size === 0) return EMPTY_QUESTIONS;
|
||||
return Array.from(this.pendingQuestionResolvers.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
|
||||
|
|
@ -951,6 +1020,19 @@ export class AgentSession {
|
|||
this.recomputeStatusIfChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every pending AskUserQuestion with `EMPTY_ANSWERS` (the
|
||||
* cancellation signal) and clear the map. The answer-shaped resolvers can't
|
||||
* reuse `flushResolvers`, which deals in `PermissionDecision`.
|
||||
*/
|
||||
private flushQuestionResolvers(): void {
|
||||
for (const { resolve } of this.pendingQuestionResolvers.values()) {
|
||||
resolve(EMPTY_ANSWERS);
|
||||
}
|
||||
this.pendingQuestionResolvers.clear();
|
||||
this.recomputeStatusIfChanged();
|
||||
}
|
||||
|
||||
private handleSessionEvent(event: SessionEvent): void {
|
||||
const update = event.update;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import type { AgentModelPreloader, WarmBackend } from "./AgentModelPreloader";
|
|||
import { MethodUnsupportedError } from "./errors";
|
||||
import { resolveMcpServers } from "./mcpResolver";
|
||||
import type {
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
BackendDescriptor,
|
||||
BackendId,
|
||||
BackendProcess,
|
||||
|
|
@ -28,12 +30,27 @@ const AUTOSAVE_DEBOUNCE_MS = 500;
|
|||
|
||||
export type PermissionPrompter = (req: PermissionPrompt) => Promise<PermissionDecision>;
|
||||
|
||||
/**
|
||||
* Session-domain handler for inline multiple-choice questions, the sibling of
|
||||
* `PermissionPrompter`. Routes a backend's `AskUserQuestion` request to its
|
||||
* owning session, which surfaces an inline card and resolves with the answers
|
||||
* (or `{}` when the user cancels / no session owns the request).
|
||||
*/
|
||||
export type AskUserQuestionPrompter = (req: AskUserQuestionPrompt) => Promise<AgentQuestionAnswers>;
|
||||
|
||||
// Injected by the barrel so `session/` doesn't have to import
|
||||
// `backends/registry` directly (would breach the layer boundary).
|
||||
export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefined;
|
||||
|
||||
export interface AgentSessionManagerOptions {
|
||||
permissionPrompter: PermissionPrompter;
|
||||
/**
|
||||
* Handler the Claude SDK backend calls for its inline `AskUserQuestion`
|
||||
* surface. Optional only so legacy callers (tests) can omit it; production
|
||||
* wiring always supplies one via the barrel in `agentMode/index.ts`. Wired
|
||||
* onto each backend that advertises `setAskUserQuestionPrompter`.
|
||||
*/
|
||||
askUserQuestionPrompter?: AskUserQuestionPrompter;
|
||||
resolveDescriptor: DescriptorResolver;
|
||||
modelPreloader: AgentModelPreloader;
|
||||
/**
|
||||
|
|
@ -1059,6 +1076,19 @@ export class AgentSessionManager {
|
|||
* (e.g. `tryResumeSessionFromHistory`) can ignore `warm` — the probe
|
||||
* session simply sits unused on the proc.
|
||||
*/
|
||||
/**
|
||||
* Register the session-domain prompters on a freshly-adopted backend. The
|
||||
* permission prompter is required; the ask-question prompter is wired only
|
||||
* when both the manager was configured with one and the backend advertises
|
||||
* the optional `setAskUserQuestionPrompter` surface (Claude SDK today).
|
||||
*/
|
||||
private wirePrompters(proc: BackendProcess): void {
|
||||
proc.setPermissionPrompter(this.opts.permissionPrompter);
|
||||
if (this.opts.askUserQuestionPrompter) {
|
||||
proc.setAskUserQuestionPrompter?.(this.opts.askUserQuestionPrompter);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBackend(
|
||||
backendId: BackendId,
|
||||
descriptor: BackendDescriptor
|
||||
|
|
@ -1072,7 +1102,7 @@ export class AgentSessionManager {
|
|||
if (warm) {
|
||||
// Probe subprocess is already started + initialize-handshaken —
|
||||
// wire it into the manager without paying either cost again.
|
||||
warm.proc.setPermissionPrompter(this.opts.permissionPrompter);
|
||||
this.wirePrompters(warm.proc);
|
||||
this.installBackendExitHandler(backendId, warm.proc, descriptor);
|
||||
this.backends.set(backendId, warm.proc);
|
||||
return { proc: warm.proc, warm };
|
||||
|
|
@ -1088,7 +1118,7 @@ export class AgentSessionManager {
|
|||
// ACP backends declare `start()` to spawn the subprocess and run the
|
||||
// initialize handshake. In-process adapters (Claude SDK) omit it.
|
||||
if (proc.start) await proc.start();
|
||||
proc.setPermissionPrompter(this.opts.permissionPrompter);
|
||||
this.wirePrompters(proc);
|
||||
this.installBackendExitHandler(backendId, proc, descriptor);
|
||||
this.backends.set(backendId, proc);
|
||||
return proc;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export {
|
||||
AgentSessionManager,
|
||||
type AgentSessionManagerOptions,
|
||||
type AskUserQuestionPrompter,
|
||||
type PermissionPrompter,
|
||||
} from "./AgentSessionManager";
|
||||
export { AgentSession, type AgentSessionStatus, type AgentSessionListener } from "./AgentSession";
|
||||
|
|
@ -16,6 +17,9 @@ export type {
|
|||
AgentToolKind,
|
||||
AgentToolStatus,
|
||||
AgentPlanEntry,
|
||||
AgentQuestion,
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
NewAgentChatMessage,
|
||||
PermissionDecision,
|
||||
PermissionPrompt,
|
||||
|
|
|
|||
|
|
@ -489,6 +489,42 @@ export interface PermissionDecision {
|
|||
denyMessage?: string;
|
||||
}
|
||||
|
||||
// ---- Ask-user-question (inline multiple-choice prompt) -----------------
|
||||
|
||||
/**
|
||||
* One question in an `AskUserQuestionPrompt` — a `header`/`question` pair plus
|
||||
* a single- or multi-select option list. Mirrors the Claude SDK's
|
||||
* `AskUserQuestion` tool input; backends translate to this shape at the
|
||||
* boundary so the inline card stays backend-agnostic.
|
||||
*/
|
||||
export interface AgentQuestion {
|
||||
question: string;
|
||||
header?: string;
|
||||
options: Array<{ label: string; description?: string }>;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer map keyed by question text. Single-select values are the chosen
|
||||
* option label; multi-select values are the chosen labels joined with `, `.
|
||||
* An empty map signals cancellation (the bridge maps it to a deny).
|
||||
*/
|
||||
export type AgentQuestionAnswers = { [questionText: string]: string };
|
||||
|
||||
/**
|
||||
* A request from the backend asking the user to answer one or more inline
|
||||
* multiple-choice questions (Claude SDK's `AskUserQuestion` tool). Routed
|
||||
* through the session-domain ask-question prompter and rendered as an inline
|
||||
* card at the tail of the chat — the sibling of `PermissionPrompt`.
|
||||
* `requestId` reuses the backend's tool-call id so the resolver can pair the
|
||||
* answer with the originating call.
|
||||
*/
|
||||
export interface AskUserQuestionPrompt {
|
||||
sessionId: SessionId;
|
||||
requestId: string;
|
||||
questions: AgentQuestion[];
|
||||
}
|
||||
|
||||
// ---- MCP server spec (neutral) -----------------------------------------
|
||||
|
||||
/**
|
||||
|
|
@ -595,6 +631,17 @@ export interface BackendProcess {
|
|||
isRunning(): boolean;
|
||||
onExit(listener: () => void): () => void;
|
||||
setPermissionPrompter(fn: (req: PermissionPrompt) => Promise<PermissionDecision>): void;
|
||||
/**
|
||||
* Optional: register the session-domain handler the backend calls when it
|
||||
* needs the user to answer inline multiple-choice questions (Claude SDK's
|
||||
* `AskUserQuestion`). Mirrors `setPermissionPrompter`; the prompter routes
|
||||
* each request to its owning session, which surfaces an inline card and
|
||||
* resolves the returned promise with the answers (or `{}` on cancel).
|
||||
* Backends with no equivalent surface (ACP today) omit it.
|
||||
*/
|
||||
setAskUserQuestionPrompter?(
|
||||
fn: (req: AskUserQuestionPrompt) => Promise<AgentQuestionAnswers>
|
||||
): void;
|
||||
registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void;
|
||||
newSession(params: OpenSessionInput): Promise<OpenSessionOutput>;
|
||||
prompt(params: PromptInput): Promise<PromptOutput>;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { expandCustomCommandPrefix } from "@/agentMode/session/expandCustomComma
|
|||
import { resolveActiveNoteToken } from "@/agentMode/session/resolveActiveNoteToken";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AskUserQuestionPrompt,
|
||||
CurrentPlan,
|
||||
PermissionPrompt,
|
||||
PromptContent,
|
||||
|
|
@ -159,6 +160,9 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
|
|||
const [pendingToolPermissions, setPendingToolPermissions] = useState<PermissionPrompt[]>(() =>
|
||||
backend.getPendingToolPermissions()
|
||||
);
|
||||
const [pendingAskUserQuestions, setPendingAskUserQuestions] = useState<AskUserQuestionPrompt[]>(
|
||||
() => backend.getPendingAskUserQuestions()
|
||||
);
|
||||
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
|
||||
const [queuedMessages, setQueuedMessages] = useState<QueuedAgentMessage[]>([]);
|
||||
const [selectedTextContexts] = useSelectedTextContexts();
|
||||
|
|
@ -188,6 +192,7 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
|
|||
setHasPendingPlanPermission(backend.hasPendingPlanPermission());
|
||||
setCurrentPlan(backend.getCurrentPlan());
|
||||
setPendingToolPermissions(backend.getPendingToolPermissions());
|
||||
setPendingAskUserQuestions(backend.getPendingAskUserQuestions());
|
||||
};
|
||||
sync();
|
||||
return backend.subscribe(() => {
|
||||
|
|
@ -508,6 +513,7 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
|
|||
onDelete={handleDelete}
|
||||
currentPlan={currentPlan}
|
||||
pendingToolPermissions={pendingToolPermissions}
|
||||
pendingAskUserQuestions={pendingAskUserQuestions}
|
||||
chatBackend={backend}
|
||||
isLoading={loading}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { AgentTrail } from "@/agentMode/ui/AgentTrailView";
|
||||
import { AskUserQuestionCard } from "@/agentMode/ui/AskUserQuestionCard";
|
||||
import { PlanProposalCard } from "@/agentMode/ui/PlanProposalCard";
|
||||
import { ToolPermissionCard } from "@/agentMode/ui/ToolPermissionCard";
|
||||
import { BottomLoadingIndicator } from "@/components/chat-components/BottomLoadingIndicator";
|
||||
|
|
@ -6,7 +7,12 @@ 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, PermissionPrompt } from "@/agentMode/session/types";
|
||||
import type {
|
||||
AgentChatMessage,
|
||||
AskUserQuestionPrompt,
|
||||
CurrentPlan,
|
||||
PermissionPrompt,
|
||||
} from "@/agentMode/session/types";
|
||||
import type { ChatMessage } from "@/types/message";
|
||||
import { App } from "obsidian";
|
||||
import React, { memo, useMemo } from "react";
|
||||
|
|
@ -17,6 +23,7 @@ interface AgentChatMessagesProps {
|
|||
onDelete: (messageId: string) => void;
|
||||
currentPlan: CurrentPlan | null;
|
||||
pendingToolPermissions: PermissionPrompt[];
|
||||
pendingAskUserQuestions: AskUserQuestionPrompt[];
|
||||
chatBackend: AgentChatBackend;
|
||||
/** True while a turn is in flight. The last assistant message in the
|
||||
* visible list is treated as the streaming placeholder. */
|
||||
|
|
@ -48,6 +55,7 @@ const AgentChatMessages = memo(
|
|||
onDelete,
|
||||
currentPlan,
|
||||
pendingToolPermissions,
|
||||
pendingAskUserQuestions,
|
||||
chatBackend,
|
||||
isLoading,
|
||||
}: AgentChatMessagesProps) => {
|
||||
|
|
@ -68,7 +76,15 @@ const AgentChatMessages = memo(
|
|||
onResolve={chatBackend.resolveToolPermission.bind(chatBackend)}
|
||||
/>
|
||||
));
|
||||
const hasTailCards = showPlanCard || pendingToolPermissions.length > 0;
|
||||
const inlineAskUserQuestionCards = pendingAskUserQuestions.map((req) => (
|
||||
<AskUserQuestionCard
|
||||
key={req.requestId}
|
||||
request={req}
|
||||
onResolve={chatBackend.resolveAskUserQuestion.bind(chatBackend)}
|
||||
/>
|
||||
));
|
||||
const hasTailCards =
|
||||
showPlanCard || pendingToolPermissions.length > 0 || pendingAskUserQuestions.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
|
||||
|
|
@ -100,6 +116,7 @@ const AgentChatMessages = memo(
|
|||
{isLoading && <BottomLoadingIndicator />}
|
||||
{inlinePlanCard}
|
||||
{inlineToolPermissionCards}
|
||||
{inlineAskUserQuestionCards}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -169,6 +186,7 @@ const AgentChatMessages = memo(
|
|||
})}
|
||||
{inlinePlanCard}
|
||||
{inlineToolPermissionCards}
|
||||
{inlineAskUserQuestionCards}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
200
src/agentMode/ui/AskUserQuestionCard.tsx
Normal file
200
src/agentMode/ui/AskUserQuestionCard.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AgentQuestion,
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
} from "@/agentMode/session/types";
|
||||
import { MessageCircleQuestion } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface AskUserQuestionCardProps {
|
||||
request: AskUserQuestionPrompt;
|
||||
onResolve: (requestId: string, answers: AgentQuestionAnswers) => void;
|
||||
}
|
||||
|
||||
/** A single-select pick is "answered" once a label is chosen; multi-select is always satisfiable. */
|
||||
function isAnswered(question: AgentQuestion, selection: string | Set<string> | undefined): boolean {
|
||||
if (question.multiSelect) return true;
|
||||
return typeof selection === "string" && selection !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline card rendered at the tail of the chat scroll container while the
|
||||
* agent's `AskUserQuestion` tool waits on the user — the sibling of
|
||||
* `ToolPermissionCard`. Replaces the old `AskUserQuestionModal`: modals steal
|
||||
* focus and resolve as a cancel on accidental click-outside, which is
|
||||
* inconsistent with the rest of Agent Mode's inline-card model.
|
||||
*
|
||||
* A single call may carry several questions; each renders under its own tab so
|
||||
* the card stays compact, while the answers still submit together to honor the
|
||||
* SDK's single-response contract. Submitting routes the answers map through the
|
||||
* ask-question prompter's happy path; Cancel resolves with `{}`, which the
|
||||
* bridge maps to the "User cancelled the question" deny.
|
||||
*/
|
||||
export const AskUserQuestionCard: React.FC<AskUserQuestionCardProps> = ({ request, onResolve }) => {
|
||||
const { questions, requestId } = request;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
// Per-question selection: a single label for radio, a Set of labels for checkbox.
|
||||
const [selections, setSelections] = useState<Record<number, string | Set<string>>>({});
|
||||
|
||||
// Gate Submit until every single-select question has a pick. Multi-select
|
||||
// questions may be left empty (the user can decline every option).
|
||||
const canSubmit = questions.every((q, idx) => isAnswered(q, selections[idx]));
|
||||
|
||||
const submit = (): void => {
|
||||
if (busy || !canSubmit) return;
|
||||
setBusy(true);
|
||||
const answers: AgentQuestionAnswers = {};
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const sel = selections[i];
|
||||
if (q.multiSelect) {
|
||||
answers[q.question] = sel instanceof Set ? Array.from(sel).join(", ") : "";
|
||||
} else {
|
||||
answers[q.question] = typeof sel === "string" ? sel : "";
|
||||
}
|
||||
}
|
||||
onResolve(requestId, answers);
|
||||
};
|
||||
|
||||
const cancel = (): void => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
onResolve(requestId, {});
|
||||
};
|
||||
|
||||
const showTabs = questions.length > 1;
|
||||
const active = questions[activeTab] ?? questions[0];
|
||||
const activeIdx = questions[activeTab] ? activeTab : 0;
|
||||
|
||||
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">
|
||||
<MessageCircleQuestion className="tw-size-4 tw-shrink-0 tw-text-accent" />
|
||||
<div className="tw-truncate tw-text-sm tw-font-medium">Question from agent</div>
|
||||
</div>
|
||||
|
||||
<div className="tw-flex tw-flex-col tw-gap-2 tw-px-3 tw-py-2">
|
||||
{showTabs ? (
|
||||
<div role="tablist" className="copilot-divider-b tw-flex tw-flex-wrap tw-gap-x-1">
|
||||
{questions.map((q, idx) => {
|
||||
const selected = idx === activeIdx;
|
||||
return (
|
||||
<button
|
||||
key={q.question}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
disabled={busy}
|
||||
onClick={() => setActiveTab(idx)}
|
||||
className={cn(
|
||||
// Underline tab: a colored inset bottom edge marks the
|
||||
// active question. box-shadow (not a border) avoids the
|
||||
// preflight-off border-style leak, and overlaps the
|
||||
// tablist's divider so the accent replaces the grey rule.
|
||||
"tw--mb-px !tw-rounded-none !tw-border-none !tw-bg-transparent tw-p-1.5 tw-text-sm tw-transition-colors",
|
||||
"disabled:tw-cursor-not-allowed disabled:tw-opacity-50",
|
||||
selected
|
||||
? "tw-font-medium tw-text-normal !tw-shadow-[inset_0_-2px_0_0_var(--interactive-accent)]"
|
||||
: "tw-text-muted !tw-shadow-none hover:tw-text-normal"
|
||||
)}
|
||||
>
|
||||
{q.header || `Question ${idx + 1}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<QuestionPanel
|
||||
key={active.question}
|
||||
question={active}
|
||||
name={`askq-${requestId}-${activeIdx}`}
|
||||
selection={selections[activeIdx]}
|
||||
disabled={busy}
|
||||
onToggle={(label) =>
|
||||
setSelections((prev) => {
|
||||
if (active.multiSelect) {
|
||||
const cur = prev[activeIdx];
|
||||
const next = new Set(cur instanceof Set ? cur : []);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return { ...prev, [activeIdx]: next };
|
||||
}
|
||||
return { ...prev, [activeIdx]: label };
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="copilot-divider-t tw-flex tw-flex-wrap tw-items-center tw-justify-end tw-gap-2 tw-px-3 tw-py-2">
|
||||
<Button variant="secondary" size="sm" disabled={busy} onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" size="sm" disabled={busy || !canSubmit} onClick={submit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface QuestionPanelProps {
|
||||
question: AgentQuestion;
|
||||
/** Radio-group name; namespaced by requestId + index so cards don't collide. */
|
||||
name: string;
|
||||
selection: string | Set<string> | undefined;
|
||||
disabled: boolean;
|
||||
onToggle: (label: string) => void;
|
||||
}
|
||||
|
||||
/** The active question's prompt text plus its single- or multi-select option list. */
|
||||
const QuestionPanel: React.FC<QuestionPanelProps> = ({
|
||||
question,
|
||||
name,
|
||||
selection,
|
||||
disabled,
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<div role="tabpanel" className="tw-flex tw-flex-col tw-gap-2">
|
||||
<div className="tw-text-sm">{question.question}</div>
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
{question.options.map((opt) => {
|
||||
const checked = question.multiSelect
|
||||
? selection instanceof Set && selection.has(opt.label)
|
||||
: selection === opt.label;
|
||||
return (
|
||||
<label
|
||||
key={opt.label}
|
||||
className="tw-flex tw-cursor-pointer tw-items-start tw-gap-2 tw-rounded tw-px-2 tw-py-1.5 hover:tw-bg-modifier-hover"
|
||||
>
|
||||
{/* Center the control in a box matching the label's line height so
|
||||
it top-aligns with the first line of text, not its mid-point.
|
||||
`tw-m-0` strips the asymmetric default margin browsers give
|
||||
native checkboxes/radios, which was throwing off alignment. */}
|
||||
<span className="tw-flex tw-h-5 tw-shrink-0 tw-items-center">
|
||||
<input
|
||||
type={question.multiSelect ? "checkbox" : "radio"}
|
||||
name={name}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => onToggle(opt.label)}
|
||||
className="tw-m-0"
|
||||
/>
|
||||
</span>
|
||||
<div className="tw-min-w-0">
|
||||
<div className="tw-text-sm tw-leading-5">{opt.label}</div>
|
||||
{opt.description ? (
|
||||
<div className="tw-text-xs tw-text-muted">{opt.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -7,4 +7,7 @@ export {
|
|||
useBackendInstallState,
|
||||
useSessionBackendDescriptor,
|
||||
} from "./useBackendDescriptor";
|
||||
export { createDefaultPermissionPrompter } from "./permissionPrompter";
|
||||
export {
|
||||
createDefaultAskUserQuestionPrompter,
|
||||
createDefaultPermissionPrompter,
|
||||
} from "./permissionPrompter";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import type { PermissionPrompter } from "@/agentMode/session/AgentSessionManager";
|
||||
import type {
|
||||
AskUserQuestionPrompter,
|
||||
PermissionPrompter,
|
||||
} from "@/agentMode/session/AgentSessionManager";
|
||||
import type { SessionId } from "@/agentMode/session/types";
|
||||
|
||||
/**
|
||||
|
|
@ -21,3 +24,20 @@ export function createDefaultPermissionPrompter(
|
|||
return session.handleToolPermission(req);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AskUserQuestion requests route into the owning session so the user answers
|
||||
* via an inline card in the chat instead of a modal — the sibling of
|
||||
* `createDefaultPermissionPrompter`. Returns `{}` (the cancellation signal)
|
||||
* when no session owns the request, so the SDK turn unblocks with the standard
|
||||
* cancellation deny instead of hanging on a dangling promise.
|
||||
*/
|
||||
export function createDefaultAskUserQuestionPrompter(
|
||||
resolveSession: (backendSessionId: SessionId) => AgentSession | null
|
||||
): AskUserQuestionPrompter {
|
||||
return (req) => {
|
||||
const session = resolveSession(req.sessionId);
|
||||
if (!session) return Promise.resolve({});
|
||||
return session.handleAskUserQuestion(req);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,16 @@ If your plugin does not need CSS, delete this file.
|
|||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
/* Bottom-only divider. Preflight is off, so `tw-border-b tw-border-solid`
|
||||
/* Single-side dividers. Preflight is off, so `tw-border-b tw-border-solid`
|
||||
leaks `border-style: solid` to all sides and renders all four borders. */
|
||||
.copilot-divider-b {
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.copilot-divider-t {
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
Loading…
Reference in a new issue