Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 65 additions & 12 deletions windows/hook/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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();
Expand All @@ -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<String> {
///
/// `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<String> {
if decision.trim_start().starts_with('{') {
let reply = serde_json::from_str::<serde_json::Value>(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.
Expand All @@ -86,8 +109,9 @@ fn decision_json(decision: &str) -> Option<String> {
))
}

/// 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<serde_json::Value>)> {
let mut raw = Vec::new();
if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() {
return None;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
11 changes: 11 additions & 0 deletions windows/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>,
) {
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.
Expand Down Expand Up @@ -388,6 +398,7 @@ pub fn run() {
hooks_preview,
hooks_apply,
approval_decision,
approval_answer,
approval_ack,
approval_decline,
log_line,
Expand Down
27 changes: 22 additions & 5 deletions windows/src-tauri/src/pipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -230,7 +232,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver<Reply>) -> Option<S
Ok(Some(Reply::Ack)) => {}
// 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)) => {
Expand All @@ -246,7 +248,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver<Reply>) -> Option<S

match tokio::time::timeout(DECISION_TIMEOUT, rx.recv()).await {
Ok(Some(Reply::Decision(d))) => {
log::line(format!("hook id={id} answered {d}"));
log::line(format!("hook id={id} answered {}", loggable(&d)));
Some(d)
}
Ok(Some(Reply::Decline)) => {
Expand All @@ -260,6 +262,11 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver<Reply>) -> Option<S
}
}

/// The log says a question was answered, never with what.
fn loggable(decision: &str) -> &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::<Pending>();
Expand Down Expand Up @@ -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<String, String>) {
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);
}
4 changes: 4 additions & 0 deletions windows/src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export const Bridge = {
call<void>("approval_decision", { requestId, decision }),
/** "The card is up" — until this lands the relay only waits a moment. */
approvalAck: (requestId: string) => call<void>("approval_ack", { requestId }),
/** Answers a question Claude Code asked: question text → chosen label. */
approvalAnswer: (requestId: string, answers: Record<string, string>) =>
call<void>("approval_answer", { requestId, answers }),

/** "Nobody can act on this" — Claude Code asks in the terminal right away. */
approvalDecline: (requestId: string) => call<void>("approval_decline", { requestId }),

Expand Down
3 changes: 3 additions & 0 deletions windows/src/core/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export const VIEW_LAYOUTS: Record<IslandViewName, ViewLayout> = {
// 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);
Expand Down
9 changes: 9 additions & 0 deletions windows/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 34 additions & 5 deletions windows/src/island/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -120,6 +120,28 @@ function approvalTarget(tool: string, input: Record<string, unknown>): 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<string, unknown>): AskedQuestion[] | null {
if (tool !== "AskUserQuestion" || !Array.isArray(input.questions)) return null;
const out: AskedQuestion[] = [];
for (const raw of input.questions as Record<string, unknown>[]) {
const question = typeof raw?.question === "string" ? raw.question : "";
const options = (Array.isArray(raw?.options) ? (raw.options as Record<string, unknown>[]) : [])
.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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading