mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 15:01:34 +08:00
fix(tui): approval flow + input ergonomics + selection perf
- tui_gateway: route approvals through gateway callback (HERMES_GATEWAY_SESSION/ HERMES_EXEC_ASK) so dangerous commands emit approval.request instead of silently falling through the CLI input() path and auto-denying - approval UX: dedicated PromptZone between transcript and composer, safer defaults (sel=0, numeric quick-picks, no Esc=deny), activity trail line, outcome footer under the cost row - text input: Ctrl+A select-all, real forward Delete, Ctrl+W always consumed (fixes Ctrl+Backspace at cursor 0 inserting literal w) - hermes-ink selection: swap synchronous onRender() for throttled scheduleRender() on drag, and only notify React subscribers on presence change — no more per-cell paint/subscribe spam - useConfigSync: silence config.get polling failures instead of surfacing 'error: timeout: config.get' in the transcript
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as Ink from '@hermes/ink'
|
||||
import type { InputEvent, Key } from '@hermes/ink'
|
||||
import * as Ink from '@hermes/ink'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
@@ -240,6 +240,14 @@ function renderWithCursor(value: string, cursor: number) {
|
||||
return done ? out : out + invert(' ')
|
||||
}
|
||||
|
||||
function renderWithSelection(value: string, start: number, end: number) {
|
||||
if (start >= end) {
|
||||
return value
|
||||
}
|
||||
|
||||
return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end)
|
||||
}
|
||||
|
||||
function useFwdDelete(active: boolean) {
|
||||
const ref = useRef(false)
|
||||
const { inputEmitter: ee } = useStdin()
|
||||
@@ -274,13 +282,16 @@ export function TextInput({
|
||||
focus = true
|
||||
}: TextInputProps) {
|
||||
const [cur, setCur] = useState(value.length)
|
||||
const [sel, setSel] = useState<null | { end: number; start: number }>(null)
|
||||
const fwdDel = useFwdDelete(focus)
|
||||
const termFocus = useTerminalFocus()
|
||||
|
||||
const curRef = useRef(cur)
|
||||
const selRef = useRef<null | { end: number; start: number }>(null)
|
||||
const vRef = useRef(value)
|
||||
const self = useRef(false)
|
||||
const pasteBuf = useRef('')
|
||||
const pasteEnd = useRef<null | number>(null)
|
||||
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pastePos = useRef(0)
|
||||
const undo = useRef<{ cursor: number; value: string }[]>([])
|
||||
@@ -296,12 +307,15 @@ export function TextInput({
|
||||
const raw = self.current ? vRef.current : value
|
||||
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
|
||||
|
||||
const selected =
|
||||
sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null
|
||||
|
||||
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
|
||||
|
||||
const boxRef = useDeclaredCursor({
|
||||
line: layout.line,
|
||||
column: layout.column,
|
||||
active: focus && termFocus
|
||||
active: focus && termFocus && !selected
|
||||
})
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
@@ -313,15 +327,21 @@ export function TextInput({
|
||||
return invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return renderWithSelection(display, selected.start, selected.end)
|
||||
}
|
||||
|
||||
return renderWithCursor(display, cur)
|
||||
}, [cur, display, focus, placeholder])
|
||||
}, [cur, display, focus, placeholder, selected])
|
||||
|
||||
useEffect(() => {
|
||||
if (self.current) {
|
||||
self.current = false
|
||||
} else {
|
||||
setCur(value.length)
|
||||
setSel(null)
|
||||
curRef.current = value.length
|
||||
selRef.current = null
|
||||
vRef.current = value
|
||||
undo.current = []
|
||||
redo.current = []
|
||||
@@ -341,6 +361,11 @@ export function TextInput({
|
||||
const prev = vRef.current
|
||||
const c = snapPos(next, nextCur)
|
||||
|
||||
if (selRef.current) {
|
||||
selRef.current = null
|
||||
setSel(null)
|
||||
}
|
||||
|
||||
if (track && next !== prev) {
|
||||
undo.current.push({ cursor: curRef.current, value: prev })
|
||||
|
||||
@@ -385,7 +410,9 @@ export function TextInput({
|
||||
const flushPaste = () => {
|
||||
const text = pasteBuf.current
|
||||
const at = pastePos.current
|
||||
const end = pasteEnd.current ?? at
|
||||
pasteBuf.current = ''
|
||||
pasteEnd.current = null
|
||||
pasteTimer.current = null
|
||||
|
||||
if (!text) {
|
||||
@@ -393,10 +420,41 @@ export function TextInput({
|
||||
}
|
||||
|
||||
if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) {
|
||||
commit(vRef.current.slice(0, at) + text + vRef.current.slice(at), at + text.length)
|
||||
commit(vRef.current.slice(0, at) + text + vRef.current.slice(end), at + text.length)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSel = () => {
|
||||
if (!selRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
selRef.current = null
|
||||
setSel(null)
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
const end = vRef.current.length
|
||||
|
||||
if (!end) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { end, start: 0 }
|
||||
selRef.current = next
|
||||
setSel(next)
|
||||
setCur(end)
|
||||
curRef.current = end
|
||||
}
|
||||
|
||||
const selRange = () => {
|
||||
const range = selRef.current
|
||||
|
||||
return range && range.start !== range.end
|
||||
? { end: Math.max(range.start, range.end), start: Math.min(range.start, range.end) }
|
||||
: null
|
||||
}
|
||||
|
||||
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
|
||||
|
||||
useInput(
|
||||
@@ -431,6 +489,8 @@ export function TextInput({
|
||||
let c = curRef.current
|
||||
let v = vRef.current
|
||||
const mod = k.ctrl || k.meta
|
||||
const range = selRange()
|
||||
const delFwd = k.delete || fwdDel.current
|
||||
|
||||
if (k.ctrl && inp === 'z') {
|
||||
return swap(undo, redo)
|
||||
@@ -440,19 +500,42 @@ export function TextInput({
|
||||
return swap(redo, undo)
|
||||
}
|
||||
|
||||
if (k.home || (k.ctrl && inp === 'a')) {
|
||||
if (k.ctrl && inp === 'a') {
|
||||
return selectAll()
|
||||
}
|
||||
|
||||
if (k.home) {
|
||||
clearSel()
|
||||
c = 0
|
||||
} else if (k.end || (k.ctrl && inp === 'e')) {
|
||||
clearSel()
|
||||
c = v.length
|
||||
} else if (k.leftArrow) {
|
||||
c = mod ? wordLeft(v, c) : prevPos(v, c)
|
||||
if (range && !mod) {
|
||||
clearSel()
|
||||
c = range.start
|
||||
} else {
|
||||
clearSel()
|
||||
c = mod ? wordLeft(v, c) : prevPos(v, c)
|
||||
}
|
||||
} else if (k.rightArrow) {
|
||||
c = mod ? wordRight(v, c) : nextPos(v, c)
|
||||
if (range && !mod) {
|
||||
clearSel()
|
||||
c = range.end
|
||||
} else {
|
||||
clearSel()
|
||||
c = mod ? wordRight(v, c) : nextPos(v, c)
|
||||
}
|
||||
} else if (k.meta && inp === 'b') {
|
||||
clearSel()
|
||||
c = wordLeft(v, c)
|
||||
} else if (k.meta && inp === 'f') {
|
||||
clearSel()
|
||||
c = wordRight(v, c)
|
||||
} else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
|
||||
} else if (range && (k.backspace || delFwd)) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
} else if (k.backspace && c > 0) {
|
||||
if (mod) {
|
||||
const t = wordLeft(v, c)
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
@@ -462,22 +545,40 @@ export function TextInput({
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
c = t
|
||||
}
|
||||
} else if (k.delete && fwdDel.current && c < v.length) {
|
||||
} else if (delFwd && c < v.length) {
|
||||
if (mod) {
|
||||
const t = wordRight(v, c)
|
||||
v = v.slice(0, c) + v.slice(t)
|
||||
} else {
|
||||
v = v.slice(0, c) + v.slice(nextPos(v, c))
|
||||
}
|
||||
} else if (k.ctrl && inp === 'w' && c > 0) {
|
||||
const t = wordLeft(v, c)
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
c = t
|
||||
} else if (k.ctrl && inp === 'w') {
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
} else if (c > 0) {
|
||||
clearSel()
|
||||
const t = wordLeft(v, c)
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
c = t
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else if (k.ctrl && inp === 'u') {
|
||||
v = v.slice(c)
|
||||
c = 0
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
} else {
|
||||
v = v.slice(c)
|
||||
c = 0
|
||||
}
|
||||
} else if (k.ctrl && inp === 'k') {
|
||||
v = v.slice(0, c)
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
} else {
|
||||
v = v.slice(0, c)
|
||||
}
|
||||
} else if (inp.length > 0) {
|
||||
const bracketed = inp.includes('[200~')
|
||||
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
@@ -496,7 +597,8 @@ export function TextInput({
|
||||
|
||||
if (text.length > 1 || text.includes('\n')) {
|
||||
if (!pasteBuf.current) {
|
||||
pastePos.current = c
|
||||
pastePos.current = range ? range.start : c
|
||||
pasteEnd.current = range ? range.end : pastePos.current
|
||||
}
|
||||
|
||||
pasteBuf.current += text
|
||||
@@ -511,8 +613,13 @@ export function TextInput({
|
||||
}
|
||||
|
||||
if (PRINTABLE.test(text)) {
|
||||
v = v.slice(0, c) + text + v.slice(c)
|
||||
c += text.length
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + text + v.slice(range.end)
|
||||
c = range.start + text.length
|
||||
} else {
|
||||
v = v.slice(0, c) + text + v.slice(c)
|
||||
c += text.length
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@@ -532,6 +639,7 @@ export function TextInput({
|
||||
return
|
||||
}
|
||||
|
||||
clearSel()
|
||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||
setCur(next)
|
||||
curRef.current = next
|
||||
|
||||
Reference in New Issue
Block a user