diff --git a/packages/voice/src/spike.ts b/packages/voice/src/spike.ts index f076e210e1..f91c880e79 100644 --- a/packages/voice/src/spike.ts +++ b/packages/voice/src/spike.ts @@ -444,11 +444,14 @@ let inflightTools = 0 async function handleFunctionCall(item: RealtimeItem) { inflightTools += 1 + const callID = item.call_id ?? crypto.randomUUID() + const input = JSON.parse(item.arguments ?? "{}") as Record + ui.toolStart(callID, item.name ?? "unknown", input) const handler = item.name ? toolHandlers[item.name] : undefined const output = handler - ? await handler(JSON.parse(item.arguments ?? "{}")).catch((error) => ({ error: String(error) })) + ? await handler(input).catch((error) => ({ error: String(error) })) : { error: `unknown tool ${item.name}` } - ui.tool(item.name ?? "unknown", output) + ui.toolDone(callID, output) send({ type: "conversation.item.create", item: { type: "function_call_output", call_id: item.call_id, output: JSON.stringify(output) }, diff --git a/packages/voice/src/ui.tsx b/packages/voice/src/ui.tsx index 6ff1bf1d2b..7f578e9e3a 100644 --- a/packages/voice/src/ui.tsx +++ b/packages/voice/src/ui.tsx @@ -21,7 +21,8 @@ export type VoiceUI = { userTranscript(itemID: string, text: string): void assistantDelta(text: string): void assistantDone(): void - tool(name: string, output: unknown): void + toolStart(callID: string, name: string, input: unknown): void + toolDone(callID: string, output: unknown): void setStatus(patch: VoiceStatus): void close(): void } @@ -29,13 +30,37 @@ export type VoiceUI = { type Message = | { kind: "user"; itemID: string; text?: string } | { kind: "assistant"; text: string; streaming: boolean } - | { kind: "tool"; name: string; json: string } + | { kind: "tool"; callID: string; name: string; input: unknown; output?: unknown } | { kind: "meta"; text: string } const truncate = (text: string, max: number) => (text.length > max ? text.slice(0, max) + "…" : text) -const compactJson = (output: unknown) => - JSON.stringify(output, (_, value) => (typeof value === "string" ? truncate(value, 200) : value)) ?? "null" +const displayJson = (value: unknown) => + JSON.stringify(value, (_, item) => (typeof item === "string" ? truncate(item, 500) : item), 2) ?? "null" + +function toolSummary(name: string, output?: unknown) { + if (output === undefined) return "running" + if (Array.isArray(output)) return `${output.length} result${output.length === 1 ? "" : "s"}` + if (!output || typeof output !== "object") return String(output) + const value = output as Record + if (name === "wait_for_reply") + return `${String(value["status"] ?? "done")} · ${typeof value["text"] === "string" ? `${value["text"].length} chars` : "no reply"}` + if (name === "prompt_session") + return `${value["admitted"] ? "admitted" : "rejected"} · ${String(value["sessionID"] ?? "unknown session")}` + if (name === "check_session") { + const tools = Array.isArray(value["runningTools"]) ? ` · ${value["runningTools"].join(", ")}` : "" + return `${String(value["status"] ?? "checked")}${tools}` + } + if (name === "create_session" || name === "select_session") + return `${name === "create_session" ? "created" : "selected"} · ${String(value["sessionID"] ?? "unknown session")}` + if (name === "interrupt_session") return value["interrupted"] ? "interrupted" : "not interrupted" + if (name === "set_voice") return `switching to ${String(value["voice"] ?? "unknown")}` + const fields = Object.entries(value) + .filter(([, item]) => ["string", "number", "boolean"].includes(typeof item)) + .slice(0, 2) + .map(([key, item]) => `${key}: ${String(item)}`) + return fields.join(" · ") || "completed" +} // --------------------------------------------------------------------------- // Console fallback (--text mode, non-TTY) @@ -48,6 +73,7 @@ export function createConsoleUI(): VoiceUI { const green = (text: string) => (tty ? `\x1b[1;32m${text}\x1b[0m` : text) let streaming = false + const tools = new Map() const line = (text: string) => { if (streaming) { process.stdout.write("\n") @@ -71,7 +97,15 @@ export function createConsoleUI(): VoiceUI { if (streaming) process.stdout.write("\n") streaming = false }, - tool: (name, output) => line(dim(` [${name}] `) + Bun.inspect(JSON.parse(compactJson(output)), { colors: tty })), + toolStart: (callID, name) => { + tools.set(callID, name) + line(dim(` ◌ ${name} running`)) + }, + toolDone: (callID, output) => { + const name = tools.get(callID) ?? "tool" + tools.delete(callID) + line(dim(` ✓ ${name} ${toolSummary(name, output)}`)) + }, setStatus: () => {}, close: () => {}, } @@ -117,7 +151,7 @@ function jsonTokens(json: string) { // `kind` never changes after creation, so branching once here is safe; the // property reads inside JSX stay reactive through the store proxy. -function MessageRow(props: { message: Message }) { +function MessageRow(props: { message: Message; details: boolean }) { const message = props.message if (message.kind === "user") return ( @@ -134,14 +168,26 @@ function MessageRow(props: { message: Message }) { ) if (message.kind === "tool") return ( - - [{message.name}]{" "} - {(token) => {token.text}} - + + + + {message.output === undefined ? "◌" : "✓"} + {" "} + {message.name}{" "} + {toolSummary(message.name, message.output)} + + {props.details ? ( + + + {(token) => {token.text}} + + + ) : null} + ) return ( - {message.text} + · {message.text} ) } @@ -154,6 +200,7 @@ export async function createVoiceTUI(options: { const [state, setState] = createSignal({ messages: [] as Message[], status: {} as VoiceStatus, + details: false, }) const renderer = await createCliRenderer({ @@ -175,6 +222,11 @@ export async function createVoiceTUI(options: { useKeyboard((evt) => { if (evt.ctrl && evt.name === "c") return if (evt.name === "v") return options.onCycleVoice() + if (evt.name === "d") { + setState((current) => ({ ...current, details: !current.details })) + renderer.requestRender() + return + } options.onInterrupt() }) const statusLine = () => @@ -184,7 +236,7 @@ export async function createVoiceTUI(options: { state().status.model, state().status.session ?? "no session", state().status.server, - "v: voice · any key interrupts · ctrl+c quits", + `v: voice · d: details ${state().details ? "on" : "off"} · any key interrupts · ctrl+c quits`, ] .filter(Boolean) .join(" ") @@ -198,7 +250,9 @@ export async function createVoiceTUI(options: { > - {(message) => } + + {(message) => } + @@ -256,7 +310,18 @@ export async function createVoiceTUI(options: { })) redraw() }, - tool: (name, output) => push({ kind: "tool", name, json: compactJson(output) }), + toolStart: (callID, name, input) => push({ kind: "tool", callID, name, input }), + toolDone: (callID, output) => { + const index = state().messages.findIndex((message) => message.kind === "tool" && message.callID === callID) + if (index === -1) return + setState((current) => ({ + ...current, + messages: current.messages.map((message, i) => + i === index && message.kind === "tool" ? { ...message, output } : message, + ), + })) + redraw() + }, setStatus: (patch) => { setState((current) => ({ ...current, status: { ...current.status, ...patch } })) redraw()