From 9fc0ad44fd70bcc4fc96795a1109e00606e960c4 Mon Sep 17 00:00:00 2001 From: PythonTilk Date: Sun, 4 Oct 2026 23:57:36 +0200 Subject: [PATCH] feat(windows): answer Claude Code's questions from the island AskUserQuestion arrived as a permission request and got the Deny / Allow card, which says nothing about what is being asked and cannot answer it. The question view now shows the question and its options as buttons. Picking one sends the answer back to the session; several questions are stepped through in turn, pick-several questions get toggles and a Done button, and "Answer in terminal" hands the request back as before. The answer travels as {"answers":{...}} on the relay connection. coucou-hook turns it into an allow whose updatedInput is the question's own input plus that map, and refuses anything else: no other field and no other tool's input can be changed from the island. Co-Authored-By: Claude Opus 5.5 --- windows/README.md | 3 +- windows/hook/src/main.rs | 77 ++++++++++++++++++++++++----- windows/src-tauri/src/lib.rs | 11 +++++ windows/src-tauri/src/pipe.rs | 27 ++++++++-- windows/src/core/bridge.ts | 4 ++ windows/src/core/layout.ts | 3 ++ windows/src/core/state.ts | 9 ++++ windows/src/island/hooks.ts | 39 +++++++++++++-- windows/src/island/island.ts | 37 +++++++++++--- windows/src/style.css | 18 +++++++ windows/src/views/views.ts | 93 ++++++++++++++++++++++++++++++++--- 11 files changed, 283 insertions(+), 38 deletions(-) diff --git a/windows/README.md b/windows/README.md index 60374024cf..2e06932913 100644 --- a/windows/README.md +++ b/windows/README.md @@ -48,7 +48,8 @@ installs for the current user only — no admin prompt. | Tray icon | Open, Settings…, Pause, Quit | Everything else happens on its own: a Claude Code permission request opens the -island with **Deny / Allow**, a finished session shows what it did, and +island with **Deny / Allow**, a question from Claude Code shows its options to +pick from, a finished session shows what it did, and your integrations sit in the coloured pills next to Mochi. ## Claude Code diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 5bb3637818..a4eb45653a 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -45,7 +45,7 @@ mod unix; use unix::connect; fn main() { - let Some((payload, event)) = read_event() else { std::process::exit(0) }; + let Some((payload, event, question)) = read_event() else { std::process::exit(0) }; let waits_for_answer = event == "PermissionRequest"; let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; @@ -60,7 +60,7 @@ fn main() { }); if let Ok(Some(decision)) = rx.recv_timeout(budget) { - if let Some(json) = decision_json(&decision) { + if let Some(json) = decision_json(&decision, question.as_ref()) { let mut out = std::io::stdout(); let _ = writeln!(out, "{json}"); let _ = out.flush(); @@ -73,7 +73,30 @@ fn main() { /// The documented PermissionRequest output. Anything we do not recognise prints /// nothing at all rather than guessing — silence is the safe answer. /// See https://code.claude.com/docs/en/hooks -fn decision_json(decision: &str) -> Option { +/// +/// `question` is the AskUserQuestion input, when that is what is being asked: +/// the island may then answer it, which Claude Code takes as the same input +/// with an `answers` map added. Nothing else of the input can be changed from +/// the island, and no other tool's input can be changed at all. +fn decision_json(decision: &str, question: Option<&serde_json::Value>) -> Option { + if decision.trim_start().starts_with('{') { + let reply = serde_json::from_str::(decision).ok()?; + let answers = reply.get("answers")?.as_object()?; + if answers.is_empty() || !answers.values().all(|v| v.is_string()) { + return None; + } + let mut input = question?.as_object()?.clone(); + input.insert("answers".into(), serde_json::Value::Object(answers.clone())); + return Some( + serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { "behavior": "allow", "updatedInput": input }, + } + }) + .to_string(), + ); + } let behavior = match decision.trim() { // "always" still answers a plain allow; remembering it is the island's // business, not Claude Code's. @@ -86,8 +109,9 @@ fn decision_json(decision: &str) -> Option { )) } -/// Reads stdin and returns the payload to forward plus the event name. -fn read_event() -> Option<(String, String)> { +/// Reads stdin and returns the payload to forward, the event name, and — for an +/// AskUserQuestion permission request — the question as Claude Code sent it. +fn read_event() -> Option<(String, String, Option)> { let mut raw = Vec::new(); if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() { return None; @@ -161,11 +185,17 @@ fn read_event() -> Option<(String, String)> { } } + // Kept whole: what goes back to Claude Code must be its own input, not the + // shortened copy the island is shown. + let question = (map.get("tool_name").and_then(|v| v.as_str()) == Some("AskUserQuestion")) + .then(|| map.get("tool_input").cloned()) + .flatten(); + truncate_strings(&mut payload); let mut line = payload.to_string(); line.push('\n'); - Some((line, event)) + Some((line, event, question)) } /// Caps every string in the payload. A single Write can carry a whole file. @@ -226,23 +256,46 @@ mod tests { #[test] fn decision_json_matches_the_documented_shape() { assert_eq!( - decision_json("allow").unwrap(), + decision_json("allow", None).unwrap(), r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"# ); assert_eq!( - decision_json("deny").unwrap(), + decision_json("deny", None).unwrap(), r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Coucou"}}}"# ); // "always" is an island concept; Claude Code just gets an allow. - assert!(decision_json("always").unwrap().contains(r#""behavior":"allow""#)); + assert!(decision_json("always", None).unwrap().contains(r#""behavior":"allow""#)); } #[test] fn anything_unrecognised_prints_nothing() { - assert!(decision_json("").is_none()); - assert!(decision_json("maybe").is_none()); + assert!(decision_json("", None).is_none()); + assert!(decision_json("maybe", None).is_none()); // The shape the app used to send must not be mistaken for a decision. - assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none()); + assert!(decision_json(r#"{"permissionDecision":"allow"}"#, None).is_none()); + } + + #[test] + fn an_answered_question_goes_back_as_the_same_input_plus_answers() { + let question = serde_json::json!({ + "questions": [{ "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }] + }); + let out = decision_json(r#"{"answers":{"Which one?":"B"}}"#, Some(&question)).unwrap(); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let decision = &v["hookSpecificOutput"]["decision"]; + assert_eq!(decision["behavior"], "allow"); + assert_eq!(decision["updatedInput"]["questions"], question["questions"]); + assert_eq!(decision["updatedInput"]["answers"]["Which one?"], "B"); + } + + #[test] + fn answers_are_only_accepted_for_a_question() { + // Any other tool: the island cannot rewrite its input. + assert!(decision_json(r#"{"answers":{"q":"a"}}"#, None).is_none()); + let question = serde_json::json!({ "questions": [] }); + assert!(decision_json(r#"{"answers":{}}"#, Some(&question)).is_none()); + assert!(decision_json(r#"{"answers":{"q":1}}"#, Some(&question)).is_none()); + assert!(decision_json(r#"{"command":"rm -rf /"}"#, Some(&question)).is_none()); } #[test] diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59e..c3f8881cc7 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -216,6 +216,16 @@ fn approval_decision(app: AppHandle, request_id: String, decision: String) { pipe::answer(&app, &request_id, &decision); } +/// An option picked on the island for a question Claude Code asked. +#[tauri::command] +fn approval_answer( + app: AppHandle, + request_id: String, + answers: std::collections::HashMap, +) { + pipe::answer_question(&app, &request_id, &answers); +} + /// The island has the card on screen, so the long wait for a human may begin. /// Until this arrives the relay only waits a few hundred milliseconds, which is /// what stops a paused or unresponsive island from freezing Claude Code. @@ -388,6 +398,7 @@ pub fn run() { hooks_preview, hooks_apply, approval_decision, + approval_answer, approval_ack, approval_decline, log_line, diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 36b6316c24..8e0bbcada6 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -14,9 +14,11 @@ // * whatever happens we drop the connection after the decision timeout, and // the terminal takes over. // -// What we write back is the bare word `allow` or `deny`. Turning that into the -// documented hookSpecificOutput JSON is coucou-hook's job, so the wire format -// Claude Code expects lives in exactly one place. +// What we write back is the bare word `allow` or `deny` — or, when the request +// was Claude Code asking a question, `{"answers":{…}}` with what was picked on +// the island. Turning either into the documented hookSpecificOutput JSON is +// coucou-hook's job, so the wire format Claude Code expects lives in exactly +// one place. use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -230,7 +232,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option {} // A click that beats the ack is still a click. Ok(Some(Reply::Decision(d))) => { - log::line(format!("hook id={id} answered {d}")); + log::line(format!("hook id={id} answered {}", loggable(&d))); return Some(d); } Ok(Some(Reply::Decline)) => { @@ -246,7 +248,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option { - log::line(format!("hook id={id} answered {d}")); + log::line(format!("hook id={id} answered {}", loggable(&d))); Some(d) } Ok(Some(Reply::Decline)) => { @@ -260,6 +262,11 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option &str { + if decision.starts_with('{') { "a question" } else { decision } +} + fn send(app: &AppHandle, request_id: &str, reply: Reply, keep: bool) { let sender = { let pending = app.state::(); @@ -295,3 +302,13 @@ pub fn answer(app: &AppHandle, request_id: &str, decision: &str) { log::line(format!("decision id={request_id} {word}")); send(app, request_id, Reply::Decision(word.to_string()), false); } + +/// Called when an option is picked for a question Claude Code asked. `answers` +/// maps each question's text to the chosen label, which is the shape +/// AskUserQuestion takes them in. +pub fn answer_question(app: &AppHandle, request_id: &str, answers: &HashMap) { + log::line(format!("decision id={request_id} answered a question")); + // One line: the relay reads up to the first newline. + let line = json!({ "answers": answers }).to_string(); + send(app, request_id, Reply::Decision(line), false); +} diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff9..18fd1b59bb 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -77,6 +77,10 @@ export const Bridge = { call("approval_decision", { requestId, decision }), /** "The card is up" — until this lands the relay only waits a moment. */ approvalAck: (requestId: string) => call("approval_ack", { requestId }), + /** Answers a question Claude Code asked: question text → chosen label. */ + approvalAnswer: (requestId: string, answers: Record) => + call("approval_answer", { requestId, answers }), + /** "Nobody can act on this" — Claude Code asks in the terminal right away. */ approvalDecline: (requestId: string) => call("approval_decline", { requestId }), diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c5..2b450a48f1 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -92,6 +92,9 @@ export const VIEW_LAYOUTS: Record = { // dropped the whole sequence — Mochi included — is drawn by src/upload, which // owns its own constants (USC) straight from UploadSequenceEngine.swift. +/** The question view with options to pick from: room for two rows of them. */ +export const QUESTION_PICKER_H = 200; + /** Chat view grows with the conversation — IslandContainer.chatPromptHeight. */ export function chatPromptHeight(messageCount: number): number { return Math.min(300, 240 + messageCount * 40); diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a9..b6060e197c 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -26,6 +26,15 @@ export interface ApprovalInfo { sessionId: string; tool: string; command: string; + /** Set when Claude Code is asking a question rather than for a permission. */ + questions?: AskedQuestion[]; +} + +/** One question of an AskUserQuestion call. */ +export interface AskedQuestion { + question: string; + options: { label: string; description: string }[]; + multiSelect: boolean; } export interface ChatMessage { diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index d90a78f099..037091b603 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -5,7 +5,7 @@ import { Bridge, onEvent } from "../core/bridge"; import { Sound } from "../core/sound"; -import { State } from "../core/state"; +import { State, type AskedQuestion } from "../core/state"; import type { Island } from "./island"; const CLAUDE_ID = "integration_claude"; @@ -120,6 +120,28 @@ function approvalTarget(tool: string, input: Record): string { return tool; } +/** + * The questions of an AskUserQuestion call, if the island can show all of them + * as options to pick from. Anything it cannot is left to the terminal. + */ +function askedQuestions(tool: string, input: Record): AskedQuestion[] | null { + if (tool !== "AskUserQuestion" || !Array.isArray(input.questions)) return null; + const out: AskedQuestion[] = []; + for (const raw of input.questions as Record[]) { + const question = typeof raw?.question === "string" ? raw.question : ""; + const options = (Array.isArray(raw?.options) ? (raw.options as Record[]) : []) + .filter((o) => typeof o?.label === "string" && o.label) + .map((o) => ({ + label: o.label as string, + description: typeof o.description === "string" ? o.description : "", + })); + // A question cut short by the relay would be answered under the wrong text. + if (!question || question.endsWith("…") || options.length < 2) return null; + out.push({ question, options, multiSelect: raw.multiSelect === true }); + } + return out.length > 0 ? out : null; +} + function upsert(projectName: string, cwd: string) { const t = State.tasks.find((x) => x.id === CLAUDE_ID); if (!t) return; @@ -291,20 +313,25 @@ function handleHook(island: Island, payload: HookPayload) { if (pendingTimeout != null) window.clearTimeout(pendingTimeout); const tool = payload.tool_name ?? "Tool"; const input = payload.tool_input ?? {}; + // Claude Code asking a question is not a permission to grant: the island + // shows the options and sends back the one that was picked. + const questions = askedQuestions(tool, input); + const view = questions ? "question" : "approval"; State.pendingApproval = { requestId, sessionId: payload.session_id ?? "", tool, command: approvalTarget(tool, input), + ...(questions ? { questions } : {}), }; // The relay's short ack window closes in 800 ms; everything below this // line is synchronous, so the card really is up by the time it lands. if (requestId) void Bridge.approvalAck(requestId); - State.updateTask(CLAUDE_ID, "approval"); + State.updateTask(CLAUDE_ID, view); State.isPinned = true; - Sound.play("approval"); + Sound.play(view); if (focused) { - island.alert("approval"); + island.alert(view); } else { // Another agent holds the view, so the card would yank it away. The badge // is the signal instead — but it has to be on screen for that to mean @@ -322,7 +349,9 @@ function handleHook(island: Island, payload: HookPayload) { island.dropPin(); State.updateTask(CLAUDE_ID, "working"); State.setPillBadge(CLAUDE_ID, null); - if (State.view === "approval") island.setView(State.defaultView()); + if (State.view === "approval" || State.view === "question") { + island.setView(State.defaultView()); + } State.notify(); }, 110_000); break; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb618027..7aee49bad4 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -7,6 +7,7 @@ import { EXPANDED_CORNER, EXPANDED_W, NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, islandSize, + QUESTION_PICKER_H, type IslandMode, type IslandViewName, } from "../core/layout"; import { Sound } from "../core/sound"; @@ -101,6 +102,16 @@ export class Island { }); } + /** The request has its answer: the card goes and the session carries on. */ + private closeApproval() { + State.pendingApproval = null; + State.isPinned = false; + this.fsm.pinned = false; + State.updateTask("integration_claude", "working"); + State.setPillBadge("integration_claude", null); + this.setView(State.defaultView()); + } + // ── DOM ───────────────────────────────────────────────────────────────────── private build() { @@ -140,12 +151,21 @@ export class Island { if (!req) return; Sound.play(d === "deny" ? "blip" : "approve"); void Bridge.approvalDecision(req.requestId, d); - State.pendingApproval = null; - State.isPinned = false; - this.fsm.pinned = false; - State.updateTask("integration_claude", "working"); - State.setPillBadge("integration_claude", null); - this.setView(State.defaultView()); + this.closeApproval(); + }, + answer: (answers) => { + const req = State.pendingApproval; + if (!req) return; + Sound.play("approve"); + void Bridge.approvalAnswer(req.requestId, answers); + this.closeApproval(); + }, + answerInTerminal: () => { + const req = State.pendingApproval; + if (!req) return; + Sound.play("blip"); + void Bridge.approvalDecline(req.requestId); + this.closeApproval(); }, toggleSound: () => { State.settings.soundEnabled = !State.settings.soundEnabled; @@ -450,7 +470,10 @@ export class Island { // ── Geometry ──────────────────────────────────────────────────────────────── private targetSize(): { w: number; h: number; r: number } { - const { w, h } = islandSize(State.mode, State.view, State.chatHistory.length); + let { w, h } = islandSize(State.mode, State.view, State.chatHistory.length); + if (State.mode === "expanded" && State.view === "question" && State.pendingApproval?.questions) { + h = QUESTION_PICKER_H; + } const r = State.mode === "expanded" ? EXPANDED_CORNER : ROUNDED_CORNER; return { w, h, r }; } diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c8..a93b2713f3 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -640,6 +640,24 @@ body { gap: 8px; } +/* Options to pick from when Claude Code asks a question. */ +.actions.options { + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.question-text { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.btn.off { + opacity: 0.4; +} + .status-badge { display: inline-flex; align-items: center; diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f7..8c42af7e54 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -21,6 +21,10 @@ export interface ViewActions { openTarget(): void; openUrl(url: string): void; decide(d: "allow" | "deny"): void; + /** Answers the question Claude Code asked: question text → chosen label. */ + answer(answers: Record): void; + /** Hands the pending request back to the terminal. */ + answerInTerminal(): void; toggleSound(): void; setVolume(v: number): void; setAutoClose(seconds: number): void; @@ -319,20 +323,93 @@ function buildApproval(actions: ViewActions): ViewHost { // ── Question ────────────────────────────────────────────────────────────────── -function buildQuestion(): ViewHost { +function buildQuestion(actions: ViewActions): ViewHost { const who = h("div"); - const title = h("div", { class: "title" }); - const row = h("div", { class: "actions" }); + const title = h("div", { class: "title question-text" }); + const row = h("div", { class: "actions options" }); const el = h("div", { class: "view" }, card("cyan", stack(116, 16, who, title, row))); + + // Where we are in the request on screen: which question, what is answered so + // far, and what is ticked in a pick-several question. + let requestId = ""; + let index = 0; + let answers: Record = {}; + let picked = new Set(); + // The buttons are only rebuilt when what they show changes: rebuilding them + // between a mouse-down and a mouse-up would swallow the click. + let rowKey = ""; + + const next = (question: string, answer: string, total: number) => { + answers[question] = answer; + picked = new Set(); + index += 1; + if (index >= total) actions.answer(answers); + else State.notify(); + }; + return { el, sync() { + const questions = State.pendingApproval?.questions; clear(who); - who.append(agentWho(State.focusTask, "Claude Code is asking a question")); - const task = State.focusTask; - title.textContent = task?.steps.at(-1) ?? "Claude needs an answer."; + + // A question that arrived as a notification has nothing to pick from. + if (!questions) { + who.append(agentWho(State.focusTask, "is asking a question")); + title.textContent = State.focusTask?.steps.at(-1) ?? "Claude needs an answer."; + if (rowKey !== "terminal") { + rowKey = "terminal"; + clear(row); + row.append(h("div", { class: "sub", text: "Answer it in your terminal." })); + } + return; + } + + if (State.pendingApproval!.requestId !== requestId) { + requestId = State.pendingApproval!.requestId; + index = 0; + answers = {}; + picked = new Set(); + } + const q = questions[Math.min(index, questions.length - 1)]; + const step = questions.length > 1 ? ` (${index + 1} of ${questions.length})` : ""; + who.append(agentWho(State.focusTask, `is asking${step}`)); + title.textContent = q.question; + title.title = q.question; + + const key = `${requestId}:${index}:${[...picked].join("|")}`; + if (rowKey === key) return; + rowKey = key; clear(row); - row.append(h("div", { class: "sub", text: "Answer in your terminal — Coucou can't reply for you yet." })); + for (const option of q.options) { + const on = picked.has(option.label); + const button = btn(option.label, on ? "primary" : "secondary", () => { + if (!q.multiSelect) { + next(q.question, option.label, questions.length); + return; + } + if (on) picked.delete(option.label); + else picked.add(option.label); + State.notify(); + }); + if (option.description) button.title = option.description; + row.append(button); + } + if (q.multiSelect) { + const done = btn("Done", "primary", () => { + if (picked.size > 0) next(q.question, [...picked].join(", "), questions.length); + }); + if (picked.size === 0) done.classList.add("off"); + row.append(done); + } + row.append( + h("button", { + class: "link-btn", + style: "color:#8e939c", + text: "Answer in terminal", + onclick: () => actions.answerInTerminal(), + }), + ); }, }; } @@ -491,7 +568,7 @@ export function buildViews( map.set("overview", buildOverview(actions)); map.set("empty", buildEmpty(actions)); map.set("approval", buildApproval(actions)); - map.set("question", buildQuestion()); + map.set("question", buildQuestion(actions)); map.set("error", buildError(actions)); map.set("finished", buildFinished(actions)); map.set("confused", buildConfused());