Something went wrong. Try again.
forked niri
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371import readline from "readline"import { createChatClient, type StreamEvent } from "@nano/chat-client"import { renderMarkdownAnsi } from "./terminal-markdown"
const HOST = process.env.NANO_HOST ?? "http://localhost"const directWorker = process.env.NANO_CHAT_DIRECT_WORKER?.trim().toLowerCase() === "true"const controlAgentId = directWorker ? undefined : (process.env.NANO_AGENT_ID ?? "nano").trim() || "nano"const PORT = directWorker ? process.env.PORT ?? "3000" : process.env.CONTROL_PORT ?? process.env.PORT ?? "3000"const BASE = process.env.NANO_SERVER_URL?.replace(/\/+$/, "") || `${HOST}:${PORT}`
const c = { gray: (s: string) => `\x1b[90m${s}\x1b[0m`, red: (s: string) => `\x1b[31m${s}\x1b[0m`, cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, cyanBright: (s: string) => `\x1b[96m${s}\x1b[0m`, yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, magentaBright: (s: string) => `\x1b[95m${s}\x1b[0m`,}
const lineCount = (text: string): number => text.split("\n").length
const formatNumber = (value: number): string => value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2)
const formatUsage = (event: Extract<StreamEvent, { type: "usage" }>): string => { const parts: string[] = [] if (typeof event.tokensPerSecond === "number") parts.push(`${formatNumber(event.tokensPerSecond)} tok/s`) if (typeof event.completionTokens === "number") parts.push(`${event.completionTokens} out`) if (typeof event.promptTokens === "number") parts.push(`${event.promptTokens} ctx`) if (typeof event.cachedPromptTokens === "number") parts.push(`${event.cachedPromptTokens} cached`) if (typeof event.cacheWriteTokens === "number") parts.push(`${event.cacheWriteTokens} cache write`) if (typeof event.totalTokens === "number") parts.push(`${event.totalTokens} total`) if (typeof event.elapsedMs === "number") parts.push(`${(event.elapsedMs / 1000).toFixed(1)}s`) return parts.length ? parts.join(" · ") : "usage unavailable"}
const toolSummary = (name: string, args: Record<string, unknown>): 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 "memory_search": return `memory ${String(args.query ?? "")}` case "rest": return `rest${args.note ? ` ${String(args.note)}` : ""}` default: return `${name} ${JSON.stringify(args)}` }}
const renderToolBody = (text: string): string => text.includes("```") ? renderMarkdownAnsi(text) : renderMarkdownAnsi(`\`\`\`text\n${text}\n\`\`\``)
const createClientId = (): string => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `cli-${crypto.randomUUID()}` } return `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`}
const clientId = createClientId()const client = createChatClient({ baseUrl: BASE, clientId, agentId: controlAgentId })
let showAllTools = falselet showThinking = falselet activeStreamKind: "text" | "thinking" | null = nulllet activeStreamMuted = falselet activeStreamText = ""let activeStreamRenderedLines = 0let streamSettleTimer: ReturnType<typeof setTimeout> | null = nulllet streamStatusCheckInFlight = false
const STREAM_SETTLE_CHECK_MS = 250
const isTerminal = (): boolean => Boolean(process.stdin.isTTY && process.stdout.isTTY)
const stripAnsi = (text: string): string => text.replace(/\x1b\[[0-9;]*m/g, "")
const charCellWidth = (char: string): number => { const code = char.codePointAt(0) ?? 0 if (code === 0) return 0 if (code < 32 || (code >= 0x7f && code < 0xa0)) return 0 if ( (code >= 0x300 && code <= 0x36f) || (code >= 0x1ab0 && code <= 0x1aff) || (code >= 0x1dc0 && code <= 0x1dff) || (code >= 0x20d0 && code <= 0x20ff) || (code >= 0xfe20 && code <= 0xfe2f) ) { return 0 } if ( code >= 0x1100 && (code <= 0x115f || code === 0x2329 || code === 0x232a || (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe10 && code <= 0xfe19) || (code >= 0xfe30 && code <= 0xfe6f) || (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)) ) { return 2 } return 1}
const visibleLineCount = (text: string): number => { const columns = Math.max(1, process.stdout.columns ?? 80) return stripAnsi(text) .split("\n") .reduce((rows, line) => { const width = Array.from(line).reduce((sum, char) => sum + charCellWidth(char), 0) return rows + Math.max(1, Math.ceil(width / columns)) }, 0)}
const repaintPrompt = () => { process.stdin.resume() rl.resume() rl.prompt(true)}
const clearPromptLine = () => { readline.clearLine(process.stdout, 0) readline.cursorTo(process.stdout, 0)}
const renderActiveStream = (): string | null => { if (!activeStreamKind) return null if (activeStreamKind === "text") return `${c.magentaBright("nano: ")}${activeStreamText}` if (activeStreamMuted) return c.gray("⟨ thinking ⟩") return c.gray(`⟨ thinking ⟩ ${activeStreamText}`)}
const repaintActiveStream = () => { if (!isTerminal()) return
clearPromptLine() if (activeStreamRenderedLines > 0) { readline.moveCursor(process.stdout, 0, -activeStreamRenderedLines) } readline.clearScreenDown(process.stdout)
const rendered = renderActiveStream() if (rendered) { process.stdout.write(`${rendered}\n`) activeStreamRenderedLines = visibleLineCount(rendered) } else { activeStreamRenderedLines = 0 }
repaintPrompt()}
const print = (line: string) => { endActiveStream() if (isTerminal()) { clearPromptLine() process.stdout.write(`${line}\n`) repaintPrompt() return }
process.stdout.write(`${line}\n`)}
const startActiveStream = (kind: "text" | "thinking") => { if (activeStreamKind === kind) return endActiveStream()
activeStreamKind = kind activeStreamMuted = kind === "thinking" && !showThinking activeStreamText = ""
if (isTerminal()) { repaintActiveStream() return }
if (kind === "text") { process.stdout.write(c.magentaBright("nano: ")) } else if (activeStreamMuted) { process.stdout.write(c.gray("⟨ thinking ⟩")) } else { process.stdout.write(c.gray("⟨ thinking ⟩ ")) }}
const appendActiveStream = (kind: "text" | "thinking", chunk: string) => { startActiveStream(kind) if (!chunk || activeStreamMuted) return
if (isTerminal()) { activeStreamText += kind === "thinking" ? c.gray(chunk) : chunk repaintActiveStream() return }
const rendered = kind === "thinking" ? c.gray(chunk) : chunk process.stdout.write(rendered)}
function endActiveStream(): void { if (streamSettleTimer) { clearTimeout(streamSettleTimer) streamSettleTimer = null }
if (!activeStreamKind) return
if (!isTerminal()) process.stdout.write("\n") activeStreamKind = null activeStreamMuted = false activeStreamText = "" activeStreamRenderedLines = 0 repaintPrompt()}
const scheduleStreamSettleCheck = () => { if (streamSettleTimer) clearTimeout(streamSettleTimer)
streamSettleTimer = setTimeout(async () => { if (!activeStreamKind || streamStatusCheckInFlight) return
streamStatusCheckInFlight = true try { const status = await client.getStatus() if (!status.running || status.idle) { endActiveStream() } else { scheduleStreamSettleCheck() } } catch { scheduleStreamSettleCheck() } finally { streamStatusCheckInFlight = false } }, STREAM_SETTLE_CHECK_MS)}
const handleStreamEvent = (event: StreamEvent) => { if (event.type === "thinking") { appendActiveStream("thinking", event.text) scheduleStreamSettleCheck() return }
if (event.type === "tool") { endActiveStream() print(c.yellow(`tool: ${toolSummary(event.name, event.args)}`)) if (showAllTools) { print(renderToolBody(event.result || "(no output)")) } else { print(c.gray(`… ${lineCount(event.result)} lines hidden · /v to expand`)) } return }
if (event.type === "user") { if (event.clientId === clientId) return endActiveStream() print(c.cyan(`${event.source}:`)) print(renderMarkdownAnsi(event.text)) return }
if (event.type === "usage") { endActiveStream() print(c.gray(`stats: ${formatUsage(event)}`)) return }
appendActiveStream("text", event.text) scheduleStreamSettleCheck()}
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: c.cyanBright("you: "), terminal: Boolean(process.stdin.isTTY),})
print(c.gray("tips: /v toggle tool output · /t toggle thinking · /status · /q quit"))
client .getStatus() .then((status) => { print(c.gray(status.running ? "nano is awake" : "nano is sleeping — your message will wake her")) }) .catch((err) => print(c.red(err instanceof Error ? err.message : String(err))))
const controller = new AbortController()client .stream({ signal: controller.signal, onEvent: handleStreamEvent }) .catch((err) => { if (controller.signal.aborted) return print(c.red(err instanceof Error ? err.message : String(err))) })
rl.prompt()
rl.on("line", async (line) => { const trimmed = line.trim() if (!trimmed) { rl.prompt() return }
if (trimmed === "/q" || trimmed === "/quit" || trimmed === "/exit") { endActiveStream() controller.abort() rl.close() process.exit(0) }
if (trimmed === "/status") { try { const status = await client.getStatus() print(c.gray(status.running ? "nano is awake" : "nano is sleeping — your message will wake her")) } catch (err) { print(c.red(err instanceof Error ? err.message : String(err))) } rl.prompt() return }
if (trimmed === "/v" || trimmed === "/verbose") { showAllTools = !showAllTools print(c.gray(`tool output: ${showAllTools ? "expanded" : "collapsed"}`)) rl.prompt() return }
if (trimmed === "/t" || trimmed === "/thinking") { showThinking = !showThinking print(c.gray(`thinking traces: ${showThinking ? "visible" : "hidden"}`)) rl.prompt() return }
try { await client.send(trimmed) } catch (err) { print(c.red(err instanceof Error ? err.message : String(err))) }
rl.prompt()})
rl.on("SIGINT", () => { endActiveStream() controller.abort() rl.close() process.exit(0)})
rl.on("close", () => { endActiveStream() controller.abort()})