fix(coding-agent): submit /agents create form and hook editor on Ctrl+Q
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
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user