feat(journal): implement JournalInput with command autocomplete
This commit is contained in:
parent
8284f5caee
commit
4c5add2414
|
|
@ -0,0 +1,307 @@
|
|||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
||||
import { Component, createSignal, onMount, onCleanup, Show, For } from "solid-js";
|
||||
import { sendMessage, revertLatest, canRevert, useJournalStream } from "../stores/journalStream";
|
||||
import { useJournalCompletions, type DiceCompletion, type LinkCompletion } from "./completions";
|
||||
|
||||
interface CompletionItem {
|
||||
label: string;
|
||||
/** "command" | "value" — used for styling and insertion */
|
||||
kind: "command" | "value";
|
||||
insertText: string;
|
||||
}
|
||||
|
||||
interface ParsedInput {
|
||||
type: "narrative" | "roll.request" | "article.reveal";
|
||||
payload: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const JournalInput: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const completions = useJournalCompletions();
|
||||
|
||||
const [text, setText] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [sending, setSending] = createSignal(false);
|
||||
const [showCompletions, setShowCompletions] = createSignal(false);
|
||||
const [selectedIdx, setSelectedIdx] = createSignal(0);
|
||||
|
||||
let textareaRef!: HTMLTextAreaElement;
|
||||
let completionsRef!: HTMLDivElement;
|
||||
|
||||
// ---------- Parsing ----------
|
||||
|
||||
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 [path, section] = parseLinkArg(arg);
|
||||
return { type: "article.reveal", payload: { path, section: section ?? undefined } };
|
||||
}
|
||||
|
||||
// /roll or /link with no space — show completions, don't send
|
||||
if (raw === "/roll" || raw === "/link") {
|
||||
return { type: "narrative", payload: {}, error: "Complete the command" };
|
||||
}
|
||||
|
||||
return { type: "narrative", payload: { text: raw } };
|
||||
}
|
||||
|
||||
/** Parse "path#section" or just "path" */
|
||||
function parseLinkArg(arg: string): [string, string | null] {
|
||||
const hashIdx = arg.indexOf("#");
|
||||
if (hashIdx === -1) return [arg, null];
|
||||
return [arg.slice(0, hashIdx), arg.slice(hashIdx + 1) || null];
|
||||
}
|
||||
|
||||
// ---------- 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();
|
||||
}
|
||||
|
||||
// ---------- Completions ----------
|
||||
|
||||
function buildCompletions(): CompletionItem[] {
|
||||
const raw = text().trim();
|
||||
|
||||
// Show commands when user types /
|
||||
if (raw === "" || raw === "/") {
|
||||
return [
|
||||
{ label: "/roll", kind: "command", insertText: "/roll " },
|
||||
{ label: "/link", kind: "command", insertText: "/link " },
|
||||
];
|
||||
}
|
||||
|
||||
// After /roll — show dice suggestions
|
||||
if (raw.startsWith("/roll ")) {
|
||||
const prefix = raw.slice("/roll ".length).toLowerCase();
|
||||
return completions.dice
|
||||
.filter(
|
||||
(d) =>
|
||||
d.notation.toLowerCase().includes(prefix) ||
|
||||
d.label.toLowerCase().includes(prefix),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.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();
|
||||
return completions.links
|
||||
.filter(
|
||||
(l) =>
|
||||
l.path.toLowerCase().includes(prefix) ||
|
||||
l.label.toLowerCase().includes(prefix),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.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) {
|
||||
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();
|
||||
|
||||
// Completions: navigate & accept
|
||||
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 === "Tab" || e.key === "Enter") {
|
||||
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() {
|
||||
const raw = text();
|
||||
|
||||
// Show completions when user types / at the start
|
||||
if (raw === "/" || raw.startsWith("/roll ") || raw.startsWith("/link ")) {
|
||||
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";
|
||||
|
||||
return (
|
||||
<div class="border-t border-gray-200 bg-white relative">
|
||||
{/* Completions dropdown — positioned above the textarea */}
|
||||
<Show when={showCompletions() && currentCompletions().length > 0}>
|
||||
<div
|
||||
ref={completionsRef}
|
||||
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
|
||||
>
|
||||
<For each={currentCompletions()}>
|
||||
{(item, idx) => (
|
||||
<div
|
||||
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
||||
idx() === selectedIdx()
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "hover:bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
onClick={() => acceptCompletion(item)}
|
||||
onMouseEnter={() => setSelectedIdx(idx())}
|
||||
>
|
||||
<span
|
||||
class={`text-xs px-1 rounded shrink-0 ${
|
||||
item.kind === "command"
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{item.kind === "command" ? "cmd" : "val"}
|
||||
</span>
|
||||
<span class="font-mono truncate flex-1">{item.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Textarea + actions */}
|
||||
<div class="flex flex-col">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message, or /roll or /link..."
|
||||
rows={2}
|
||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||
text-gray-800 placeholder-gray-400 focus:outline-none
|
||||
bg-transparent min-h-[60px]"
|
||||
/>
|
||||
|
||||
{/* Bottom row: error + buttons */}
|
||||
<div class="flex items-center justify-between px-2 pb-2">
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<Show when={showRevert}>
|
||||
<button
|
||||
onClick={handleRevert}
|
||||
class="text-xs text-gray-400 hover:text-red-500 px-1.5 py-0.5"
|
||||
title="Revert last message"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending() || !text().trim()}
|
||||
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
|
||||
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
disconnectStream,
|
||||
} from "../stores/journalStream";
|
||||
import { StreamView } from "./StreamView";
|
||||
import { ComposePanel } from "./ComposePanel";
|
||||
import { JournalInput } from "./JournalInput";
|
||||
|
||||
export interface JournalPanelProps {
|
||||
open: boolean;
|
||||
|
|
@ -58,7 +58,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
<div class="flex-1 min-h-0">
|
||||
<StreamView />
|
||||
</div>
|
||||
<ComposePanel />
|
||||
<JournalInput />
|
||||
</Show>
|
||||
</aside>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Journal completions — client-side loader for /__COMPLETIONS.json
|
||||
*
|
||||
* Fetches once on first call, caches the result. Call `invalidateCompletions()`
|
||||
* to force a re-fetch (e.g., when the content source changes).
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
// ------------------- Types (mirrors CLI) -------------------
|
||||
|
||||
export interface DiceCompletion {
|
||||
label: string;
|
||||
notation: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface LinkCompletion {
|
||||
path: string;
|
||||
label: string;
|
||||
section: string | null;
|
||||
}
|
||||
|
||||
export interface JournalCompletions {
|
||||
dice: DiceCompletion[];
|
||||
links: LinkCompletion[];
|
||||
}
|
||||
|
||||
// ------------------- Cache -------------------
|
||||
|
||||
let _cache: JournalCompletions = { dice: [], links: [] };
|
||||
let _loaded = false;
|
||||
|
||||
/** Thawed value signal; invalidate resets it. */
|
||||
const [completions, setCompletions] = createSignal<JournalCompletions>(_cache);
|
||||
|
||||
// ------------------- Fetch -------------------
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/__COMPLETIONS.json");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_cache = {
|
||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||
links: Array.isArray(data.links) ? data.links : [],
|
||||
};
|
||||
} else {
|
||||
_cache = { dice: [], links: [] };
|
||||
}
|
||||
} catch {
|
||||
_cache = { dice: [], links: [] };
|
||||
}
|
||||
_loaded = true;
|
||||
setCompletions(_cache);
|
||||
}
|
||||
|
||||
/** Ensure data is loaded (lazy, idempotent). */
|
||||
let _promise: Promise<void> | null = null;
|
||||
export function ensureCompletions(): Promise<void> {
|
||||
if (!_promise) _promise = load();
|
||||
return _promise;
|
||||
}
|
||||
|
||||
/** Force a re-fetch on next use. */
|
||||
export function invalidateCompletions(): void {
|
||||
_loaded = false;
|
||||
_promise = null;
|
||||
}
|
||||
|
||||
// ------------------- Hook -------------------
|
||||
|
||||
/**
|
||||
* Reactive completions data for the journal input.
|
||||
* Triggers a lazy fetch on first access; returns empty arrays while loading.
|
||||
*/
|
||||
export function useJournalCompletions(): JournalCompletions {
|
||||
if (!_loaded) {
|
||||
void ensureCompletions();
|
||||
}
|
||||
return completions();
|
||||
}
|
||||
|
|
@ -41,4 +41,6 @@ export { ConnectionBar } from "./ConnectionBar";
|
|||
export { StreamView } from "./StreamView";
|
||||
export { StreamMessageCard } from "./StreamMessage";
|
||||
export { ComposePanel } from "./ComposePanel";
|
||||
export { JournalInput } from "./JournalInput";
|
||||
export { DynamicForm } from "./DynamicForm";
|
||||
export { useJournalCompletions, invalidateCompletions } from "./completions";
|
||||
|
|
|
|||
Loading…
Reference in New Issue