diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index eae4043714..2efaca53ea 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -13,7 +13,7 @@ import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' -import { TextInput } from './components/textInput.js' +import { type PasteEvent, TextInput } from './components/textInput.js' import { Thinking } from './components/thinking.js' import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' @@ -229,18 +229,24 @@ export function App({ gw }: { gw: GatewayClient }) { const [cols, setCols] = useState(stdout?.columns ?? 80) useEffect(() => { - if (!stdout) {return} + if (!stdout) { + return + } const sync = () => setCols(stdout.columns ?? 80) stdout.on('resize', sync) // Enable bracketed paste so image-only clipboard paste reaches the app - if (stdout.isTTY) {stdout.write('\x1b[?2004h')} + if (stdout.isTTY) { + stdout.write('\x1b[?2004h') + } return () => { stdout.off('resize', sync) - if (stdout.isTTY) {stdout.write('\x1b[?2004l')} + if (stdout.isTTY) { + stdout.write('\x1b[?2004l') + } } }, [stdout]) @@ -513,14 +519,20 @@ export function App({ gw }: { gw: GatewayClient }) { ) const handleTextPaste = useCallback( - ({ bracketed, cursor, hotkey, text, value }: import('./components/textInput.js').PasteEvent) => { - if (hotkey) { void paste(false); + ({ bracketed, cursor, hotkey, text, value }: PasteEvent) => { + if (hotkey) { + void paste(false) - return null } + return null + } - if (bracketed) {void paste(true)} + if (bracketed) { + void paste(true) + } - if (!text) {return null} + if (!text) { + return null + } const lineCount = text.split('\n').length @@ -770,23 +782,38 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Input handling ─────────────────────────────────────────────── - const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => - key.ctrl && ch.toLowerCase() === target + const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target useInput((ch, key) => { if (isBlocked) { if (pasteReview) { - if (key.return) { setPasteReview(null); dispatchSubmission(pasteReview.text, true) } - else if (key.escape || ctrl(key, ch, 'c')) { setPasteReview(null); setStatus('ready') } + if (key.return) { + setPasteReview(null) + dispatchSubmission(pasteReview.text, true) + } else if (key.escape || ctrl(key, ch, 'c')) { + setPasteReview(null) + setStatus('ready') + } return } if (ctrl(key, ch, 'c')) { - if (approval) { gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}); setApproval(null); sys('denied') } - else if (sudo) { gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}); setSudo(null); sys('sudo cancelled') } - else if (secret) { gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}); setSecret(null); sys('secret entry cancelled') } - else if (picker) {setPicker(false)} + if (approval) { + gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) + setApproval(null) + sys('denied') + } else if (sudo) { + gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}) + setSudo(null) + sys('sudo cancelled') + } else if (secret) { + gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}) + setSecret(null) + sys('secret entry cancelled') + } else if (picker) { + setPicker(false) + } } else if (key.escape && picker) { setPicker(false) } @@ -803,7 +830,9 @@ export function App({ gw }: { gw: GatewayClient }) { if (!inputBuf.length && key.tab && completions.length) { const row = completions[compIdx] - if (row) {setInput(input.slice(0, compReplace) + row.text)} + if (row) { + setInput(input.slice(0, compReplace) + row.text) + } return } @@ -811,12 +840,19 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.upArrow && !inputBuf.length) { if (queueRef.current.length) { const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % queueRef.current.length - setQueueEdit(idx); setHistoryIdx(null); setInput(queueRef.current[idx] ?? '') + setQueueEdit(idx) + setHistoryIdx(null) + setInput(queueRef.current[idx] ?? '') } else if (historyRef.current.length) { const idx = historyIdx === null ? historyRef.current.length - 1 : Math.max(0, historyIdx - 1) - if (historyIdx === null) {historyDraftRef.current = input} - setHistoryIdx(idx); setQueueEdit(null); setInput(historyRef.current[idx] ?? '') + if (historyIdx === null) { + historyDraftRef.current = input + } + + setHistoryIdx(idx) + setQueueEdit(null) + setInput(historyRef.current[idx] ?? '') } return @@ -824,16 +860,24 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.downArrow && !inputBuf.length) { if (queueRef.current.length) { - const idx = queueEditIdx === null - ? queueRef.current.length - 1 - : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length + const idx = + queueEditIdx === null + ? queueRef.current.length - 1 + : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length - setQueueEdit(idx); setHistoryIdx(null); setInput(queueRef.current[idx] ?? '') + setQueueEdit(idx) + setHistoryIdx(null) + setInput(queueRef.current[idx] ?? '') } else if (historyIdx !== null) { const next = historyIdx + 1 - if (next >= historyRef.current.length) { setHistoryIdx(null); setInput(historyDraftRef.current) } - else { setHistoryIdx(next); setInput(historyRef.current[next] ?? '') } + if (next >= historyRef.current.length) { + setHistoryIdx(null) + setInput(historyDraftRef.current) + } else { + setHistoryIdx(next) + setInput(historyRef.current[next] ?? '') + } } return @@ -846,10 +890,20 @@ export function App({ gw }: { gw: GatewayClient }) { const partial = (streaming || buf.current).trimStart() partial ? appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) : sys('interrupted') - idle(); setReasoning(''); setActivity([]); turnToolsRef.current = []; setStatus('interrupted') + idle() + setReasoning('') + setActivity([]) + turnToolsRef.current = [] + setStatus('interrupted') - if (statusTimerRef.current) {clearTimeout(statusTimerRef.current)} - statusTimerRef.current = setTimeout(() => { statusTimerRef.current = null; setStatus('ready') }, 1500) + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus('ready') + }, 1500) } else if (input || inputBuf.length) { clearIn() } else { @@ -859,13 +913,20 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (ctrl(key, ch, 'd')) {return die()} + if (ctrl(key, ch, 'd')) { + return die() + } - if (ctrl(key, ch, 'l')) { setStatus('forging session…'); newSession(); + if (ctrl(key, ch, 'l')) { + setStatus('forging session…') + newSession() - return } + return + } - if (ctrl(key, ch, 'g')) {return openEditor()} + if (ctrl(key, ch, 'g')) { + return openEditor() + } }) // ── Gateway events ─────────────────────────────────────────────── diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 6ec5cc5fca..9ec083c9dd 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -4,9 +4,13 @@ import { useEffect, useRef, useState } from 'react' function wordLeft(s: string, p: number) { let i = p - 1 - while (i > 0 && /\s/.test(s[i]!)) {i--} + while (i > 0 && /\s/.test(s[i]!)) { + i-- + } - while (i > 0 && !/\s/.test(s[i - 1]!)) {i--} + while (i > 0 && !/\s/.test(s[i - 1]!)) { + i-- + } return Math.max(0, i) } @@ -14,9 +18,13 @@ function wordLeft(s: string, p: number) { function wordRight(s: string, p: number) { let i = p - while (i < s.length && !/\s/.test(s[i]!)) {i++} + while (i < s.length && !/\s/.test(s[i]!)) { + i++ + } - while (i < s.length && /\s/.test(s[i]!)) {i++} + while (i < s.length && /\s/.test(s[i]!)) { + i++ + } return i } @@ -77,7 +85,14 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } }, [value]) - useEffect(() => () => { if (pasteTimer.current) {clearTimeout(pasteTimer.current)} }, []) + useEffect( + () => () => { + if (pasteTimer.current) { + clearTimeout(pasteTimer.current) + } + }, + [] + ) // ── Buffer ops (synchronous, ref-based) ───────────────────────── @@ -88,7 +103,10 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' if (track && next !== prev) { undoStack.current.push({ cursor: curRef.current, value: prev }) - if (undoStack.current.length > 200) {undoStack.current.shift()} + if (undoStack.current.length > 200) { + undoStack.current.shift() + } + redoStack.current = [] } @@ -105,7 +123,10 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' const swap = (from: typeof undoStack, to: typeof redoStack) => { const entry = from.current.pop() - if (!entry) {return} + if (!entry) { + return + } + to.current.push({ cursor: curRef.current, value: vRef.current }) commit(entry.value, entry.cursor, false) } @@ -113,7 +134,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' const emitPaste = (e: PasteEvent) => { const handled = onPasteRef.current?.(e) - if (handled) {commit(handled.value, handled.cursor)} + if (handled) { + commit(handled.value, handled.cursor) + } return !!handled } @@ -124,7 +147,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' pasteBuf.current = '' pasteTimer.current = null - if (!text) {return} + if (!text) { + return + } 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) @@ -146,15 +171,20 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' // Keys handled by App.useInput if ( - k.upArrow || k.downArrow || + k.upArrow || + k.downArrow || (k.ctrl && inp === 'c') || - k.tab || (k.shift && k.tab) || - k.pageUp || k.pageDown || + k.tab || + (k.shift && k.tab) || + k.pageUp || + k.pageDown || k.escape - ) {return} + ) { + return + } if (k.return) { - ;(k.shift || k.meta) + k.shift || k.meta ? commit(insert(vRef.current, curRef.current, '\n'), curRef.current + 1) : onSubmitRef.current?.(vRef.current) @@ -165,46 +195,85 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' let v = vRef.current const mod = k.ctrl || k.meta - if (k.ctrl && inp === 'z') {return swap(undoStack, redoStack)} - - if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) {return swap(redoStack, undoStack)} - - if (k.home || (k.ctrl && inp === 'a')) {c = 0} - else if (k.end || (k.ctrl && inp === 'e')) {c = v.length} - else if (k.leftArrow) {c = mod ? wordLeft(v, c) : Math.max(0, c - 1)} - else if (k.rightArrow) {c = mod ? wordRight(v, c) : Math.min(v.length, c + 1)} - else if ((k.backspace || k.delete) && c > 0) { - if (mod) { const t = wordLeft(v, c); v = v.slice(0, t) + v.slice(c); c = t } - else { v = v.slice(0, c - 1) + v.slice(c); c-- } + if (k.ctrl && inp === 'z') { + return swap(undoStack, redoStack) } - 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 === 'u') { v = v.slice(c); c = 0 } - else if (k.ctrl && inp === 'k') {v = v.slice(0, c)} - else if (k.meta && inp === 'b') {c = wordLeft(v, c)} - else if (k.meta && inp === 'f') {c = wordRight(v, c)} - else if (inp.length > 0) { + + if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { + return swap(redoStack, undoStack) + } + + if (k.home || (k.ctrl && inp === 'a')) { + c = 0 + } else if (k.end || (k.ctrl && inp === 'e')) { + c = v.length + } else if (k.leftArrow) { + c = mod ? wordLeft(v, c) : Math.max(0, c - 1) + } else if (k.rightArrow) { + c = mod ? wordRight(v, c) : Math.min(v.length, c + 1) + } else if ((k.backspace || k.delete) && c > 0) { + if (mod) { + const t = wordLeft(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else { + v = v.slice(0, c - 1) + v.slice(c) + 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 === 'u') { + v = v.slice(c) + c = 0 + } else if (k.ctrl && inp === 'k') { + v = v.slice(0, c) + } else if (k.meta && inp === 'b') { + c = wordLeft(v, c) + } else if (k.meta && inp === 'f') { + c = wordRight(v, c) + } else if (inp.length > 0) { const bracketed = inp.includes('[200~') const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') - if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) {return} + if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) { + return + } - if (!raw) {return} + if (!raw) { + return + } - if (raw === '\n') {return commit(insert(v, c, '\n'), c + 1)} + if (raw === '\n') { + return commit(insert(v, c, '\n'), c + 1) + } if (raw.length > 1 || raw.includes('\n')) { - if (!pasteBuf.current) {pastePos.current = c} + if (!pasteBuf.current) { + pastePos.current = c + } + pasteBuf.current += raw - if (pasteTimer.current) {clearTimeout(pasteTimer.current)} + if (pasteTimer.current) { + clearTimeout(pasteTimer.current) + } + pasteTimer.current = setTimeout(flushPaste, 50) return } - if (PRINTABLE.test(raw)) { v = v.slice(0, c) + raw + v.slice(c); c += raw.length } - else {return} - } else {return} + if (PRINTABLE.test(raw)) { + v = v.slice(0, c) + raw + v.slice(c) + c += raw.length + } else { + return + } + } else { + return + } commit(v, c) }, @@ -213,7 +282,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' // ── Render ────────────────────────────────────────────────────── - if (!focus) {return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')}} + if (!focus) { + return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} + } if (!value && placeholder) { return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF}