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:14:30 +02:00
parent c4ed34ea6c
commit ff0fc74f53
9 changed files with 191 additions and 5 deletions
@@ -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) {