mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Merge pull request #74736 from NousResearch/bb/clear-draft
feat: double ESC discards the draft; make Ctrl+U/K line-scoped
This commit is contained in:
commit
c581ad402e
6 changed files with 154 additions and 8 deletions
26
cli.py
26
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)."""
|
||||
|
|
|
|||
62
ui-tui/src/__tests__/textInputKillLine.test.ts
Normal file
62
ui-tui/src/__tests__/textInputKillLine.test.ts
Normal file
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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~')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue