mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
fix: preserve Ctrl+J newlines in Ghostty
This commit is contained in:
parent
1386a7e478
commit
cf8862cfa3
5 changed files with 82 additions and 8 deletions
13
cli.py
13
cli.py
|
|
@ -2481,8 +2481,9 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = (
|
|||
def _preserve_ctrl_enter_newline() -> bool:
|
||||
"""Detect environments where Ctrl+Enter must produce a newline, not submit.
|
||||
|
||||
Native Windows, WSL, SSH sessions, and Windows Terminal all send Ctrl+Enter
|
||||
as bare LF (c-j). On those terminals c-j must NOT be bound to submit;
|
||||
Windows Terminal, WSL, SSH sessions, Ghostty, and some modern terminals
|
||||
deliver Ctrl+Enter/Ctrl+J as bare LF (c-j). On those terminals c-j must
|
||||
NOT be bound to submit;
|
||||
binding it to submit makes Ctrl+Enter (intended as 'newline like Alt+Enter')
|
||||
submit instead. Local POSIX TTYs that deliver Enter as LF (docker exec,
|
||||
some thin PTYs without SSH) still need c-j bound to submit, so we keep
|
||||
|
|
@ -2496,6 +2497,12 @@ def _preserve_ctrl_enter_newline() -> bool:
|
|||
return True
|
||||
if os.environ.get("WT_SESSION"):
|
||||
return True
|
||||
if os.environ.get("GHOSTTY_RESOURCES_DIR") or os.environ.get("GHOSTTY_BIN_DIR"):
|
||||
return True
|
||||
if os.environ.get("TERM", "").lower() == "xterm-ghostty":
|
||||
return True
|
||||
if os.environ.get("TERM_PROGRAM", "").lower() == "ghostty":
|
||||
return True
|
||||
if "microsoft" in os.environ.get("WSL_DISTRO_NAME", "").lower():
|
||||
return True
|
||||
# WSL detection — env vars can be scrubbed under sudo, also peek /proc.
|
||||
|
|
@ -2516,7 +2523,7 @@ def _bind_prompt_submit_keys(kb, handler) -> None:
|
|||
some thin PTYs (docker exec, certain SSH flavors) deliver Enter as LF
|
||||
instead of CR — without this, Enter appears dead on those terminals.
|
||||
|
||||
Exception: on Windows, WSL, SSH sessions, and Windows Terminal,
|
||||
Exception: on Windows, WSL, SSH sessions, Windows Terminal, and Ghostty,
|
||||
c-j is the wire encoding of Ctrl+Enter (a distinct keystroke from
|
||||
plain Enter / c-m). We leave c-j unbound there so the c-j newline
|
||||
handler registered separately can fire — giving the user an
|
||||
|
|
|
|||
|
|
@ -180,9 +180,9 @@ class TestPromptToolkitTerminalCompatibility:
|
|||
def test_lf_enter_binds_to_submit_handler_posix(self):
|
||||
"""Some thin PTYs deliver Enter as LF/c-j instead of CR/enter.
|
||||
|
||||
On a bare local POSIX TTY (no SSH/WSL/WT) we keep c-j → submit so
|
||||
On a bare local POSIX TTY (no SSH/WSL/WT/Ghostty) we keep c-j → submit so
|
||||
Enter works on thin PTYs (docker exec, certain ssh configurations).
|
||||
On Windows, WSL, SSH sessions, and Windows Terminal we leave c-j
|
||||
On Windows, WSL, SSH sessions, Windows Terminal, and Ghostty we leave c-j
|
||||
unbound here so it can be used as the Ctrl+Enter newline keystroke
|
||||
without conflicting with submit. See issue #22379.
|
||||
"""
|
||||
|
|
@ -217,6 +217,17 @@ class TestPromptToolkitTerminalCompatibility:
|
|||
assert bindings[("c-m",)] is submit_handler
|
||||
assert ("c-j",) not in bindings
|
||||
|
||||
# Ghostty through tmux: TERM_PROGRAM is tmux, but Ghostty exports a
|
||||
# stable env marker. Keep c-j free so Ctrl+J inserts a newline.
|
||||
with _patch.object(_sys, "platform", "linux"), \
|
||||
_patch.dict(_os.environ, {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, clear=True), \
|
||||
_patch("builtins.open", side_effect=OSError("no /proc")):
|
||||
kb = KeyBindings()
|
||||
_bind_prompt_submit_keys(kb, submit_handler)
|
||||
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
||||
assert bindings[("c-m",)] is submit_handler
|
||||
assert ("c-j",) not in bindings
|
||||
|
||||
# Windows: only enter submits; c-j is free for the newline binding
|
||||
# added separately in the prompt setup.
|
||||
with _patch.object(_sys, "platform", "win32"):
|
||||
|
|
|
|||
|
|
@ -51,8 +51,20 @@ def test_windows_terminal_session_preserves_newline():
|
|||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
def test_ghostty_tmux_session_preserves_ctrl_j_newline():
|
||||
"""Ghostty-inherited env survives tmux even when TERM_PROGRAM becomes tmux."""
|
||||
import cli as cli_mod
|
||||
with patch.object(sys, "platform", "linux"):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"},
|
||||
clear=True,
|
||||
):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
def test_pure_local_linux_does_not_preserve():
|
||||
"""A bare local Linux TTY (no SSH/WSL/WT) keeps c-j → submit so docker exec
|
||||
"""A bare local Linux TTY (no SSH/WSL/WT/Ghostty) keeps c-j → submit so docker exec
|
||||
style Enter-as-LF stays usable."""
|
||||
import cli as cli_mod
|
||||
# Stub out /proc reads — those are the WSL fallback signal.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldPassThroughToGlobalHandler } from '../components/textInput.js'
|
||||
import { shouldPassThroughToGlobalHandler, shouldPreserveCtrlJNewline } from '../components/textInput.js'
|
||||
import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js'
|
||||
|
||||
const key = (overrides: Record<string, unknown> = {}) =>
|
||||
({ ctrl: false, meta: false, ...overrides }) as any
|
||||
|
||||
describe('shouldPreserveCtrlJNewline', () => {
|
||||
it('preserves Ctrl+J as newline in Ghostty even when tmux masks TERM/TERM_PROGRAM', () => {
|
||||
expect(
|
||||
shouldPreserveCtrlJNewline({
|
||||
GHOSTTY_RESOURCES_DIR: '/usr/share/ghostty',
|
||||
TERM: 'tmux-256color',
|
||||
TERM_PROGRAM: 'tmux'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps bare local POSIX LF-compatible prompts submitting on Ctrl+J', () => {
|
||||
expect(shouldPreserveCtrlJNewline({ TERM: 'xterm-256color' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPassThroughToGlobalHandler', () => {
|
||||
it('passes through the configured voice shortcut while composer is focused', () => {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
|
|||
const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
|
||||
const FRAME_BATCH_MS = 16
|
||||
const MULTI_CLICK_MS = 500
|
||||
type MinimalEnv = Record<string, string | undefined>
|
||||
|
||||
const invert = (s: string) => INV + s + INV_OFF
|
||||
const dim = (s: string) => DIM + s + DIM_OFF
|
||||
|
|
@ -122,6 +123,30 @@ export function applyPrintableInsert(
|
|||
|
||||
export const shouldRouteMultiCharInputAsPaste = (text: string): boolean => text.includes('\n')
|
||||
|
||||
export function shouldPreserveCtrlJNewline(env: MinimalEnv = process.env): boolean {
|
||||
if (env.WT_SESSION) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (env.GHOSTTY_RESOURCES_DIR || env.GHOSTTY_BIN_DIR) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ((env.TERM ?? '').toLowerCase() === 'xterm-ghostty') {
|
||||
return true
|
||||
}
|
||||
|
||||
if ((env.TERM_PROGRAM ?? '').toLowerCase() === 'ghostty') {
|
||||
return true
|
||||
}
|
||||
|
||||
return (env.WSL_DISTRO_NAME ?? '').toLowerCase().includes('microsoft')
|
||||
}
|
||||
|
||||
function prevPos(s: string, p: number) {
|
||||
const pos = snapPos(s, p)
|
||||
let prev = 0
|
||||
|
|
@ -943,7 +968,10 @@ export function TextInput({
|
|||
if (k.return) {
|
||||
flushKeyBurst()
|
||||
|
||||
if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) {
|
||||
const sequence = (event.keypress as { sequence?: string }).sequence
|
||||
const preserveBareLineFeed = shouldPreserveCtrlJNewline() && sequence === '\n'
|
||||
|
||||
if (k.shift || k.ctrl || preserveBareLineFeed || (isMac ? isActionMod(k) : k.meta)) {
|
||||
commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
|
||||
} else {
|
||||
cbSubmit.current?.(vRef.current)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue