app.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. import { useState, useCallback, useRef, useMemo, useEffect } from "react";
  2. import { Box, Text, useInput, useApp, useStdout } from "ink";
  3. import { streamText, stepCountIs } from "ai";
  4. import { gateway } from "@ai-sdk/gateway";
  5. import {
  6. createMixedStreamParser,
  7. createStateStore,
  8. applySpecPatch,
  9. type Spec,
  10. } from "@json-render/core";
  11. import { JSONUIProvider, Renderer, useFocusDisable } from "@json-render/ink";
  12. import { catalog } from "./catalog.js";
  13. import { tools } from "./tools.js";
  14. const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
  15. // Component types stepped through one-at-a-time in the wizard.
  16. // Tabs are excluded — they're navigation, rendered inline with the full spec.
  17. const WIZARD_TYPES = new Set([
  18. "TextInput",
  19. "Select",
  20. "MultiSelect",
  21. "ConfirmInput",
  22. ]);
  23. // Interactive component types that need live keyboard input (wizard types + Tabs)
  24. const INTERACTIVE_TYPES = new Set([...WIZARD_TYPES, "Tabs"]);
  25. /** Check if a spec contains any interactive components */
  26. function hasInteractiveElements(spec: Spec): boolean {
  27. return Object.values(spec.elements).some((el) =>
  28. INTERACTIVE_TYPES.has(el.type),
  29. );
  30. }
  31. /** Collect an element and all its descendants from the spec tree */
  32. function collectSubtree(spec: Spec, rootKey: string): Spec["elements"] {
  33. const result: Spec["elements"] = {};
  34. const queue = [rootKey];
  35. while (queue.length > 0) {
  36. const key = queue.shift()!;
  37. const el = spec.elements[key];
  38. if (!el) continue;
  39. result[key] = el;
  40. if (el.children) queue.push(...el.children);
  41. }
  42. return result;
  43. }
  44. /** Get event→action bindings that auto-advance the wizard for each component type */
  45. function getAdvanceEvents(
  46. type: string,
  47. ): Record<string, Array<{ action: string }>> | null {
  48. switch (type) {
  49. case "Select":
  50. return { change: [{ action: "advance" }] };
  51. case "TextInput":
  52. case "MultiSelect":
  53. return { submit: [{ action: "advance" }] };
  54. case "ConfirmInput":
  55. return {
  56. confirm: [{ action: "advance" }],
  57. deny: [{ action: "advance" }],
  58. };
  59. default:
  60. return null;
  61. }
  62. }
  63. /** Step-specific hint text */
  64. function getStepHint(type: string, isLast: boolean): string {
  65. const action = isLast ? "submit" : "continue";
  66. switch (type) {
  67. case "Select":
  68. return `Use arrow keys, Enter to ${action}`;
  69. case "MultiSelect":
  70. return `Space to toggle, Enter to ${action}`;
  71. case "TextInput":
  72. return `Type your answer, Enter to ${action}`;
  73. case "ConfirmInput":
  74. return `Press Y or N to ${action}`;
  75. default:
  76. return `Make your selection to ${action}`;
  77. }
  78. }
  79. // ---------------------------------------------------------------------------
  80. // System prompt — handwritten design guidance + catalog documentation.
  81. // Follows the same pattern as examples/chat/lib/agent.ts: a rich
  82. // AGENT_INSTRUCTIONS string with catalog.prompt() appended at the end.
  83. // ---------------------------------------------------------------------------
  84. const AGENT_INSTRUCTIONS = `You are a terminal assistant that renders polished, information-dense UIs. You call tools for real-time data, then build clean terminal dashboards.
  85. WORKFLOW:
  86. 1. Call the appropriate tools to gather real data. Use web_search for topics not covered by the specialized tools (get_weather, get_hacker_news, get_github_repo, get_crypto_price).
  87. 2. While tools run, output a single short status line (e.g. "Looking up weather data..."). This is the ONLY text allowed outside the spec fence.
  88. 3. After tools return, output ALL content inside a \`\`\`spec fence. Never write paragraphs of prose outside the fence.
  89. 4. For simple text replies (greetings, clarifications), still use a \`\`\`spec with a Markdown component.
  90. DESIGN PRINCIPLES:
  91. - HIERARCHY: Every response needs clear visual structure. Start with an h1 Heading for the topic. Use h2 Headings for subsections. Use Card to group related content into shaded areas — Cards render as subtle background fills, not bordered boxes.
  92. - LEAD WITH THE STORY: Open with a brief Markdown paragraph (2-3 sentences) that tells the user the key insight or takeaway. Don't just dump data — frame it.
  93. - SUMMARY METRICS: After the narrative, show 2-4 Metric components for the most important numbers. Metric displays a dim label, bold value, and optional colored trend (up=green, down=red). Group them in a horizontal Box (flexDirection: row, gap: 3) so they read like a dashboard header. Use KeyValue only for simple label:value pairs that don't need emphasis.
  94. - DETAIL SECTIONS: Below the summary, use h2 Headings to introduce each section, followed by a single focused visualization (Table, BarChart, or set of KeyValues).
  95. - ONE REPRESENTATION PER DATA POINT: Never show the same value as both a number and a percentage and a bar. Pick the most meaningful format. Use BarChart with showValues:true OR showPercentage:true, not both.
  96. - TABLES: Always set explicit column widths so columns don't collapse. Use headerColor:"cyan". Keep column headers short (abbreviate if needed). Right-align numeric columns.
  97. - CHARTS: Use distinct colors per bar in BarChart. Good palette: cyan, green, yellow, magenta, blue, red. Use Sparkline for compact inline trends alongside other content.
  98. - COLOR STRATEGY: Use color with intention, not decoration. cyan for labels and headers. green for positive values, growth, success. red for negative values, decline, errors. yellow for warnings or neutral highlights. dimColor:true for secondary/supporting text. Avoid coloring everything — contrast comes from restraint.
  99. - TABLES: Use borderStyle:"single" on Tables for a clean outline. Do NOT put Tables inside Cards — Tables have their own border and don't need additional wrapping.
  100. - SPACING: Use gap:1 between sections. Don't over-pad. Keep the UI compact and scannable. NEVER add padding to the root element — the app already provides outer padding.
  101. - WIDTH: Target 80 columns. Set explicit widths on Tables (total columns should sum to ~70-75). Use wrap:"truncate-end" on Text in tight spaces.
  102. - CALLOUTS: Use Callout for key takeaways, important notes, tips, and warnings. Set type (info/tip/warning/important) for a colored left border accent. Keep content concise — one key point per Callout.
  103. - TIMELINES: Use Timeline for historical events, step-by-step processes, and milestones. Set status per item (completed/current/upcoming) for colored dots. Include dates when available.
  104. - NEVER use emojis anywhere — not in text, labels, titles, table cells, Heading text, or component props. Plain text only.
  105. DASHBOARD PATTERN (use for data-heavy responses):
  106. Root Box (column, gap:1) >
  107. Heading (h1, topic title)
  108. Markdown (2-3 sentence summary with key takeaway)
  109. Box (row, gap:3) > [Metric, Metric, Metric] (top-line metrics, no Card)
  110. Heading (h2, section title)
  111. Table (borderStyle:"single")
  112. Card (title:"Section Name") > BarChart (bar charts go in a titled Card — the Card title replaces h2)
  113. Callout (type:"tip", key takeaway or closing note)
  114. Card wrapping rules: Wrap BarCharts in a Card with a title. Do NOT wrap Metrics or Tables in Cards — Metrics stand alone, Tables have their own border.
  115. COMPARISON PATTERN:
  116. Use BarChart when you want the user to see relative magnitudes at a glance.
  117. Use Table when there are 3+ columns of mixed data types.
  118. Never use both for the same data.
  119. TREND PATTERN:
  120. Use Sparkline for compact inline trend next to a KeyValue.
  121. Use BarChart with year/period labels for detailed time-series.
  122. INTERACTIVITY:
  123. - You can create interactive forms, surveys, and selection interfaces. The user navigates with arrow keys, selects with Space/Enter, and types into text fields.
  124. - ALWAYS include a submit action on interactive UIs. Add a Text or StatusLine telling the user how to submit. Wire submit events to a "submit" action — the app collects form state automatically.
  125. - ALWAYS populate the state field with sensible defaults for all bound values.
  126. - Use $bindState on interactive components for two-way binding. Example: { "value": { "$state": "/choice" }, "$bindState": { "value": "/choice" } }.
  127. - Use Tabs for multi-section surveys. Use ConfirmInput for yes/no prompts.
  128. - After receiving form data, acknowledge the user's choices meaningfully — don't just echo them back.
  129. ${catalog.prompt({
  130. mode: "inline",
  131. customRules: [
  132. "ALL text MUST go inside the spec using the Markdown component. The ONLY text outside the fence is a short tool-status line.",
  133. "For text-only answers, still output a spec with a Markdown component.",
  134. "Prefer Table for structured data and KeyValue for label-value pairs.",
  135. "NEVER use emojis anywhere in your output. Plain text only.",
  136. ],
  137. })}`;
  138. // ---------------------------------------------------------------------------
  139. // Types
  140. // ---------------------------------------------------------------------------
  141. interface Message {
  142. id: number;
  143. role: "user" | "assistant";
  144. text: string;
  145. spec: Spec | null;
  146. }
  147. // ---------------------------------------------------------------------------
  148. // ChatInput — simple terminal text input
  149. // ---------------------------------------------------------------------------
  150. function ChatInput({
  151. onSubmit,
  152. disabled,
  153. }: {
  154. onSubmit: (text: string) => void;
  155. disabled: boolean;
  156. }) {
  157. const [value, setValue] = useState("");
  158. useInput(
  159. (input, key) => {
  160. if (key.return && value.trim()) {
  161. onSubmit(value.trim());
  162. setValue("");
  163. return;
  164. }
  165. if (key.backspace || key.delete) {
  166. setValue((prev) => prev.slice(0, -1));
  167. return;
  168. }
  169. if (key.ctrl || key.meta || key.escape || key.tab) return;
  170. if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
  171. return;
  172. if (input) {
  173. setValue((prev) => prev + input);
  174. }
  175. },
  176. { isActive: !disabled },
  177. );
  178. return (
  179. <Box>
  180. <Text bold>{"› "}</Text>
  181. {value ? (
  182. <Text>{value}</Text>
  183. ) : (
  184. <Text dimColor>{disabled ? "Thinking..." : "Type a message..."}</Text>
  185. )}
  186. </Box>
  187. );
  188. }
  189. // ---------------------------------------------------------------------------
  190. // Small UI helpers
  191. // ---------------------------------------------------------------------------
  192. const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  193. function AnimatedSpinner({
  194. label,
  195. color = "cyan",
  196. }: {
  197. label: string;
  198. color?: string;
  199. }) {
  200. const [frame, setFrame] = useState(0);
  201. useEffect(() => {
  202. const timer = setInterval(() => {
  203. setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
  204. }, 80);
  205. return () => clearInterval(timer);
  206. }, []);
  207. return (
  208. <Box gap={1}>
  209. <Text color={color}>{SPINNER_FRAMES[frame]}</Text>
  210. <Text dimColor>{label}</Text>
  211. </Box>
  212. );
  213. }
  214. /** Suppress Tab-cycling inside read-only message providers so old interactive
  215. * components (e.g. Tabs) can't steal focus/arrow-key input. */
  216. function DisableFocus() {
  217. useFocusDisable(true);
  218. return null;
  219. }
  220. /** Render markdown text through the standard Renderer pipeline (uses the
  221. * built-in Markdown component without exporting MarkdownText). */
  222. function RenderedMarkdown({ text }: { text: string }) {
  223. const spec: Spec = useMemo(
  224. () => ({
  225. root: "md",
  226. elements: { md: { type: "Markdown", props: { text }, children: [] } },
  227. }),
  228. [text],
  229. );
  230. return (
  231. <JSONUIProvider initialState={{}}>
  232. <DisableFocus />
  233. <Renderer spec={spec} />
  234. </JSONUIProvider>
  235. );
  236. }
  237. function MessageView({ message }: { message: Message }) {
  238. if (message.role === "user") {
  239. return (
  240. <Box marginBottom={1}>
  241. <Text bold>You: </Text>
  242. <Text>{message.text}</Text>
  243. </Box>
  244. );
  245. }
  246. return (
  247. <Box flexDirection="column" marginBottom={1}>
  248. {message.spec ? (
  249. <JSONUIProvider initialState={message.spec.state ?? {}}>
  250. <DisableFocus />
  251. <Renderer spec={message.spec} />
  252. </JSONUIProvider>
  253. ) : message.text ? (
  254. <RenderedMarkdown text={message.text} />
  255. ) : null}
  256. </Box>
  257. );
  258. }
  259. // ---------------------------------------------------------------------------
  260. // LiveInteractiveSpec — wizard that shows one interactive element at a time
  261. // ---------------------------------------------------------------------------
  262. function LiveInteractiveSpec({
  263. spec,
  264. onSubmit,
  265. }: {
  266. spec: Spec;
  267. onSubmit: (state: Record<string, unknown>) => void;
  268. }) {
  269. // Extract wizard-steppable element keys (Tabs are excluded)
  270. const interactiveKeys = useMemo(
  271. () =>
  272. Object.entries(spec.elements)
  273. .filter(([_, el]) => WIZARD_TYPES.has(el.type))
  274. .map(([key]) => key),
  275. [spec],
  276. );
  277. const [step, setStep] = useState(0);
  278. const store = useMemo(() => createStateStore(spec.state ?? {}), [spec]);
  279. // Guard against double-advance from key repeats (e.g. holding Y on ConfirmInput)
  280. const advancingRef = useRef(false);
  281. const currentKey = interactiveKeys[step];
  282. const currentElement = currentKey ? spec.elements[currentKey] : null;
  283. const isLast = step >= interactiveKeys.length - 1;
  284. // Reset the guard when the step changes
  285. useEffect(() => {
  286. advancingRef.current = false;
  287. }, [step]);
  288. const advance = useCallback(() => {
  289. if (advancingRef.current) return;
  290. advancingRef.current = true;
  291. if (isLast) {
  292. onSubmit(store.getSnapshot());
  293. } else {
  294. setStep((s) => s + 1);
  295. }
  296. }, [isLast, onSubmit, store]);
  297. // Build a minimal spec containing only the current interactive element
  298. const stepSpec = useMemo<Spec | null>(() => {
  299. if (!currentKey || !currentElement) return null;
  300. const elements = collectSubtree(spec, currentKey);
  301. // Wire auto-advance events
  302. const advanceEvents = getAdvanceEvents(currentElement.type);
  303. if (advanceEvents) {
  304. elements[currentKey] = {
  305. ...elements[currentKey]!,
  306. on: { ...(elements[currentKey] as any).on, ...advanceEvents },
  307. };
  308. }
  309. return { root: currentKey, elements, state: spec.state };
  310. }, [currentKey, currentElement, spec]);
  311. const handlers = useMemo(() => ({ submit: advance, advance }), [advance]);
  312. // No wizard-steppable elements (e.g. Tabs-only spec) → render the full spec
  313. if (interactiveKeys.length === 0) {
  314. return (
  315. <Box flexDirection="column" marginBottom={1}>
  316. <JSONUIProvider store={store} handlers={handlers}>
  317. <Renderer spec={spec} />
  318. </JSONUIProvider>
  319. </Box>
  320. );
  321. }
  322. if (!stepSpec || !currentElement) return null;
  323. return (
  324. <Box flexDirection="column" marginBottom={1}>
  325. {interactiveKeys.length > 1 && (
  326. <Text dimColor>
  327. Step {step + 1} of {interactiveKeys.length}
  328. </Text>
  329. )}
  330. <JSONUIProvider store={store} handlers={handlers}>
  331. <Renderer spec={stepSpec} />
  332. </JSONUIProvider>
  333. <Box marginTop={1}>
  334. <Text dimColor italic>
  335. {getStepHint(currentElement.type, isLast)}
  336. </Text>
  337. </Box>
  338. </Box>
  339. );
  340. }
  341. // ---------------------------------------------------------------------------
  342. // App — main chat loop
  343. // ---------------------------------------------------------------------------
  344. export function App() {
  345. const { exit } = useApp();
  346. const { stdout } = useStdout();
  347. const [messages, setMessages] = useState<Message[]>([]);
  348. const [isStreaming, setIsStreaming] = useState(false);
  349. const [streamingStatus, setStreamingStatus] = useState("Thinking...");
  350. const [streamingSpec, setStreamingSpec] = useState<Spec | null>(null);
  351. const nextMessageIdRef = useRef(0);
  352. const abortRef = useRef<AbortController | null>(null);
  353. // Ref tracks latest messages so sendMessage doesn't need it as a dep
  354. const messagesRef = useRef(messages);
  355. messagesRef.current = messages;
  356. // Track a live interactive spec awaiting user input
  357. const [liveSpec, setLiveSpec] = useState<Spec | null>(null);
  358. // Ctrl+C to exit (suppress Escape when interactive spec is live)
  359. useInput((_input, key) => {
  360. if (key.ctrl && _input === "c") {
  361. abortRef.current?.abort();
  362. exit();
  363. }
  364. if (key.escape && !liveSpec) {
  365. abortRef.current?.abort();
  366. exit();
  367. }
  368. });
  369. const sendMessage = useCallback(async (text: string) => {
  370. abortRef.current?.abort();
  371. // Clear any live interactive spec
  372. setLiveSpec(null);
  373. // Add user message
  374. const userMsg: Message = {
  375. id: nextMessageIdRef.current++,
  376. role: "user",
  377. text,
  378. spec: null,
  379. };
  380. setMessages((prev) => [...prev, userMsg]);
  381. setIsStreaming(true);
  382. setStreamingStatus("Thinking...");
  383. // Build conversation history from ref (avoids stale closure).
  384. // For assistant messages with specs, serialize the spec so the model
  385. // remembers what it rendered in previous turns.
  386. const history = [
  387. ...messagesRef.current.map((m) => ({
  388. role: m.role as "user" | "assistant",
  389. content: m.spec
  390. ? `${m.text}\n\`\`\`spec\n${JSON.stringify(m.spec)}\n\`\`\``
  391. : m.text,
  392. })),
  393. { role: "user" as const, content: text },
  394. ];
  395. const controller = new AbortController();
  396. abortRef.current = controller;
  397. try {
  398. const result = streamText({
  399. model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
  400. system: AGENT_INSTRUCTIONS,
  401. messages: history,
  402. temperature: 0.7,
  403. abortSignal: controller.signal,
  404. tools,
  405. stopWhen: stepCountIs(3),
  406. });
  407. let conversationText = "";
  408. let spec: Spec = { root: "", elements: {} };
  409. let hasSpec = false;
  410. const parser = createMixedStreamParser({
  411. onText: (chunk) => {
  412. conversationText += chunk + "\n";
  413. },
  414. onPatch: (patch) => {
  415. hasSpec = true;
  416. spec = applySpecPatch(structuredClone(spec), patch);
  417. setStreamingSpec(structuredClone(spec));
  418. },
  419. });
  420. let hadTextInStep = false;
  421. for await (const part of result.fullStream) {
  422. if (part.type === "tool-call") {
  423. const name = part.toolName.replace(/_/g, " ");
  424. setStreamingStatus(`Using ${name}...`);
  425. } else if (part.type === "tool-result") {
  426. setStreamingStatus("Generating...");
  427. } else if (part.type === "text-start") {
  428. hadTextInStep = false;
  429. } else if (part.type === "text-delta") {
  430. hadTextInStep = true;
  431. parser.push(part.text);
  432. } else if (part.type === "text-end") {
  433. // Insert a paragraph break between text segments so text from
  434. // before/after tool calls doesn't merge into a wall.
  435. // Injected directly into conversationText (not through the
  436. // parser, which may drop empty lines in older builds).
  437. if (hadTextInStep) {
  438. parser.flush();
  439. conversationText += "\n\n";
  440. hadTextInStep = false;
  441. }
  442. }
  443. }
  444. parser.flush();
  445. // Finalize: add assistant message
  446. const finalSpec = hasSpec ? spec : null;
  447. const isInteractive = finalSpec && hasInteractiveElements(finalSpec);
  448. const assistantMsg: Message = {
  449. id: nextMessageIdRef.current++,
  450. role: "assistant",
  451. text: conversationText.trim(),
  452. // If interactive, don't store spec in message history (it'll be live)
  453. spec: isInteractive ? null : finalSpec,
  454. };
  455. setMessages((prev) => [...prev, assistantMsg]);
  456. // If the spec has interactive components, keep it live
  457. if (isInteractive && finalSpec) {
  458. setLiveSpec(finalSpec);
  459. }
  460. } catch (err) {
  461. if ((err as Error).name === "AbortError") return;
  462. const errorMsg: Message = {
  463. id: nextMessageIdRef.current++,
  464. role: "assistant",
  465. text: `Error: ${(err as Error).message}`,
  466. spec: null,
  467. };
  468. setMessages((prev) => [...prev, errorMsg]);
  469. } finally {
  470. setIsStreaming(false);
  471. setStreamingSpec(null);
  472. }
  473. }, []);
  474. // Handle interactive spec submission — collect state and send back to AI
  475. const handleInteractiveSubmit = useCallback(
  476. (state: Record<string, unknown>) => {
  477. // Freeze the spec into message history as a non-interactive snapshot
  478. if (liveSpec) {
  479. // Update the last assistant message to include the spec with submitted state
  480. const frozenSpec = { ...liveSpec, state };
  481. setMessages((prev) => {
  482. const updated = [...prev];
  483. // Find the last assistant message (which has spec: null for interactive)
  484. for (let i = updated.length - 1; i >= 0; i--) {
  485. if (updated[i]!.role === "assistant" && !updated[i]!.spec) {
  486. updated[i] = { ...updated[i]!, spec: frozenSpec };
  487. break;
  488. }
  489. }
  490. return updated;
  491. });
  492. setLiveSpec(null);
  493. }
  494. // Format the submitted state as a user message and send to AI
  495. const formattedState = Object.entries(state)
  496. .map(([key, value]) => {
  497. if (Array.isArray(value)) return `${key}: ${value.join(", ")}`;
  498. return `${key}: ${value}`;
  499. })
  500. .join("\n");
  501. sendMessage(`[Form submitted]\n${formattedState}`);
  502. },
  503. [liveSpec, sendMessage],
  504. );
  505. return (
  506. <Box flexDirection="column" padding={1} minHeight={stdout.rows}>
  507. {/* Header */}
  508. <Box marginBottom={1} gap={1}>
  509. <Text bold color="cyan">
  510. json-render
  511. </Text>
  512. <Text dimColor>Ctrl+C to exit</Text>
  513. </Box>
  514. {/* Empty state — show example prompts when no conversation yet */}
  515. {messages.length === 0 && !isStreaming && (
  516. <Box flexDirection="column" marginBottom={1}>
  517. <Text dimColor>Try asking:</Text>
  518. <Box flexDirection="column" paddingLeft={2} marginTop={1} gap={0}>
  519. <Text dimColor>{" weather in tokyo"}</Text>
  520. <Text dimColor>{" top hacker news stories"}</Text>
  521. <Text dimColor>{" tell me about vercel/next.js"}</Text>
  522. <Text dimColor>{" bitcoin price"}</Text>
  523. </Box>
  524. </Box>
  525. )}
  526. {/* Message history — collapsed when interactive wizard is active */}
  527. {liveSpec && !isStreaming ? (
  528. <>
  529. {messages.length > 1 && (
  530. <Text dimColor italic>
  531. {messages.length - 1} earlier message
  532. {messages.length > 2 ? "s" : ""} hidden
  533. </Text>
  534. )}
  535. {messages.length > 0 && (
  536. <MessageView message={messages[messages.length - 1]!} />
  537. )}
  538. <LiveInteractiveSpec
  539. spec={liveSpec}
  540. onSubmit={handleInteractiveSubmit}
  541. />
  542. </>
  543. ) : (
  544. <>
  545. {messages.map((msg) => (
  546. <MessageView key={msg.id} message={msg} />
  547. ))}
  548. </>
  549. )}
  550. {/* Live spec preview while streaming */}
  551. {isStreaming && streamingSpec && streamingSpec.root && (
  552. <Box flexDirection="column" marginBottom={1}>
  553. <JSONUIProvider initialState={streamingSpec.state ?? {}}>
  554. <DisableFocus />
  555. <Renderer spec={streamingSpec} loading />
  556. </JSONUIProvider>
  557. </Box>
  558. )}
  559. {/* Spacer pushes input to bottom when content is short */}
  560. <Box flexGrow={1} />
  561. {/* Input — spinner replaces input while streaming, hidden during wizard */}
  562. {!liveSpec && (
  563. <Box borderStyle="single" borderColor="gray" paddingX={1}>
  564. {isStreaming ? (
  565. <AnimatedSpinner label={streamingStatus} />
  566. ) : (
  567. <ChatInput onSubmit={sendMessage} disabled={false} />
  568. )}
  569. </Box>
  570. )}
  571. </Box>
  572. );
  573. }