import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { ActionContext, DockEdge, LogKind, LogSection, ReplyMarks, UIFMode, UltimateInputFieldProps } from '../types'; import { layoutFor, type Action, type ActionsLayout } from '../core/actions'; import { MODE_BAR } from '../core/builtins'; import { parseCommand } from '../core/commands'; import { KEYBOARD_HINT, useDragResize } from '../hooks/useDragResize'; import { Button } from './ui/Button'; import { IconSend, IconTerminal, IconType } from './ui/Icons'; import { ActionBar } from './ActionBar'; import { ResizeCorners } from './ResizeCorners'; import { WindowsMenu } from './WindowsMenu'; import { ResponsesMenu } from './ResponsesMenu'; import { responseSettings } from '../core/responseSettings'; import { HUB_KEY, UIFProvider, useOptionalUIF, useUIF } from './UIFContext'; import { loadJSON, saveJSON } from '../core/storage'; import type { HubArrangement, HubLayout, IsolatedBar as IsolatedBarState } from '../core/profile'; import { useHoldDrag } from '../hooks/useHoldDrag'; import { IsolatedBar } from './IsolatedBar'; import { createPortal } from 'react-dom'; import { modelLabel } from '../core/context'; import { magnetEdge } from '../core/dock'; import { useVoice, VOICE_ENGINE } from '../hooks/useVoice'; import '../styles/UltimateInputField.css'; interface LogLine { id: number; kind: LogKind; text: string; /** A report: sections that open and close under the line. */ sections?: LogSection[]; } const MAX_LOG = 300; /** Below this height the input and the button row cannot both have a full row: the field goes sideways. */ const STACK_MIN = 104; /** A narrow viewport, or a touch screen that is not wide: a handheld. */ const HANDHELD_QUERY = '(max-width: 640px), (pointer: coarse) and (max-width: 900px)'; function useViewport() { const read = () => ({ width: typeof window !== 'undefined' ? window.innerWidth : 1024, handheld: typeof window !== 'undefined' && !!window.matchMedia?.(HANDHELD_QUERY).matches, }); const [vp, setVp] = useState(read); useEffect(() => { const on = () => setVp(read()); window.addEventListener('resize', on); return () => window.removeEventListener('resize', on); }, []); return vp; } function isAsyncIterable(v: unknown): v is AsyncIterable { return !!v && typeof (v as AsyncIterable)[Symbol.asyncIterator] === 'function'; } /** * The hub of the bar: chat input, T terminal, extensible action buttons. * Works on its own, or inside / to share outputs. */ export const UltimateInputField: React.FC = props => { const ctx = useOptionalUIF(); if (ctx) return ; return ( ); }; const HubField: React.FC = ({ value: controlledValue, onChange, onSend, onCommand, onAction, onModeChange, actions: extraActions, isLoading: externalLoading = false, className = '', placeholder, terminalPlaceholder, disabled = false, mode: initialMode = 'chat', initialPosition, initialSize = { width: 600, height: 120 }, minWidth = 300, maxWidth, minHeight = 60, maxHeight, draggable = true, resizable = true, dockable = true, modeBar, persona, glowEffect = true, glassEffect = true, voice: voiceOn = true, autoGrow = true, focusKeys = true, left, right, }) => { const uif = useUIF(); const { registry, outputs, strings, setStrings, hubRef, requestScreens, storageKey, raise, zIndexOf, registerSender, layout, registerHubLayout, context: contextStore, } = uif; const hubKey = `uif:${storageKey}:hub`; const [saved] = useState(() => loadJSON>(hubKey, {})); const [innerValue, setInnerValue] = useState(''); const value = controlledValue ?? innerValue; const valueRef = useRef(value); valueRef.current = value; const setValue = useCallback( (v: string) => { if (controlledValue === undefined) setInnerValue(v); onChange?.(v); }, [controlledValue, onChange], ); const [mode, setModeState] = useState(saved.mode ?? (initialMode === 'text' ? 'chat' : initialMode)); // a handheld with nothing saved starts docked at the bottom, full width const [docked, setDocked] = useState( saved.docked !== undefined ? saved.docked : typeof window !== 'undefined' && window.matchMedia?.(HANDHELD_QUERY).matches && dockable ? 'bottom' : null, ); const [arrangement, setArrangement] = useState(saved.arrange ?? 'auto'); const [isolated, setIsolated] = useState(saved.isolated ?? null); const viewport = useViewport(); const [isGlowing, setIsGlowing] = useState(false); const [busy, setBusy] = useState(false); const stopRef = useRef<(() => void) | null>(null); const [log, setLog] = useState([]); const [status, setStatus] = useState(null); const history = useRef([]); const historyIndex = useRef(-1); const textareaRef = useRef(null); const logRef = useRef(null); const logSeq = useRef(0); const { position, size, setPosition, setSize, isDragging, isResizing, startDrag, startResize, onHandleKeyDown } = useDragResize({ initialPosition: saved.x !== undefined && saved.y !== undefined ? { x: saved.x, y: saved.y } : initialPosition ?? { x: window.innerWidth / 2 - initialSize.width / 2, y: window.innerHeight / 2 - initialSize.height / 2, }, initialSize: saved.width && saved.height ? { width: saved.width, height: saved.height } : initialSize, minWidth, maxWidth, minHeight, maxHeight, draggable, resizable, // magnetic docking: let go within reach of an edge, and the field docks there onCommit: (r, how) => magnet.current(r, how), }); const magnet = useRef<(r: { x: number; y: number; width: number; height: number }, how: string) => void>(() => {}); // Remember where the field is and how it is set up. Skipped while a gesture is in progress. useEffect(() => { if (isDragging || isResizing) return; saveJSON(hubKey, { x: position.x, y: position.y, width: size.width, height: size.height, mode, docked, arrange: arrangement, isolated } satisfies HubLayout); }, [hubKey, position, size, mode, docked, arrangement, isolated, isDragging, isResizing]); // Actions passed as props join the shared registry while this field is mounted. useEffect(() => { extraActions?.forEach(a => registry.register(a)); return () => extraActions?.forEach(a => registry.unregister(a.id)); }, [extraActions, registry]); // The rolodex: each mode keeps its own bar (its buttons and their order, saved as they change), and switching mode // turns the bar to the other mode's face. A mode never arranged starts from MODE_BAR. Custom actions are shared. const barFace = useRef(null); const [rolling, setRolling] = useState(false); useEffect( () => registry.subscribe(() => { if (barFace.current) saveJSON(`uif:${storageKey}:bar:${barFace.current}`, registry.snapshot()); }), [registry, storageKey], ); /** A mode's standard face: the host's button set for it, or the field's. */ const faceStandard = useCallback( (m: UIFMode) => layoutFor(registry, modeBar?.[m] ?? MODE_BAR[m === 'terminal' ? 'terminal' : 'chat']), [registry, modeBar], ); useEffect(() => { if (barFace.current === mode) return; const first = barFace.current === null; const saved = loadJSON(`uif:${storageKey}:bar:${mode}`, null); barFace.current = mode; registry.restore(saved ? { ...saved, custom: registry.snapshot().custom } : faceStandard(mode)); if (first) return; setRolling(true); const t = setTimeout(() => setRolling(false), 720); return () => clearTimeout(t); }, [mode, registry, storageKey]); useEffect(() => { textareaRef.current?.focus(); }, [mode]); useEffect(() => { const el = logRef.current; if (el) el.scrollTop = el.scrollHeight; }, [log]); useEffect(() => { if (!status) return; const t = setTimeout(() => setStatus(null), 3500); return () => clearTimeout(t); }, [status]); const print = useCallback((text: string, kind: LogKind = 'out') => { const line = { id: logSeq.current++, kind, text }; setLog(prev => [...prev.slice(-MAX_LOG + 1), line]); if (kind !== 'cmd') setStatus(line); }, []); const printSections = useCallback((title: string, sections: LogSection[]) => { const line = { id: logSeq.current++, kind: 'out' as LogKind, text: title, sections }; setLog(prev => [...prev.slice(-MAX_LOG + 1), line]); }, []); const lastAsk = useRef('chat'); const modeNow = useRef('chat'); const triggerGlow = useCallback(() => { if (!glowEffect) return; setIsGlowing(true); setTimeout(() => setIsGlowing(false), 500); }, [glowEffect]); const setMode = useCallback( (next: UIFMode | 'toggle') => { setModeState(prev => { // T and back: the terminal returns to the ask mode it came from (generic or the persona's) if (prev !== 'terminal') lastAsk.current = prev; const m = next === 'toggle' ? (prev === 'terminal' ? lastAsk.current : 'terminal') : next === 'persona' && !persona ? prev : next; if (m !== prev) onModeChange?.(m); return m; }); }, [onModeChange, persona], ); const dock = useCallback( (edge: DockEdge | null) => { if (!dockable) return; setDocked(edge); if (edge === null) { setPosition({ x: window.innerWidth / 2 - size.width / 2, y: window.innerHeight / 2 - size.height / 2 }); } }, [dockable, setPosition, size.width, size.height], ); magnet.current = (r, how) => { if (how !== 'drag' || !dockable) return; const edge = magnetEdge(r, { width: window.innerWidth, height: window.innerHeight }); if (edge) setDocked(edge); }; // while dragging: the edge it would dock on, drawn as a glowing strip const drawnTo = isDragging && dockable ? magnetEdge({ ...position, ...size }, { width: viewport.width, height: window.innerHeight }) : null; const send = useCallback( async (text: string, target?: string) => { if (!text.trim()) return; // "@notes hello" goes to the output called notes, which is opened if it does not exist yet. const routed = target ? null : /^@(\S+)\s+([\s\S]+)$/.exec(text); let id: string | undefined = target; if (id) { outputs.setActive(id); } else if (routed) { text = routed[2]; id = outputs.resolve(routed[1]) ?? outputs.spawn(routed[1]); outputs.setActive(id); raise(id); } else { // the Responses settings: a new window per question, or the active one const fresh = responseSettings.get().answerIn === 'new'; id = (fresh ? undefined : outputs.resolve(undefined)) ?? outputs.spawn(); if (fresh || !outputs.list().some(o => o.id === id && o.messages.length)) { if (responseSettings.get().openMaximized) outputs.maximize(id, true); } } outputs.append(id, { role: 'user', text }); triggerGlow(); const context = contextStore.forSend(text, id); const outputId = id; const extra: Record = {}; // Each finished exchange goes into .history (bankml's record shape, plus the output it was in). // a reply the engine marked undelivered (no completion tokens, an error) never joins it const record = (reply: string) => reply && !replyMarks.undelivered && contextStore.appendHistory({ ts: Date.now() / 1000, session: storageKey, output: outputs.list().find(f => f.id === outputId)?.title, output_id: outputId, user: text, assistant: reply, prompt: context.promptSource, model: modelLabel(context.model), ...extra, }); // what the engine says about its own reply (e.g. silent), applied when the reply message exists const replyMarks: ReplyMarks = {}; let replyId: string | null = null; const markReply = (patch: ReplyMarks) => { Object.assign(replyMarks, patch); if (replyId) outputs.update(outputId, replyId, patch); }; const abort = new AbortController(); const result = onSend?.(text, { outputId: id, mode: modeNow.current, context, annotate: fields => Object.assign(extra, fields), markReply, signal: abort.signal }); if (result === undefined) return; if (typeof result === 'string') { outputs.append(id, { role: 'assistant', text: result, ...replyMarks }); record(result); return; } const mid = outputs.append(id, { role: 'assistant', text: '', pending: true, ...replyMarks }); replyId = mid; setBusy(true); // Stop: the reply stops where it is, at once (even while the engine is still reading the prompt) let stopNow = () => {}; const stopped = new Promise<'stop'>(r => { stopNow = () => { abort.abort(); r('stop'); }; }); stopRef.current = stopNow; try { if (isAsyncIterable(result)) { let acc = ''; const it = result[Symbol.asyncIterator](); for (;;) { const step = await Promise.race([it.next(), stopped]); if (step === 'stop') { void it.return?.(undefined)?.catch(() => {}); outputs.update(id, mid, { pending: false, undelivered: true, ...(acc ? {} : { text: 'Stopped before the model wrote anything.' }) }); outputs.append(id, { role: 'system', kind: 'delivery', tone: 'bad', text: 'stopped by you: not delivered in full, not kept in .history' }); return; } if (step.done) break; acc += step.value; outputs.update(id, mid, { text: acc }); } outputs.update(id, mid, { pending: false }); record(acc); } else { const reply = await result; outputs.update(id, mid, { text: reply ?? '', pending: false }); record(reply ?? ''); } } catch (err) { outputs.update(id, mid, { text: `Error: ${(err as Error).message ?? err}`, pending: false, undelivered: true }); } finally { stopRef.current = null; setBusy(false); } }, [onSend, outputs, raise, triggerGlow, contextStore, storageKey], ); // Reply boxes on the outputs (in-page and popped out) send through this field. useEffect(() => registerSender((text, outputId) => void send(text, outputId)), [registerSender, send]); // Profiles read and apply the field's layout through this handle. const hubLive = useRef({ position, size, mode, docked, arrangement, isolated }); hubLive.current = { position, size, mode, docked, arrangement, isolated }; const standardMode: UIFMode = initialMode === 'text' ? 'chat' : initialMode; useEffect( () => registerHubLayout({ get: () => { const h = hubLive.current; return { ...h.position, ...h.size, mode: h.mode, docked: h.docked, arrange: h.arrangement, isolated: h.isolated }; }, set: next => { const l = next ?? { ...(initialPosition ?? { x: window.innerWidth / 2 - initialSize.width / 2, y: window.innerHeight / 2 - initialSize.height / 2, }), ...initialSize, mode: standardMode, docked: null, arrange: 'auto' as HubArrangement, isolated: null, }; setSize({ width: l.width, height: l.height }); setPosition({ x: Math.max(60 - l.width, Math.min(l.x, window.innerWidth - 60)), y: Math.max(0, Math.min(l.y, window.innerHeight - 30)), }); setDocked(dockable ? l.docked : null); setMode(l.mode); setArrangement(l.arrange ?? 'auto'); setIsolated(l.isolated ?? null); }, }), [registerHubLayout, initialPosition, initialSize, standardMode, setPosition, setSize, setMode, dockable], ); /** Take the button row out of the field; it floats just above the field unless told where. */ const barRef = useRef(null); const isolateBar = useCallback( (vertical: boolean, at?: { x: number; y: number }) => { const hub = hubRef.current?.getBoundingClientRect(); const x = at?.x ?? (hub ? hub.left : 16); const y = at?.y ?? (hub ? Math.max(8, hub.top - 56) : 16); setIsolated({ x: Math.round(Math.max(0, x)), y: Math.round(Math.max(0, y)), vertical }); }, [hubRef], ); // press and hold the grip, drag the row out of the field and drop it anywhere: it floats there const grip = useHoldDrag({ onDrop: p => { const hub = hubRef.current?.getBoundingClientRect(); const inside = hub && p.x >= hub.left - 24 && p.x <= hub.right + 24 && p.y >= hub.top - 24 && p.y <= hub.bottom + 24; if (!inside) isolateBar(false, { x: p.x - p.offsetX, y: p.y - p.offsetY }); }, onTap: () => print('hold the grip, then drag the button row out of the field (or type: isolate)'), }); // runCommand and runAction call each other (custom actions run command lines). const runCommandRef = useRef<(line: string) => void>(() => {}); const makeContext = useCallback( (args: string[]): ActionContext => ({ args, print, printSections, clearLog: () => setLog([]), send: text => void send(text), runCommand: line => runCommandRef.current(line), mode, setMode, docked, dock, strings, setStrings, outputs, registry, layout, windows: uif.windows, remember: text => contextStore.remember(text), bar: { isolated: !!isolated, isolate: opts => isolateBar(opts?.vertical ?? false), join: () => setIsolated(null), arrangement, arrange: setArrangement, face: barFace.current ?? mode, faces: { get: m => (m === barFace.current ? registry.snapshot() : (() => { const kept = loadJSON(`uif:${storageKey}:bar:${m}`, null); return kept ? { ...kept, custom: registry.snapshot().custom } : faceStandard(m); })()), set: (m, l) => (m === barFace.current ? registry.restore(l) : saveJSON(`uif:${storageKey}:bar:${m}`, l)), standard: faceStandard, }, }, requestScreens: async () => print(await requestScreens()), }), [print, printSections, send, mode, setMode, docked, dock, strings, setStrings, outputs, registry, layout, uif.windows, contextStore, requestScreens, isolated, arrangement, isolateBar, storageKey, faceStandard], ); const runAction = useCallback( async (action: Action, args: string[] = []) => { try { await action.run(makeContext(args)); onAction?.(action, args); } catch (err) { print(`${action.command}: ${(err as Error).message ?? err}`, 'err'); } }, [makeContext, onAction, print], ); const runCommand = useCallback( (line: string) => { print(`❯ ${line}`, 'cmd'); const parsed = parseCommand(line, registry); if (!parsed) return; if (parsed.action) return void runAction(parsed.action, parsed.args); const handled = onCommand?.(parsed.name, parsed.args, makeContext(parsed.args)); if (typeof handled === 'string') print(handled); else if (!onCommand) print(`unknown command: ${parsed.name} (try help)`, 'err'); }, [makeContext, onCommand, print, registry, runAction], ); runCommandRef.current = runCommand; const submit = () => { const text = value; if (!text.trim()) return; // the switches the placeholders name: "T" alone in ask mode goes to T mode, ">" alone in T mode back to ask if ((mode !== 'terminal' && text.trim() === 'T') || (mode === 'terminal' && text.trim() === '>')) { setMode('toggle'); setValue(''); return; } // the persona's label alone switches to its mode; ">" in it goes back to the generic mode if (persona && text.trim().toLowerCase() === persona.label.toLowerCase() && mode !== 'persona') { setMode('persona'); setValue(''); return; } if (mode === 'persona' && text.trim() === '>') { setMode('chat'); setValue(''); return; } if (mode === 'terminal' || text.startsWith('/')) { history.current = [...history.current.filter(h => h !== text), text].slice(-100); historyIndex.current = -1; runCommand(text.trim()); triggerGlow(); } else { void send(text); } setValue(''); }; const handleKeyDown = (e: React.KeyboardEvent) => { // Enter sends, Shift+Enter is a new line; Enter that ends an IME composition only commits the text (Safari reports // that Enter with isComposing false but keyCode 229) if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.nativeEvent.keyCode !== 229) { e.preventDefault(); submit(); return; } if (mode !== 'terminal' || value.includes('\n') || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return; const h = history.current; if (!h.length) return; e.preventDefault(); let i = historyIndex.current; if (e.key === 'ArrowUp') i = i === -1 ? h.length - 1 : Math.max(0, i - 1); else i = i === -1 ? -1 : i + 1 >= h.length ? -1 : i + 1; historyIndex.current = i; setValue(i === -1 ? '' : h[i]); }; const setHub = useCallback( (el: HTMLDivElement | null) => { hubRef.current = el; }, [hubRef], ); // a free field saved on a wider screen (or before a rotation) is drawn inside this one; its saved place is kept const freeLeft = Math.max(8, Math.min(position.x, viewport.width - Math.min(size.width, Math.max(200, viewport.width - 16)) - 8)); const freeTop = typeof window !== 'undefined' ? Math.max(8, Math.min(position.y, window.innerHeight - size.height - 8)) : position.y; let dockStyle: React.CSSProperties = { left: freeLeft, top: freeTop }; if (docked === 'top') dockStyle = { top: 0, left: '50%', transform: 'translateX(-50%)' }; if (docked === 'bottom') dockStyle = { bottom: 0, left: '50%', transform: 'translateX(-50%)' }; if (docked === 'left') dockStyle = { left: 0, top: '50%', transform: 'translateY(-50%)' }; if (docked === 'right') dockStyle = { right: 0, top: '50%', transform: 'translateY(-50%)' }; const isLoading = externalLoading || busy; const terminal = mode === 'terminal'; modeNow.current = mode; const sideways = arrangement === 'sideways' || (arrangement === 'auto' && size.height < STACK_MIN); const fitW = Math.max(200, viewport.width - 16); // the field fills the screen (double-click its grip; again, or Esc, shrinks it back where it was) const [hubMax, setHubMax] = useState(false); useEffect(() => { if (!hubMax) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setHubMax(false); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [hubMax]); // "/" (outside a text field) or Ctrl/⌘+K focuses the input from anywhere on the page useEffect(() => { if (!focusKeys) return; const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; const typing = !!t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); const k = (e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 'k'; if (!k && (e.key !== '/' || typing || e.ctrlKey || e.metaKey || e.altKey)) return; if (e.isComposing) return; e.preventDefault(); textareaRef.current?.focus(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [focusKeys]); // the field grows with its text (stacked, not filling the screen), up to 45% of the screen; empty, it is back const [grow, setGrow] = useState(0); const growRef = useRef(0); useEffect(() => { const ta = textareaRef.current; if (!autoGrow || !ta || hubMax || sideways || !value) { if (growRef.current) { growRef.current = 0; setGrow(0); } return; } const over = ta.scrollHeight - ta.clientHeight; const room = Math.max(0, Math.round(window.innerHeight * 0.45) - size.height); const next = Math.max(0, Math.min(room, growRef.current + over)); if (Math.abs(next - growRef.current) > 2 || (over < 0 && growRef.current > 0)) { // shrinking: try smaller until the text just fits const fit = over < 0 ? Math.max(0, growRef.current + over) : next; growRef.current = fit; setGrow(fit); } }, [value, autoGrow, hubMax, sideways, size.height]); const voice = useVoice(voiceOn, text => setValue((valueRef.current ? valueRef.current.replace(/\s*$/, ' ') : '') + text)); const containerClasses = [ 'ultimate-input-field', isGlowing ? 'animate-glow' : '', docked ? `transition-all is-docked docked-${docked}` : '', drawnTo ? `is-drawn-${drawnTo}` : '', hubMax ? 'is-maximized' : '', terminal ? 'terminal-glass' : glassEffect ? 'glass-effect' : '', terminal ? 'is-terminal' : 'is-chat', mode === 'persona' ? 'is-persona' : '', sideways ? 'is-sideways' : 'is-stacked', viewport.handheld ? 'is-handheld' : '', isolated ? 'has-isolated-bar' : '', className, ] .filter(Boolean) .join(' '); const actionBar = (vertical: boolean) => ( void runAction(action)} vertical={vertical} rolling={rolling} pressed={{ strings, dock: !!docked, ...Object.fromEntries(uif.windows.defs.map(d => [`win-${d.id}`, uif.windows.isOpen(d.id)])), }} /> ); return (
raise(HUB_KEY)} > {terminal && (
{log.length === 0 &&
T mode: type a command, or help
} {log.map(l => (
{l.text} {l.sections?.map((s, i) => (
{s.title} {s.level ?? 'ok'} {s.lines.map((x, j) =>
{x}
)}
))}
))}
)} {drawnTo && createPortal(