diff --git a/ui-tui/src/altScreen.tsx b/ui-tui/src/altScreen.tsx deleted file mode 100644 index 34f88a6a99..0000000000 --- a/ui-tui/src/altScreen.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Box, useStdout } from 'ink' -import { type PropsWithChildren, useEffect } from 'react' - -const ENTER = '\x1b[?1049h\x1b[2J\x1b[H' -const LEAVE = '\x1b[?1049l' - -export function AltScreen({ children }: PropsWithChildren) { - const { stdout } = useStdout() - const rows = stdout?.rows ?? 24 - const cols = stdout?.columns ?? 80 - - useEffect(() => { - process.stdout.write(ENTER) - - const leave = () => process.stdout.write(LEAVE) - process.on('exit', leave) - - return () => { - leave() - process.off('exit', leave) - } - }, []) - - return ( - - {children} - - ) -} diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 0109be3f44..dac76ff8ef 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -16,7 +16,9 @@ import { 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' -import * as inputHistory from './lib/history.js' +import { useCompletion } from './hooks/useCompletion.js' +import { useInputHistory } from './hooks/useInputHistory.js' +import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' import { fmtK, hasInterpolation, pick } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' @@ -42,8 +44,6 @@ const introMsg = (info: SessionInfo): Msg => ({ info }) -const TAB_PATH_RE = /((?:\.\.?\/|~\/|\/|@)[^\s]*)$/ - function StatusRule({ cols, color, @@ -113,60 +113,24 @@ export function App({ gw }: { gw: GatewayClient }) { const [thinkingText, setThinkingText] = useState('') const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') - const [queueEditIdx, setQueueEditIdx] = useState(null) - const [historyIdx, setHistoryIdx] = useState(null) const [streaming, setStreaming] = useState('') - const [queuedDisplay, setQueuedDisplay] = useState([]) const [catalog, setCatalog] = useState(null) const buf = useRef('') const interruptedRef = useRef(false) - const queueRef = useRef([]) - const historyRef = useRef(inputHistory.load()) - const historyDraftRef = useRef('') - const queueEditRef = useRef(null) const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) const pasteCounterRef = useRef(0) + const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = + useQueue() + + const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() + const empty = !messages.length const blocked = !!(clarify || approval || sudo || secret || picker) - const syncQueue = () => setQueuedDisplay([...queueRef.current]) - - const setQueueEdit = (idx: number | null) => { - queueEditRef.current = idx - setQueueEditIdx(idx) - } - - const enqueue = (text: string) => { - queueRef.current.push(text) - syncQueue() - } - - const dequeue = () => { - const [head, ...rest] = queueRef.current - queueRef.current = rest - syncQueue() - - return head - } - - const replaceQ = (i: number, text: string) => { - queueRef.current[i] = text - syncQueue() - } - - const pushHistory = (text: string) => { - const trimmed = text.trim() - - if (trimmed && historyRef.current.at(-1) !== trimmed) { - historyRef.current.push(trimmed) - inputHistory.append(trimmed) - } - } - useEffect(() => { if (!sid || !stdout) { return @@ -180,63 +144,7 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps - const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) - const [compIdx, setCompIdx] = useState(0) - const [compReplace, setCompReplace] = useState(0) - const compInputRef = useRef('') - - useEffect(() => { - if (blocked) { - if (completions.length) { - setCompletions([]) - setCompIdx(0) - } - - return - } - - if (input === compInputRef.current) { - return - } - - compInputRef.current = input - - const isSlash = input.startsWith('/') - const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null - - if (!isSlash && !pathWord) { - if (completions.length) { - setCompletions([]) - setCompIdx(0) - } - - return - } - - const t = setTimeout(() => { - if (compInputRef.current !== input) { - return - } - - const req = isSlash - ? gw.request('complete.slash', { text: input }) - : gw.request('complete.path', { word: pathWord }) - - req - .then((r: any) => { - if (compInputRef.current !== input) { - return - } - - setCompletions(r?.items ?? []) - setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) - }) - .catch(() => {}) - }, 60) - - return () => clearTimeout(t) - }, [input, blocked, gw]) // eslint-disable-line react-hooks/exhaustive-deps + const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked, gw) const appendMessage = useCallback((msg: Msg) => { setMessages(prev => [...prev, msg]) diff --git a/ui-tui/src/components/commandPalette.tsx b/ui-tui/src/components/commandPalette.tsx deleted file mode 100644 index 2dad8c04d2..0000000000 --- a/ui-tui/src/components/commandPalette.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Box, Text } from 'ink' - -import type { Theme } from '../theme.js' - -export function CommandPalette({ matches, t }: { matches: [string, string][]; t: Theme }) { - if (!matches.length) { - return null - } - - return ( - - {matches.map(([cmd, desc], i) => ( - - - {cmd} - - {desc ? — {desc} : null} - - ))} - - ) -} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 2ac64efb37..389fd27c3e 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -116,6 +116,7 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder let c = cur, v = value + const mod = k.ctrl || k.meta if (k.home || (k.ctrl && inp === 'a')) { @@ -161,11 +162,13 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder if (!pasteBuf.current) { pastePos.current = c } + pasteBuf.current += raw if (pasteTimer.current) { clearTimeout(pasteTimer.current) } + pasteTimer.current = setTimeout(flushPaste, 50) return diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts new file mode 100644 index 0000000000..a700127115 --- /dev/null +++ b/ui-tui/src/hooks/useCompletion.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' + +const TAB_PATH_RE = /((?:\.\.?\/|~\/|\/|@)[^\s]*)$/ + +export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { + const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) + const [compIdx, setCompIdx] = useState(0) + const [compReplace, setCompReplace] = useState(0) + const ref = useRef('') + + useEffect(() => { + if (blocked) { + if (completions.length) { + setCompletions([]) + setCompIdx(0) + } + + return + } + + if (input === ref.current) { + return + } + + ref.current = input + + const isSlash = input.startsWith('/') + const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null + + if (!isSlash && !pathWord) { + if (completions.length) { + setCompletions([]) + setCompIdx(0) + } + + return + } + + const t = setTimeout(() => { + if (ref.current !== input) { + return + } + + const req = isSlash + ? gw.request('complete.slash', { text: input }) + : gw.request('complete.path', { word: pathWord }) + + req + .then((r: any) => { + if (ref.current !== input) { + return + } + + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + }) + .catch(() => {}) + }, 60) + + return () => clearTimeout(t) + }, [input, blocked, gw]) // eslint-disable-line react-hooks/exhaustive-deps + + return { completions, compIdx, setCompIdx, compReplace } +} diff --git a/ui-tui/src/hooks/useInputHistory.ts b/ui-tui/src/hooks/useInputHistory.ts new file mode 100644 index 0000000000..a7b7d2ecae --- /dev/null +++ b/ui-tui/src/hooks/useInputHistory.ts @@ -0,0 +1,20 @@ +import { useRef, useState } from 'react' + +import * as inputHistory from '../lib/history.js' + +export function useInputHistory() { + const historyRef = useRef(inputHistory.load()) + const [historyIdx, setHistoryIdx] = useState(null) + const historyDraftRef = useRef('') + + const pushHistory = (text: string) => { + const trimmed = text.trim() + + if (trimmed && historyRef.current.at(-1) !== trimmed) { + historyRef.current.push(trimmed) + inputHistory.append(trimmed) + } + } + + return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } +} diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts new file mode 100644 index 0000000000..c0df224ff0 --- /dev/null +++ b/ui-tui/src/hooks/useQueue.ts @@ -0,0 +1,35 @@ +import { useRef, useState } from 'react' + +export function useQueue() { + const queueRef = useRef([]) + const [queuedDisplay, setQueuedDisplay] = useState([]) + const queueEditRef = useRef(null) + const [queueEditIdx, setQueueEditIdx] = useState(null) + + const syncQueue = () => setQueuedDisplay([...queueRef.current]) + + const setQueueEdit = (idx: number | null) => { + queueEditRef.current = idx + setQueueEditIdx(idx) + } + + const enqueue = (text: string) => { + queueRef.current.push(text) + syncQueue() + } + + const dequeue = () => { + const [head, ...rest] = queueRef.current + queueRef.current = rest + syncQueue() + + return head + } + + const replaceQ = (i: number, text: string) => { + queueRef.current[i] = text + syncQueue() + } + + return { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } +} diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 7beb1516ac..50125d3b56 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -70,10 +70,12 @@ export function append(line: string): void { } const ts = new Date().toISOString().replace('T', ' ').replace('Z', '') + const encoded = trimmed .split('\n') .map(l => '+' + l) .join('\n') + appendFileSync(file, `\n# ${ts}\n${encoded}\n`) } catch { /* ignore */ diff --git a/ui-tui/src/lib/slash.ts b/ui-tui/src/lib/slash.ts deleted file mode 100644 index 07b106c7d1..0000000000 --- a/ui-tui/src/lib/slash.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { SlashCatalog } from '../types.js' - -/** Match SlashCommandCompleter: command names, subcommands, then skills. */ -export function paletteForLine(line: string, c: SlashCatalog | null): [string, string][] { - if (!c || !line.startsWith('/')) { - return [] - } - - const parts = line.split(/\s+/) - const baseRaw = parts[0]! - const base = baseRaw.toLowerCase() - const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' ')) - - if (inSub) { - const subText = parts.length > 1 ? parts.slice(1).join(' ') : '' - - if (subText.includes(' ') || parts.length > 2) { - return [] - } - - const head = subText.split(/\s+/)[0] ?? '' - - if (subText.includes(' ') && head !== subText) { - return [] - } - - const canonical = c.canon[base] ?? baseRaw - const subs = c.sub[canonical] - - if (!subs?.length) { - return [] - } - - const lo = head.toLowerCase() - - return subs - .filter(s => s.toLowerCase().startsWith(lo) && s.toLowerCase() !== lo) - .slice(0, 14) - .map(s => [s, '']) - } - - const word = line.slice(1) - - return c.pairs - .filter(([k]) => k.slice(1).startsWith(word)) - .slice(0, 16) - .map(([k, d]) => [k, d]) -} - -/** Tab: longest common prefix of palette matches, or first unique completion + space. */ -export function tabAdvance(line: string, c: SlashCatalog | null): string | null { - if (!c || !line.startsWith('/')) { - return null - } - - const rows = paletteForLine(line, c) - - if (!rows.length) { - return null - } - - const parts = line.split(/\s+/) - const baseRaw = parts[0]! - const base = baseRaw.toLowerCase() - const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' ')) - - if (inSub) { - const subText = parts.length > 1 ? parts.slice(1).join(' ') : '' - const head = subText.split(/\s+/)[0] ?? '' - const picks = rows.map(([s]) => s) - - if (picks.length === 1) { - return `${baseRaw} ${picks[0]!} ` - } - - const cp = commonPrefix(picks) - - if (cp.length > head.length) { - return `${baseRaw} ${cp}` - } - - return null - } - - const word = line.slice(1) - const names = rows.map(([k]) => k.slice(1)) - const cp = commonPrefix(names) - - if (names.length === 1) { - return `/${names[0]!} ` - } - - if (cp.length > word.length) { - return `/${cp}` - } - - return null -} - -function commonPrefix(xs: string[]): string { - if (!xs.length) { - return '' - } - - let n = 0 - - outer: while (true) { - const ch = xs[0]![n] - - if (ch === undefined) { - break - } - - for (const x of xs) { - if (x[n] !== ch) { - break outer - } - } - - n++ - } - - return xs[0]!.slice(0, n) -} diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx deleted file mode 100644 index b8c247d97a..0000000000 --- a/ui-tui/src/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { render } from 'ink' -import React from 'react' - -import { App } from './app.js' -import { GatewayClient } from './gatewayClient.js' - -if (!process.stdin.isTTY) { - console.log('hermes-tui: no TTY') - process.exit(0) -} - -const gw = new GatewayClient() -gw.start() -render(, { exitOnCtrlC: false })