import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" import { createChatClient, type StreamEvent } from "@niri/chat-client" import { MarkdownBlock } from "./MarkdownBlock" type Entry = | { id: number; kind: "info"; text: string } | { id: number; kind: "error"; text: string } | { id: number; kind: "user"; text: string } | { id: number; kind: "incoming"; source: string; text: string } | { id: number; kind: "text"; text: string } | { id: number; kind: "thinking"; text: string } | { id: number; kind: "tool"; name: string; args: Record; result: string } type WithoutId = T extends { id: number } ? Omit : never type NewEntry = WithoutId const toolSummary = (name: string, args: Record): string => { switch (name) { case "shell": return `$ ${String(args.command ?? "")}` case "read_file": { const start = args.start_line ? `:${String(args.start_line)}` : "" const end = args.end_line ? `-${String(args.end_line)}` : "" return `read ${String(args.path ?? "")}${start}${end}` } case "edit_file": return `edit ${String(args.path ?? "")}` case "rest": return `rest${args.note ? ` ${String(args.note)}` : ""}` default: return `${name} ${JSON.stringify(args)}` } } const hiddenToolSummary = (result: string): string => { const normalized = result || "(no output)" const lines = normalized.split("\n") if (lines.length <= 1) return lines[0] ?? "(no output)" return `${lines[0] ?? "(no output)"}\n… ${lines.length - 1} more lines hidden` } const toToolMarkdown = (result: string): string => { const normalized = result || "(no output)" return normalized.includes("```") ? normalized : `\`\`\`text\n${normalized}\n\`\`\`` } const baseUrl = import.meta.env.VITE_NIRI_BASE_URL ?? "" const createClientId = (): string => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `web-${crypto.randomUUID()}` } return `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` } export function App() { const clientId = useMemo(() => createClientId(), []) const client = useMemo(() => createChatClient({ baseUrl, clientId }), [clientId]) const [entries, setEntries] = useState([]) const [running, setRunning] = useState(null) const [input, setInput] = useState("") const [sending, setSending] = useState(false) const [showThinking, setShowThinking] = useState(false) const [showAllTools, setShowAllTools] = useState(false) const [collapsedToolIds, setCollapsedToolIds] = useState>(() => new Set()) const nextId = useRef(0) const push = useCallback((entry: NewEntry): number => { const id = nextId.current++ setEntries((prev) => [...prev, { ...entry, id }]) return id }, []) const toggleTool = useCallback((id: number) => { setCollapsedToolIds((prev) => { const next = new Set(prev) if (next.has(id)) { next.delete(id) } else { next.add(id) } return next }) }, []) const collapseAllTools = useCallback(() => { setCollapsedToolIds(new Set(entries.filter((entry) => entry.kind === "tool").map((entry) => entry.id))) }, [entries]) const expandAllTools = useCallback(() => { setCollapsedToolIds(new Set()) }, []) useEffect(() => { const controller = new AbortController() client .stream({ signal: controller.signal, onEvent: (event: StreamEvent) => { if (event.type === "thinking") { push({ kind: "thinking", text: event.text }) return } if (event.type === "tool") { const id = push({ kind: "tool", name: event.name, args: event.args, result: event.result }) setCollapsedToolIds((prev) => { const next = new Set(prev) next.add(id) return next }) return } if (event.type === "user") { if (event.clientId === clientId) return push({ kind: "incoming", source: event.source, text: event.text }) return } push({ kind: "text", text: event.text }) }, }) .catch((err) => { if (controller.signal.aborted) return push({ kind: "error", text: err instanceof Error ? err.message : String(err) }) }) return () => controller.abort() }, [client, clientId, push]) useEffect(() => { client .getStatus() .then((status) => setRunning(status.running)) .catch((err) => push({ kind: "error", text: err instanceof Error ? err.message : String(err) })) }, [client, push]) const onSubmit = useCallback( async (event: FormEvent) => { event.preventDefault() const message = input.trim() if (!message || sending) return setSending(true) setInput("") push({ kind: "user", text: message }) try { await client.send(message) } catch (err) { push({ kind: "error", text: err instanceof Error ? err.message : String(err) }) } finally { setSending(false) } }, [client, input, push, sending], ) return (

niri chat

{running === null ? "checking status…" : running ? "niri is awake" : "niri is sleeping — your message will wake her"}

{entries.map((entry) => { if (entry.kind === "thinking") { if (!showThinking) { return (
⟨ thinking — {entry.text.split("\n").length} lines ⟩
) } return (
thinking
) } if (entry.kind === "tool") { const collapsed = !showAllTools && collapsedToolIds.has(entry.id) return (
tool #{entry.id}: {toolSummary(entry.name, entry.args)}
{collapsed ?
{hiddenToolSummary(entry.result)}
: }
) } if (entry.kind === "text") { return (
niri
) } if (entry.kind === "incoming") { return (
{entry.source}
) } if (entry.kind === "user") { return (
you
{entry.text}
) } return (
{entry.kind === "error" ? `error: ${entry.text}` : entry.text}
) })}
setInput(event.target.value)} placeholder="send a message to niri" aria-label="message" />
) }