Repository navigation
feat(chat): hold Space in the empty composer for Claude Code's voice mode #1639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| ### Added | ||
| - Settings → Voice → Hold Space for Claude Code voice: in a Claude Code pane's empty chat composer, | ||
| holding Space starts Claude Code's own push-to-talk voice mode, as it does in the terminal, and | ||
| Enter then submits the transcript from Claude Code's input. A tap still types a space. Off by | ||
| default; Claude Code listens on the server PC's microphone. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,6 +66,7 @@ import { useT } from "../lib/i18n.ts"; | |
| import { offersRetry, userFailure, type UserFailure } from "../lib/userError.ts"; | ||
| import { AttachmentTooLargeError } from "../lib/api.ts"; | ||
| import { modalOpen } from "../lib/focus.ts"; | ||
| import { spaceHoldDown, spaceHoldUp, type SpaceHold } from "../lib/spaceHold.ts"; | ||
|
|
||
| export interface ComposerProps { | ||
| connected: boolean; | ||
|
|
@@ -108,6 +109,9 @@ export interface ComposerProps { | |
| cwd?: string | null; | ||
| /** the pane's branch, beside the folder in that strip; nothing is drawn for it while absent */ | ||
| branch?: string | null; | ||
| /** writes raw keys to the pane (Settings → Voice → Hold Space for Claude Code voice): a Space held in the empty | ||
| * box goes there as the terminal's own key repeats, and Claude Code's push-to-talk starts. None: Space types */ | ||
| onPaneInput?: (text: string) => boolean; | ||
| /** the workspace is a linked worktree of this repository: the strip names the repository in the folder's | ||
| * place (the checkout folder is the branch again, with dashes) and says it is a worktree; null: not one */ | ||
| worktreeRepo?: string | null; | ||
|
|
@@ -256,6 +260,7 @@ export function Composer({ | |
| onSend, | ||
| onAbort, | ||
| onUploadImage, | ||
| onPaneInput, | ||
| }: ComposerProps) { | ||
| const t = useT(); | ||
| const activeRef = useRef(active); | ||
|
|
@@ -338,8 +343,15 @@ export function Composer({ | |
| // the agent's suggestion stands in the empty box as it does in its own input, until anything is typed | ||
| const offered = connected && answerHint === null && suggestion !== null ? suggestion : null; | ||
| const offlineSentence = t(connectedBefore ? "Reconnecting…" : "Connecting…"); | ||
| /** Space pressed in the empty box with onPaneInput: "pending" until a repeat (a hold) or the release (a tap) tells which */ | ||
| const spaceHold = useRef<SpaceHold>("idle"); | ||
| /** a hold put Claude Code's transcript into its own input line: Enter in the empty box submits it there */ | ||
| const [voiceInPane, setVoiceInPane] = useState(false); | ||
| // a message sent meanwhile went through Claude's input, the transcript with it or refused over it | ||
| useEffect(() => { if (sending) setVoiceInPane(false); }, [sending]); | ||
| const placeholder = !connected | ||
| ? offlineSentence | ||
| : voiceInPane ? t("Enter sends the transcript in {agent}'s input", { agent: agentLabel }) | ||
| : answerHint ?? offered ?? t("Message {agent}…", { agent: agentLabel }); | ||
|
|
||
| // A cancelled send the agent put back in its input box fills an empty box again — without | ||
|
|
@@ -923,6 +935,14 @@ export function Composer({ | |
| (event: KeyboardEvent<HTMLTextAreaElement>) => { | ||
| // an IME keeps its keys; WebKit can send the committing Enter after compositionend, as key code 229 | ||
| if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; | ||
| // a Space held in the empty box goes to Claude Code as its key repeats (lib/spaceHold.ts) | ||
| if (onPaneInput && event.key === " " && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && textRef.current === "") { | ||
| event.preventDefault(); | ||
| const next = spaceHoldDown(spaceHold.current, event.repeat); | ||
| spaceHold.current = next.state; | ||
| if (next.send > 0 && onPaneInput(" ".repeat(next.send))) setVoiceInPane(true); | ||
| return; | ||
| } | ||
| if (menuOpen && trigger) { | ||
| if (event.key === "ArrowDown" || event.key === "ArrowUp") { | ||
| event.preventDefault(); | ||
|
|
@@ -967,11 +987,24 @@ export function Composer({ | |
| : (event.metaKey || event.ctrlKey) && !event.shiftKey; | ||
| if (!shouldSend) return; | ||
| event.preventDefault(); | ||
| if (voiceInPane && onPaneInput && textRef.current.trim() === "" && attachmentsRef.current.length === 0) { | ||
| if (onPaneInput("\r")) setVoiceInPane(false); | ||
| return; | ||
|
Comment on lines
+990
to
+992
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Handle voice Enter when If the user disables 🤖 Prompt for AI Agents |
||
| } | ||
| send(); | ||
| }, | ||
| [agentStatus, choices, connected, menuDismissed, menuOpen, offered, onAbort, selectCompletion, selectedIndex, send, setTextAndCaret, settings.enterSends, trigger], | ||
| [agentStatus, choices, connected, menuDismissed, menuOpen, offered, onAbort, onPaneInput, selectCompletion, selectedIndex, send, setTextAndCaret, settings.enterSends, trigger, voiceInPane], | ||
| ); | ||
|
|
||
| const onKeyUp = useCallback((event: KeyboardEvent<HTMLTextAreaElement>) => { | ||
| if (event.key === " " && spaceHold.current !== "idle") { | ||
| const { state, tap } = spaceHoldUp(spaceHold.current); | ||
| spaceHold.current = state; | ||
| if (tap && textRef.current === "") { setTextAndCaret(" ", 1); return; } | ||
| } | ||
| setCaret(event.currentTarget.selectionStart); | ||
| }, [setTextAndCaret]); | ||
|
|
||
| const onPaste = useCallback( | ||
| (event: ClipboardEvent<HTMLTextAreaElement>) => { | ||
| const box = event.currentTarget; | ||
|
|
@@ -1292,7 +1325,9 @@ export function Composer({ | |
| setCaret(event.currentTarget.selectionStart); | ||
| setMenuDismissed(false); | ||
| }} | ||
| onKeyUp={(event) => setCaret(event.currentTarget.selectionStart)} | ||
| onKeyUp={onKeyUp} | ||
| // a release that lands elsewhere must not leave the next one here waiting as a tap | ||
| onBlur={() => { spaceHold.current = "idle"; }} | ||
| onChange={(event) => { | ||
| setText(event.target.value); | ||
| setCaret(event.target.selectionStart); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2914,6 +2914,11 @@ export function PaneTerminal({ | |
| // a turn waiting on an approval (blocked) is still the same turn | ||
| useEffect(() => { if (agentStatus !== "working" && agentStatus !== "blocked") hermesStopSentRef.current = false; }, [agentStatus]); | ||
| useEffect(() => { hermesStopSentRef.current = false; hermesCtrlCAtRef.current = null; }, [paneId, agent]); | ||
| // Settings → Voice: a Space held in the chat's empty box reaches Claude Code as the terminal's does | ||
| const paneVoiceInput = useCallback((text: string): boolean => { | ||
| const socket = socketRef.current; | ||
| return paneId !== null && socket !== null && socket.connected && socket.sendInput(paneId, text); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Reject pane voice input while terminal input is held. After terminal input is held during a disconnection, the socket can reconnect while that draft remains held. This callback can then send Space and 🤖 Prompt for AI Agents |
||
| }, [paneId]); | ||
| const abortTurn = useCallback(() => { | ||
| const term = termRef.current; | ||
| const socket = socketRef.current; | ||
|
|
@@ -3638,6 +3643,7 @@ export function PaneTerminal({ | |
| onSend={composerSend} | ||
| onAbort={abortTurn} | ||
| onUploadImage={uploadImage} | ||
| onPaneInput={settings.holdSpaceVoice && agent === "claude" ? paneVoiceInput : undefined} | ||
| /> | ||
| )} | ||
| {/* In the terminal lens the input line and the masked input float over the grid's last rows | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { spaceHoldDown, spaceHoldUp, type SpaceHold } from "./spaceHold.ts"; | ||
|
|
||
| function run(events: Array<"down" | "repeat" | "up">): { sent: number; taps: number; state: SpaceHold } { | ||
| let state: SpaceHold = "idle"; | ||
| let sent = 0; | ||
| let taps = 0; | ||
| for (const event of events) { | ||
| if (event === "up") { | ||
| const next = spaceHoldUp(state); | ||
| state = next.state; | ||
| if (next.tap) taps += 1; | ||
| } else { | ||
| const next = spaceHoldDown(state, event === "repeat"); | ||
| state = next.state; | ||
| sent += next.send; | ||
| } | ||
| } | ||
| return { sent, taps, state }; | ||
| } | ||
|
|
||
| describe("spaceHold", () => { | ||
| test("a tap sends nothing to the pane and types one space", () => { | ||
| expect(run(["down", "up"])).toEqual({ sent: 0, taps: 1, state: "idle" }); | ||
| }); | ||
|
|
||
| test("a hold sends the first press and every repeat, as a terminal does", () => { | ||
| expect(run(["down", "repeat", "repeat", "repeat", "up"])).toEqual({ sent: 4, taps: 0, state: "idle" }); | ||
| }); | ||
|
|
||
| test("a repeat with no press seen (focus came mid-hold) sends the held space too", () => { | ||
| expect(run(["repeat", "repeat", "up"])).toEqual({ sent: 3, taps: 0, state: "idle" }); | ||
| }); | ||
|
|
||
| test("two taps in a row each type a space", () => { | ||
| expect(run(["down", "up", "down", "up"])).toEqual({ sent: 0, taps: 2, state: "idle" }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| /** | ||
| * A Space held in the chat's empty box, sent to Claude Code as a terminal sends it (Settings → Voice → | ||
| * Hold Space for Claude Code voice). A terminal has no key release: Claude Code tells a hold by the | ||
| * key's repeats, and the end of them is the release. So the first press waits ("pending"); a repeat | ||
| * sends it and itself, and every later repeat itself; a release with no repeat was a tap. | ||
| */ | ||
| export type SpaceHold = "idle" | "pending" | "held"; | ||
|
|
||
| /** A Space keydown in the empty box: the next state and how many spaces go to the pane. */ | ||
| export function spaceHoldDown(state: SpaceHold, repeat: boolean): { state: SpaceHold; send: number } { | ||
| if (!repeat) return { state: "pending", send: 0 }; | ||
| return { state: "held", send: state === "held" ? 1 : 2 }; | ||
| } | ||
|
|
||
| /** A Space release: idle again, and a tap when no repeat came, which types a space in the box. */ | ||
| export function spaceHoldUp(state: SpaceHold): { state: "idle"; tap: boolean } { | ||
| return { state: "idle", tap: state === "pending" }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep a failed Space hold in the composer.
If the socket is disconnected or not ready on the first repeat,
onPaneInputreturnsfalse. The code still changesspaceHoldto"held". On release,spaceHoldUpthen reports no tap, so neither the pane nor the composer receives the Space input. Preserve the unsent input as a local draft; do not replay it after reconnection. As per path instructions, “Never queue terminal input across disconnections; unsent user input remains a draft until the user sends or discards it.”🤖 Prompt for AI Agents
Source: Path instructions