From ff0fc74f532a7ff689e4300a6a73507757f58320 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 14 Jun 2026 08:14:30 +0200 Subject: [PATCH] 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. --- docs/keybindings.md | 4 +- packages/coding-agent/CHANGELOG.md | 2 + .../coding-agent/src/config/keybindings.ts | 4 +- .../modes/components/custom-editor.test.ts | 69 ++++++++++++++ .../src/modes/components/custom-editor.ts | 89 +++++++++++++++++++ .../src/modes/controllers/input-controller.ts | 6 ++ .../src/modes/utils/hotkeys-markdown.ts | 2 +- packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/editor.ts | 19 ++++ 9 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/custom-editor.test.ts diff --git a/docs/keybindings.md b/docs/keybindings.md index a2c6a178e..e3e66ab6f 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -17,7 +17,7 @@ Chord names are case-insensitive and use the same notation shown in the UI, such Set an action to an empty array to disable it: ```yaml -app.stt.toggle: [] +app.history.search: [] ``` ## Common action IDs @@ -40,7 +40,7 @@ app.stt.toggle: [] | `app.clipboard.copyLine` | `Alt+Shift+L` | Copy the current line | | `app.clipboard.copyPrompt` | `Alt+Shift+C` | Copy the whole prompt | | `app.clipboard.pasteImage` | `Ctrl+V` (`Alt+V` fallback on Windows) | Paste from the clipboard (image preferred, text fallback) | -| `app.stt.toggle` | `Alt+H` | Toggle speech-to-text recording | +| `app.stt.toggle` | Unbound (hold `Space`) | Toggle speech-to-text. By default there is no key chord — hold the space bar to record (push-to-talk) and release to transcribe; bind a chord here for a press-to-toggle alternative | On Windows Terminal, `Ctrl+V` may be handled by the terminal paste command before `omp` sees it; use the `Alt+V` fallback when clipboard image paste appears to do nothing. When the clipboard holds no image, `app.clipboard.pasteImage` pastes the clipboard text instead, so hosts that deliver only this chord (VS Code's integrated terminal when configured to forward `Ctrl+V`, Windows clipboard history via `Win+V`) work for both payload kinds. Windows Terminal also swallows `Ctrl+Enter`, so the follow-up shortcut also binds `Ctrl+Q` — the same chord GitHub Copilot CLI uses. If your existing `keybindings.yml` already assigns `Ctrl+Q` to another action, that user remap wins and follow-up keeps `Ctrl+Enter` unless you explicitly bind `app.message.followUp`. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 27079972c..6976bbb0e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,9 +20,11 @@ - Changed the double-tap-← gesture (empty editor, main session) to stay inert when there are no subagents to show, instead of opening an empty Agent Hub roster. The explicit Agent Hub / observe keybindings still open the empty roster. The gating reuses the hub's own row count (after its persisted-subagent scan), so it matches exactly what the hub would display. - Changed the `job` tool's `async.pollWaitDuration` setting (relabeled **Max Poll Time**) to add a `smart` value, now the default. A fixed value (`5s`–`5m`) still blocks for exactly that long; `smart` adapts: a blocking poll starts at a 5s floor and climbs a ladder (5s → 10s → 30s → 1m → 5m) with each back-to-back poll, so a tight poll loop backs off and stops spending turns on "still running" frames, then resets to the 5s floor after ~1 minute without polling (i.e. when the agent steps away to do real work). Escalation is tracked per agent (owner-scoped on `AsyncJobManager`). - Added the `compat.supportsForcedToolChoice` custom-model flag for OpenAI-compatible models whose endpoints accept tools but reject forced `tool_choice` values ([#2546](https://github.com/can1357/oh-my-pi/issues/2546)). +- Changed the speech-to-text trigger from the `Alt+H` keybinding to a hold-`Space` push-to-talk gesture. Holding the space bar emits an OS auto-repeat burst; once more than 10 spaces land in the editor it recognizes the hold, deletes (tracks back) those inserted spaces, and starts recording, then stops and transcribes when the repeats stop (the space bar is released). `app.stt.toggle` is now unbound by default but can be rebound to a chord for press-to-toggle; the gesture is gated on `stt.enabled`, and `Shift+Space` still inserts a literal space. ### Fixed +- Fixed `read` on a GitHub commit URL (`github.com///commit/`) returning the raw commit HTML page instead of structured content. `parseGitHubUrl` had no `commit` case, so commit URLs fell through to generic HTML rendering; they now resolve via the commits API and render as markdown (subject, author, stats, parents, full commit message, and a per-file unified diff), matching the existing blob/tree/issue/PR handling. - Fixed the tool-result renderer re-shaping on every `invalidate()` (spinner tick, stream chunk, resize, keystroke), which made large grep/find/read results block the main thread for seconds and made typing sluggish. `ToolExecutionComponent.#updateDisplay()` now memoizes on a dirty key (result version, expand state, partial flag, spinner frame, image visibility, theme epoch, background-task freeze state, the resolved terminal image protocol, and a display-input version that covers streamed call args, the async edit-diff preview, and Kitty image conversions) and a `#displayBuilt` guard that also fast-paths the `#contentText` fallback, so the O(result-size) shaping runs once per change instead of every frame without freezing streamed args, previews, converted images, a backgrounded task settling to its static form, or images that arrive before the async image-protocol probe resolves. Image-bearing results also re-shape on terminal resize (keyed on the resolved image dimensions only when images are present) so inline images rescale, while image-free results never re-shape on resize ([#2484](https://github.com/can1357/oh-my-pi/issues/2484)) - Fixed `setTheme()` not bumping the theme epoch on its invalid-theme fallback path: a failed theme load swaps the active theme to the dark fallback, so memoized renderers (the tool-result renderer above) must re-shape — previously they kept the failed theme's stale colors until some other state changed ([#2484](https://github.com/can1357/oh-my-pi/issues/2484)) - Fixed tool-call spinners animating out of phase across parallel tool calls — each live tool block advanced its glyph from its own per-instance start time, so concurrent spinners showed different frames. Glyphs now derive from a single shared monotonic clock (`sharedSpinnerFrame`), keeping every live block in lockstep. diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 0ff23bc93..c691426f3 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -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; diff --git a/packages/coding-agent/src/modes/components/custom-editor.test.ts b/packages/coding-agent/src/modes/components/custom-editor.test.ts new file mode 100644 index 000000000..979d29dc0 --- /dev/null +++ b/packages/coding-agent/src/modes/components/custom-editor.test.ts @@ -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([]); + }); +}); diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 786595e2d..9f365c807 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -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 void>(); #customMatchKeys = new Map 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( 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) { diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 5fc29e3cb..35e73c23d 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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()); } diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts index 7239d4a81..33f0d5d85 100644 --- a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts +++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts @@ -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 |", diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 53f8b2e03..50b7e3209 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -7,6 +7,7 @@ - Added an always-on `LoopWatchdog` armed in `TUI.start()`/`TUI.stop()` that logs `ui.loop-blocked` (rising-edge deduped, with `blockedMs` and the phase active during the elapsed interval) when a self-scheduled probe tick runs late, plus a `ui.select-filter` breadcrumb around the `SelectList` fuzzy filter. The phase is read via `takeRecentLoopPhase`, so a synchronous block whose breadcrumb was pushed and popped before the delayed tick runs is still attributed to its phase instead of "unknown". `stop()` cancels the armed timer (via `clearTimeout` on the default handle) so repeated start/stop cycles leave no pending probe, with the generation guard as a fallback ([#2485](https://github.com/can1357/oh-my-pi/issues/2485)) - Added `ctrl+j` as a second default binding for the `tui.input.newLine` action alongside `shift+enter`, so terminals that cannot emit `shift+enter` still have a newline key. On terminals with Kitty-protocol / `modifyOtherKeys` disambiguation `ctrl+j` inserts a newline while `Enter` still submits; on legacy terminals where `ctrl+j` and `Enter` are both byte-identical `LF` it submits (documented limitation). User keybinding overrides still take precedence ([#2473](https://github.com/can1357/oh-my-pi/issues/2473)) - Added an `Editor.onLargePaste(text, lineCount)` hook, fired for a "marker-sized" paste (the point where the editor would otherwise collapse it into a `[Paste #N]` token). Returning `true` lets the host intercept the paste — e.g. to offer wrap-in-code-block / wrap-in-XML / attach-as-file choices — and suppresses the default marker (no undo state is recorded). Added `Editor.insertPaste(content)` so the host can re-insert a (possibly transformed) collapsed paste marker without re-triggering the hook. +- Added `Editor.deleteBeforeCursor(count)`, which removes up to `count` characters immediately before the cursor on the current line (capped at the cursor column, single line, records one undo state). Hosts use it to "track back" optimistically-inserted characters — e.g. the coding-agent hold-`Space` push-to-talk gesture deleting the space-bar auto-repeat burst. ### Fixed diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 487591476..e75742d8b 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1600,6 +1600,25 @@ export class Editor implements Component, Focusable { this.#insertTextAtCursor(text); } + /** Delete up to `count` characters immediately before the cursor on the current line. + * Used to "track back" the auto-repeat spaces that the space-hold push-to-talk gesture + * optimistically inserts before it recognizes the hold. Capped at the cursor column so it + * never crosses a line boundary or under-runs the line. */ + deleteBeforeCursor(count: number): void { + const removable = Math.min(count, this.#state.cursorCol); + if (removable <= 0) return; + this.#exitHistoryForEditing(); + this.#recordUndoState(); + const line = this.#state.lines[this.#state.cursorLine] ?? ""; + this.#state.lines[this.#state.cursorLine] = + line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol); + this.#setCursorCol(this.#state.cursorCol - removable); + this.#lastAction = null; + if (this.onChange) { + this.onChange(this.getText()); + } + } + /** Apply terminal paste semantics to text from non-bracketed paste transports. */ pasteText(text: string): void { this.#handlePaste(text);