import { spawnSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { useStore } from '@nanostores/react' import { useCallback, useMemo, useState } from 'react' import type { PasteEvent } from '../components/textInput.js' import { LARGE_PASTE } from '../config/limits.js' import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' import { useQueue } from '../hooks/useQueue.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' import { $isBlocked } from './overlayStore.js' const PASTE_SNIP_MAX_COUNT = 32 const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024 const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => { let total = 0 const out: PasteSnippet[] = [] for (let i = snips.length - 1; i >= 0; i--) { const snip = snips[i]! const size = snip.text.length if (out.length >= PASTE_SNIP_MAX_COUNT || total + size > PASTE_SNIP_MAX_TOTAL_BYTES) { break } total += size out.unshift(snip) } return out.length === snips.length ? snips : out } export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult { const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) const [pasteSnips, setPasteSnips] = useState([]) const isBlocked = useStore($isBlocked) const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = useQueue() const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw) const clearIn = useCallback(() => { setInput('') setInputBuf([]) setPasteSnips([]) setQueueEdit(null) setHistoryIdx(null) historyDraftRef.current = '' }, [historyDraftRef, setQueueEdit, setHistoryIdx]) const handleTextPaste = useCallback( ({ bracketed, cursor, hotkey, text, value }: PasteEvent) => { if (hotkey) { void onClipboardPaste(false) return null } const cleanedText = stripTrailingPasteNewlines(text) if (!cleanedText || !/[^\n]/.test(cleanedText)) { if (bracketed) { void onClipboardPaste(true) } return null } const lineCount = cleanedText.split('\n').length if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { return { cursor: cursor + cleanedText.length, value: value.slice(0, cursor) + cleanedText + value.slice(cursor) } } const label = pasteTokenLabel(cleanedText, lineCount) const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' const insert = `${lead}${label}${tail}` setPasteSnips(prev => trimSnips([...prev, { label, text: cleanedText }])) void gw .request<{ path?: string }>('paste.collapse', { text: cleanedText }) .then(r => { const path = r?.path if (!path) { return } setPasteSnips(prev => prev.map(s => (s.label === label ? { ...s, path } : s))) }) .catch(() => {}) return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) } }, [gw, onClipboardPaste] ) const openEditor = useCallback(() => { const editor = process.env.EDITOR || process.env.VISUAL || 'vi' const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') writeFileSync(file, [...inputBuf, input].join('\n')) process.stdout.write('\x1b[?1049l') const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' }) process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H') if (code === 0) { const text = readFileSync(file, 'utf8').trimEnd() if (text) { setInput('') setInputBuf([]) submitRef.current(text) } } rmSync(file, { force: true }) }, [input, inputBuf, submitRef]) const actions = useMemo( () => ({ clearIn, dequeue, enqueue, handleTextPaste, openEditor, pushHistory, replaceQueue: replaceQ, setCompIdx, setHistoryIdx, setInput, setInputBuf, setPasteSnips, setQueueEdit, syncQueue }), [ clearIn, dequeue, enqueue, handleTextPaste, openEditor, pushHistory, replaceQ, setCompIdx, setHistoryIdx, setQueueEdit, syncQueue ] ) const refs = useMemo( () => ({ historyDraftRef, historyRef, queueEditRef, queueRef, submitRef }), [historyDraftRef, historyRef, queueEditRef, queueRef, submitRef] ) const state = useMemo( () => ({ compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay }), [compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay] ) return { actions, refs, state } }