my harness for niri
1import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
2import { createChatClient, type StreamEvent } from "@niri/chat-client"
3import { MarkdownBlock } from "./MarkdownBlock"
4import { MetricsWorkbench } from "./MetricsWorkbench"
5
6type Entry =
7 | { id: number; kind: "info"; text: string }
8 | { id: number; kind: "error"; text: string }
9 | { id: number; kind: "user"; text: string }
10 | { id: number; kind: "incoming"; source: string; text: string }
11 | { id: number; kind: "text"; text: string }
12 | { id: number; kind: "thinking"; text: string }
13 | { id: number; kind: "tool"; name: string; args: Record<string, unknown>; result: string }
14
15type WithoutId<T> = T extends { id: number } ? Omit<T, "id"> : never
16
17type NewEntry = WithoutId<Entry>
18
19const toolSummary = (name: string, args: Record<string, unknown>): string => {
20 switch (name) {
21 case "shell":
22 return `$ ${String(args.command ?? "")}`
23 case "read_file": {
24 const start = args.start_line ? `:${String(args.start_line)}` : ""
25 const end = args.end_line ? `-${String(args.end_line)}` : ""
26 return `read ${String(args.path ?? "")}${start}${end}`
27 }
28 case "edit_file":
29 return `edit ${String(args.path ?? "")}`
30 case "memory_search":
31 return `memory ${String(args.query ?? "")}`
32 case "rest":
33 return `rest${args.note ? ` ${String(args.note)}` : ""}`
34 default:
35 return `${name} ${JSON.stringify(args)}`
36 }
37}
38
39const hiddenToolSummary = (result: string): string => {
40 const normalized = result || "(no output)"
41 const lines = normalized.split("\n")
42 if (lines.length <= 1) return lines[0] ?? "(no output)"
43 return `${lines[0] ?? "(no output)"}\n… ${lines.length - 1} more lines hidden`
44}
45
46const toToolMarkdown = (result: string): string => {
47 const normalized = result || "(no output)"
48 return normalized.includes("```") ? normalized : `\`\`\`text\n${normalized}\n\`\`\``
49}
50
51const baseUrl = import.meta.env.VITE_NIRI_BASE_URL ?? ""
52
53const createClientId = (): string => {
54 if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
55 return `web-${crypto.randomUUID()}`
56 }
57 return `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
58}
59
60export function App() {
61 const clientId = useMemo(() => createClientId(), [])
62 const client = useMemo(() => createChatClient({ baseUrl, clientId }), [clientId])
63 const [view, setView] = useState<"metrics" | "chat">(() => (window.location.hash === "#chat" ? "chat" : "metrics"))
64
65 const [entries, setEntries] = useState<Entry[]>([])
66 const [running, setRunning] = useState<boolean | null>(null)
67 const [input, setInput] = useState("")
68 const [sending, setSending] = useState(false)
69 const [showThinking, setShowThinking] = useState(false)
70 const [showAllTools, setShowAllTools] = useState(false)
71 const [collapsedToolIds, setCollapsedToolIds] = useState<Set<number>>(() => new Set<number>())
72 const nextId = useRef(0)
73
74 const push = useCallback((entry: NewEntry): number => {
75 const id = nextId.current++
76 setEntries((prev) => [...prev, { ...entry, id }])
77 return id
78 }, [])
79
80 const appendStreamText = useCallback((kind: "text" | "thinking", text: string) => {
81 setEntries((prev) => {
82 const last = prev[prev.length - 1]
83 if (last?.kind === kind) {
84 return [...prev.slice(0, -1), { ...last, text: last.text + text }]
85 }
86
87 const id = nextId.current++
88 return [...prev, { id, kind, text }]
89 })
90 }, [])
91
92 const toggleTool = useCallback((id: number) => {
93 setCollapsedToolIds((prev) => {
94 const next = new Set(prev)
95 if (next.has(id)) {
96 next.delete(id)
97 } else {
98 next.add(id)
99 }
100 return next
101 })
102 }, [])
103
104 const collapseAllTools = useCallback(() => {
105 setCollapsedToolIds(new Set(entries.filter((entry) => entry.kind === "tool").map((entry) => entry.id)))
106 }, [entries])
107
108 const expandAllTools = useCallback(() => {
109 setCollapsedToolIds(new Set<number>())
110 }, [])
111
112 const switchView = useCallback((next: "metrics" | "chat") => {
113 setView(next)
114 window.history.replaceState(null, "", next === "chat" ? "#chat" : "#metrics")
115 }, [])
116
117 useEffect(() => {
118 const controller = new AbortController()
119
120 client
121 .stream({
122 signal: controller.signal,
123 onEvent: (event: StreamEvent) => {
124 if (event.type === "thinking") {
125 appendStreamText("thinking", event.text)
126 return
127 }
128
129 if (event.type === "tool") {
130 const id = push({ kind: "tool", name: event.name, args: event.args, result: event.result })
131 setCollapsedToolIds((prev) => {
132 const next = new Set(prev)
133 next.add(id)
134 return next
135 })
136 return
137 }
138
139 if (event.type === "user") {
140 if (event.clientId === clientId) return
141 push({ kind: "incoming", source: event.source, text: event.text })
142 return
143 }
144
145 appendStreamText("text", event.text)
146 },
147 })
148 .catch((err) => {
149 if (controller.signal.aborted) return
150 push({ kind: "error", text: err instanceof Error ? err.message : String(err) })
151 })
152
153 return () => controller.abort()
154 }, [appendStreamText, client, clientId, push])
155
156 useEffect(() => {
157 client
158 .getStatus()
159 .then((status) => setRunning(status.running))
160 .catch((err) => push({ kind: "error", text: err instanceof Error ? err.message : String(err) }))
161 }, [client, push])
162
163 const onSubmit = useCallback(
164 async (event: FormEvent<HTMLFormElement>) => {
165 event.preventDefault()
166 const message = input.trim()
167 if (!message || sending) return
168
169 setSending(true)
170 setInput("")
171 push({ kind: "user", text: message })
172
173 try {
174 await client.send(message)
175 } catch (err) {
176 push({ kind: "error", text: err instanceof Error ? err.message : String(err) })
177 } finally {
178 setSending(false)
179 }
180 },
181 [client, input, push, sending],
182 )
183
184 return (
185 <div className="shell">
186 <nav className="top-nav" aria-label="primary">
187 <button type="button" className={view === "metrics" ? "is-active" : ""} onClick={() => switchView("metrics")}>
188 metrics
189 </button>
190 <button type="button" className={view === "chat" ? "is-active" : ""} onClick={() => switchView("chat")}>
191 chat
192 </button>
193 </nav>
194
195 {view === "metrics" ? (
196 <MetricsWorkbench />
197 ) : (
198 <main className="app">
199 <header className="header">
200 <h1>niri chat</h1>
201 <p className="status">
202 {running === null ? "checking status…" : running ? "niri is awake" : "niri is sleeping — your message will wake her"}
203 </p>
204 <div className="toggles">
205 <label>
206 <input type="checkbox" checked={showThinking} onChange={(event) => setShowThinking(event.target.checked)} /> show thinking
207 </label>
208 <label>
209 <input type="checkbox" checked={showAllTools} onChange={(event) => setShowAllTools(event.target.checked)} /> expand all tool calls
210 </label>
211 <button type="button" onClick={collapseAllTools}>collapse tools</button>
212 <button type="button" onClick={expandAllTools}>expand tools</button>
213 </div>
214 </header>
215
216 <section className="feed" aria-live="polite">
217 {entries.map((entry) => {
218 if (entry.kind === "thinking") {
219 if (!showThinking) {
220 return (
221 <article key={entry.id} className="entry entry-info">
222 ⟨ thinking — {entry.text.split("\n").length} lines ⟩
223 </article>
224 )
225 }
226
227 return (
228 <article key={entry.id} className="entry entry-thinking">
229 <strong>thinking</strong>
230 <MarkdownBlock content={entry.text} />
231 </article>
232 )
233 }
234
235 if (entry.kind === "tool") {
236 const collapsed = !showAllTools && collapsedToolIds.has(entry.id)
237
238 return (
239 <article key={entry.id} className="entry entry-tool">
240 <div className="entry-header">
241 <strong>tool #{entry.id}: {toolSummary(entry.name, entry.args)}</strong>
242 <button type="button" onClick={() => toggleTool(entry.id)}>
243 {collapsed ? "expand" : "collapse"}
244 </button>
245 </div>
246 {collapsed ? <pre>{hiddenToolSummary(entry.result)}</pre> : <MarkdownBlock content={toToolMarkdown(entry.result)} />}
247 </article>
248 )
249 }
250
251 if (entry.kind === "text") {
252 return (
253 <article key={entry.id} className="entry entry-niri">
254 <strong>niri</strong>
255 <MarkdownBlock content={entry.text} />
256 </article>
257 )
258 }
259
260 if (entry.kind === "incoming") {
261 return (
262 <article key={entry.id} className="entry entry-incoming">
263 <strong>{entry.source}</strong>
264 <MarkdownBlock content={entry.text} />
265 </article>
266 )
267 }
268
269 if (entry.kind === "user") {
270 return (
271 <article key={entry.id} className="entry entry-user">
272 <strong>you</strong>
273 <pre>{entry.text}</pre>
274 </article>
275 )
276 }
277
278 return (
279 <article key={entry.id} className={`entry ${entry.kind === "error" ? "entry-error" : "entry-info"}`}>
280 {entry.kind === "error" ? `error: ${entry.text}` : entry.text}
281 </article>
282 )
283 })}
284 </section>
285
286 <form className="composer" onSubmit={onSubmit}>
287 <input
288 value={input}
289 onChange={(event) => setInput(event.target.value)}
290 placeholder="send a message to niri"
291 aria-label="message"
292 />
293 <button type="submit" disabled={sending || !input.trim()}>
294 {sending ? "sending…" : "send"}
295 </button>
296 </form>
297 </main>
298 )}
299 </div>
300 )
301}