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