Spaces:
Running
Running
Download src/components/UltimateInputField.tsx from PYTHAI/bankML-UIF: direct link, hf CLI and curl.
- Browser
- Download file 35.1 kB
-
https://huggingface.co/spaces/PYTHAI/bankML-UIF/resolve/main/src/components/UltimateInputField.tsx
- Command line
-
hf download hf://spaces/PYTHAI/bankML-UIF/src/components/UltimateInputField.tsx
-
curl -L -o UltimateInputField.tsx https://huggingface.co/spaces/PYTHAI/bankML-UIF/resolve/main/src/components/UltimateInputField.tsx
35.1 kB
| 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<string> { | |
| return !!v && typeof (v as AsyncIterable<string>)[Symbol.asyncIterator] === 'function'; | |
| } | |
| /** | |
| * The hub of the bar: chat input, T terminal, extensible action buttons. | |
| * Works on its own, or inside <UltimateBar>/<UIFProvider> to share outputs. | |
| */ | |
| export const UltimateInputField: React.FC<UltimateInputFieldProps> = props => { | |
| const ctx = useOptionalUIF(); | |
| if (ctx) return <HubField {...props} />; | |
| return ( | |
| <UIFProvider storageKey={props.storageKey} popoutUrl={props.popoutUrl} defaultStrings={props.defaultStrings} modules={props.modules}> | |
| <HubField {...props} /> | |
| </UIFProvider> | |
| ); | |
| }; | |
| const HubField: React.FC<UltimateInputFieldProps> = ({ | |
| 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<Partial<HubLayout>>(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<UIFMode>(saved.mode ?? (initialMode === 'text' ? 'chat' : initialMode)); | |
| // a handheld with nothing saved starts docked at the bottom, full width | |
| const [docked, setDocked] = useState<DockEdge | null>( | |
| saved.docked !== undefined ? saved.docked : typeof window !== 'undefined' && window.matchMedia?.(HANDHELD_QUERY).matches && dockable ? 'bottom' : null, | |
| ); | |
| const [arrangement, setArrangement] = useState<HubArrangement>(saved.arrange ?? 'auto'); | |
| const [isolated, setIsolated] = useState<IsolatedBarState | null>(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<LogLine[]>([]); | |
| const [status, setStatus] = useState<LogLine | null>(null); | |
| const history = useRef<string[]>([]); | |
| const historyIndex = useRef(-1); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| const logRef = useRef<HTMLDivElement>(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<UIFMode | null>(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<ActionsLayout | null>(`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<UIFMode>('chat'); | |
| const modeNow = useRef<UIFMode>('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<string, unknown> = {}; | |
| // 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<HTMLDivElement>(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<ActionsLayout | null>(`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<HTMLTextAreaElement>) => { | |
| // 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) => ( | |
| <ActionBar | |
| registry={registry} | |
| layout={layout} | |
| onRun={action => 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 ( | |
| <div | |
| ref={setHub} | |
| className={containerClasses} | |
| style={hubMax | |
| ? { left: 0, top: 0, right: 'auto', bottom: 'auto', transform: 'none', width: '100vw', height: '100vh', maxWidth: 'none', zIndex: zIndexOf(HUB_KEY) } | |
| : { | |
| ...dockStyle, | |
| // grown with its text: a free field moves up so it stays on screen (its saved place does not change) | |
| ...(grow && !docked ? { top: Math.max(8, Math.min(freeTop, window.innerHeight - size.height - grow - 8)) } : {}), | |
| width: Math.min(size.width, fitW), | |
| height: size.height + grow, | |
| minWidth: Math.min(minWidth, fitW), | |
| maxWidth: maxWidth === undefined ? fitW : Math.min(maxWidth, fitW), | |
| minHeight, | |
| zIndex: zIndexOf(HUB_KEY), | |
| }} | |
| onPointerDownCapture={() => raise(HUB_KEY)} | |
| > | |
| {terminal && ( | |
| <div className="uif-termlog" ref={logRef} role="log" aria-live="polite"> | |
| {log.length === 0 && <div className="uif-termline is-dim">T mode: type a command, or help</div>} | |
| {log.map(l => ( | |
| <div key={l.id} className={`uif-termline is-${l.kind} ${l.sections ? 'is-report' : ''}`}> | |
| {l.text} | |
| {l.sections?.map((s, i) => ( | |
| <details key={i} className={`uif-termsection is-${s.level ?? 'ok'}`} open={s.open}> | |
| <summary> | |
| <span className="uif-termmark" aria-hidden="true">{s.level === 'bad' ? '✗' : s.level === 'warn' ? '!' : '✓'}</span> | |
| {s.title} | |
| <span className="uif-termhint">{s.level ?? 'ok'}</span> | |
| </summary> | |
| {s.lines.map((x, j) => <div key={j} className="uif-termsub">{x}</div>)} | |
| </details> | |
| ))} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {drawnTo && createPortal(<div className={`uif-dock-hint is-${drawnTo}`} aria-hidden="true" />, document.body)} | |
| {draggable && ( | |
| <div | |
| className={`drag-handle ${isDragging ? 'dragging' : ''}`} | |
| onPointerDown={hubMax ? undefined : e => { | |
| if (!docked) return startDrag(e); | |
| // pull a docked field free: it leaves the edge from where it is, and redocks if let go within reach | |
| const r = (e.currentTarget.closest('.ultimate-input-field') as HTMLElement).getBoundingClientRect(); | |
| setDocked(null); | |
| startDrag(e, { x: r.left, y: r.top }); | |
| }} | |
| onDoubleClick={() => setHubMax(m => !m)} | |
| onKeyDown={onHandleKeyDown} | |
| tabIndex={0} | |
| role="group" | |
| aria-label={`Input field. ${KEYBOARD_HINT}; double-click to fill the screen or shrink back`} | |
| title={`${KEYBOARD_HINT}. Double-click: fill the screen / shrink back (Esc)`} | |
| > | |
| <div className="drag-indicator" /> | |
| </div> | |
| )} | |
| <div className="uif-row"> | |
| {left && <div className="uif-modules uif-modules-left">{left}</div>} | |
| <div className="uif-core"> | |
| <div className={`uif-prompt ${voice.available && !terminal ? 'has-mic' : ''}`}> | |
| {terminal && <span className="uif-prompt-sigil">❯</span>} | |
| <textarea | |
| ref={textareaRef} | |
| value={value} | |
| onChange={e => setValue(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={terminal ? terminalPlaceholder || 'Enter a command — help · > for ask mode' | |
| : mode === 'persona' && persona ? persona.placeholder || `${persona.label} mode · > for the generic ask · T for terminal mode` | |
| : placeholder || 'Message… @output to target · /command to run · T for terminal mode'} | |
| disabled={disabled} | |
| spellCheck={!terminal} | |
| enterKeyHint={terminal ? 'go' : 'send'} | |
| aria-label={terminal ? 'Command' : 'Message'} | |
| className={`ultimate-textarea ${terminal ? 'terminal-mode' : 'text-mode'} ${isDragging ? 'pointer-events-none' : ''}`} | |
| /> | |
| {voice.available && !terminal && ( | |
| <button | |
| type="button" | |
| className={`uif-mic ${voice.listening ? 'is-on' : ''}`} | |
| aria-pressed={voice.listening} | |
| onClick={voice.toggle} | |
| title={voice.listening ? 'Listening — press to stop' : `Speak instead of typing (off until pressed). ${VOICE_ENGINE()}`} | |
| aria-label={voice.listening ? 'Stop voice input' : 'Voice input'} | |
| > | |
| <span aria-hidden="true">{voice.listening ? '●' : '🎤'}</span> | |
| </button> | |
| )} | |
| </div> | |
| <div className="ultimate-controls"> | |
| <div className="ultimate-controls-left"> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className={`uif-mode-toggle ${terminal ? 'is-terminal' : ''}`} | |
| onClick={() => setMode('toggle')} | |
| type="button" | |
| title={terminal ? '> : back to ask mode' : 'T : terminal mode'} | |
| aria-label={terminal ? 'Switch to ask mode' : 'Switch to terminal mode'} | |
| > | |
| {/* the key the placeholder names: T in ask mode (to the terminal), > in T mode (back to ask) */} | |
| {terminal ? <IconTerminal /> : <IconType />} | |
| </Button> | |
| {persona && ( | |
| <button | |
| type="button" | |
| className={`uif-persona-toggle ${mode === 'persona' ? 'is-on' : ''}`} | |
| aria-pressed={mode === 'persona'} | |
| onClick={() => setMode(mode === 'persona' ? 'chat' : 'persona')} | |
| title={mode === 'persona' ? `${persona.label} mode · press for the generic ask mode (or type >)` : persona.title || `${persona.label} mode (or type ${persona.label})`} | |
| > | |
| {persona.label} | |
| </button> | |
| )} | |
| <WindowsMenu /> | |
| <ResponsesMenu /> | |
| {!isolated && ( | |
| <> | |
| <button | |
| type="button" | |
| className={`uif-bar-grip ${grip.dragging ? 'is-dragging' : ''}`} | |
| onPointerDown={grip.onPointerDown} | |
| onKeyDown={e => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| isolateBar(false); | |
| } | |
| }} | |
| title="Hold, then drag the button row out of the field" | |
| aria-label="Button row: hold and drag to take it out of the field (Enter does it)" | |
| > | |
| ⠿ | |
| </button> | |
| <div className="uif-bar-slot" ref={barRef}> | |
| {actionBar(false)} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {status && !terminal && <span className={`uif-status is-${status.kind}`}>{status.text}</span>} | |
| {/* while an answer streams, Stop takes Send's place (one answer at a time; nothing is drawn over the bar) */} | |
| {busy && !terminal ? ( | |
| <button type="button" className="uif-stop" onClick={() => stopRef.current?.()} title="Stop this answer" aria-label="Stop"> | |
| <span aria-hidden="true">■</span> | |
| </button> | |
| ) : ( | |
| <Button | |
| variant="default" | |
| size="icon" | |
| className="uif-send" | |
| onClick={submit} | |
| disabled={disabled || (isLoading && !terminal) || !value.trim()} | |
| type="button" | |
| aria-label={terminal ? 'Run' : 'Send'} | |
| > | |
| <IconSend /> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {right && <div className="uif-modules uif-modules-right">{right}</div>} | |
| </div> | |
| {!docked && resizable && <ResizeCorners onStart={startResize} />} | |
| {isolated && ( | |
| <IsolatedBar state={isolated} onChange={setIsolated}> | |
| {actionBar(isolated.vertical)} | |
| </IsolatedBar> | |
| )} | |
| {grip.dragging && | |
| createPortal( | |
| <div | |
| className="uif-bar-ghost" | |
| style={{ | |
| left: grip.dragging.x - grip.dragging.offsetX, | |
| top: grip.dragging.y - grip.dragging.offsetY, | |
| width: barRef.current?.offsetWidth ?? 200, | |
| }} | |
| > | |
| button row | |
| </div>, | |
| document.body, | |
| )} | |
| </div> | |
| ); | |
| }; | |