feat(coding-agent/modes): implemented space-bar hold push-to-talk STT gesture

- Added a space-hold gesture state machine in CustomEditor, tracking repeated spaces, detecting holds beyond SPACE_HOLD_THRESHOLD, and firing start/end callbacks via a release timer.
- Hooked editor space-hold callbacks in InputController so STT toggles on hold start and again on release when STT is enabled.
- Added tests for space-hold start/stop behavior and updated keybinding docs to describe the hold-to-record STT workflow.
This commit is contained in:
can1357
2026-06-14 08:34:56 +02:00
parent c4ed34ea6c
commit ff0fc74f53
9 changed files with 191 additions and 5 deletions
@@ -212,8 +212,8 @@ export const KEYBINDINGS = {
description: "Search history",
},
"app.stt.toggle": {
defaultKeys: "alt+h",
description: "Toggle speech-to-text",
defaultKeys: [],
description: "Toggle speech-to-text (default gesture: hold Space)",
},
} as const satisfies KeybindingDefinitions;
@@ -0,0 +1,69 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { getEditorTheme, initTheme } from "../theme/theme";
import { CustomEditor, SPACE_HOLD_RELEASE_MS, SPACE_HOLD_THRESHOLD } from "./custom-editor";
function makeEditor() {
const editor = new CustomEditor(getEditorTheme());
const events: string[] = [];
editor.sttHoldEnabled = () => true;
editor.onSpaceHoldStart = () => events.push("start");
editor.onSpaceHoldEnd = () => events.push("end");
return { editor, events };
}
function holdSpace(editor: CustomEditor, count: number): void {
for (let i = 0; i < count; i++) editor.handleInput(" ");
}
describe("CustomEditor space-hold push-to-talk", () => {
beforeAll(async () => {
await initTheme();
});
afterEach(() => {
vi.useRealTimers();
});
it("inserts spaces normally below the hold threshold", () => {
const { editor, events } = makeEditor();
holdSpace(editor, SPACE_HOLD_THRESHOLD);
expect(editor.getText()).toBe(" ".repeat(SPACE_HOLD_THRESHOLD));
expect(events).toEqual([]);
});
it("tracks back the space burst and drives the hold lifecycle", () => {
vi.useFakeTimers();
const { editor, events } = makeEditor();
editor.handleInput("h");
editor.handleInput("i");
// Crossing the threshold deletes the optimistically-inserted spaces and starts recording,
// leaving only the pre-burst text behind.
holdSpace(editor, SPACE_HOLD_THRESHOLD + 1);
expect(editor.getText()).toBe("hi");
expect(events).toEqual(["start"]);
// Continued auto-repeat while the bar is held is swallowed: no spam, no re-trigger.
holdSpace(editor, 5);
expect(editor.getText()).toBe("hi");
expect(events).toEqual(["start"]);
// An idle gap with no further repeats means the bar was released -> stop + transcribe.
vi.advanceTimersByTime(SPACE_HOLD_RELEASE_MS + 1);
expect(events).toEqual(["start", "end"]);
});
it("does not trigger when a non-space breaks the run", () => {
const { editor, events } = makeEditor();
holdSpace(editor, SPACE_HOLD_THRESHOLD);
editor.handleInput("x");
holdSpace(editor, SPACE_HOLD_THRESHOLD);
expect(events).toEqual([]);
expect(editor.getText()).toBe(`${" ".repeat(SPACE_HOLD_THRESHOLD)}x${" ".repeat(SPACE_HOLD_THRESHOLD)}`);
});
it("leaves the space bar typing normally when the gesture is disabled", () => {
const { editor, events } = makeEditor();
editor.sttHoldEnabled = () => false;
holdSpace(editor, SPACE_HOLD_THRESHOLD + 5);
expect(editor.getText()).toBe(" ".repeat(SPACE_HOLD_THRESHOLD + 5));
expect(events).toEqual([]);
});
});
@@ -62,6 +62,14 @@ const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
/** Plain spaces from one auto-repeat run that trigger the space-hold push-to-talk STT gesture.
* Holding the space bar makes the terminal emit a burst of spaces; once more than this many land
* in the editor we treat it as "space held", track them back out, and start recording. */
export const SPACE_HOLD_THRESHOLD = 5;
/** Idle gap (ms) after the last repeated space that counts as the space bar being released, ending
* the push-to-talk recording. Must comfortably exceed the OS key-repeat interval. */
export const SPACE_HOLD_RELEASE_MS = 250;
function isPastedPathSeparator(char: string | undefined): boolean {
return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
}
@@ -235,9 +243,25 @@ export class CustomEditor extends Editor {
/** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
onLeftAtStart?: () => void;
/** Fired when a sustained space-bar hold is recognized — the push-to-talk STT start. The
* optimistically-typed spaces have already been deleted by the time this runs. */
onSpaceHoldStart?: () => void;
/** Fired when the held space bar is released (detected as an idle gap with no further repeated
* spaces) — the push-to-talk STT stop. */
onSpaceHoldEnd?: () => void;
/** Gate for the space-hold gesture. Returns false to keep the space bar inserting spaces
* normally; wired to `stt.enabled` so disabling STT restores plain space behavior. */
sttHoldEnabled?: () => boolean;
/** Custom key handlers from extensions and non-built-in app actions. */
#customKeyHandlers = new Map<KeyId, () => void>();
#customMatchKeys = new Map<string, () => void>();
/** Consecutive plain spaces inserted in the current run; any other key resets it. */
#spaceRunInserted = 0;
/** True while a recognized space-hold push-to-talk recording is in progress. */
#spaceHoldActive = false;
/** Idle timer that fires `onSpaceHoldEnd` once repeated spaces stop arriving. */
#spaceHoldTimer: NodeJS.Timeout | undefined;
#actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
);
@@ -295,6 +319,68 @@ export class CustomEditor extends Editor {
this.#rebuildCustomMatchKeys();
}
#spaceHoldGestureEnabled(): boolean {
return this.onSpaceHoldStart !== undefined && (this.sttHoldEnabled?.() ?? false) && !this.isShowingAutocomplete();
}
/** Drive the space-hold push-to-talk state machine. Returns true when the gesture consumed the
* input so it must not reach normal editing. Holding the space bar makes the terminal emit a
* burst of auto-repeat spaces; once more than {@link SPACE_HOLD_THRESHOLD} of them land we treat
* it as a hold, delete the spam, and start recording until the repeats stop. */
#handleSpaceHold(data: string, canonical: string | undefined): boolean {
const isSpace = canonical === "space";
if (this.#spaceHoldActive) {
if (isSpace) {
// Auto-repeat while held: swallow it and keep the release timer alive.
this.#armSpaceHoldReleaseTimer();
return true;
}
// Any non-space means the bar was released — stop recording, then let the key through.
this.#endSpaceHold();
return false;
}
if (!isSpace) {
this.#spaceRunInserted = 0;
return false;
}
if (!this.#spaceHoldGestureEnabled()) return false;
// A short tap should still type a normal space, so insert optimistically and count the run.
super.handleInput(data);
this.#spaceRunInserted++;
if (this.#spaceRunInserted > SPACE_HOLD_THRESHOLD) {
this.deleteBeforeCursor(this.#spaceRunInserted);
this.#spaceRunInserted = 0;
this.#beginSpaceHold();
}
return true;
}
#beginSpaceHold(): void {
this.#spaceHoldActive = true;
this.#armSpaceHoldReleaseTimer();
this.onSpaceHoldStart?.();
}
#armSpaceHoldReleaseTimer(): void {
if (this.#spaceHoldTimer) clearTimeout(this.#spaceHoldTimer);
this.#spaceHoldTimer = setTimeout(() => {
this.#spaceHoldTimer = undefined;
this.#endSpaceHold();
}, SPACE_HOLD_RELEASE_MS);
this.#spaceHoldTimer.unref?.();
}
#endSpaceHold(): void {
if (!this.#spaceHoldActive) return;
this.#spaceHoldActive = false;
this.#spaceRunInserted = 0;
if (this.#spaceHoldTimer) {
clearTimeout(this.#spaceHoldTimer);
this.#spaceHoldTimer = undefined;
}
this.onSpaceHoldEnd?.();
}
handleInput(data: string): void {
const kittyParsed = parseKittySequence(data);
if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) {
@@ -324,6 +410,9 @@ export class CustomEditor extends Editor {
return;
}
// Space-hold push-to-talk: a sustained space bar starts/stops STT instead of typing spaces.
if (this.#handleSpaceHold(data, canonical)) return;
if (canonical !== undefined) {
// Intercept configured image paste (async - fires and handles result)
if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) {
@@ -347,6 +347,12 @@ export class InputController {
for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
}
// Hold the space bar to push-to-talk: the editor recognizes the auto-repeat burst, tracks
// the spam back out, and toggles STT on hold start / release. Gated on `stt.enabled` so a
// disabled STT leaves the space bar typing normally.
this.ctx.editor.sttHoldEnabled = () => settings.get("stt.enabled");
this.ctx.editor.onSpaceHoldStart = () => void this.ctx.handleSTTToggle();
this.ctx.editor.onSpaceHoldEnd = () => void this.ctx.handleSTTToggle();
for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
}
@@ -49,7 +49,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
`| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
`| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`,
`| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
"| Hold `Space` | Speech-to-text (push-to-talk): hold to record, release to transcribe |",
`| \`${appKey(bindings, "app.agents.hub")}\` / \`${appKey(bindings, "app.session.observe")}\` / double-tap \`←\` (empty editor) | Open the agent hub |`,
"| `#` | Open prompt actions |",
"| `/` | Slash commands |",