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 })