my harness for niri
1
fork

Configure Feed

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

at master 265 lines 9.0 kB view raw
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}