Skip to content

Commit a48fe7e

Browse files
oratisclaude
andauthored
feat(desktop): AskUserQuestion inline UI (was a dead no-op) (#101)
The desktop never wired the agent's askUser callback, so any AskUserQuestion tool call hung forever (agent.answer was a no-op). Wire it end-to-end, mirroring the existing approval round-trip: - mac-agent: pass an askUser callback into runAgent (new onAskUser arg). - window-shim: emit an `ask_user` event with a requestId + question/options, stash the resolver, and resolve it in a now-real agent.answer({requestId,answer}). - Repl: render an inline question card with a button per option (disables the composer until answered), records the choice as a system breadcrumb. Typecheck + lint + format clean; verified live via tauri dev HMR. (UI — still worth an eyeball when an AskUserQuestion actually fires.) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70e93a0 commit a48fe7e

4 files changed

Lines changed: 102 additions & 14 deletions

File tree

apps/desktop/src/lib/mac-agent.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ export interface StartTurnArgs {
112112
* 'always' — permit + persist a permissions.allow matcher
113113
*/
114114
onApproval?: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>;
115+
/** Called when the agent's AskUserQuestion tool needs an answer. Resolves to
116+
* the chosen option label (or free text). */
117+
onAskUser?: (req: {
118+
question: string;
119+
options: Array<{ label: string; description: string }>;
120+
multiSelect?: boolean;
121+
}) => Promise<string>;
115122
}
116123

117124
export interface StartTurnResult {
@@ -184,6 +191,7 @@ export async function startAgentTurn(args: StartTurnArgs): Promise<StartTurnResu
184191
return decision === 'allow';
185192
}
186193
: undefined,
194+
askUser: args.onAskUser ? async (req) => args.onAskUser!(req) : undefined,
187195
onEvent: args.onEvent,
188196
// No hook dispatcher, no sessions persistence, no autoCompact in v1 Mac MVP.
189197
});

apps/desktop/src/lib/window-shim.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ function emitEvent(e: unknown): void {
3636
// resolver here. The UI calls api.agent.approve({ requestId, decision })
3737
// which pops the resolver and resolves the original promise.
3838
const pendingApprovals = new Map<string, (decision: 'allow' | 'deny' | 'always') => void>();
39+
// AskUserQuestion round-trips: same pattern — emit an `ask_user` event, stash
40+
// the resolver, resolve it when the UI calls api.agent.answer({ requestId, answer }).
41+
const pendingQuestions = new Map<string, (answer: string) => void>();
3942

4043
function nextRequestId(): string {
4144
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
@@ -138,6 +141,21 @@ export function installTauriShim(): void {
138141
});
139142
});
140143
},
144+
onAskUser: (req) => {
145+
const requestId = nextRequestId();
146+
return new Promise<string>((resolve) => {
147+
pendingQuestions.set(requestId, resolve);
148+
emitEvent({
149+
kind: 'event',
150+
turnId: pendingTurnId,
151+
type: 'ask_user',
152+
requestId,
153+
question: req.question,
154+
options: req.options,
155+
multiSelect: req.multiSelect,
156+
});
157+
});
158+
},
141159
});
142160
pendingTurnId = result.turnId;
143161
return result;
@@ -156,10 +174,11 @@ export function installTauriShim(): void {
156174
pendingApprovals.delete(requestId);
157175
resolver(decision);
158176
},
159-
async answer() {
160-
// AskUserQuestion answers: same — for v1 Mac MVP we don't wire
161-
// the inline askUser callback because the renderer doesn't yet
162-
// surface that UI.
177+
async answer({ requestId, answer }) {
178+
const resolver = pendingQuestions.get(requestId);
179+
if (!resolver) return; // stale / already answered
180+
pendingQuestions.delete(requestId);
181+
resolver(answer);
163182
},
164183
onEvent(cb: (e: unknown) => void): () => void {
165184
listeners.push(cb);

apps/desktop/src/screens/Repl.tsx

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ interface AgentEvt {
179179
requestId?: string;
180180
toolName?: string;
181181
reason?: string;
182+
// ask_user fields (AskUserQuestion tool)
183+
question?: string;
184+
options?: Array<{ label: string; description: string }>;
185+
multiSelect?: boolean;
182186
// tool_use carries id; we use it on tool_result to attach output to the right card
183187
id?: string;
184188
}
@@ -189,6 +193,13 @@ interface PendingApproval {
189193
reason: string;
190194
}
191195

196+
interface PendingQuestion {
197+
requestId: string;
198+
question: string;
199+
options: Array<{ label: string; description: string }>;
200+
multiSelect?: boolean;
201+
}
202+
192203
// ─── Component ────────────────────────────────────────────────────────
193204

194205
export function ReplScreen({
@@ -217,6 +228,7 @@ export function ReplScreen({
217228
const [busy, setBusy] = useState(false);
218229
const [activeTurnId, setActiveTurnId] = useState<string | null>(null);
219230
const [pendingApproval, setPendingApproval] = useState<PendingApproval | null>(null);
231+
const [pendingQuestion, setPendingQuestion] = useState<PendingQuestion | null>(null);
220232
// 'high' (6k output) by default — 'medium' (3k) truncates multi-file writes.
221233
// Overridden by a persisted effortLevel in settings on mount.
222234
const [effort, setEffort] = useState<Effort>('high');
@@ -279,6 +291,8 @@ export function ReplScreen({
279291
if (e.kind === 'turn_done') {
280292
setBusy(false);
281293
setActiveTurnId(null);
294+
setPendingApproval(null);
295+
setPendingQuestion(null);
282296
setMessages((m) => finalizeStreaming(m));
283297
onTurnComplete?.();
284298
return;
@@ -351,6 +365,16 @@ export function ReplScreen({
351365
});
352366
}
353367
break;
368+
case 'ask_user':
369+
if (e.requestId && e.question) {
370+
setPendingQuestion({
371+
requestId: e.requestId,
372+
question: e.question,
373+
options: e.options ?? [],
374+
multiSelect: e.multiSelect,
375+
});
376+
}
377+
break;
354378
}
355379
});
356380
return () => off();
@@ -491,11 +515,20 @@ export function ReplScreen({
491515
await window.deepcode.agent.approve({ requestId: req.requestId, decision });
492516
}
493517

518+
// ── AskUserQuestion answer ──
519+
async function handleAnswer(answer: string): Promise<void> {
520+
if (!pendingQuestion) return;
521+
const req = pendingQuestion;
522+
setPendingQuestion(null);
523+
setMessages((m) => [...m, { role: 'system', text: `❯ ${req.question}${answer}` }]);
524+
await window.deepcode.agent.answer({ requestId: req.requestId, answer });
525+
}
526+
494527
// ── Send ──
495528
async function handleSubmit(e: React.FormEvent): Promise<void> {
496529
e.preventDefault();
497530
const text = input.trim();
498-
if (!text || busy || pendingApproval) return;
531+
if (!text || busy || pendingApproval || pendingQuestion) return;
499532
setInput('');
500533
setMessages((m) => [...m, { role: 'user', text }]);
501534
setBusy(true);
@@ -530,7 +563,7 @@ export function ReplScreen({
530563
// Lock all the toolbar controls (mode / model / effort) once a turn
531564
// is in flight or pending approval — changing them mid-turn would
532565
// contradict the system prompt already sent.
533-
const controlsLocked = busy || pendingApproval !== null;
566+
const controlsLocked = busy || pendingApproval !== null || pendingQuestion !== null;
534567

535568
// Only the last assistant turn is "active" — its cursor blinks while the rest
536569
// stay static. Guards against a second cursor if a turn was left streaming.
@@ -577,7 +610,7 @@ export function ReplScreen({
577610
renderMessage(m, i, pendingApproval, handleApproval, i === activeAssistantIdx),
578611
)}
579612

580-
{busy && !pendingApproval && (
613+
{busy && !pendingApproval && !pendingQuestion && (
581614
<div className="msg assistant">
582615
<div className="avatar">DC</div>
583616
<div className="body">
@@ -588,6 +621,31 @@ export function ReplScreen({
588621
</div>
589622
</div>
590623
)}
624+
625+
{pendingQuestion && (
626+
<div className="msg assistant">
627+
<div className="avatar">DC</div>
628+
<div className="body">
629+
<div className="author">DeepCode · needs your input</div>
630+
<div className="content">
631+
<div style={{ marginBottom: 8 }}>{pendingQuestion.question}</div>
632+
<div className="approval-row" style={{ flexWrap: 'wrap' }}>
633+
{pendingQuestion.options.map((o) => (
634+
<button
635+
key={o.label}
636+
type="button"
637+
className="btn btn-secondary"
638+
title={o.description}
639+
onClick={() => void handleAnswer(o.label)}
640+
>
641+
{o.label}
642+
</button>
643+
))}
644+
</div>
645+
</div>
646+
</div>
647+
</div>
648+
)}
591649
</div>
592650

593651
<div className="composer">
@@ -599,13 +657,15 @@ export function ReplScreen({
599657
onChange={(e) => setInput(e.target.value)}
600658
onKeyDown={handleKeyDown}
601659
placeholder={
602-
pendingApproval
603-
? 'Approve or reject the tool call above to continue…'
604-
: busy
605-
? 'Agent is responding…'
606-
: '问点什么… @ 引用文件 · / 命令 · # 写入 DEEPCODE.md'
660+
pendingQuestion
661+
? 'Pick an option above to continue…'
662+
: pendingApproval
663+
? 'Approve or reject the tool call above to continue…'
664+
: busy
665+
? 'Agent is responding…'
666+
: '问点什么… @ 引用文件 · / 命令 · # 写入 DEEPCODE.md'
607667
}
608-
disabled={busy || pendingApproval !== null}
668+
disabled={busy || pendingApproval !== null || pendingQuestion !== null}
609669
rows={Math.min(6, Math.max(1, input.split('\n').length))}
610670
/>
611671
<div className="toolbar">

apps/desktop/src/types/global.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export interface DeepCodeAPI {
7979
/** Resolve an in-flight permission_request event. `decision === 'always'`
8080
* also persists a matcher to ~/.deepcode/settings.json. */
8181
approve: (args: { requestId: string; decision: 'allow' | 'deny' | 'always' }) => Promise<void>;
82-
answer: (args: { turnId: string; questionId: string; answer: string }) => Promise<void>;
82+
/** Resolve an in-flight ask_user event with the chosen option label / text. */
83+
answer: (args: { requestId: string; answer: string }) => Promise<void>;
8384
onEvent: (cb: (e: unknown) => void) => () => void;
8485
};
8586
onUpdateDownloaded: (cb: (info: UpdateInfo) => void) => () => void;

0 commit comments

Comments
 (0)