Voice-call this Claude Code session: Call, Steer and Hang up above the prompt. macOS.

Put this Claude Code session on a full-duplex voice call. You talk while it codes, interrupt it at any time, and hear results read back. A spoken "yes" never approves anything: permission prompts stay on your keyboard. macOS only.
/bettercallgpt:on starts a voice call bound to this session. Claude Code asks you to approve the start./bettercallgpt:status shows whether the call is live./bettercallgpt:off ends the call.On Claude Code 2.1.287+ (CLI and the desktop Code tab) a row above the prompt does the same with one press: Call, Steer (send what you just said now; Claude's running turn makes way; unsent words exist only on the GPT-Live voice, on Voice Live Steer brings a waiting message forward) and Hang up (/clear and closing the session end the call too). /call, /steer and /hangup are the same actions; /call-icons picks the symbols.
.env.example to ~/.config/bettercallgpt/.env and paste your voice key into that file, not into the chat. Keys come from Azure (Voice Live or GPT-Live) or OpenAI (Realtime)./bettercallgpt:on. A rising tone means you are live.Everything below is the whole list. The plugin is one hooks module (the Mod, hooks/register.tsx) and three commands; the voice itself is a separate program, bettercallgpt, from this repository.
bettercallgpt, the voice process. Nothing else.~/.local/bin/bettercallgpt, /opt/homebrew/bin/bettercallgpt or /usr/local/bin/bettercallgpt (checked as files, in that order; the Mod runs it once with the single fixed argument --help to see that it knows the console, and passes it over otherwise): bettercallgpt --session <session id> --nonce <nonce> --mod startuvx --from git+https://github.com/insta-fusion/bettercallgpt@v0.2.1 bettercallgpt --session <session id> --nonce <nonce> --mod start<session id> is this Claude Code session's id; <nonce> is a fresh single-use token the Mod makes for that start. No other value is read into the command.--session <session id> stop and --session <session id> steer.*.cognitiveservices.azure.com, the default), the Azure GPT-Live API (*.openai.azure.com), or the OpenAI Realtime API (api.openai.com). When Claude makes a permission request during a call, a one-line summary of it (the tool's name and the Bash command, file path or MCP tool name) is spoken, so it goes to that service too. Nothing goes anywhere else, and nothing is sent when no call is running.HOME, XDG_STATE_HOME and VOICE_LISTEN_STATE_DIR, to find the state directory: ~/.local/state/bettercallgpt unless one of the last two is set.status.json in <state directory>/<session id>/, every 2 seconds (twice a second during a call). During a call that file holds the last 60 characters you said that are not sent yet (credentials masked), which the call row shows./call-icons), from the plugin's store.The voice process reads your key from ~/.config/bettercallgpt/.env (you fill it yourself; setup never asks for it), and this session's own transcript and terminal pane, to relay results and to notice permission prompts.
classic.PermissionRequest), only while a call is live in this session. With no call it does nothing.next(e)), so the outcome is whatever your permission settings and your own hooks decide. It never allows, denies or rewords a request, and never changes what a dialog shows. Approvals stay on your keyboard; a spoken "yes" never approves anything.<state directory>/<session id>/permission.json, that is ~/.local/state/bettercallgpt/<session id>/permission.json by default."disableAllHooks": true in your settings turns the Mod off (with all your hooks), or disable the plugin in /plugin. The three commands keep working without it.
Claude Code on macOS: the CLI in any terminal, and the Code tab of the Claude desktop app. It needs a Mac microphone, so it does nothing in Claude chat, Cowork, or Claude Code on the web.
The voice service key lives in ~/.config/bettercallgpt/.env rather than in plugin settings so that it never enters a prompt, a command line or the model's context; only the local voice process reads it.
"GPT" names the voice model the call runs on. This project is not affiliated with OpenAI, Microsoft or Anthropic.
Full guide, security notes and source: https://github.com/insta-fusion/bettercallgpt
hooks/register.tsx 584 lines1// Better Call GPT's hooks module: the call console.
2//
3// - ui.render AbovePrompt: one band. No call: [ Call ]. On a call: what the voice heard and has
4// not handed over, how many spoken messages wait in Claude's queue, and [ Steer ] [ Hang up ].
5// - Call: starts the voice process as this session's child ($.process.spawn), on the operator's
6// own press. No model turn runs and no permission prompt is involved: the press is the
7// consent to open the microphone and the paid voice connection.
8// - Hang up: the voice process's own `stop` (goodbye, falling tone, device released).
9// - Steer: "take what I said now". The voice process is asked to hand over what it heard; and
10// when a spoken message waits behind Claude's running turn, that turn is ended so the message
11// is read next. Nothing is ever sent twice.
12// - /call, /hangup, /steer do the same as the buttons.
13// - classic.PermissionRequest: while on a call, write permission.json so the voice can say
14// Claude is asking for a permission. Observed only: the hook returns what next(e) returns.
15// Approvals stay on the keyboard.
16//
17// It reads status.json (written by the voice process) and writes only permission.json, both in
18// <state root>/<session id>/, the directory the voice process created (0700).
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, Register } from 'claude-code'
21
22import type { CallView } from '../types'
23
24const APP = 'bettercallgpt'
25// The release this plugin starts when no `bettercallgpt` is installed on PATH.
26const RELEASE = 'git+https://github.com/insta-fusion/bettercallgpt@v0.2.1'
27const TICK_MS = 500
28// With no call, status.json is read every IDLE_TICKS ticks (a call started with
29// /bettercallgpt:on shows up within two seconds).
30const IDLE_TICKS = 4
31// A Hang up the voice process has not answered after this long ends the child directly.
32const HANGUP_BOUND_MS = 8000
33// An installed command that does not answer `--help` in this long is passed over.
34const PROBE_BOUND_MS = 5000
35// Longer than this after flattening, no summary is written at all (the tool's name still is):
36// a cut command could split a credential the voice process would then fail to mask.
37const SUMMARY_MAX = 2000
38// The voice process refreshes `at` in status.json every 10 s while the call runs; older than
39// this, the status is one a killed process left behind. A little ahead is clock skew.
40const STALE_AFTER_S = 30
41const AHEAD_S = 5
42// The session ids the launcher accepts (bettercallgpt/cli.py SESSION_ID): a name, never a path.
43const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
44const FILE_TOOLS = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
45// A spoken message as it arrives in the session: its text ends with the voice tag.
46const VOICE_TAG = /⟨v#[^⟩]*⟩\s*$/
47
48// The band's colors are the terminal's own named colors, so they follow the person's theme:
49// green is live and "call", red is "hang up" and failure, yellow is waiting, cyan is the queue.
50const LIVE = 'green'
51const WARN = 'yellow'
52const QUEUE = 'cyan'
53const END = 'red'
54const INK = 'black'
55// Below this many columns the words heard and not sent get a row of their own.
56const NARROW = 80
57
58// The symbols on the controls, chosen with /call-icons and kept in this plugin's store.
59// `keys` (the default) and `nerd` draw each symbol as a small colored key before its button:
60// green to call, red to hang up, as on a phone. `keys` uses characters every font has; `nerd`
61// needs a Nerd Font and has a real hung-up handset and a steering wheel. `emoji` puts emoji in
62// the labels; `none` is words only.
63export const ICON_SETS = {
64 // Steer keeps the wheel emoji in its label: no plain character reads as a steering wheel.
65 keys: { call: '☎', steer: '🛞 ', hangup: '☎', live: '● ' },
66 nerd: { call: '\uf095', steer: '\u{f04d4}', hangup: '\u{f03fa}', live: '\u{f036c} ' },
67 emoji: { call: '📞 ', steer: '🛞 ', hangup: '📴 ', live: '🎙 ' },
68 none: { call: '', steer: '', hangup: '', live: '● ' },
69} as const
70export type IconSet = keyof typeof ICON_SETS
71const ICONS_KEY = 'icons'
72
73export const IDLE: CallView = { phase: 'idle', unsent: '', queued: 0, working: false, note: '' }
74
75const call = atom({ plugin: 'bettercallgpt', key: 'call' } as const, IDLE)
76
77type Child = AsyncGenerator<unknown, unknown, unknown>
78type Status = Record<string, unknown>
79type LiveCall = { dir: string; instance: string; status: Status }
80
81// This module's own memory. A reload starts it over, and a reload also ends the child.
82let child: Child | undefined
83let command: string | undefined // an installed bettercallgpt that fits, once probed
84let probed = false
85let mainTurn = ''
86let steerWaits = false
87let isStarting = false
88let steerShown = ''
89let icons: IconSet = 'keys'
90let ticks = 0
91let isPolling = false
92
93function set($: EngineInterface, patch: Partial<CallView>) {
94 return update($, call, view => ({ ...view, ...patch }))
95}
96
97/** Where the voice process keeps its state. The order mirrors bettercallgpt/cli.py
98 * user_state_dir + apply_defaults, which main() applies before daemon.main in the same process. */
99async function stateRoot($: EngineInterface): Promise<string | undefined> {
100 const absolute = (path: string | undefined) =>
101 path?.startsWith('/') ? path.replace(/\/+$/, '') : undefined
102 const explicit = absolute(await $.env.get('VOICE_LISTEN_STATE_DIR'))
103 if (explicit !== undefined) return explicit
104 const xdg = absolute(await $.env.get('XDG_STATE_HOME'))
105 if (xdg !== undefined) return `${xdg}/${APP}`
106 const home = absolute(await $.env.get('HOME'))
107 return home === undefined ? undefined : `${home}/.local/state/${APP}`
108}
109
110/** The rule of `bettercallgpt statusline` with a fresh heartbeat in place of its pid probe:
111 * running, relay qualified, not ended (an empty `ended` object is "not ended", as in Python),
112 * and a finite `at` (epoch seconds) between AHEAD_S ahead and STALE_AFTER_S behind now. */
113export function isLive(status: unknown, nowSeconds: number): boolean {
114 if (typeof status !== 'object' || status === null) return false
115 const { phase, relay, ended, at } = status as Status
116 const hasEnded =
117 typeof ended === 'object' && ended !== null ? Object.keys(ended).length > 0 : Boolean(ended)
118 const age = typeof at === 'number' && Number.isFinite(at) ? nowSeconds - at : NaN
119 const isFresh = age >= -AHEAD_S && age <= STALE_AFTER_S
120 return phase === 'running' && relay === 'qualified' && !hasEnded && isFresh
121}
122
123/** This session's live call (its state directory, the voice process's instance id and its
124 * status), or undefined. Rejects when status.json cannot be read or parsed. */
125async function liveCall($: EngineInterface, sessionId: string): Promise<LiveCall | undefined> {
126 if (!SESSION_ID.test(sessionId)) return undefined
127 const root = await stateRoot($)
128 if (root === undefined) return undefined
129 const dir = `${root}/${sessionId}`
130 const statusPath = `${dir}/status.json`
131 if (!(await $.fs.exists(statusPath))) return undefined
132 const status: unknown = JSON.parse(await $.fs.read(statusPath))
133 if (!isLive(status, (await $.clock.now()) / 1000)) return undefined
134 const { instance } = status as Status
135 return { dir, instance: typeof instance === 'string' ? instance : '', status: status as Status }
136}
137
138/** One line naming what the request is about: the Bash command, the file, or the MCP tool;
139 * empty when that line is longer than SUMMARY_MAX (never a partial command). */
140export function summarize(tool: string, input: unknown): string {
141 const fields = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {}
142 const text = (value: unknown) => (typeof value === 'string' ? value : '')
143 let summary = ''
144 if (tool === 'Bash') summary = text(fields.command)
145 else if (FILE_TOOLS.has(tool)) summary = text(fields.file_path) || text(fields.notebook_path)
146 else if (tool.startsWith('mcp__')) summary = tool
147 const line = summary.replace(/\s+/g, ' ').trim()
148 return line.length <= SUMMARY_MAX ? line : ''
149}
150
151/** What status.json says the band should show. */
152export function liveFields(status: Status): Pick<CallView, 'unsent' | 'queued'> {
153 const unsent = status.unsent
154 const preview =
155 typeof unsent === 'object' && unsent !== null ? (unsent as Status).preview : undefined
156 const queued = Array.isArray(status.queued) ? status.queued.length : 0
157 return { unsent: typeof preview === 'string' ? preview : '', queued }
158}
159
160/** The last line of what a failed start wrote, as one short note. */
161export function failure(stderr: string, code: unknown): string {
162 const lines = stderr.split('\n').map(line => line.trim()).filter(line => line !== '')
163 const last = (lines.pop() ?? '').replace(/^voice:\s*/, '')
164 if (last.includes('bind_no_transcript')) return 'send Claude one message first, then call again'
165 if (last.includes('missing ')) return `${last.split(' — ')[0]} — add your key, see the README`
166 if (last === '') return `the voice process ended (exit ${String(code)})`
167 return last.length <= 160 ? last : `${last.slice(0, 159)}…`
168}
169
170/** Where `uv tool install` and Homebrew put an installed `bettercallgpt`. Checked as files, in
171 * this order; no shell and no PATH search is run to find the command. */
172const INSTALLED_AT = ['.local/bin/bettercallgpt', '/opt/homebrew/bin/bettercallgpt', '/usr/local/bin/bettercallgpt']
173
174/** An installed `bettercallgpt` that knows the console's commands (a checkout installed for
175 * development), or undefined: then the pinned release runs through `uvx`. */
176async function installed($: EngineInterface): Promise<string | undefined> {
177 if (probed) return command
178 probed = true
179 const home = await $.env.get('HOME')
180 for (const at of INSTALLED_AT) {
181 const path = at.startsWith('/') ? at : `${home ?? ''}/${at}`
182 if (!path.startsWith('/') || !(await $.fs.exists(path).catch(() => false))) continue
183 // One program, by name, with one fixed argument: an installed command from before the call
184 // console knows neither `--mod` nor `steer`, and is passed over for the release.
185 const fits = await $.process
186 .run([path, '--help'], { timeoutMs: PROBE_BOUND_MS })
187 .then(result => result.exitCode === 0 && result.stdout.includes('--mod'), () => false)
188 if (fits) command = path
189 break
190 }
191 return command
192}
193
194/** The command for `stop` and `steer`: the installed command or the release through uvx. */
195async function voiceCommand($: EngineInterface): Promise<string[]> {
196 const bin = await installed($)
197 return bin !== undefined ? [bin] : ['uvx', '--from', RELEASE, 'bettercallgpt']
198}
199
200/** Read the child's output until it ends; then the call is over, whatever ended it. */
201async function follow($: EngineInterface, stream: Child) {
202 let stderr = ''
203 let code: unknown
204 try {
205 for (;;) {
206 const piece = await stream.next()
207 if (piece.done) {
208 code = (piece.value as { code?: unknown } | undefined)?.code
209 break
210 }
211 const { stream: pipe, text } = piece.value as { stream: string; text: string }
212 if (pipe === 'stderr') stderr = (stderr + text).slice(-4000)
213 }
214 } catch (error) {
215 stderr = String(error)
216 }
217 if (child !== stream) return // a newer call owns the band
218 child = undefined
219 steerWaits = false
220 try {
221 const { phase } = await read($, call)
222 const note = phase === 'starting' ? failure(stderr, code) : ''
223 await set($, { ...IDLE, working: mainTurn !== '', note })
224 } catch {
225 // The module was unloaded while the child was ending: there is no band left to update.
226 }
227}
228
229async function startCall($: EngineInterface): Promise<string> {
230 // Two presses in a row: the flag is taken before anything is awaited, so one call starts.
231 if (isStarting || child !== undefined) return 'a call is already on'
232 isStarting = true
233 try {
234 const view = await read($, call)
235 if (view.phase !== 'idle') return 'a call is already on'
236 const sessionId = await $.session.id()
237 if (!SESSION_ID.test(sessionId)) return 'this session has no usable id'
238 const nonce = `mod-${(await $.clock.now()).toString(36)}-${Math.random().toString(36).slice(2, 10)}`
239 // The whole command, as text: the installed command, or the pinned release through uvx.
240 // `sessionId` and `nonce` are the only values; nothing else is read from the environment.
241 const bin = await installed($)
242 const argv =
243 bin !== undefined
244 ? [bin, '--session', sessionId, '--nonce', nonce, '--mod', 'start']
245 : ['uvx', '--from', RELEASE, 'bettercallgpt', '--session', sessionId, '--nonce', nonce, '--mod', 'start']
246 const stream = $.process.spawn({ argv, env: { NONCE: nonce } }) as unknown as Child
247 child = stream
248 await set($, { ...IDLE, phase: 'starting', working: mainTurn !== '', note: '' })
249 void follow($, stream)
250 return 'calling'
251 } finally {
252 isStarting = false
253 }
254}
255
256async function hangUp($: EngineInterface): Promise<string> {
257 const view = await read($, call)
258 if (view.phase === 'idle') return 'no call to hang up'
259 const sessionId = await $.session.id()
260 await set($, { phase: 'ending', note: '' })
261 steerWaits = false
262 const ending = child
263 if (ending !== undefined) {
264 // The voice process says goodbye and exits by itself; one that does not is ended here.
265 // Armed before the stop is sent: a stop that hangs must not leave the call running.
266 $.clock.after(HANGUP_BOUND_MS, () => {
267 if (child === ending) void ending.return(undefined)
268 })
269 }
270 await $.process
271 .run([...(await voiceCommand($)), '--session', sessionId, 'stop'])
272 .catch(() => undefined)
273 return 'hanging up'
274}
275
276/** End Claude's running turn when a Steer is waiting and a spoken message sits behind it.
277 * The status is read again right here: a message Claude took in the meantime (it is no longer
278 * queued) must not cost the turn that is now working on it. */
279async function bringForward($: EngineInterface) {
280 if (!steerWaits) return
281 const turnId = mainTurn
282 if (turnId === '') {
283 steerWaits = false // idle: a message starts its own turn
284 return
285 }
286 const sessionId = await $.session.id()
287 const live = await liveCall($, sessionId).catch(() => undefined)
288 if (live === undefined || liveFields(live.status).queued === 0) return
289 if (!steerWaits || turnId !== mainTurn) return // answered while reading
290 steerWaits = false
291 try {
292 await $.turn.abort({ turnId })
293 $.ui.toast('Steer: Claude takes your message now')
294 } catch {
295 // That turn ended by itself in the meantime: the message is read next anyway.
296 }
297}
298
299async function chooseIcons($: EngineInterface, wanted: string): Promise<string> {
300 const name = wanted.trim()
301 if (!Object.hasOwn(ICON_SETS, name)) return `icons: ${icons} (choose keys, nerd, emoji or none)`
302 icons = name as IconSet
303 await $.store.set(ICONS_KEY, icons)
304 await update($, call, view => ({ ...view })) // draw the band again
305 return `icons: ${icons}`
306}
307
308/** What the voice process answered to the last Steer, as the band's note. */
309export function steerNote(result: unknown): string {
310 if (typeof result !== 'string' || result === '') return ''
311 if (result === 'sent') return ''
312 if (result === 'nothing_unsent') return ''
313 if (result === 'uncertain') return 'steer: not confirmed (it is not sent again)'
314 return `steer did not send (${result.slice(0, 60)})`
315}
316
317async function steer($: EngineInterface): Promise<string> {
318 const view = await read($, call)
319 if (view.phase !== 'live') return 'no call to steer'
320 const sessionId = await $.session.id()
321 // A Steer waits only for the turn it was pressed in (turn.complete clears it).
322 steerWaits = mainTurn !== ''
323 // The voice process sends what it heard and has not handed over, itself, as a message that
324 // ends Claude's running turn. Its answer comes back in status.json (`steer`).
325 await $.process
326 .run([...(await voiceCommand($)), '--session', sessionId, 'steer'])
327 .catch(() => undefined)
328 await bringForward($)
329 return 'steering'
330}
331
332async function poll($: EngineInterface, isFirst = false) {
333 if (isPolling) return // one read at a time: a slow one never lands after a newer one
334 isPolling = true
335 try {
336 const view = await read($, call)
337 ticks += 1
338 if (!isFirst && view.phase === 'idle' && ticks % IDLE_TICKS !== 0) return
339 const sessionId = await $.session.id()
340 // A status that cannot be read or parsed is no call.
341 const live = await liveCall($, sessionId).catch(() => undefined)
342 // A /clear while reading moved to another session: this answer is for the old one.
343 if (sessionId !== (await $.session.id())) return
344 if (live === undefined) {
345 // No child and no live status: the call (one started by /bettercallgpt:on) is over.
346 if (child === undefined && view.phase !== 'idle') await set($, { ...IDLE, working: view.working })
347 return
348 }
349 const fields = liveFields(live.status)
350 // The voice process's answer to a Steer, shown once per press.
351 const answer = (live.status.steer ?? {}) as Status
352 const answerId = typeof answer.id === 'string' ? answer.id : ''
353 let note: string | undefined
354 if (answerId !== '' && answerId !== steerShown) {
355 // What happened is said once, in passing; only a failure stays in the band.
356 note = steerNote(answer.result)
357 if (answer.result === 'sent') $.ui.toast('Steer: sent what you said')
358 if (answer.result === 'nothing_unsent' && fields.queued === 0) $.ui.toast('Nothing to steer')
359 steerShown = answerId
360 }
361 const isSame =
362 view.phase === 'live' && view.unsent === fields.unsent && view.queued === fields.queued
363 if (!isSame || note !== undefined) {
364 // The phase is decided on the view as it is when written: a Hang up pressed while this
365 // read was out stays "ending".
366 await update($, call, now => ({
367 ...now,
368 ...fields,
369 phase: now.phase === 'ending' ? 'ending' : 'live',
370 note: note ?? now.note,
371 }))
372 }
373 await bringForward($)
374 } catch {
375 // The session itself could not be asked: try again on the next tick.
376 } finally {
377 isPolling = false
378 }
379}
380
381export const register: Register = on => {
382 on('classic.PermissionRequest', async ($, e, next) => {
383 try {
384 const live = await liveCall($, e.session_id)
385 if (live !== undefined) {
386 const at = (await $.clock.now()) / 1000
387 const summary = summarize(e.tool_name, e.tool_input)
388 // `instance` ties the notice to this call: a later call in the same session ignores it.
389 const event = { at, tool: e.tool_name, summary, instance: live.instance }
390 // The one file this module writes: <state root>/<session id>/permission.json, read by the
391 // voice process of this call only. The state root is ~/.local/state/bettercallgpt unless
392 // VOICE_LISTEN_STATE_DIR or XDG_STATE_HOME is set (see stateRoot).
393 await $.fs.write(`${live.dir}/permission.json`, JSON.stringify(event))
394 }
395 } catch {
396 // Observing failed. The prompt is the same either way.
397 }
398 return next(e)
399 })
400
401 on('session.start', async ($, e, next) => {
402 const kept = await $.store.get(ICONS_KEY).catch(() => undefined)
403 if (typeof kept === 'string' && Object.hasOwn(ICON_SETS, kept)) icons = kept as IconSet
404 try {
405 await $.command.register({
406 name: 'call-icons',
407 description: 'Symbols on the call controls: keys, nerd (Nerd Font), emoji or none',
408 argumentHint: '[keys|nerd|emoji|none]',
409 immediate: true,
410 })
411 await $.command.register({
412 name: 'call',
413 description: 'Start a voice call in this session',
414 immediate: true,
415 })
416 await $.command.register({ name: 'hangup', description: 'End the voice call', immediate: true })
417 await $.command.register({
418 name: 'steer',
419 description: 'Have Claude take what you just said now',
420 immediate: true,
421 })
422 } catch {
423 // A host that takes no commands still gets the band and its buttons.
424 }
425 await poll($, true)
426 $.clock.every(TICK_MS, () => void poll($))
427 return next(e)
428 })
429
430 on('session.end', async ($, e, next) => {
431 // The call belongs to the session that started it: a /clear or an exit ends it.
432 await hangUp($) // also a call /bettercallgpt:on started; with no call it does nothing
433 return next(e)
434 })
435
436 on('command.run', { command: 'call' }, async $ => ({ text: await startCall($) }))
437 on('command.run', { command: 'call-icons' }, async ($, e) => ({ text: await chooseIcons($, e.args) }))
438 on('command.run', { command: 'hangup' }, async $ => ({ text: await hangUp($) }))
439 on('command.run', { command: 'steer' }, async $ => ({ text: await steer($) }))
440
441 on('turn.start', async ($, e, next) => {
442 mainTurn = e.turnId
443 await set($, { working: true })
444 return next(e)
445 })
446
447 on('turn.complete', async ($, e, next) => {
448 // A subagent's turn completes here too; only the main turn's own end clears it.
449 if (e.agentId === undefined && e.turnId === mainTurn) {
450 mainTurn = ''
451 steerWaits = false // a Steer is about the turn it was pressed in
452 await set($, { working: false })
453 }
454 return next(e)
455 })
456
457 on('prompt.submit', async ($, e, next) => {
458 // A spoken message was just read by Claude: a Steer that was waiting for that is done.
459 if (e.origin.kind === 'peer' && VOICE_TAG.test(e.text)) steerWaits = false
460 return next(e)
461 })
462
463 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
464 const isDrawn = e.surface === 'terminal' || e.surface === 'desktop'
465 if (!isDrawn || e.props.hasSurvey) return next(e)
466 const view = await read($, call)
467 // The band is shared: keep what the mods after this one draw (mods_interface.md, "Pick
468 // where to draw"), and add one line under it.
469 const below = await next(e)
470 const { Box, Button, Text } = $.ui.resolve(e)
471 const icon = ICON_SETS[icons]
472 // Emoji carry their own colors, inside the label. The other symbols are one-color
473 // characters: each is drawn before its button as a key in the control's color.
474 const isKeyed = icons === 'keys' || icons === 'nerd'
475 const inLabel = (symbol: string) => (isKeyed ? '' : symbol)
476 const tint = (symbol: string, color: string) =>
477 isKeyed ? (
478 <Box flexShrink={0}>
479 <Text backgroundColor={color} color={INK} bold>
480 {` ${symbol} `}
481 </Text>
482 </Box>
483 ) : null
484 // A state pill keeps its width: when the row is tight the words beside it are cut, never it.
485 const pill = (words: string, color: string) => (
486 <Box flexShrink={0}>
487 <Text backgroundColor={color} color={INK} bold>
488 {words}
489 </Text>
490 </Box>
491 )
492 const isNarrow = e.props.bodyColumns < NARROW
493 if (view.phase === 'idle') {
494 const hasFailed = view.note !== ''
495 return (
496 <Box flexDirection="column">
497 {below}
498 <Box gap={1}>
499 {hasFailed ? pill(' ✕ CALL FAILED ', END) : null}
500 {tint(icon.call, LIVE)}
501 <Button
502 key="call"
503 label={`${inLabel(icon.call)}${hasFailed ? 'Call again' : 'Call'}`}
504 hotkey="c"
505 variant="primary"
506 onPress={() => void startCall($)}
507 />
508 {hasFailed ? (
509 <Text wrap="truncate-end">{view.note}</Text>
510 ) : (
511 <Text wrap="truncate-end">
512 <Text bold>Better Call GPT</Text>
513 <Text dimColor>{isNarrow ? '' : ' · talk to this session by voice'}</Text>
514 </Text>
515 )}
516 </Box>
517 </Box>
518 )
519 }
520 if (view.phase === 'starting' || view.phase === 'ending') {
521 return (
522 <Box flexDirection="column">
523 {below}
524 <Box gap={1}>
525 {pill(view.phase === 'starting' ? ' ◌ CONNECTING ' : ' ◌ HANGING UP ', WARN)}
526 <Text dimColor wrap="truncate-end">
527 {view.phase === 'starting' ? 'opening the microphone and the voice…' : 'saying goodbye…'}
528 </Text>
529 {view.phase === 'starting' ? tint(icon.hangup, END) : null}
530 {view.phase === 'starting' ? (
531 <Button key="hangup" label={`${inLabel(icon.hangup)}Hang up`} hotkey="h" onPress={() => void hangUp($)} />
532 ) : null}
533 </Box>
534 </Box>
535 )
536 }
537 const canSteer = view.unsent !== '' || view.queued > 0
538 const quote =
539 view.unsent === '' ? null : (
540 <Text color={WARN} italic wrap="truncate-start">
541 «{view.unsent}» not sent
542 </Text>
543 )
544 return (
545 <Box flexDirection="column">
546 {below}
547 <Box gap={1}>
548 {pill(` ${icon.live}LIVE `, LIVE)}
549 <Box flexGrow={1} flexShrink={1} gap={1} overflow="hidden">
550 {canSteer ? null : <Text dimColor>{view.note === '' ? 'listening' : view.note}</Text>}
551 {isNarrow ? null : quote}
552 {view.queued === 0 ? null : (
553 <Text color={QUEUE} bold>
554 {view.queued} queued
555 </Text>
556 )}
557 {canSteer && view.note !== '' ? <Text dimColor>{view.note}</Text> : null}
558 </Box>
559 <Box flexShrink={0} gap={1}>
560 {icons === 'nerd' ? tint(icon.steer, QUEUE) : null}
561 <Button
562 key="steer"
563 label={`${icons === 'nerd' ? '' : icon.steer}Steer`}
564 hotkey="s"
565 variant={canSteer ? 'primary' : 'secondary'}
566 dimColor={!canSteer}
567 onPress={() => void steer($)}
568 />
569 {tint(icon.hangup, END)}
570 <Button
571 key="hangup"
572 label={`${inLabel(icon.hangup)}Hang up`}
573 hotkey="h"
574 variant="secondary"
575 onPress={() => void hangUp($)}
576 />
577 </Box>
578 </Box>
579 {isNarrow ? quote : null}
580 </Box>
581 )
582 })
583}
584types/index.d.ts 24 lines1// The values this plugin keeps in $.state for the session.
2// `call`: what the band above the prompt draws.
3// phase idle (no call), starting (the voice process is coming up), live, ending
4// unsent the tail of what the voice heard and has not handed to Claude yet ('' when none)
5// queued how many spoken messages wait in Claude's queue behind the running turn
6// working whether Claude is in a turn right now
7// note one line about the last thing that happened (a failed start, a Steer's result)
8
9export type CallPhase = 'idle' | 'starting' | 'live' | 'ending'
10
11export type CallView = {
12 phase: CallPhase
13 unsent: string
14 queued: number
15 working: boolean
16 note: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 bettercallgpt: { call: CallView }
22 }
23}
24