From c1ec394160377155177e5dd38cf15731809c6b9d Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Thu, 30 Jul 2026 04:20:41 -0500 Subject: [PATCH 1/4] feat(cli): double ESC discards the draft, even mid-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+C interrupts a running turn and only clears the composer when idle, so there was no way to discard a half-typed prompt while the agent was streaming. Claude Code and Gemini CLI both bind that to double-Esc. Appends the draft to history before clearing, so Up recalls it — the same undo affordance Claude Code gives, which is what makes this safe on a key people hit by reflex. Excluded when a modal prompt is up, since those bind ESC eagerly and cancel should still win. Co-authored-by: Brooklyn Nicholson --- cli.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/cli.py b/cli.py index c1d5ada5562..8cf25b13f56 100644 --- a/cli.py +++ b/cli.py @@ -15592,6 +15592,32 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): event.app.invalidate() return + @kb.add('escape', 'escape', filter=~_modal_prompt_active) + def handle_double_escape(event): + """Double ESC: discard the current draft and any attached images. + + Matches Claude Code / Gemini CLI, where double-Esc is the + clear-the-composer gesture. It works while the agent is + streaming, which is the gap Ctrl+C leaves: Ctrl+C interrupts a + running turn and only clears the draft when idle, so mid-stream + there was no way to discard a half-typed prompt. + + The draft is appended to history first, so Up recalls it — the + same undo affordance Claude Code provides, and the reason this + is safe to bind to a key pressed by reflex. + + Single ESC is the prefix for Alt sequences (escape+enter, + escape+g, escape+v), so prompt_toolkit's escape-timeout keeps + those distinct from the double press. Modal prompts bind ESC + eagerly and are excluded here so cancel still wins. + """ + buf = event.app.current_buffer + if not (buf.text or cli_ref._attached_images): + return + buf.reset(append_to_history=bool(buf.text)) + cli_ref._attached_images.clear() + event.app.invalidate() + @kb.add('c-z') def handle_ctrl_z(event): """Handle Ctrl+Z - suspend process to background (Unix only).""" From 59a7da9a894943039bf8870859da5870ceb62f82 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 04:26:42 -0500 Subject: [PATCH 2/4] fix(tui): scope Ctrl+U / Ctrl+K to the current line, per readline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both kills operated on the whole buffer: Ctrl+U wiped everything before the cursor and Ctrl+K everything after, regardless of newlines. Readline scopes them to the current logical line, and Claude Code documents Ctrl+U as "repeat to clear across lines in multiline input" — which only works if a press at a line boundary consumes the newline and makes progress. Extract killToLineStart / killToLineEnd and route all four call sites through them, so the Cmd chords and their Ctrl equivalents cannot drift. --- .../src/__tests__/textInputKillLine.test.ts | 62 +++++++++++++++++++ ui-tui/src/components/textInput.tsx | 39 ++++++++++-- 2 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 ui-tui/src/__tests__/textInputKillLine.test.ts diff --git a/ui-tui/src/__tests__/textInputKillLine.test.ts b/ui-tui/src/__tests__/textInputKillLine.test.ts new file mode 100644 index 00000000000..6c2f3614ff9 --- /dev/null +++ b/ui-tui/src/__tests__/textInputKillLine.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { killToLineEnd, killToLineStart } from '../components/textInput.js' + +// Ctrl+U / Ctrl+K are readline motions scoped to the *current logical line*, +// not the whole buffer. Claude Code documents Ctrl+U as "repeat to clear +// across lines in multiline input", which only works if a press at a line +// boundary consumes the newline and makes progress. + +describe('killToLineStart', () => { + it('clears the whole value in single-line input', () => { + expect(killToLineStart('hello world', 11)).toEqual({ cursor: 0, value: '' }) + }) + + it('keeps text after the cursor', () => { + expect(killToLineStart('hello world', 6)).toEqual({ cursor: 0, value: 'world' }) + }) + + it('only kills the current line, leaving earlier lines intact', () => { + expect(killToLineStart('one\ntwo', 7)).toEqual({ cursor: 4, value: 'one\n' }) + }) + + it('consumes the newline when already at a line start, so repeats progress', () => { + // Second press from the position the first press left us at. + expect(killToLineStart('one\n', 4)).toEqual({ cursor: 3, value: 'one' }) + }) + + it('repeated presses walk up a multiline draft to empty', () => { + let state = { cursor: 11, value: 'one\ntwo\nsix' } + const seen: string[] = [] + + for (let i = 0; i < 6 && state.value !== ''; i++) { + state = killToLineStart(state.value, state.cursor) + seen.push(state.value) + } + + expect(seen).toEqual(['one\ntwo\n', 'one\ntwo', 'one\n', 'one', '']) + expect(state).toEqual({ cursor: 0, value: '' }) + }) + + it('is a no-op at the very start of the buffer', () => { + expect(killToLineStart('abc', 0)).toEqual({ cursor: 0, value: 'abc' }) + }) +}) + +describe('killToLineEnd', () => { + it('kills to end of a single-line value', () => { + expect(killToLineEnd('hello world', 6)).toEqual({ cursor: 6, value: 'hello ' }) + }) + + it('stops at the newline, leaving later lines intact', () => { + expect(killToLineEnd('one\ntwo', 0)).toEqual({ cursor: 0, value: '\ntwo' }) + }) + + it('consumes the newline when already at a line end, joining the next line', () => { + expect(killToLineEnd('one\ntwo', 3)).toEqual({ cursor: 3, value: 'onetwo' }) + }) + + it('is a no-op at the very end of the buffer', () => { + expect(killToLineEnd('abc', 3)).toEqual({ cursor: 3, value: 'abc' }) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 6fafe03b57b..4f2f4cea47f 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -321,6 +321,35 @@ export function resolveCursorLayout(display: string, cur: number, curRefCurrent: return cursorLayout(display, curRefCurrent, columns) } +/** + * Readline `unix-line-discard` (Ctrl+U / Cmd+Backspace): kill backward to + * the start of the *current logical line*, not to the start of the whole + * buffer. In single-line input the two are identical; in multiline input + * they are not, and repeating the keystroke walks up one line at a time. + * + * When the cursor already sits at a line start, consume the preceding + * newline so a repeat press makes progress instead of wedging — this is + * what makes "repeat to clear across lines" work. + */ +export function killToLineStart(value: string, cursor: number): { value: string; cursor: number } { + const start = value.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1 + const from = start === cursor && cursor > 0 ? start - 1 : start + + return { value: value.slice(0, from) + value.slice(cursor), cursor: from } +} + +/** + * Readline `kill-line` (Ctrl+K / Cmd+ForwardDelete): kill forward to the + * end of the current logical line. At a line end, consume the newline so a + * repeat press joins the next line rather than doing nothing. + */ +export function killToLineEnd(value: string, cursor: number): { value: string; cursor: number } { + const nl = value.indexOf('\n', cursor) + const to = nl < 0 ? value.length : nl === cursor ? nl + 1 : nl + + return { value: value.slice(0, cursor) + value.slice(to), cursor } +} + /** * True when a Backspace / ForwardDelete keystroke should kill to the line * boundary rather than delete a single word. @@ -1219,8 +1248,7 @@ export function TextInput({ if (isLineKillModifier(k)) { // Cmd+Backspace — kill backward to start of line, matching the // Ctrl+U (unix-line-discard) path below. - v = v.slice(c) - c = 0 + ;({ cursor: c, value: v } = killToLineStart(v, c)) } else if (wordMod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -1247,7 +1275,7 @@ export function TextInput({ } else if (delFwd && c < v.length) { if (isLineKillModifier(k)) { // Cmd+ForwardDelete — kill to end of line, matching Ctrl+K. - v = v.slice(0, c) + ;({ cursor: c, value: v } = killToLineEnd(v, c)) } else if (wordMod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t) @@ -1271,15 +1299,14 @@ export function TextInput({ v = v.slice(0, range.start) + v.slice(range.end) c = range.start } else { - v = v.slice(c) - c = 0 + ;({ cursor: c, value: v } = killToLineStart(v, c)) } } else if (actionKillToEnd) { if (range) { v = v.slice(0, range.start) + v.slice(range.end) c = range.start } else { - v = v.slice(0, c) + ;({ cursor: c, value: v } = killToLineEnd(v, c)) } } else if (event.keypress.isPasted || inp.length > 0) { const bracketed = event.keypress.isPasted || inp.includes('[200~') From 01022d737abd891bc87a4b899e79c4b2684f8903 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 04:26:48 -0500 Subject: [PATCH 3/4] feat(tui): double ESC discards the draft, even mid-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the CLI binding. Placed above the isBlocked early-return so it works while the agent streams — that is the whole gap, since Ctrl+C interrupts a running turn and only clears the composer when idle. Pushes the draft to history before clearing so Up recalls it. --- ui-tui/src/app/useInputHandlers.ts | 26 +++++++++++++++++++++++++- ui-tui/src/config/timing.ts | 6 ++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index f9461919915..9624c6ec87d 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef } from 'react' import { DASHBOARD_TUI_MODE } from '../config/env.js' -import { TYPING_IDLE_MS } from '../config/timing.js' +import { DOUBLE_ESC_MS, TYPING_IDLE_MS } from '../config/timing.js' import { applyCompletion } from '../domain/slash.js' import type { ApprovalRespondResponse, @@ -320,9 +320,33 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { }) } + // Double-Esc discards the draft, matching Claude Code / Gemini CLI. It + // sits above the isBlocked early-return on purpose: Ctrl+C interrupts a + // running turn rather than clearing, so while the agent streams there is + // otherwise no way to throw away a half-typed prompt. The draft is pushed + // to history first so Up recalls it. + const lastEscRef = useRef(0) + useInput((ch, key) => { const live = getUiState() + if (key.escape) { + const now = Date.now() + const isDouble = now - lastEscRef.current <= DOUBLE_ESC_MS + + lastEscRef.current = isDouble ? 0 : now + + if (isDouble && (cState.input || cState.inputBuf.length)) { + if (cState.input.trim()) { + cActions.pushHistory(cState.input) + } + + cActions.clearIn() + + return + } + } + if (isBlocked) { // When approval/clarify/confirm overlays are active, their own useInput // handlers must receive keystrokes (arrow keys, numbers, Enter). Only diff --git a/ui-tui/src/config/timing.ts b/ui-tui/src/config/timing.ts index 9a18796e168..abecd3e87e9 100644 --- a/ui-tui/src/config/timing.ts +++ b/ui-tui/src/config/timing.ts @@ -12,3 +12,9 @@ export const REASONING_PULSE_MS = 700 // responsive enough to track the drag, cheap enough to stay smooth, and the // trailing edge always lands the final width so the settled layout is exact. export const RESIZE_COALESCE_MS = 32 + +// Two Esc presses within this window discard the draft (Claude Code / +// Gemini CLI parity). Long enough for a deliberate double-tap, short +// enough that two unrelated Escs — dismissing a completion, then a +// selection — don't silently clear the composer. +export const DOUBLE_ESC_MS = 500 From 6d5b3e2520d7b935bdda4d941c37cd3d0efab24b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 04:27:44 -0500 Subject: [PATCH 4/4] docs(tui): list Esc Esc in the hotkey panel; correct the Ctrl+U/K wording The kills are line-scoped now, so "delete to start / end" overstated them. --- ui-tui/src/content/hotkeys.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index c1a4553a49d..273700114c0 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -21,13 +21,14 @@ export const HOTKEYS: [string, string][] = [ [action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'], [action + '+L', 'redraw / repaint'], [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], + ['Esc Esc', 'discard draft (recall with ↑)'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], ['Ctrl+X', 'open live session switcher (deletes queued message while editing)'], [action + '+A/E', 'home / end of line'], [action + '+Z / ' + action + '+Y', 'undo / redo input edits'], [action + '+W', 'delete word'], - [action + '+U/K', 'delete to start / end'], + [action + '+U/K', 'kill to line start / end (repeat across lines)'], [action + '+←/→', 'jump word'], ['Home/End', 'start / end of line'], ['Shift+Enter / Alt+Enter', 'insert newline'],