From b91b6aa0dab38d61fe6731014f399fee7bc14d80 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 20:33:34 +0000 Subject: [PATCH 1/3] fix(coding-agent): submit /agents create form and hook editor on Ctrl+Q MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows Terminal does not deliver a distinct Ctrl+Enter event, so the agent dashboard's new-agent description form (`AgentDashboard#handleInput` in the `createInput` branch) and the hook editor's hook-style mode (`HookEditorComponent#handleHookStyleInput`) could not be submitted: both branches only matched Ctrl+Enter encodings, and the user-reported Ctrl+Enter press registers as plain \r — same as Enter — so it fell straight through to the newline branch. Fix #1903 already added Ctrl+Q as the Windows-friendly default for `app.message.followUp`, but the create form and hook editor hardcoded their own Ctrl+Enter check that bypassed that keybinding. Routes both submit checks through a new `matchesAppFollowUp` helper that delegates to `app.message.followUp`, falling back to a Ctrl+Q/Ctrl+Enter literal match for component tests that don't install the app keybindings manager. Updated hint text in both components to surface Ctrl+Q alongside Ctrl+Enter. Added regression tests asserting Ctrl+Q submits after typing a multi-line description (the exact Windows Terminal scenario). Fixes #2118 --- docs/keybindings.md | 2 +- docs/skills/authoring-hooks.md | 2 +- packages/coding-agent/CHANGELOG.md | 3 ++ .../src/modes/components/agent-dashboard.ts | 15 ++++--- .../src/modes/components/hook-editor.ts | 23 +++++----- .../src/modes/utils/keybinding-matchers.ts | 23 ++++++++++ .../agent-dashboard-create-editor.test.ts | 45 ++++++++++++++++++- .../coding-agent/test/hook-editor.test.ts | 35 +++++++++++++++ 8 files changed, 127 insertions(+), 21 deletions(-) diff --git a/docs/keybindings.md b/docs/keybindings.md index dfc881bbe..3e6f48cb2 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -42,7 +42,7 @@ app.stt.toggle: [] | `app.clipboard.pasteImage` | `Ctrl+V` (`Alt+V` fallback on Windows) | Paste an image from the clipboard | | `app.stt.toggle` | `Alt+H` | Toggle speech-to-text recording | -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. 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`. +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. Windows Terminal also swallows `Ctrl+Enter`, so the `app.message.followUp` chord also binds `Ctrl+Q` — the same chord GitHub Copilot CLI uses — and the same chord submits the agent dashboard's new-agent description and hook-editor prompts. 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`. Terminals that implement OSC 5522 enhanced paste can send clipboard MIME data directly to `omp`; image pastes are attached as `[Image #N]`, while text/plain paste events keep normal paste behavior. When OSC 5522 is unavailable, bracketed paste still handles text, and a pasted single image-file path is loaded as an image when the file is readable from the `omp` host. diff --git a/docs/skills/authoring-hooks.md b/docs/skills/authoring-hooks.md index 65f9678ca..1c22c94bf 100644 --- a/docs/skills/authoring-hooks.md +++ b/docs/skills/authoring-hooks.md @@ -255,7 +255,7 @@ export default function contextFilter(omp: HookAPI): void { | `custom(factory)` | Render a custom TUI component | | `theme` | Current theme object | -Pass `{ promptStyle: true }` as the fourth argument when Enter should submit and Shift+Enter should insert a newline. The default hook editor behavior keeps Enter as newline and Ctrl+Enter as submit. +Pass `{ promptStyle: true }` as the fourth argument when Enter should submit and Shift+Enter should insert a newline. The default hook editor behavior keeps Enter as newline and submits on the `app.message.followUp` chord (`Ctrl+Q` or `Ctrl+Enter`). `ctx.hasUI` is `false` in headless/print/subagent mode — always guard interactive calls. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 26c45db9c..07794e966 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -23,6 +23,9 @@ - Fixed plan-mode subagents preserving read-only specialty tools such as `report_finding` while still stripping mutating tools ([#1998](https://github.com/can1357/oh-my-pi/issues/1998)). - Removed unreachable standalone Exa tool-suite exports and stale tool-count barrel exposure while keeping the live Exa `web_search` provider helpers ([#2093](https://github.com/can1357/oh-my-pi/issues/2093)). - Fixed `omp commit` split plans accepting hunk selectors that resolve to no parsed hunks, which crashed the apply step after the index reset and left the working tree fully unstaged ([#2098](https://github.com/can1357/oh-my-pi/issues/2098)). +### Fixed + +- Fixed the agent dashboard new-agent description and the hook editor (default hook-style mode) treating Ctrl+Q as plain text on Windows Terminal, leaving Windows users unable to submit because the terminal cannot deliver a distinct Ctrl+Enter event. Both forms now submit on the shared `app.message.followUp` chord (Ctrl+Q or Ctrl+Enter), matching the main prompt editor and any user remap of `app.message.followUp` ([#2118](https://github.com/can1357/oh-my-pi/issues/2118)). ## [15.10.5] - 2026-06-08 diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index c4496ac55..78aa54c9a 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -52,7 +52,12 @@ import { discoverAgents } from "../../task/discovery"; import type { AgentDefinition, AgentSource } from "../../task/types"; import { shortenPath } from "../../tools/render-utils"; import { getEditorTheme, theme } from "../theme/theme"; -import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; +import { + matchesAppFollowUp, + matchesAppInterrupt, + matchesSelectDown, + matchesSelectUp, +} from "../utils/keybinding-matchers"; import { DynamicBorder } from "./dynamic-border"; type SourceTabId = "all" | AgentSource; @@ -649,10 +654,6 @@ export class AgentDashboard extends Container { this.#buildLayout(); } - #shouldSubmitCreateDescription(data: string): boolean { - if (matchesKey(data, "ctrl+enter")) return true; - return process.platform === "win32" && data === "\n" && this.#createDescription.trim().length > 0; - } async #generateAgentFromDescription(rawDescription: string): Promise { const description = rawDescription.trim(); @@ -908,7 +909,7 @@ export class AgentDashboard extends Container { this.addChild(new Spacer(1)); const hints = this.#createGenerating ? " Generating..." - : " Ctrl+Enter: generate Enter: newline Tab: toggle scope Esc: cancel"; + : " Ctrl+Q/Ctrl+Enter: generate Enter: newline Tab: toggle scope Esc: cancel"; this.addChild(new Text(theme.fg("dim", hints), 0, 0)); } @@ -1099,7 +1100,7 @@ export class AgentDashboard extends Container { } return; } - if (!this.#createGenerating && this.#shouldSubmitCreateDescription(data)) { + if (!this.#createGenerating && matchesAppFollowUp(data)) { this.#submitCreateDescription(); return; } diff --git a/packages/coding-agent/src/modes/components/hook-editor.ts b/packages/coding-agent/src/modes/components/hook-editor.ts index fe0de86a0..7b729d1a8 100644 --- a/packages/coding-agent/src/modes/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/components/hook-editor.ts @@ -3,12 +3,17 @@ * Supports Ctrl+G for external editor. * * Two modes: - * - Default (hook): Enter inserts newline, Ctrl+Enter submits, bordered popup + * - Default (hook): Enter inserts newline, the `app.message.followUp` chord + * (Ctrl+Q / Ctrl+Enter) submits, bordered popup * - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome */ import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { getEditorTheme, theme } from "../../modes/theme/theme"; -import { matchesAppExternalEditor, matchesAppInterrupt } from "../../modes/utils/keybinding-matchers"; +import { + matchesAppExternalEditor, + matchesAppFollowUp, + matchesAppInterrupt, +} from "../../modes/utils/keybinding-matchers"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { DynamicBorder } from "./dynamic-border"; @@ -17,10 +22,6 @@ export interface HookEditorOptions { promptStyle?: boolean; } -function isCtrlEnterSubmit(keyData: string): boolean { - return matchesKey(keyData, "ctrl+enter") || (keyData.charCodeAt(0) === 10 && keyData.length > 1); -} - export class HookEditorComponent extends Container { #editor: Editor; #onSubmitCallback: (value: string) => void; @@ -67,7 +68,7 @@ export class HookEditorComponent extends Container { // Hint const hint = this.#promptStyle ? "enter submit esc cancel ctrl+g external editor" - : "ctrl+enter submit esc cancel ctrl+g external editor"; + : "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor"; this.addChild(new Text(theme.fg("dim", hint), 1, 0)); this.addChild(new Spacer(1)); @@ -110,10 +111,12 @@ export class HookEditorComponent extends Container { this.#editor.handleInput(keyData); } - /** Hook-style: Enter=newline, Ctrl+Enter=submit (original behavior) */ + /** Hook-style: Enter=newline, app.message.followUp chord (Ctrl+Q/Ctrl+Enter) submits. */ #handleHookStyleInput(keyData: string): void { - // Ctrl+Enter to submit. Use key matching so lock-key and keypad Enter variants work. - if (isCtrlEnterSubmit(keyData)) { + // Submit on the follow-up chord. Uses the shared keybinding so Ctrl+Q works + // on Windows Terminal (#1903) and any user remap of `app.message.followUp` + // applies here too. + if (matchesAppFollowUp(keyData)) { this.#submitCurrentText(); return; } diff --git a/packages/coding-agent/src/modes/utils/keybinding-matchers.ts b/packages/coding-agent/src/modes/utils/keybinding-matchers.ts index 289fd1938..654f3d967 100644 --- a/packages/coding-agent/src/modes/utils/keybinding-matchers.ts +++ b/packages/coding-agent/src/modes/utils/keybinding-matchers.ts @@ -49,3 +49,26 @@ export function matchesAppExternalEditor(data: string): boolean { } return matchesKey(data, "ctrl+g"); } + +/** + * Match the "submit multi-line text input" keybinding (`app.message.followUp`). + * + * Used by forms where plain Enter inserts a newline and a modified-Enter chord + * submits — the main editor's follow-up handler, the agent dashboard's new-agent + * description, and the hook editor's hook-style mode. The keybinding defaults to + * `["ctrl+q", "ctrl+enter"]` so Windows Terminal (which can't deliver a distinct + * Ctrl+Enter event; #1903) still has a working chord without user remapping. + * + * Also recognizes a modifier-tagged LF (e.g. modifyOtherKeys legacy encoding for + * Ctrl+Enter), which the keybinding matcher itself does not cover. + */ +export function matchesAppFollowUp(data: string): boolean { + // Modifier-tagged LF: terminals that send `\n` followed by the CSI modifier + // payload (legacy modifyOtherKeys) report Ctrl+Enter this way. + if (data.charCodeAt(0) === 10 && data.length > 1) return true; + const keybindings = getKeybindings(); + if (keybindings.getKeys("app.message.followUp").length > 0) { + return keybindings.matches(data, "app.message.followUp"); + } + return matchesKey(data, "ctrl+enter") || matchesKey(data, "ctrl+q"); +} diff --git a/packages/coding-agent/test/agent-dashboard-create-editor.test.ts b/packages/coding-agent/test/agent-dashboard-create-editor.test.ts index 429b982fa..8569492c8 100644 --- a/packages/coding-agent/test/agent-dashboard-create-editor.test.ts +++ b/packages/coding-agent/test/agent-dashboard-create-editor.test.ts @@ -71,7 +71,7 @@ describe("AgentDashboard create editor", () => { expect(rendered).toContain("> first line"); expect(rendered).toContain(" second line"); - expect(rendered).toContain("Ctrl+Enter: generate"); + expect(rendered).toContain("Ctrl+Q/Ctrl+Enter: generate"); expect(rendered).toContain("Enter: newline"); expect(rendered).not.toContain("Description is required."); }); @@ -105,11 +105,52 @@ describe("AgentDashboard create editor", () => { expect(rendered).toContain("> first line"); expect(rendered).toContain(" second line"); - expect(rendered).toContain("Ctrl+Enter: generate"); + expect(rendered).toContain("Ctrl+Q/Ctrl+Enter: generate"); expect(rendered).toContain("Enter: newline"); expect(rendered).not.toContain("Model registry unavailable in current session."); expect(rendered).not.toContain("Description is required."); }); + + test("submits new-agent descriptions on Ctrl+Q (Windows Terminal fallback for #2118)", async () => { + await initTheme(false); + const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 24, {}); + + dashboard.handleInput("n"); + typeText(dashboard, "first line"); + dashboard.handleInput("\r"); + typeText(dashboard, "second line"); + // Ctrl+Q raw byte (0x11). Windows Terminal can't deliver a distinct + // Ctrl+Enter event, so the app.message.followUp keybinding doubles as a + // portable submit chord and must apply to the create form too. + dashboard.handleInput("\x11"); + await Bun.sleep(0); + const rendered = dashboard.render(80).join("\n").replace(ANSI_PATTERN, ""); + + expect(rendered).toContain("Model registry unavailable in current session."); + expect(rendered).not.toContain("Description is required."); + }); + + test("Ctrl+Q still works after pressing Enter for a newline (Windows Terminal)", async () => { + await initTheme(false); + const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 24, {}); + + dashboard.handleInput("n"); + typeText(dashboard, "line one"); + // Windows Terminal sends bare `\r` for both Enter and Ctrl+Enter; the + // dashboard must treat `\r` as a newline so the user can keep typing. + dashboard.handleInput("\r"); + typeText(dashboard, "line two"); + const beforeSubmit = dashboard.render(80).join("\n").replace(ANSI_PATTERN, ""); + expect(beforeSubmit).toContain("> line one"); + expect(beforeSubmit).toContain(" line two"); + expect(beforeSubmit).not.toContain("Model registry unavailable in current session."); + + dashboard.handleInput("\x11"); + await Bun.sleep(0); + const afterSubmit = dashboard.render(80).join("\n").replace(ANSI_PATTERN, ""); + + expect(afterSubmit).toContain("Model registry unavailable in current session."); + }); }); describe("AgentDashboard layout", () => { diff --git a/packages/coding-agent/test/hook-editor.test.ts b/packages/coding-agent/test/hook-editor.test.ts index 0e3eb5019..67bbb966a 100644 --- a/packages/coding-agent/test/hook-editor.test.ts +++ b/packages/coding-agent/test/hook-editor.test.ts @@ -141,6 +141,41 @@ describe("HookEditorComponent default (hook) mode", () => { expect(onSubmit).toHaveBeenCalledWith("draft"); expect(onCancel).not.toHaveBeenCalled(); }); + it("submits the current text on Ctrl+Q (Windows Terminal fallback for #2118)", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", "line 1\nline 2", onSubmit, onCancel); + + // Ctrl+Q raw byte (0x11). Windows Terminal cannot deliver a distinct + // Ctrl+Enter, so app.message.followUp also binds Ctrl+Q (#1903), and the + // hook editor must honor it for the same reason. + component.handleInput("\x11"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("line 1\nline 2"); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("keeps Ctrl+Q working after Enter inserts a newline (Windows Terminal)", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel); + + component.handleInput("a"); + component.handleInput("b"); + // Windows Terminal sends bare `\r` for both Enter and Ctrl+Enter; the + // hook editor must treat `\r` as a newline and reserve Ctrl+Q for submit. + component.handleInput("\r"); + component.handleInput("c"); + component.handleInput("d"); + expect(onSubmit).not.toHaveBeenCalled(); + + component.handleInput("\x11"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("ab\ncd"); + expect(onCancel).not.toHaveBeenCalled(); + }); it("expands large paste markers when submitting on Ctrl+Enter", () => { const onSubmit = vi.fn(); From bc839d474577a21bf77ad46ccdb71a4a6fd77264 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 20:39:50 +0000 Subject: [PATCH 2/3] fix(coding-agent): respected effective follow-up bindings --- .../src/modes/utils/keybinding-matchers.ts | 30 +++++++++++++------ .../test/keybindings-migration.test.ts | 7 +++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/modes/utils/keybinding-matchers.ts b/packages/coding-agent/src/modes/utils/keybinding-matchers.ts index 654f3d967..4befdee2c 100644 --- a/packages/coding-agent/src/modes/utils/keybinding-matchers.ts +++ b/packages/coding-agent/src/modes/utils/keybinding-matchers.ts @@ -1,4 +1,4 @@ -import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui"; +import { getKeybindings, type KeyId, matchesKey } from "@oh-my-pi/pi-tui"; /** * Match the coding-agent interrupt key. @@ -50,6 +50,20 @@ export function matchesAppExternalEditor(data: string): boolean { return matchesKey(data, "ctrl+g"); } +function matchesEffectiveKey(data: string, key: KeyId): boolean { + if ((key === "ctrl+enter" || key === "ctrl+return") && data.charCodeAt(0) === 10 && data.length > 1) { + return true; + } + return matchesKey(data, key); +} + +function matchesEffectiveKeys(data: string, keys: readonly KeyId[]): boolean { + for (const key of keys) { + if (matchesEffectiveKey(data, key)) return true; + } + return false; +} + /** * Match the "submit multi-line text input" keybinding (`app.message.followUp`). * @@ -59,16 +73,14 @@ export function matchesAppExternalEditor(data: string): boolean { * `["ctrl+q", "ctrl+enter"]` so Windows Terminal (which can't deliver a distinct * Ctrl+Enter event; #1903) still has a working chord without user remapping. * - * Also recognizes a modifier-tagged LF (e.g. modifyOtherKeys legacy encoding for - * Ctrl+Enter), which the keybinding matcher itself does not cover. + * Also recognizes modifier-tagged LF as Ctrl+Enter only when Ctrl+Enter is an + * effective follow-up binding. */ export function matchesAppFollowUp(data: string): boolean { - // Modifier-tagged LF: terminals that send `\n` followed by the CSI modifier - // payload (legacy modifyOtherKeys) report Ctrl+Enter this way. - if (data.charCodeAt(0) === 10 && data.length > 1) return true; const keybindings = getKeybindings(); - if (keybindings.getKeys("app.message.followUp").length > 0) { - return keybindings.matches(data, "app.message.followUp"); + const keys = keybindings.getKeys("app.message.followUp"); + if (keys.length > 0) { + return matchesEffectiveKeys(data, keys); } - return matchesKey(data, "ctrl+enter") || matchesKey(data, "ctrl+q"); + return matchesEffectiveKeys(data, ["ctrl+enter", "ctrl+q"]); } diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts index 6cca930c5..0197dd77e 100644 --- a/packages/coding-agent/test/keybindings-migration.test.ts +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -3,9 +3,13 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; +import { matchesAppFollowUp } from "@oh-my-pi/pi-coding-agent/modes/utils/keybinding-matchers"; import { setKeybindings } from "@oh-my-pi/pi-tui"; import { YAML } from "bun"; +function ctrl(key: string): string { + return String.fromCharCode(key.toLowerCase().charCodeAt(0) & 31); +} describe("KeybindingsManager.create", () => { beforeEach(() => { setKeybindings(KeybindingsManager.inMemory()); @@ -151,11 +155,14 @@ describe("KeybindingsManager.create", () => { const manager = KeybindingsManager.inMemory({ "app.plan.toggle": "ctrl+q", }); + setKeybindings(manager); expect(manager.getKeys("app.plan.toggle")).toEqual(["ctrl+q"]); expect(manager.getKeys("app.message.followUp")).toEqual(["ctrl+enter"]); expect(manager.getDisplayString("app.message.followUp")).toBe("Ctrl+Enter"); expect(manager.getEffectiveConfig()["app.message.followUp"]).toBe("ctrl+enter"); + expect(matchesAppFollowUp(ctrl("q"))).toBe(false); + expect(matchesAppFollowUp("\x1b[13;5u")).toBe(true); }); it("keeps the Ctrl+Q follow-up default when only an unknown config key claims it (#1903)", () => { From 2022f873cbe88e77e5480df48132ed7feb263573 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 20:43:33 +0000 Subject: [PATCH 3/3] style: bun run fix --- packages/coding-agent/src/modes/components/agent-dashboard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index 78aa54c9a..99f61f34c 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -654,7 +654,6 @@ export class AgentDashboard extends Container { this.#buildLayout(); } - async #generateAgentFromDescription(rawDescription: string): Promise { const description = rawDescription.trim(); this.#createDescription = description;