/** * JournalInput — chat-style textarea with command dispatch and autocomplete. * * - Enter sends, Shift+Enter inserts newline * - Plain text → "narrative" type * - `/roll 3d6kh1` → "roll.request" type * - `/link path#section` → "article.reveal" type * - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json * in CLI mode, or client-side scan of the in-memory file index in dev mode) */ import { Component, createSignal, createEffect, onMount, onCleanup, Show, For, Switch, Match, } from "solid-js"; import { sendMessage, revertLatest, canRevert, useJournalStream, } from "../stores/journalStream"; import { useJournalCompletions, ensureCompletions, type CompletionsState, } from "./completions"; // ---- Helpers ---- interface CompletionItem { label: string; kind: "command" | "value" | "no-results"; insertText: string; } interface ParsedInput { type: "narrative" | "roll.request" | "article.reveal"; payload: Record; error?: string; } function parseInput(raw: string): ParsedInput { if (raw.startsWith("/roll ")) { const notation = raw.slice("/roll ".length).trim(); if (!notation) return { type: "roll.request", payload: {}, error: "Dice notation required", }; return { type: "roll.request", payload: { notation, label: notation } }; } if (raw.startsWith("/link ")) { const arg = raw.slice("/link ".length).trim(); if (!arg) return { type: "article.reveal", payload: {}, error: "Path required" }; const hashIdx = arg.indexOf("#"); const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx); const section = hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined; return { type: "article.reveal", payload: { path, section } }; } // /roll or /link with no space — need to complete, don't send if (raw === "/roll" || raw === "/link") { return { type: "narrative", payload: {}, error: "Complete the command" }; } return { type: "narrative", payload: { text: raw } }; } // ---- Component ---- export const JournalInput: Component = () => { const stream = useJournalStream(); const comp = useJournalCompletions(); const [text, setText] = createSignal(""); const [error, setError] = createSignal(null); const [sending, setSending] = createSignal(false); const [showCompletions, setShowCompletions] = createSignal(false); const [selectedIdx, setSelectedIdx] = createSignal(0); let textareaRef!: HTMLTextAreaElement; let completionsRef!: HTMLDivElement; // Ensure completions are loading on mount onMount(() => { void ensureCompletions(); }); // ---- Send ---- function handleSend() { const raw = text().trim(); if (!raw) return; const parsed = parseInput(raw); if (parsed.error) { setError(parsed.error); return; } setError(null); setSending(true); const result = sendMessage(parsed.type, parsed.payload); if (!result.success) { setError(result.error); } else { setText(""); } setSending(false); textareaRef?.focus(); } function handleRevert() { revertLatest(); } // ---- Scroll selected completion into view ---- createEffect(() => { const idx = selectedIdx(); if (!showCompletions() || !completionsRef) return; const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`); if (el) el.scrollIntoView({ block: "nearest" }); }); // ---- Completions ---- function buildCompletions(): CompletionItem[] { const raw = text().trim(); const data = comp.data; const commands = [ { label: "/roll", kind: "command" as const, insertText: "/roll " }, { label: "/link", kind: "command" as const, insertText: "/link " }, ]; // Show commands when user types / or starts typing a command name if (raw.startsWith("/") && !raw.includes(" ")) { const prefix = raw.toLowerCase(); const matches = commands.filter((c) => c.label.toLowerCase().startsWith(prefix), ); return matches.length > 0 ? matches : [{ label: "Unknown command", kind: "no-results", insertText: "" }]; } // After /roll — show dice suggestions if (raw.startsWith("/roll ")) { const prefix = raw.slice("/roll ".length).toLowerCase(); const matches = data.dice .filter( (d) => d.notation.toLowerCase().includes(prefix) || d.label.toLowerCase().includes(prefix), ) .slice(0, 8); if (matches.length === 0) { return [{ label: "No dice found", kind: "no-results", insertText: "" }]; } return matches.map((d) => ({ label: d.notation, kind: "value" as const, insertText: "/roll " + d.notation, })); } // After /link — show article and heading suggestions if (raw.startsWith("/link ")) { const prefix = raw.slice("/link ".length).toLowerCase(); const matches = data.links .filter( (l) => l.path.toLowerCase().includes(prefix) || l.label.toLowerCase().includes(prefix), ) .slice(0, 8); if (matches.length === 0) { return [ { label: "No links found", kind: "no-results", insertText: "" }, ]; } return matches.map((l) => { const insert = l.section ? `/link ${l.path}#${l.section}` : `/link ${l.path}`; return { label: l.label, kind: "value" as const, insertText: insert, }; }); } return []; } function currentCompletions(): CompletionItem[] { return buildCompletions(); } function acceptCompletion(item: CompletionItem) { if (item.kind === "no-results") return; setText(item.insertText); setShowCompletions(false); textareaRef?.focus(); } function selectCompletion(dir: "up" | "down") { const comps = currentCompletions(); if (comps.length === 0) return; setSelectedIdx((prev) => { if (dir === "down") return (prev + 1) % comps.length; return (prev - 1 + comps.length) % comps.length; }); } // ---- Keyboard ---- function handleKeyDown(e: KeyboardEvent) { const comps = currentCompletions(); const open = showCompletions(); // When opening completions with Tab, if the text matches a command prefix // and there are options, accept the first. Otherwise just show. if (e.key === "Tab") { if (open) { e.preventDefault(); if (comps.length > 0 && comps[selectedIdx()].kind !== "no-results") { acceptCompletion(comps[selectedIdx()]); } return; } // If not open and starts with /, open completions const raw = text(); if (raw.startsWith("/")) { e.preventDefault(); setShowCompletions(true); setSelectedIdx(0); return; } return; } if (open && comps.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); selectCompletion("down"); return; } if (e.key === "ArrowUp") { e.preventDefault(); selectCompletion("up"); return; } if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") { e.preventDefault(); acceptCompletion(comps[selectedIdx()]); return; } if (e.key === "Escape") { setShowCompletions(false); return; } } // Send if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } } function handleInput(e: InputEvent) { const input = e.currentTarget as HTMLTextAreaElement; const raw = input.value; setText(raw); // Completions visibility — keep open while user types any / if (raw.startsWith("/")) { setShowCompletions(true); setSelectedIdx(0); } else { setShowCompletions(false); } // Auto-resize textareaRef.style.height = "auto"; textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px"; } // ---- Click outside ---- onMount(() => { const handler = (e: MouseEvent) => { if (completionsRef && !completionsRef.contains(e.target as Node)) { setShowCompletions(false); } }; document.addEventListener("mousedown", handler); onCleanup(() => document.removeEventListener("mousedown", handler)); }); const showRevert = () => canRevert() && stream.myName === "gm"; // Completions placeholder — shown in the dropdown area when no matches function renderCompletionsDropdown() { const comps = currentCompletions(); if (comps.length === 0) return null; return (
{(item, idx) => (
acceptCompletion(item)} onMouseEnter={() => item.kind !== "no-results" && setSelectedIdx(idx()) } > —} > {item.kind === "command" ? "cmd" : "val"} {item.label}
)}
); } return (
{/* Completions loading / error / empty teaser */}
Loading completions…
{(comp.state as any).message || "Failed to load completions"}
{renderCompletionsDropdown()}
{/* Textarea + actions */}