my harness for niri
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 301 lines 10 kB view raw
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}