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());