// Ink-based TUI: same Agent, rendered with React. The transcript is committed to // (user bars, finished replies), the in-flight reply streams in a live region, and the footer // holds the composer (ink-text-input), a spinner while the model works, and inline y/a/n // approval prompts. The agent's `onEvent` stream already carries ANSI-styled markdown and // ⏺ tool lines, so items render as raw — no markdown logic here. import { Box, Static, Text, render, useApp, useInput } from "ink"; import TextInput from "ink-text-input"; // Default React import is required: when ada runs from another directory, tsx may not find // this repo's tsconfig and falls back to the classic JSX transform (React.createElement). import { type JSX, useEffect, useRef, useState } from "react"; import type { Agent } from "./agent.ts"; import { subscriptionFor, subscriptionLogin } from "../server/providers/subscription-oauth.ts"; import { setAsker } from "./tools.ts"; import type { AskOption } from "./tools.ts"; const GOLD = "#ffaf00"; // ada accent (xterm 214) const WORDS = ["Cogitating", "Pondering", "Noodling", "Percolating", "Ruminating", "Tinkering", "Untangling", "Brewing", "Mulling", "Crunching"]; const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const THINKING_TAIL = 600; // chars of live thinking kept on screen — roughly a screenful type Item = | { id: number; kind: "header"; text: string } | { id: number; kind: "user" | "reply" | "ask" | "error" | "info"; text: string }; function ItemView({ item }: { item: Item }): JSX.Element { switch (item.kind) { case "header": return {item.text}; case "user": return ( {"› "} {item.text} ); case "reply": return ( {"◆ "} {item.text.replace(/^\n+/, "")} ); case "info": return {item.text}; case "ask": return {`? ${item.text}`}; case "error": return ( {item.text} ); } } function AdaApp({ agent, model }: { agent: Agent; model: string }): JSX.Element { const { exit } = useApp(); const nextId = useRef(1); const [items, setItems] = useState([ { id: 0, kind: "header", text: `\x1b[38;5;214m█▀█ █▀▄ █▀█\n█▀█ █▄▀ █▀█\x1b[0m \x1b[2m${model}\x1b[0m\n` + `\x1b[2mAsk me to build, edit, or explain code in ${process.cwd()}\x1b[0m\n`, }, ]); const [input, setInput] = useState(""); const [queued, setQueued] = useState([]); const [live, setLive] = useState(""); // Shown while the turn runs and dropped once the answer lands — thinking is not part of the // reply, so it never joins the transcript. const [thinking, setThinking] = useState(""); const [running, setRunning] = useState(false); const [tokens, setTokens] = useState(agent.contextTokens()); const [confirm, setConfirm] = useState<{ risk: string; detail: string; danger: boolean; resolve: (d: "yes" | "all" | "no") => void } | null>(null); const [asking, setAsking] = useState<{ options?: AskOption[]; resolve: (s: string) => void } | null>(null); const [word, setWord] = useState(WORDS[0]!); const [secs, setSecs] = useState(0); const [spin, setSpin] = useState(0); const liveRef = useRef(""); const runningRef = useRef(false); const abortRef = useRef(null); const steerRef = useRef([]); const hist = useRef<{ list: string[]; i: number }>({ list: [], i: -1 }); const push = (kind: Exclude, text: string): void => setItems((prev) => [...prev, { id: nextId.current++, kind, text }]); agent.setOnApprove( (name, summary) => new Promise((resolve) => { // summary is "\n" (same contract as the REPL's approvePrompt) const nl = summary.indexOf("\n"); const risk = ((nl >= 0 ? summary.slice(0, nl) : summary) || `run the ${name} tool`).trim(); setConfirm({ risk: risk.replace(/^⚠ /, ""), danger: risk.startsWith("⚠"), detail: nl >= 0 ? summary.slice(nl + 1).trim() : "", resolve }); }), ); setAsker( (question, options) => new Promise((resolve) => { push( "ask", options?.length ? `${question}\n${options.map((o, i) => ` ${i + 1}. ${o.label}${o.description ? ` — ${o.description}` : ""}`).join("\n")}` : question, ); setAsking({ options, resolve }); }), ); useEffect(() => { if (!running) return; const start = Date.now(); setSecs(0); setWord(WORDS[Math.floor(Math.random() * WORDS.length)]!); const t = setInterval(() => { setSecs(Math.floor((Date.now() - start) / 1000)); setSpin((s) => (s + 1) % SPIN.length); if (Math.random() < 0.05) setWord(WORDS[Math.floor(Math.random() * WORDS.length)]!); }, 120); return () => clearInterval(t); }, [running]); const run = async (text: string): Promise => { const abort = new AbortController(); abortRef.current = abort; steerRef.current = []; liveRef.current = ""; setLive(""); setRunning(true); runningRef.current = true; let error = ""; try { await agent.send(text, { signal: abort.signal, steer: steerRef.current, onEvent: (e) => { if (e.type === "text") { liveRef.current += e.delta; setLive(liveRef.current); setQueued((q) => (q.length === steerRef.current.length ? q : [...steerRef.current])); // reflect drained steers } else if (e.type === "reasoning") { // Keep only the tail: a high-effort thought is longer than the terminal, and the // interesting part is always the most recent line. setThinking((t) => (t + e.delta).slice(-THINKING_TAIL)); } else if (e.type === "done" && e.context) setTokens(e.context); }, }); } catch (err) { error = err instanceof Error ? err.message : String(err); } finally { if (liveRef.current.trim()) push("reply", liveRef.current); if (error) push("error", error); liveRef.current = ""; setLive(""); setThinking(""); setQueued([]); setRunning(false); runningRef.current = false; setTokens(agent.contextTokens()); } }; const submit = (raw: string): void => { const text = raw.trim(); setInput(""); if (asking) { setAsking(null); const n = Number(text); const opts = asking.options; asking.resolve(opts?.length && Number.isInteger(n) && n >= 1 && n <= opts.length ? opts[n - 1]!.label : text); return; } if (!text) return; hist.current.list.push(text); hist.current.i = -1; if (text === "/exit" || text === "/quit") return exit(); // /login is handled here (rather than falling through to the stub below) because a plan sign-in // is the one command you may need BEFORE the TUI can talk to a model at all — being told to // restart without --tui to run it would be a dead end. if (text === "/login" || text.startsWith("/login ")) { push("user", text); const arg = text.slice("/login".length).trim().toLowerCase(); const provider = subscriptionFor(arg); if (!provider) { push("info", ` usage: /login claude | /login chatgpt${arg ? ` (unknown: ${arg})` : ""}`); return; } push("info", " opening your browser — finish the sign-in there…"); void subscriptionLogin(provider, (s) => { for (const line of s.split("\n")) if (line.trim()) push("info", ` ${line}`); }).catch((e: unknown) => push("error", `login failed: ${e instanceof Error ? e.message : String(e)}`)); return; } if (text.startsWith("/") && !text.includes(" ") && text.length > 1) { // ponytail: TUI only implements /exit; other commands live in the plain REPL for now push("user", text); push("info", ` ${text} isn't wired up in the TUI yet — run ada without --tui for slash commands. (Not sent to the model.)`); return; } if (runningRef.current) { steerRef.current.push(text); setQueued([...steerRef.current]); return; } push("user", text); void run(text); }; useInput((ch, key) => { if (confirm) { const k = ch.toLowerCase(); const done = (d: "yes" | "all" | "no"): void => { setConfirm(null); const mark = d === "no" ? "✗ denied" : d === "all" ? "✓ approved · always this session" : "✓ approved"; push("info", `${mark} — ${confirm.risk}`); confirm.resolve(d); }; if (k === "y") done("yes"); else if (k === "a") done("all"); else if (k === "n" || key.return || key.escape) done("no"); return; } if (key.escape && runningRef.current) return abortRef.current?.abort(); if (key.ctrl && ch === "c") { if (runningRef.current) abortRef.current?.abort(); else exit(); return; } if (key.upArrow || key.downArrow) { if (runningRef.current) { // ↑ pulls the newest queued steer back into the composer for editing if (key.upArrow && steerRef.current.length) { const t = steerRef.current.pop()!; setQueued([...steerRef.current]); setInput(t); } return; } if (hist.current.list.length) { const h = hist.current; if (key.upArrow) h.i = h.i < 0 ? h.list.length - 1 : Math.max(0, h.i - 1); else h.i = h.i < 0 ? -1 : h.i + 1; setInput(h.i >= 0 && h.i < h.list.length ? h.list[h.i]! : ""); } } }); return ( {(item) => } {thinking.trim() && !live.trim() ? ( {"✻ Thinking…"} {thinking.replace(/^\s+/, "")} ) : null} {live.trim() ? ( {"◆ "} {live.replace(/^\n+/, "")} ) : null} {running && !confirm && !asking ? ( {`${SPIN[spin]} `} {`${word}… (${secs}s · esc to interrupt)`} ) : null} {confirm ? ( {confirm.danger ? {"⚠ "} : null}ada wants to {confirm.risk} {confirm.detail ? {confirm.detail} : null} y = yes · a = always this session · n or esc = no ) : null} {queued.length ? ( {queued.map((q, i) => ( {"› "} {q} ))} ) : null} {!confirm ? ( {"› "} ) : null} {running ? "esc to interrupt · enter to queue" : "/exit to quit"} {`${model} · ~${tokens} tok`} ); } export async function runInkTui(agent: Agent, model: string): Promise { const app = render(, { exitOnCtrlC: false }); await app.waitUntilExit(); }