From 8f922c0c0887975226e9ebb849073a64d9c655f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 08:41:50 +0000 Subject: [PATCH 01/12] Add Ink terminal (TUI) POC that renders streamed OpenUI Lang examples/openui-tui-chat: a terminal chat client that streams OpenUI Lang from an OpenAI-compatible model and renders it as an interactive Ink TUI. Reuses @openuidev/lang-core (streaming parser + runtime) and @openuidev/react-headless (chat store + streaming adapter); adds an Ink component library, tree walker, and keyboard focus/action handling. Includes vitest + ink-testing-library tests for rendering and interactivity. Co-authored-by: Ankit Das --- examples/openui-tui-chat/README.md | 65 +++ examples/openui-tui-chat/package.json | 33 ++ .../src/__tests__/genui.test.tsx | 132 +++++ examples/openui-tui-chat/src/app.tsx | 104 ++++ examples/openui-tui-chat/src/cli.tsx | 35 ++ examples/openui-tui-chat/src/genui/chart.ts | 44 ++ .../openui-tui-chat/src/genui/components.tsx | 334 +++++++++++ .../openui-tui-chat/src/genui/context.tsx | 44 ++ examples/openui-tui-chat/src/genui/library.ts | 175 ++++++ examples/openui-tui-chat/src/genui/state.tsx | 150 +++++ examples/openui-tui-chat/src/llm.ts | 38 ++ examples/openui-tui-chat/tsconfig.json | 18 + pnpm-lock.yaml | 533 +++++++++++++++++- 13 files changed, 1676 insertions(+), 29 deletions(-) create mode 100644 examples/openui-tui-chat/README.md create mode 100644 examples/openui-tui-chat/package.json create mode 100644 examples/openui-tui-chat/src/__tests__/genui.test.tsx create mode 100644 examples/openui-tui-chat/src/app.tsx create mode 100644 examples/openui-tui-chat/src/cli.tsx create mode 100644 examples/openui-tui-chat/src/genui/chart.ts create mode 100644 examples/openui-tui-chat/src/genui/components.tsx create mode 100644 examples/openui-tui-chat/src/genui/context.tsx create mode 100644 examples/openui-tui-chat/src/genui/library.ts create mode 100644 examples/openui-tui-chat/src/genui/state.tsx create mode 100644 examples/openui-tui-chat/src/llm.ts create mode 100644 examples/openui-tui-chat/tsconfig.json diff --git a/examples/openui-tui-chat/README.md b/examples/openui-tui-chat/README.md new file mode 100644 index 000000000..f2b422bfa --- /dev/null +++ b/examples/openui-tui-chat/README.md @@ -0,0 +1,65 @@ +# OpenUI TUI Chat (Ink) + +A proof-of-concept **terminal** chat client that renders streamed **OpenUI Lang** +as an interactive TUI, built with [Ink](https://github.com/vadimdemedes/ink) +(React for the terminal). + +It demonstrates that OpenUI Lang is renderer-agnostic: the same language, +prompt, parser, and headless chat runtime that power the browser SDK also drive +a terminal UI — you just swap the view layer. + +``` +you ──▶ prompt + ──▶ OpenAI-compatible stream (react-headless) + ──▶ createStreamingParser().set(text) (lang-core, incremental) + ──▶ evaluateElementProps (lang-core runtime/store) + ──▶ Ink components (Card→box, BarChart→ASCII, Table→grid, Form→inputs) +``` + +## What it reuses + +- **`@openuidev/lang-core`** — `createStreamingParser`, `evaluateElementProps`, and the runtime store. No React/DOM. +- **`@openuidev/react-headless`** — `ChatProvider` chat state + `openAIReadableStreamAdapter` streaming. DOM-free (Ink is React). +- **New here** — an Ink component library (`src/genui/`) that maps `typeName → Ink component`, and a small tree walker (`RenderValue`) that mirrors react-lang's renderer. + +## Run + +Requires Node 20+ and an OpenAI-compatible key. + +```sh +export OPENAI_API_KEY=sk-... +# optional: export OPENAI_BASE_URL=... OPENAI_MODEL=... +pnpm --filter openui-tui-chat dev +``` + +Then type a prompt, e.g. _"Compare the 4 largest countries by population as a bar chart"_ +or _"Build a contact form with name, email and a topic dropdown"_. + +### Controls + +- Type + **Enter** — send a message. +- **Tab / Shift+Tab** — move focus between the composer and interactive UI (follow-ups, buttons, form fields). +- **Enter** — activate the focused follow-up/button, or (in a Select) choose the highlighted option. +- **↑ / ↓** — move within a focused Select. +- **Ctrl+C** — quit. + +## Supported components (v1) + +`Card`, `CardHeader`, `TextContent`, `Table`/`Col`, `BarChart`/`Series`, +`FollowUpBlock`/`FollowUpItem`, `Form`/`FormControl`/`Input`/`Select`/`Buttons`/`Button`. + +Follow-ups, buttons and form submits drive the assistant loop via the OpenUI +`@ToAssistant` action. + +## Test + +```sh +pnpm --filter openui-tui-chat test # vitest + ink-testing-library +pnpm --filter openui-tui-chat typecheck +``` + +## Limitations (POC) + +- Read-oriented charts/tables render as ASCII; not pixel-faithful. +- Interactivity targets the latest assistant message; prior turns show as compact prompt lines. +- Queries/`$state` two-way binding beyond simple form fields are out of scope for v1. diff --git a/examples/openui-tui-chat/package.json b/examples/openui-tui-chat/package.json new file mode 100644 index 000000000..9f690f4cc --- /dev/null +++ b/examples/openui-tui-chat/package.json @@ -0,0 +1,33 @@ +{ + "name": "openui-tui-chat", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "A terminal (Ink/TUI) chat client that renders streamed OpenUI Lang as an interactive terminal UI.", + "bin": { + "openui-tui-chat": "src/cli.tsx" + }, + "scripts": { + "dev": "tsx src/cli.tsx", + "start": "tsx src/cli.tsx", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openuidev/lang-core": "workspace:*", + "@openuidev/react-headless": "workspace:*", + "ink": "^5.1.0", + "openai": "^6.22.0", + "react": "^18.3.1", + "zod": "^4.3.6", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "ink-testing-library": "^4.0.0", + "tsx": "^4.19.2", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/examples/openui-tui-chat/src/__tests__/genui.test.tsx b/examples/openui-tui-chat/src/__tests__/genui.test.tsx new file mode 100644 index 000000000..b9378fb6f --- /dev/null +++ b/examples/openui-tui-chat/src/__tests__/genui.test.tsx @@ -0,0 +1,132 @@ +import { + createStore, + createStreamingParser, + evaluateElementProps, +} from "@openuidev/lang-core"; +import { render } from "ink-testing-library"; +import { createElement, type ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import { RenderValue } from "../genui/components.js"; +import { TuiProvider, type TuiContextValue } from "../genui/context.js"; +import { tuiLibrary } from "../genui/library.js"; +import { useGenUi } from "../genui/state.js"; + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Parse + evaluate an OpenUI Lang program with the TUI library. */ +function evalProgram(src: string) { + const sp = createStreamingParser(tuiLibrary.toJSONSchema(), tuiLibrary.root); + const pr = sp.set(src); + const store = createStore(); + store.initialize(pr.stateDeclarations ?? {}, {}); + const root = pr.root + ? evaluateElementProps(pr.root, { + ctx: { getState: (n) => store.get(n), resolveRef: () => undefined }, + library: tuiLibrary, + store, + errors: [], + }) + : null; + return root; +} + +const noopCtx: TuiContextValue = { + library: tuiLibrary, + triggerAction: () => {}, + getFieldValue: () => undefined, + setFieldValue: () => {}, +}; + +/** Drives the real state hook (parse → evaluate → action loop), no LLM/react-headless. */ +function Harness({ src, onSend }: { src: string; onSend: (c: string) => void }): ReactNode { + const { result, ctx } = useGenUi(tuiLibrary, "m1", src, false, onSend); + return createElement( + TuiProvider, + { value: ctx }, + result?.root ? createElement(RenderValue, { value: result.root }) : null, + ); +} + +describe("TUI renderer", () => { + it("renders header, bar chart and table from streamed OpenUI Lang", () => { + const src = [ + "root = Card([h, chart, tbl])", + 'h = CardHeader("Setup Status", "All green")', + 'chart = BarChart(["lang-core", "react-headless"], [s1], "Package", "Tests")', + 's1 = Series("Tests", [68, 70])', + "tbl = Table([c1, c2])", + 'c1 = Col("Step", ["install", "build"])', + 'c2 = Col("Result", ["ok", "ok"])', + ].join("\n"); + + const root = evalProgram(src); + const { lastFrame } = render( + createElement(TuiProvider, { value: noopCtx }, createElement(RenderValue, { value: root })), + ); + + const frame = lastFrame() ?? ""; + expect(frame).toContain("Setup Status"); + expect(frame).toContain("All green"); + expect(frame).toContain("█"); // chart bars + expect(frame).toContain("Step"); // table header + expect(frame).toContain("install"); // table cell + expect(frame).toContain("react-headless"); + }); + + it("renders unknown components as a visible marker instead of crashing", () => { + const root = evalProgram('root = Card([x])\nx = TextContent("hello world")'); + const { lastFrame } = render( + createElement(TuiProvider, { value: noopCtx }, createElement(RenderValue, { value: root })), + ); + expect(lastFrame() ?? "").toContain("hello world"); + }); +}); + +describe("TUI interactivity", () => { + it("sends a follow-up's text to the assistant on Enter", async () => { + const sent: string[] = []; + const src = [ + "root = Card([fu])", + "fu = FollowUpBlock([f1])", + 'f1 = FollowUpItem("Show this as a table")', + ].join("\n"); + + const { stdin } = render(createElement(Harness, { src, onSend: (c) => sent.push(c) })); + await delay(30); + stdin.write("\t"); // focus the follow-up + await delay(30); + stdin.write("\r"); // activate it + await delay(30); + + expect(sent).toContain("Show this as a table"); + }); + + it("collects form field values and submits them via the button's action", async () => { + const sent: string[] = []; + const src = [ + "root = Card([form])", + "form = Form(\"contact\", btns, [nameField])", + 'nameField = FormControl("Name", nameInput)', + 'nameInput = Input("name", "Your name")', + "btns = Buttons([submit])", + 'submit = Button("Send", Action([@ToAssistant("Contact submitted")]))', + ].join("\n"); + + const { stdin } = render(createElement(Harness, { src, onSend: (c) => sent.push(c) })); + await delay(30); + stdin.write("\t"); // focus the name input + await delay(20); + for (const ch of "Ada") { + stdin.write(ch); + await delay(5); + } + stdin.write("\t"); // focus the submit button + await delay(20); + stdin.write("\r"); // submit + await delay(30); + + expect(sent.length).toBe(1); + expect(sent[0]).toContain("Contact submitted"); + expect(sent[0]).toContain('"name":"Ada"'); + }); +}); diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx new file mode 100644 index 000000000..7be41805a --- /dev/null +++ b/examples/openui-tui-chat/src/app.tsx @@ -0,0 +1,104 @@ +import { useThread } from "@openuidev/react-headless"; +import { Box, Text, useFocus, useInput } from "ink"; +import { useState } from "react"; +import { RenderValue } from "./genui/components.js"; +import { TuiProvider } from "./genui/context.js"; +import { tuiLibrary } from "./genui/library.js"; +import { useGenUi } from "./genui/state.js"; + +function messageText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((p) => (p && typeof p === "object" && "text" in p ? String((p as { text: unknown }).text) : "")) + .join(""); + } + return ""; +} + +export function App() { + const messages = useThread((s) => s.messages); + const isRunning = useThread((s) => s.isRunning); + const processMessage = useThread((s) => s.processMessage); + const [draft, setDraft] = useState(""); + + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); + const response = lastAssistant ? messageText(lastAssistant.content) : null; + + const onSend = (content: string) => { + if (!isRunning) processMessage({ role: "user", content }); + }; + + const { result, ctx } = useGenUi( + tuiLibrary, + lastAssistant?.id ?? null, + response, + isRunning, + onSend, + ); + + const { isFocused: composerFocused } = useFocus({ id: "composer", autoFocus: true }); + useInput( + (input, key) => { + if (key.return) { + const text = draft.trim(); + if (text && !isRunning) { + onSend(text); + setDraft(""); + } + return; + } + if (key.backspace || key.delete) { + setDraft((d) => d.slice(0, -1)); + return; + } + if (input && !key.ctrl && !key.meta && !key.tab) setDraft((d) => d + input); + }, + { isActive: composerFocused }, + ); + + const userMessages = messages.filter((m) => m.role === "user"); + + return ( + + + + OpenUI TUI Chat{" "} + · streamed OpenUI Lang, rendered in your terminal + + + {userMessages.map((m) => ( + + {"› "} + {messageText(m.content).split("\n")[0]} + + ))} + + {result?.root ? ( + + + + ) : messages.length === 0 ? ( + + Ask for a chart, a table, or a form. Try: + · "Compare the 4 largest countries by population as a bar chart" + · "Build a contact form with name, email and a topic dropdown" + + ) : null} + + {isRunning ? ( + {"\n"}◐ thinking… + ) : null} + + + {composerFocused ? "❯ " : " "} + {draft || (composerFocused ? "" : "")} + {composerFocused ? "▏" : ""} + {draft.length === 0 && composerFocused ? ( + type a message · Enter to send · Tab to focus UI · Ctrl+C to quit + ) : null} + + + + ); +} diff --git a/examples/openui-tui-chat/src/cli.tsx b/examples/openui-tui-chat/src/cli.tsx new file mode 100644 index 000000000..6e87ef4b7 --- /dev/null +++ b/examples/openui-tui-chat/src/cli.tsx @@ -0,0 +1,35 @@ +// Polyfill requestAnimationFrame for react-headless's streaming updates (it +// debounces message updates via rAF, which bare Node does not provide). +const g = globalThis as unknown as { + requestAnimationFrame?: (cb: (t: number) => void) => unknown; + cancelAnimationFrame?: (id: unknown) => void; +}; +if (typeof g.requestAnimationFrame !== "function") { + g.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); + g.cancelAnimationFrame = (id) => clearTimeout(id as ReturnType); +} + +import { ChatProvider, openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless"; +import { render } from "ink"; +import { createElement } from "react"; +import { App } from "./app.js"; +import { tuiLibrary } from "./genui/library.js"; +import { makeProcessMessage } from "./llm.js"; + +if (!process.env.OPENAI_API_KEY) { + console.error( + "\nOPENAI_API_KEY is not set. Export it (optionally OPENAI_BASE_URL / OPENAI_MODEL) and retry.\n", + ); + process.exit(1); +} + +const systemPrompt = tuiLibrary.prompt(); + +render( + createElement(ChatProvider, { + processMessage: makeProcessMessage(systemPrompt), + streamProtocol: openAIReadableStreamAdapter(), + messageFormat: openAIMessageFormat, + children: createElement(App), + }), +); diff --git a/examples/openui-tui-chat/src/genui/chart.ts b/examples/openui-tui-chat/src/genui/chart.ts new file mode 100644 index 000000000..38276e4d9 --- /dev/null +++ b/examples/openui-tui-chat/src/genui/chart.ts @@ -0,0 +1,44 @@ +/** Element-node shape after evaluation (only the bits the chart needs). */ +interface SeriesNode { + props?: { category?: unknown; values?: unknown }; +} + +function padEnd(s: string, w: number): string { + return s.length >= w ? s : s + " ".repeat(w - s.length); +} + +/** + * Render a horizontal ASCII bar chart. Single-series charts show one bar per + * label; multi-series charts show one bar per (label, series) pair. + */ +export function renderBars( + labels: string[], + seriesNodes: SeriesNode[], + width = 32, +): string[] { + const series = seriesNodes.map((s) => ({ + category: String(s?.props?.category ?? ""), + values: Array.isArray(s?.props?.values) ? (s!.props!.values as unknown[]).map(Number) : [], + })); + + const rows: { name: string; value: number }[] = []; + labels.forEach((label, i) => { + series.forEach((s) => { + rows.push({ + name: series.length > 1 ? `${label} · ${s.category}` : label, + value: Number.isFinite(s.values[i]) ? s.values[i]! : 0, + }); + }); + }); + + if (rows.length === 0) return ["(no data)"]; + + const max = Math.max(1, ...rows.map((r) => Math.abs(r.value))); + const nameW = Math.max(0, ...rows.map((r) => r.name.length)); + + return rows.map((r) => { + const barLen = Math.max(0, Math.round((Math.abs(r.value) / max) * width)); + const bar = "█".repeat(barLen) || "▏"; + return `${padEnd(r.name, nameW)} │ ${bar} ${r.value}`; + }); +} diff --git a/examples/openui-tui-chat/src/genui/components.tsx b/examples/openui-tui-chat/src/genui/components.tsx new file mode 100644 index 000000000..e0516c210 --- /dev/null +++ b/examples/openui-tui-chat/src/genui/components.tsx @@ -0,0 +1,334 @@ +import { Box, Text, useFocus, useInput } from "ink"; +import { Component, Fragment, type ReactNode, useState } from "react"; +import { renderBars } from "./chart.js"; +import { FormNameProvider, useFormName, useTui } from "./context.js"; + +/** The render contract each library component receives (matches lang-core's ComponentRenderProps). */ +interface ViewProps { + props: Record; + renderNode: (value: unknown) => ReactNode; + statementId?: string; +} + +/** Common signature for every library view (keeps the library's component type uniform). */ +export type View = (p: ViewProps) => ReactNode; + +const str = (v: unknown): string => (v == null ? "" : String(v)); + +// ─────────────────────────── tree walker ─────────────────────────── + +/** Walk any evaluated value into Ink nodes (mirrors react-lang's renderDeep). */ +export function RenderValue({ value }: { value: unknown }): ReactNode { + if (value == null || value === false) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return {String(value)}; + } + if (Array.isArray(value)) { + return ( + <> + {value.map((v, i) => ( + + + + ))} + + ); + } + if (typeof value === "object" && (value as { type?: string }).type === "element") { + return ; + } + return null; +} + +interface ElementLike { + type: "element"; + typeName: string; + props: Record; + statementId?: string; +} + +function RenderNode({ node }: { node: ElementLike }): ReactNode { + const { library } = useTui(); + const Comp = library.components[node.typeName]?.component as + | ((p: ViewProps) => ReactNode) + | undefined; + + if (!Comp) return [unknown component: {node.typeName}]; + + return ( + + } + statementId={node.statementId} + /> + + ); +} + +class ElementErrorBoundary extends Component< + { name: string; children: ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + render() { + if (this.state.failed) { + return [render error in {this.props.name}]; + } + return this.props.children; + } +} + +// ─────────────────────────── content components ─────────────────────────── + +function CardView({ props, renderNode }: ViewProps) { + const children = Array.isArray(props.children) ? props.children : []; + return ( + + {children.map((c, i) => ( + + {renderNode(c)} + + ))} + + ); +} + +function CardHeaderView({ props }: ViewProps) { + return ( + + {props.title ? {str(props.title)} : null} + {props.subtitle ? {str(props.subtitle)} : null} + + ); +} + +function TextContentView({ props }: ViewProps) { + const size = str(props.size); + const heavy = size.includes("heavy") || size === "large"; + return {str(props.text)}; +} + +function TableView({ props }: ViewProps) { + const columns = Array.isArray(props.columns) ? props.columns : []; + const headers = columns.map((c) => str((c as ElementLike)?.props?.label)); + const data = columns.map((c) => { + const d = (c as ElementLike)?.props?.data; + return Array.isArray(d) ? d.map((x) => str(x)) : []; + }); + const rowCount = Math.max(0, ...data.map((d) => d.length)); + const widths = headers.map((h, ci) => + Math.max(h.length, ...(data[ci]!.length ? data[ci]!.map((s) => s.length) : [0])), + ); + const pad = (s: string, w: number) => (s.length >= w ? s : s + " ".repeat(w - s.length)); + const line = (cells: string[]) => cells.map((c, ci) => pad(c, widths[ci]!)).join(" "); + + return ( + + {line(headers)} + {widths.map((w) => "─".repeat(w)).join(" ")} + {Array.from({ length: rowCount }).map((_, ri) => ( + {line(headers.map((_, ci) => data[ci]![ri] ?? ""))} + ))} + + ); +} + +function BarChartView({ props }: ViewProps) { + const labels = Array.isArray(props.labels) ? props.labels.map(str) : []; + const series = Array.isArray(props.series) ? (props.series as { props?: Record }[]) : []; + const lines = renderBars(labels, series); + return ( + + {props.yLabel ? {str(props.yLabel)} : null} + {lines.map((ln, i) => ( + + {ln} + + ))} + {props.xLabel ? {str(props.xLabel)} : null} + + ); +} + +// ─────────────────────────── interactive components ─────────────────────────── + +function FollowUpBlockView({ props, renderNode }: ViewProps) { + const items = Array.isArray(props.items) ? props.items : []; + return ( + + Related (Tab to focus, Enter to ask): + {items.map((it, i) => ( + {renderNode(it)} + ))} + + ); +} + +function FollowUpItemView({ props }: ViewProps) { + const { isFocused } = useFocus(); + const { triggerAction } = useTui(); + const text = str(props.text); + useInput( + (_input, key) => { + if (key.return) triggerAction(text); + }, + { isActive: isFocused }, + ); + return ( + + {isFocused ? "❯ " : "• "} + {text} + + ); +} + +function ButtonsView({ props, renderNode }: ViewProps) { + const buttons = Array.isArray(props.buttons) ? props.buttons : []; + const direction = props.direction === "column" ? "column" : "row"; + return ( + + {buttons.map((b, i) => ( + {renderNode(b)} + ))} + + ); +} + +function ButtonView({ props }: ViewProps) { + const { isFocused } = useFocus(); + const formName = useFormName(); + const { triggerAction } = useTui(); + const label = str(props.label) || "Button"; + useInput( + (_input, key) => { + if (key.return) triggerAction(label, formName, props.action); + }, + { isActive: isFocused }, + ); + return ( + + {` ${label} `} + + ); +} + +function FormView({ props, renderNode }: ViewProps) { + const name = str(props.name) || "form"; + const fields = Array.isArray(props.fields) ? props.fields : []; + return ( + + + {fields.map((f, i) => ( + {renderNode(f)} + ))} + {renderNode(props.buttons)} + + + ); +} + +function FormControlView({ props, renderNode }: ViewProps) { + return ( + + {str(props.label)} + {props.hint ? {str(props.hint)} : null} + {renderNode(props.input)} + + ); +} + +function InputView({ props }: ViewProps) { + const { isFocused } = useFocus(); + const formName = useFormName(); + const { getFieldValue, setFieldValue } = useTui(); + const name = str(props.name); + const value = str(getFieldValue(formName, name)); + useInput( + (input, key) => { + if (key.backspace || key.delete) { + setFieldValue(formName, "Input", name, value.slice(0, -1)); + return; + } + if (key.return || key.tab || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) + return; + if (input && !key.ctrl && !key.meta) setFieldValue(formName, "Input", name, value + input); + }, + { isActive: isFocused }, + ); + const shown = value.length ? value : str(props.placeholder); + return ( + + {isFocused ? "❯ " : " "} + {shown} + {isFocused ? "▏" : ""} + + ); +} + +function SelectView({ props }: ViewProps) { + const { isFocused } = useFocus(); + const formName = useFormName(); + const { getFieldValue, setFieldValue } = useTui(); + const name = str(props.name); + const items = Array.isArray(props.items) ? props.items : []; + const options = items.map((it) => ({ + value: str((it as ElementLike)?.props?.value), + label: str((it as ElementLike)?.props?.label ?? (it as ElementLike)?.props?.value), + })); + const current = str(getFieldValue(formName, name)); + const [cursor, setCursor] = useState(0); + useInput( + (_input, key) => { + if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); + else if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); + else if (key.return) { + const o = options[cursor]; + if (o) setFieldValue(formName, "Select", name, o.value); + } + }, + { isActive: isFocused }, + ); + return ( + + {options.map((o, i) => { + const isSel = o.value === current; + const isCursor = isFocused && i === cursor; + return ( + + {isCursor ? "❯ " : " "} + {isSel ? "(•) " : "( ) "} + {o.label} + + ); + })} + + ); +} + +/** No-op view for purely structural nodes (parent reads their props directly). */ +function StructuralView(_props: ViewProps): ReactNode { + return null; +} + +export const views: Record = { + Card: CardView, + CardHeader: CardHeaderView, + TextContent: TextContentView, + Table: TableView, + Col: StructuralView, + BarChart: BarChartView, + Series: StructuralView, + FollowUpBlock: FollowUpBlockView, + FollowUpItem: FollowUpItemView, + Form: FormView, + FormControl: FormControlView, + Input: InputView, + Select: SelectView, + SelectItem: StructuralView, + Buttons: ButtonsView, + Button: ButtonView, +}; diff --git a/examples/openui-tui-chat/src/genui/context.tsx b/examples/openui-tui-chat/src/genui/context.tsx new file mode 100644 index 000000000..547ea9ffd --- /dev/null +++ b/examples/openui-tui-chat/src/genui/context.tsx @@ -0,0 +1,44 @@ +import type { Library } from "@openuidev/lang-core"; +import { createContext, useContext } from "react"; + +/** + * Everything the Ink component library needs at render time. Mirrors the role + * of react-lang's OpenUIContext, but scoped to what a terminal renderer uses. + */ +export interface TuiContextValue { + library: Library; + /** Fire an action (button / follow-up / form submit) → sends a message to the assistant. */ + triggerAction: ( + userMessage: string, + formName?: string, + action?: unknown, + ) => void; + /** Read a form field value (unwrapped) from the runtime store. */ + getFieldValue: (formName: string | undefined, name: string) => unknown; + /** Write a form field value into the runtime store. */ + setFieldValue: ( + formName: string | undefined, + componentType: string, + name: string, + value: unknown, + ) => void; +} + +const TuiContext = createContext(null); + +export const TuiProvider = TuiContext.Provider; + +export function useTui(): TuiContextValue { + const ctx = useContext(TuiContext); + if (!ctx) throw new Error("useTui must be used within a TuiProvider"); + return ctx; +} + +/** The name of the enclosing Form, so inputs know where to store their value. */ +const FormNameContext = createContext(undefined); + +export const FormNameProvider = FormNameContext.Provider; + +export function useFormName(): string | undefined { + return useContext(FormNameContext); +} diff --git a/examples/openui-tui-chat/src/genui/library.ts b/examples/openui-tui-chat/src/genui/library.ts new file mode 100644 index 000000000..0493a95bb --- /dev/null +++ b/examples/openui-tui-chat/src/genui/library.ts @@ -0,0 +1,175 @@ +import { createLibrary, defineComponent } from "@openuidev/lang-core"; +import { z } from "zod"; +import { views } from "./components.js"; + +// Leaf / structural components first so containers can reference `.ref`. + +const Series = defineComponent({ + name: "Series", + description: "One data series for a chart.", + props: z.object({ category: z.string(), values: z.array(z.number()) }), + component: views.Series, +}); + +const Col = defineComponent({ + name: "Col", + description: "A table column: a label plus its column data (one entry per row).", + props: z.object({ + label: z.string(), + data: z.array(z.union([z.string(), z.number()])), + type: z.enum(["string", "number"]).optional(), + }), + component: views.Col, +}); + +const SelectItem = defineComponent({ + name: "SelectItem", + description: "An option inside a Select.", + props: z.object({ value: z.string(), label: z.string() }), + component: views.SelectItem, +}); + +const FollowUpItem = defineComponent({ + name: "FollowUpItem", + description: "A clickable follow-up suggestion. Its text is sent to the assistant when chosen.", + props: z.object({ text: z.string() }), + component: views.FollowUpItem, +}); + +const Button = defineComponent({ + name: "Button", + description: + "A clickable button. Provide an action Action([@ToAssistant(\"message\")]) to control what is sent to the assistant.", + props: z.object({ + label: z.string(), + action: z.any().optional(), + variant: z.enum(["primary", "secondary", "tertiary"]).optional(), + }), + component: views.Button, +}); + +const Input = defineComponent({ + name: "Input", + description: "A single-line text field inside a Form.", + props: z.object({ + name: z.string(), + placeholder: z.string().optional(), + type: z.enum(["text", "email", "password", "number", "url"]).optional(), + }), + component: views.Input, +}); + +const Select = defineComponent({ + name: "Select", + description: "A single-choice dropdown inside a Form.", + props: z.object({ + name: z.string(), + items: z.array(SelectItem.ref), + placeholder: z.string().optional(), + }), + component: views.Select, +}); + +const FormControl = defineComponent({ + name: "FormControl", + description: "A labelled form field wrapping one Input or Select.", + props: z.object({ + label: z.string(), + input: z.union([Input.ref, Select.ref]), + hint: z.string().optional(), + }), + component: views.FormControl, +}); + +const Buttons = defineComponent({ + name: "Buttons", + description: "A group of Button components.", + props: z.object({ + buttons: z.array(Button.ref), + direction: z.enum(["row", "column"]).optional(), + }), + component: views.Buttons, +}); + +const Form = defineComponent({ + name: "Form", + description: "A form with fields and explicit action buttons. Provide Buttons(...) as the second argument.", + props: z.object({ + name: z.string(), + buttons: Buttons.ref, + fields: z.array(FormControl.ref).default([]), + }), + component: views.Form, +}); + +const BarChart = defineComponent({ + name: "BarChart", + description: "A bar chart. Use for comparing values across categories.", + props: z.object({ + labels: z.array(z.string()), + series: z.array(Series.ref), + xLabel: z.string().optional(), + yLabel: z.string().optional(), + }), + component: views.BarChart, +}); + +const Table = defineComponent({ + name: "Table", + description: "A column-oriented data table. Each Col holds its own data array.", + props: z.object({ columns: z.array(Col.ref) }), + component: views.Table, +}); + +const TextContent = defineComponent({ + name: "TextContent", + description: "A block of text. Optional size controls emphasis.", + props: z.object({ + text: z.string(), + size: z.enum(["small", "default", "large", "small-heavy", "large-heavy"]).optional(), + }), + component: views.TextContent, +}); + +const CardHeader = defineComponent({ + name: "CardHeader", + description: "A header with an optional title and subtitle.", + props: z.object({ title: z.string().optional(), subtitle: z.string().optional() }), + component: views.CardHeader, +}); + +const FollowUpBlock = defineComponent({ + name: "FollowUpBlock", + description: "A list of follow-up suggestions shown at the end of a response.", + props: z.object({ items: z.array(FollowUpItem.ref) }), + component: views.FollowUpBlock, +}); + +const Card = defineComponent({ + name: "Card", + description: "The root container. Children stack vertically. Every response is a single Card.", + props: z.object({ children: z.array(z.any()) }), + component: views.Card, +}); + +export const tuiLibrary = createLibrary({ + components: [ + Card, + CardHeader, + TextContent, + Table, + Col, + BarChart, + Series, + FollowUpBlock, + FollowUpItem, + Form, + FormControl, + Input, + Select, + SelectItem, + Buttons, + Button, + ], + root: "Card", +}); diff --git a/examples/openui-tui-chat/src/genui/state.tsx b/examples/openui-tui-chat/src/genui/state.tsx new file mode 100644 index 000000000..0152615a3 --- /dev/null +++ b/examples/openui-tui-chat/src/genui/state.tsx @@ -0,0 +1,150 @@ +import { + createStore, + createStreamingParser, + evaluateElementProps, + type EvaluationContext, + type Library, + type ParseResult, + type Store, +} from "@openuidev/lang-core"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import type { TuiContextValue } from "./context.js"; + +/** Unwrap the { value, componentType } wrapper the store keeps for form fields. */ +function unwrap(v: unknown): unknown { + if (v && typeof v === "object" && !Array.isArray(v) && "value" in (v as Record)) { + return (v as Record).value; + } + return v; +} + +export interface GenUiState { + /** Evaluated parse result (props resolved to concrete values). */ + result: ParseResult | null; + ctx: TuiContextValue; +} + +/** + * Trimmed, terminal-oriented port of react-lang's useOpenUIState: streaming + * parse → runtime store → evaluate props → action/field helpers. No queries or + * DOM. `messageId` scopes the parser to a single assistant turn. + */ +export function useGenUi( + library: Library, + messageId: string | null, + response: string | null, + isStreaming: boolean, + onSend: (content: string) => void, +): GenUiState { + const onSendRef = useRef(onSend); + onSendRef.current = onSend; + + // Fresh streaming parser per assistant turn (set() resets on replacement anyway). + const sp = useMemo( + () => createStreamingParser(library.toJSONSchema(), library.root), + [library, messageId], + ); + + const parseResult = useMemo(() => { + if (!response) return null; + try { + return sp.set(response); + } catch { + return null; + } + }, [sp, response]); + + const store = useMemo(() => createStore(), [messageId]); + + useEffect(() => { + store.initialize(parseResult?.stateDeclarations ?? {}, {}); + }, [parseResult?.stateDeclarations, store]); + + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + + const evaluationContext = useMemo( + () => ({ + getState: (name: string) => unwrap(store.get(name)), + resolveRef: () => undefined, + }), + [store], + ); + + const result = useMemo(() => { + if (!parseResult?.root) return parseResult; + try { + const root = evaluateElementProps(parseResult.root, { + ctx: evaluationContext, + library, + store, + errors: [], + }); + return { ...parseResult, root }; + } catch { + return parseResult; + } + // snapshot is a dependency so form edits re-evaluate reactive props. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parseResult, evaluationContext, library, store, snapshot]); + + const getFieldValue = useCallback( + (formName, name) => { + if (!formName) return unwrap(store.get(name)); + const formData = store.get(formName); + if (!formData || typeof formData !== "object" || Array.isArray(formData)) return undefined; + return unwrap((formData as Record)[name]); + }, + [store], + ); + + const setFieldValue = useCallback( + (formName, componentType, name, value) => { + const wrapped = { value, componentType }; + if (!formName) { + store.set(name, wrapped); + return; + } + const raw = store.get(formName); + const formData = + raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + store.set(formName, { ...formData, [name]: wrapped }); + }, + [store], + ); + + const triggerAction = useCallback( + (userMessage, formName, action) => { + let message = userMessage; + const steps = + action && typeof action === "object" && Array.isArray((action as { steps?: unknown }).steps) + ? ((action as { steps: { type?: string; message?: string }[] }).steps) + : null; + if (steps) { + const toAssistant = steps.find((s) => s?.type === "continue_conversation" || typeof s?.message === "string"); + if (toAssistant?.message) message = toAssistant.message; + } + + let content = message; + if (formName) { + const raw = store.get(formName); + const values: Record = {}; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const [k, v] of Object.entries(raw as Record)) values[k] = unwrap(v); + } + content = `${message}\n\n[form "${formName}" values: ${JSON.stringify(values)}]`; + } + onSendRef.current(content); + }, + [store], + ); + + const ctx = useMemo( + () => ({ library, triggerAction, getFieldValue, setFieldValue }), + [library, triggerAction, getFieldValue, setFieldValue], + ); + + // isStreaming currently only affects display in the app; kept for parity/future use. + void isStreaming; + + return { result, ctx }; +} diff --git a/examples/openui-tui-chat/src/llm.ts b/examples/openui-tui-chat/src/llm.ts new file mode 100644 index 000000000..31bc62eb8 --- /dev/null +++ b/examples/openui-tui-chat/src/llm.ts @@ -0,0 +1,38 @@ +import type { Message } from "@openuidev/react-headless"; +import { openAIMessageFormat } from "@openuidev/react-headless"; +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; + +/** + * Build a react-headless `processMessage` that streams from an OpenAI-compatible + * endpoint. Returns a Response wrapping the SDK's NDJSON ReadableStream, which + * pairs with `openAIReadableStreamAdapter`. + */ +export function makeProcessMessage(systemPrompt: string) { + const client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_BASE_URL || undefined, + }); + const model = process.env.OPENAI_MODEL || "gpt-5.5"; + + return async ({ + messages, + abortController, + }: { + threadId: string; + messages: Message[]; + abortController: AbortController; + }): Promise => { + const apiMessages: ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...(openAIMessageFormat.toApi(messages) as ChatCompletionMessageParam[]), + ]; + + const stream = await client.chat.completions.create( + { model, messages: apiMessages, stream: true }, + { signal: abortController.signal }, + ); + + return new Response(stream.toReadableStream() as unknown as ReadableStream); + }; +} diff --git a/examples/openui-tui-chat/tsconfig.json b/examples/openui-tui-chat/tsconfig.json new file mode 100644 index 000000000..8bb361f5a --- /dev/null +++ b/examples/openui-tui-chat/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 759229d5a..1a2885d4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,7 +320,7 @@ importers: version: 0.0.45 '@ag-ui/mastra': specifier: ^1.0.1 - version: 1.0.1(mo2d2k6urt4bfbrhfsfscm4ram) + version: 1.0.1(dd1f7da5383fb7dbf1cafaa81aeabe83) '@mastra/core': specifier: 1.15.0 version: 1.15.0(@cfworker/json-schema@4.1.1)(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.3.6) @@ -703,6 +703,49 @@ importers: specifier: ~5.9.2 version: 5.9.3 + examples/openui-tui-chat: + dependencies: + '@openuidev/lang-core': + specifier: workspace:* + version: link:../../packages/lang-core + '@openuidev/react-headless': + specifier: workspace:* + version: link:../../packages/react-headless + ink: + specifier: ^5.1.0 + version: 5.2.1(@types/react@19.2.14)(react@18.3.1) + openai: + specifier: ^6.22.0 + version: 6.34.0(ws@8.20.0)(zod@4.3.6) + react: + specifier: ^18.3.1 + version: 18.3.1 + zod: + specifier: ^4.3.6 + version: 4.3.6 + zustand: + specifier: ^4.5.5 + version: 4.5.7(@types/react@19.2.14)(react@18.3.1) + devDependencies: + '@types/node': + specifier: ^20 + version: 20.19.35 + '@types/react': + specifier: ^19 + version: 19.2.14 + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@19.2.14) + tsx: + specifier: ^4.19.2 + version: 4.20.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) + examples/react-email: dependencies: '@openuidev/cli': @@ -1752,6 +1795,10 @@ packages: peerDependencies: vue: ^3.3.4 + '@alcalzone/ansi-tokenize@0.1.3': + resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==} + engines: {node: '>=14.13.1'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -2420,10 +2467,12 @@ packages: '@copilotkitnext/agent@0.0.0-mme-ag-ui-0-0-46-20260227141603': resolution: {integrity: sha512-HAaAVKWD+WS1/GTxY6xLMj65Ro9evnOM5UC0DueTFwlmgCkuHPVE4rDveiGVaNc0x4X75tofi5Ul9g6Tb9FT/w==} engines: {node: '>=18'} + deprecated: Moved into @copilotkit/runtime. Import from '@copilotkit/runtime/v2'. '@copilotkitnext/runtime@0.0.0-mme-ag-ui-0-0-46-20260227141603': resolution: {integrity: sha512-c6vosi7xzKvyujmmwb4rNvAnlr656ybCrPeeX3kO1V70zrnUVkS4EXlSz1T0fGqqNgf5Tfqmssy3xqyLLZwgbQ==} engines: {node: '>=18'} + deprecated: Moved into @copilotkit/runtime. Import from '@copilotkit/runtime/v2'. peerDependencies: '@ag-ui/client': 0.0.46 '@ag-ui/core': 0.0.46 @@ -2433,6 +2482,7 @@ packages: '@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603': resolution: {integrity: sha512-tbw37m+MgOO58dxYsXvGTN9YqHt6DPLMqtDEQftJHrUrQkNqXOxhOporx4p2DG0R+RiQqWrT+r44D2eRCQhlkA==} engines: {node: '>=18'} + deprecated: Use @copilotkit/shared instead. '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} @@ -3583,89 +3633,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -4078,72 +4144,84 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-gnu@16.1.6': resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-gnu@16.2.3': resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.12': resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-arm64-musl@16.1.6': resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-arm64-musl@16.2.3': resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.12': resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-gnu@16.1.6': resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-gnu@16.2.3': resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.12': resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-linux-x64-musl@16.1.6': resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-linux-x64-musl@16.2.3': resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.12': resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} @@ -4580,48 +4658,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-C3zapJconWpl2Y7LR3GkRkH6jxpuV2iVUfkFcHT5Ffn4Zu7l88mZa2dhcfdULZDybN1Phka/P34YUzuskUUrXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-minify/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-2T/Bm+3/qTfuNS4gKSzL8qbiYk+ErHW2122CtDx+ilZAzvWcJ8IbqdZIbEWOlwwe03lESTxPwTBLFqVgQU2OeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-MKLjpldYkeoB4T+yAi4aIAb0waifxUjLcKkCUDmYAY3RqBJTvWK34KtfaKZL0IBMIXfD92CbKkcxQirDUS9Xcg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-UFVcbPvKUStry6JffriobBp8BHtjmLLPl4bCY+JMxIn/Q3pykCpZzRwFTcDurG/kY8tm+uSNfKKdRNa5Nh9A7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-minify/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-B9GyPQ1NKbvpETVAMyJMfRlD3c6UJ7kiuFUAlx9LTYiQL+YIyT6vpuRlq1zgsXxavZluVrfeJv6x0owV4KDx4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-fXfhtr+WWBGNy4M5GjAF5vu/lpulR4Me34FjTyaK9nDrTZs7LM595UDsP1wliksqp4hD/KdoqHGmbCrC+6d4vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-jFBgGbx1oLadb83ntJmy1dWlAHSQanXTS21G4PgkxyONmxZdZ/UMKr7KsADzMuoPsd2YhJHxzRpwJd9U+4BFBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-minify/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-nxPd9vx1vYz8IlIMdl9HFdOK/ood1H5hzbSFsyO8JU55tkcJoBL8TLCbuFf9pHpOy27l2gcPyV6z3p4eAcTH5Q==} @@ -4699,48 +4785,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-QagKTDF4lrz8bCXbUi39Uq5xs7C7itAseKm51f33U+Dyar9eJY/zGKqfME9mKLOiahX7Fc1J3xMWVS0AdDXLPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-RPddpcE/0xxWaommWy0c5i/JdrXcXAkxBS2GOrAUh5LKmyCh03hpJedOAWszG4ADsKQwoUQQ1/tZVGRhZIWtKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-ur/WVZF9FSOiZGxyP+nfxZzuv6r5OJDYoVxJnUR7fM/hhXLh4V/be6rjbzm9KLCDBRwYCEKJtt+XXNccwd06IA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-ujGcAx8xAMvhy7X5sBFi3GXML1EtyORuJZ5z2T6UV3U416WgDX/4OCi3GnoteeenvxIf6JgP45B+YTHpt71vpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-hbsfKjUwRjcMZZvvmpZSc+qS0bHcHRu8aV/I3Ikn9BzOA0ZAgUE7ctPtce5zCU7bM8dnTLi4sJ1Pi9YHdx6Urw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-1QrTrf8rige7UPJrYuDKJLQOuJlgkt+nRSJLBMHWNm9TdivzP48HaK3f4q18EjNlglKtn03lgjMu4fryDm8X4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-gRvK6HPzF5ITRL68fqb2WYYs/hGviPIbkV84HWCgiJX+LkaOpp+HIHQl3zVZdyKHwopXToTbXbtx/oFjDjl8pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-QPJvFbnnDZZY7xc+xpbIBWLThcGBakwaYA9vKV8b3+oS5MGfAZUoTFJcix5+Zg2Ri46sOfrUim6Y6jsKNcssAQ==} @@ -4824,48 +4918,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-ykxpPQp0eAcSmhy0Y3qKvdanHY4d8THPonDfmCoktUXb6r0X6qnjpJB3V+taN1wevW55bOEZd97kxtjTKjqhmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-Rvspti4Kr7eq6zSrURK5WjscfWQPvmy/KjJZV45neRKW8RLonE3r9+NgrwSLGoHvQ3F24fbqlkplox1RtlhH5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-Dr2ZW9ZZ4l1eQ5JUEUY3smBh4JFPCPuybWaDZTLn3ADZjyd8ZtNXEjeMT8rQbbhbgSL9hEgbwaqraole3FNThQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-oD1Bnes1bIC3LVBSrWEoSUBj6fvatESPwAVWfJVGVQlqWuOs/ZBn1e4Nmbipo3KGPHK7DJY75r/j7CQCxhrOFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-qT//IAPLvse844t99Kff5j055qEbXfwzWgvCMb0FyjisnB8foy25iHZxZIocNBe6qwrCYWUP1M8rNrB/WyfS1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-2YEO5X+KgNzFqRVO5dAkhjcI5gwxus4NSWVl/+cs2sI6P0MNPjqE3VWPawl4RTC11LvetiiZdHcujUCPM8aaUw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-3wqWbTSaIFZvDr1aqmTul4cg8PRWYh6VC52E8bLI7ytgS/BwJLW+sDUU2YaGIds4sAf/1yKeJRmudRCDPW9INg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-transform/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-Ebxx6NPqhzlrjvx4+PdSqbOq+li0f7X59XtJljDghkbJsbnkHvhLmPR09ifHt5X32UlZN63ekjwcg/nbmHLLlA==} @@ -4925,36 +5027,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-wasm@2.5.6': resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} @@ -6883,6 +6991,7 @@ packages: '@rolldown/binding-darwin-arm64@1.0.0-rc.16': resolution: {integrity: sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==} engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] os: [darwin] '@rolldown/binding-darwin-x64@1.0.0-rc.12': @@ -6908,36 +7017,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} @@ -7107,121 +7222,145 @@ packages: resolution: {integrity: sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.60.1': resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.43.0': resolution: {integrity: sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.43.0': resolution: {integrity: sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.43.0': resolution: {integrity: sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.43.0': resolution: {integrity: sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.43.0': resolution: {integrity: sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.43.0': resolution: {integrity: sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.43.0': resolution: {integrity: sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.43.0': resolution: {integrity: sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.43.0': resolution: {integrity: sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.43.0': resolution: {integrity: sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -7752,48 +7891,56 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -7876,24 +8023,28 @@ packages: engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@takumi-rs/core-linux-arm64-musl@0.68.17': resolution: {integrity: sha512-4CiEF518wDnujF0fjql2XN6uO+OXl0svy0WgAF2656dCx2gJtWscaHytT2rsQ0ZmoFWE0dyWcDW1g/FBVPvuvA==} engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@takumi-rs/core-linux-x64-gnu@0.68.17': resolution: {integrity: sha512-jm8lTe2E6Tfq2b97GJC31TWK1JAEv+MsVbvL9DCLlYcafgYFlMXDUnOkZFMjlrmh0HcFAYDaBkniNDgIQfXqzg==} engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@takumi-rs/core-linux-x64-musl@0.68.17': resolution: {integrity: sha512-nbdzQgC4ywzltDDV1fer1cKswwGE+xXZHdDiacdd7RM5XBng209Bmo3j1iv9dsX+4xXhByzCCGbxdWhhHqVXmw==} engines: {node: '>= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@takumi-rs/core-win32-arm64-msvc@0.68.17': resolution: {integrity: sha512-kE4F0LRmuhSwiNkFG7dTY9ID8+B7zb97QedyN/IO2fBJmRQDkqCGcip2gloh8YPPhCuKGjCqqqh2L+Tg9PKW7w==} @@ -8369,41 +8520,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -8989,6 +9148,10 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + autoprefixer@10.4.27: resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} @@ -9437,10 +9600,18 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@2.1.0: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-highlight@2.1.11: resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} engines: {node: '>=8.0.0', npm: '>=5.0.0'} @@ -9454,6 +9625,10 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -9488,6 +9663,10 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -9618,6 +9797,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} @@ -10306,6 +10489,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -11479,6 +11665,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -11493,6 +11683,28 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink@5.2.1: + resolution: {integrity: sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + react-devtools-core: ^4.19.1 + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} @@ -11624,6 +11836,14 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-generator-function@1.1.0: resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} engines: {node: '>= 0.4'} @@ -11638,6 +11858,11 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@1.0.0: + resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==} + engines: {node: '>=18'} + hasBin: true + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -12098,48 +12323,56 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -12724,6 +12957,10 @@ packages: resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} engines: {node: '>=4'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -13132,6 +13369,10 @@ packages: resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} engines: {node: '>=4'} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -13316,6 +13557,10 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -13983,6 +14228,12 @@ packages: react-promise-suspense@0.3.4: resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} + react-reconciler@0.29.2: + resolution: {integrity: sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^18.3.1 + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -14053,6 +14304,10 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -14283,6 +14538,10 @@ packages: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -14417,6 +14676,9 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -14577,6 +14839,14 @@ packages: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + slugify@1.6.8: resolution: {integrity: sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==} engines: {node: '>=8.0.0'} @@ -15175,6 +15445,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-fest@5.5.0: resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} @@ -15991,6 +16265,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + wonka@6.3.5: resolution: {integrity: sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==} @@ -16155,6 +16433,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -16294,12 +16575,12 @@ snapshots: - react - react-dom - '@ag-ui/mastra@1.0.1(mo2d2k6urt4bfbrhfsfscm4ram)': + '@ag-ui/mastra@1.0.1(dd1f7da5383fb7dbf1cafaa81aeabe83)': dependencies: '@ag-ui/client': 0.0.49 '@ag-ui/core': 0.0.45 '@ai-sdk/ui-utils': 1.2.11(zod@4.3.6) - '@copilotkit/runtime': 0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/encoder@0.0.49)(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603)(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)))(@langchain/langgraph-sdk@0.1.10(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@copilotkit/runtime': 0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/encoder@0.0.46)(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603)(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)))(@langchain/langgraph-sdk@0.1.10(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@mastra/client-js': 1.11.2(@cfworker/json-schema@4.1.1)(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.3.6) '@mastra/core': 1.15.0(@cfworker/json-schema@4.1.1)(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@1.0.0)(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(openapi-types@12.1.3)(zod@4.3.6) rxjs: 7.8.1 @@ -16491,6 +16772,11 @@ snapshots: transitivePeerDependencies: - zod + '@alcalzone/ansi-tokenize@0.1.3': + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + '@alloc/quick-lru@5.2.0': {} '@andrewbranch/untar.js@1.0.3': {} @@ -16498,7 +16784,7 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 '@arethetypeswrong/cli@0.18.2': dependencies: @@ -17251,7 +17537,7 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@copilotkit/runtime@0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/encoder@0.0.49)(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603)(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)))(@langchain/langgraph-sdk@0.1.10(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@copilotkit/runtime@0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/encoder@0.0.46)(@cfworker/json-schema@4.1.1)(@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603)(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)))(@langchain/langgraph-sdk@0.1.10(@langchain/core@0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@ag-ui/client': 0.0.46 '@ag-ui/core': 0.0.46 @@ -17260,7 +17546,7 @@ snapshots: '@ai-sdk/openai': 2.0.101(zod@3.25.76) '@copilotkit/shared': 0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/core@0.0.46) '@copilotkitnext/agent': 0.0.0-mme-ag-ui-0-0-46-20260227141603(@cfworker/json-schema@4.1.1) - '@copilotkitnext/runtime': 0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/client@0.0.46)(@ag-ui/core@0.0.46)(@ag-ui/encoder@0.0.49)(@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603) + '@copilotkitnext/runtime': 0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/client@0.0.46)(@ag-ui/core@0.0.46)(@ag-ui/encoder@0.0.46)(@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603) '@graphql-yoga/plugin-defer-stream': 3.19.0(graphql-yoga@5.19.0(graphql@16.13.2))(graphql@16.13.2) '@hono/node-server': 1.19.12(hono@4.12.9) '@langchain/core': 0.3.80(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(openai@4.104.0(ws@8.20.0)(zod@4.3.6)) @@ -17320,11 +17606,11 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@copilotkitnext/runtime@0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/client@0.0.46)(@ag-ui/core@0.0.46)(@ag-ui/encoder@0.0.49)(@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603)': + '@copilotkitnext/runtime@0.0.0-mme-ag-ui-0-0-46-20260227141603(@ag-ui/client@0.0.46)(@ag-ui/core@0.0.46)(@ag-ui/encoder@0.0.46)(@copilotkitnext/shared@0.0.0-mme-ag-ui-0-0-46-20260227141603)': dependencies: '@ag-ui/client': 0.0.46 '@ag-ui/core': 0.0.46 - '@ag-ui/encoder': 0.0.49 + '@ag-ui/encoder': 0.0.46 '@copilotkitnext/shared': 0.0.0-mme-ag-ui-0-0-46-20260227141603 cors: 2.8.6 express: 4.22.1 @@ -19393,7 +19679,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@3.21.2(thirvmyz7u7sotpvl5josuvsem)': + '@nuxt/vite-builder@3.21.2(cda632d3acfa8c47554ee5be1e146aef)': dependencies: '@nuxt/kit': 3.21.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.1) @@ -20059,7 +20345,7 @@ snapshots: '@parcel/watcher-wasm@2.5.6': dependencies: is-glob: 4.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 '@parcel/watcher-win32-arm64@2.5.1': optional: true @@ -23498,10 +23784,10 @@ snapshots: '@rollup/pluginutils': 5.2.0(rollup@4.60.1) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) is-reference: 1.2.1 magic-string: 0.30.21 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.60.1 @@ -24927,7 +25213,7 @@ snapshots: glob: 13.0.6 graceful-fs: 4.2.11 node-gyp-build: 4.8.4 - picomatch: 4.0.3 + picomatch: 4.0.4 resolve-from: 5.0.0 transitivePeerDependencies: - encoding @@ -25003,6 +25289,14 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@22.15.32)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.18 @@ -25222,7 +25516,7 @@ snapshots: alien-signals: 3.1.2 muggle-string: 0.4.1 path-browserify: 1.0.1 - picomatch: 4.0.3 + picomatch: 4.0.4 '@vue/reactivity@3.5.31': dependencies: @@ -25655,6 +25949,8 @@ snapshots: atomic-sleep@1.0.0: {} + auto-bind@5.0.1: {} + autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 @@ -26171,10 +26467,16 @@ snapshots: dependencies: clsx: 2.1.1 + cli-boxes@3.0.0: {} + cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-highlight@2.1.11: dependencies: chalk: 4.1.2 @@ -26192,6 +26494,11 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + cli-width@4.1.0: {} client-only@0.0.1: {} @@ -26226,6 +26533,10 @@ snapshots: cluster-key-slot@1.1.2: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + collapse-white-space@2.1.0: {} color-convert@1.9.3: @@ -26346,6 +26657,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie-es@1.2.2: {} cookie-es@2.0.0: {} @@ -27056,6 +27369,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.50.0: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -28655,6 +28970,8 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -28666,6 +28983,43 @@ snapshots: ini@4.1.1: {} + ink-testing-library@4.0.0(@types/react@19.2.14): + optionalDependencies: + '@types/react': 19.2.14 + + ink@5.2.1(@types/react@19.2.14)(react@18.3.1): + dependencies: + '@alcalzone/ansi-tokenize': 0.1.3 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.1 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 4.0.0 + code-excerpt: 4.0.0 + es-toolkit: 1.50.0 + indent-string: 5.0.0 + is-in-ci: 1.0.0 + patch-console: 2.0.0 + react: 18.3.1 + react-reconciler: 0.29.2(react@18.3.1) + scheduler: 0.23.2 + signal-exit: 3.0.7 + slice-ansi: 7.1.2 + stack-utils: 2.0.6 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + ws: 8.20.0 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.2.4: {} inline-style-prefixer@7.0.1: @@ -28803,6 +29157,12 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.5.0 + is-generator-function@1.1.0: dependencies: call-bound: 1.0.4 @@ -28818,6 +29178,8 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ci@1.0.0: {} + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -30392,6 +30754,8 @@ snapshots: mimic-fn@1.2.0: {} + mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} min-indent@1.0.1: {} @@ -30785,7 +31149,7 @@ snapshots: '@nuxt/nitro-server': 3.21.2(db0@0.3.4)(ioredis@5.10.1)(magicast@0.5.2)(nuxt@3.21.2(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@parcel/watcher@2.5.1)(@types/node@25.3.2)(@vue/compiler-sfc@3.5.31)(cac@6.7.14)(db0@0.3.4)(eslint@9.29.0(jiti@2.6.1))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1))(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1))(rollup@4.60.1))(rollup@4.60.1)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.8.3))(rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1))(typescript@5.9.3) '@nuxt/schema': 3.21.2 '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.21.2(magicast@0.5.2)) - '@nuxt/vite-builder': 3.21.2(thirvmyz7u7sotpvl5josuvsem) + '@nuxt/vite-builder': 3.21.2(cda632d3acfa8c47554ee5be1e146aef) '@unhead/vue': 2.1.13(vue@3.5.31(typescript@5.9.3)) '@vue/shared': 3.5.31 c12: 3.3.3(magicast@0.5.2) @@ -31011,6 +31375,10 @@ snapshots: dependencies: mimic-fn: 1.2.0 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -31280,6 +31648,8 @@ snapshots: partial-json@0.1.7: {} + patch-console@2.0.0: {} + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -32162,6 +32532,12 @@ snapshots: dependencies: fast-deep-equal: 2.0.1 + react-reconciler@0.29.2(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-refresh@0.14.2: {} react-refresh@0.17.0: {} @@ -32326,6 +32702,10 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + react@19.2.0: {} react@19.2.3: {} @@ -32669,6 +33049,11 @@ snapshots: onetime: 2.0.1 signal-exit: 3.0.7 + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + retry@0.13.1: {} reusify@1.1.0: {} @@ -32724,7 +33109,7 @@ snapshots: rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1))(rollup@4.60.1): dependencies: open: 11.0.0 - picomatch: 4.0.3 + picomatch: 4.0.4 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: @@ -32870,6 +33255,10 @@ snapshots: dependencies: xmlchars: 2.2.0 + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + scheduler@0.27.0: {} schema-utils@4.3.2: @@ -33111,6 +33500,16 @@ snapshots: slash@5.1.0: {} + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.1.0 + slugify@1.6.8: {} smob@1.6.1: {} @@ -33731,7 +34130,7 @@ snapshots: tsx@4.20.3: dependencies: esbuild: 0.25.12 - get-tsconfig: 4.10.1 + get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 @@ -33749,6 +34148,8 @@ snapshots: type-fest@0.7.1: {} + type-fest@4.41.0: {} + type-fest@5.5.0: dependencies: tagged-tag: 1.0.0 @@ -33968,7 +34369,7 @@ snapshots: unplugin-utils@0.3.1: dependencies: pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 unplugin-vue-router@0.19.2(@vue/compiler-sfc@3.5.31)(vue-router@4.6.4(vue@3.5.31(typescript@5.9.3)))(vue@3.5.31(typescript@5.9.3)): dependencies: @@ -34004,7 +34405,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.16.0 - picomatch: 4.0.3 + picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 unplugin@3.0.0: @@ -34139,14 +34540,18 @@ snapshots: dependencies: react: 19.2.3 - use-sync-external-store@1.5.0(react@19.2.4): + use-sync-external-store@1.6.0(react@18.3.1): dependencies: - react: 19.2.4 + react: 18.3.1 use-sync-external-store@1.6.0(react@19.2.3): dependencies: react: 19.2.3 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + util-deprecate@1.0.2: {} util@0.12.5: @@ -34271,7 +34676,7 @@ snapshots: chokidar: 4.0.3 npm-run-path: 6.0.0 picocolors: 1.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 tiny-invariant: 1.3.3 tinyglobby: 0.2.15 vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) @@ -34321,6 +34726,24 @@ snapshots: sass: 1.89.2 terser: 5.43.0 + vite@6.4.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.43.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.35 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + sass: 1.89.2 + terser: 5.43.0 + tsx: 4.20.3 + yaml: 2.8.3 + vite@6.4.1(@types/node@22.15.32)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3): dependencies: esbuild: 0.25.12 @@ -34360,8 +34783,8 @@ snapshots: vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3): dependencies: esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.8 rollup: 4.60.1 tinyglobby: 0.2.15 @@ -34422,6 +34845,45 @@ snapshots: - tsx - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 20.19.35 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.15.32)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.18 @@ -34436,10 +34898,10 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 vite: 6.4.1(@types/node@22.15.32)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) @@ -34475,10 +34937,10 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 vite: 6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3) @@ -34692,6 +35154,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + wonka@6.3.5: {} word-wrap@1.2.5: {} @@ -34811,6 +35277,8 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -34856,9 +35324,16 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.14)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.14 + react: 18.3.1 + zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): dependencies: - use-sync-external-store: 1.5.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: '@types/react': 19.2.14 react: 19.2.4 From d0527c57630f9f8f64f514c2e143b176e19323ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 09:04:50 +0000 Subject: [PATCH 02/12] Run Ink on a single React instance via a local chat store ChatProvider from react-headless resolved the workspace's react@19 while Ink's reconciler uses react@18, causing a dual-React 'invalid hook call' crash. Drop ChatProvider/useThread and add a small local chat store (src/chat.ts) that runs on Ink's own React while still reusing react-headless's DOM-free streaming pipeline (processStreamedMessage + openAIReadableStreamAdapter). Co-authored-by: Ankit Das --- examples/openui-tui-chat/src/app.tsx | 12 ++--- examples/openui-tui-chat/src/chat.ts | 77 ++++++++++++++++++++++++++++ examples/openui-tui-chat/src/cli.tsx | 10 +--- 3 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 examples/openui-tui-chat/src/chat.ts diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx index 7be41805a..98c3396b7 100644 --- a/examples/openui-tui-chat/src/app.tsx +++ b/examples/openui-tui-chat/src/app.tsx @@ -1,6 +1,6 @@ -import { useThread } from "@openuidev/react-headless"; import { Box, Text, useFocus, useInput } from "ink"; import { useState } from "react"; +import { useLocalChat, type ProcessFn } from "./chat.js"; import { RenderValue } from "./genui/components.js"; import { TuiProvider } from "./genui/context.js"; import { tuiLibrary } from "./genui/library.js"; @@ -16,18 +16,14 @@ function messageText(content: unknown): string { return ""; } -export function App() { - const messages = useThread((s) => s.messages); - const isRunning = useThread((s) => s.isRunning); - const processMessage = useThread((s) => s.processMessage); +export function App({ processMessage }: { processMessage: ProcessFn }) { + const { messages, isRunning, send } = useLocalChat(processMessage); const [draft, setDraft] = useState(""); const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); const response = lastAssistant ? messageText(lastAssistant.content) : null; - const onSend = (content: string) => { - if (!isRunning) processMessage({ role: "user", content }); - }; + const onSend = (content: string) => send(content); const { result, ctx } = useGenUi( tuiLibrary, diff --git a/examples/openui-tui-chat/src/chat.ts b/examples/openui-tui-chat/src/chat.ts new file mode 100644 index 000000000..8d735134c --- /dev/null +++ b/examples/openui-tui-chat/src/chat.ts @@ -0,0 +1,77 @@ +import { + openAIReadableStreamAdapter, + processStreamedMessage, + type AssistantMessage, + type Message, +} from "@openuidev/react-headless"; +import { useCallback, useRef, useState } from "react"; + +export type ProcessFn = (params: { + threadId: string; + messages: Message[]; + abortController: AbortController; +}) => Promise; + +export interface LocalChat { + messages: Message[]; + isRunning: boolean; + send: (content: string) => void; +} + +/** + * Minimal chat store that runs on the SAME React instance Ink uses. It reuses + * react-headless's DOM-free streaming pipeline (`processStreamedMessage` + + * `openAIReadableStreamAdapter`) without its React `ChatProvider`, which would + * otherwise pull in a second React copy and break Ink's reconciler. + */ +export function useLocalChat(processMessage: ProcessFn): LocalChat { + const [messages, setMessages] = useState([]); + const [isRunning, setIsRunning] = useState(false); + const messagesRef = useRef([]); + messagesRef.current = messages; + const runningRef = useRef(false); + + const send = useCallback( + async (content: string) => { + if (!content.trim() || runningRef.current) return; + runningRef.current = true; + + const userMessage = { + id: crypto.randomUUID(), + role: "user", + content, + } as unknown as Message; + + const next = [...messagesRef.current, userMessage]; + setMessages(next); + setIsRunning(true); + + const abortController = new AbortController(); + try { + const response = await processMessage({ threadId: "tui", messages: next, abortController }); + await processStreamedMessage({ + response, + adapter: openAIReadableStreamAdapter(), + createMessage: (m: AssistantMessage) => setMessages((cur) => [...cur, m]), + updateMessage: (m: AssistantMessage) => + setMessages((cur) => cur.map((x) => (x.id === m.id ? { ...m } : x))), + deleteMessage: (id: string) => setMessages((cur) => cur.filter((x) => x.id !== id)), + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + const errorMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: `root = Card([e])\ne = TextContent("Error: ${message.replace(/"/g, "'")}")`, + } as unknown as Message; + setMessages((cur) => [...cur, errorMessage]); + } finally { + runningRef.current = false; + setIsRunning(false); + } + }, + [processMessage], + ); + + return { messages, isRunning, send }; +} diff --git a/examples/openui-tui-chat/src/cli.tsx b/examples/openui-tui-chat/src/cli.tsx index 6e87ef4b7..26042c436 100644 --- a/examples/openui-tui-chat/src/cli.tsx +++ b/examples/openui-tui-chat/src/cli.tsx @@ -9,7 +9,6 @@ if (typeof g.requestAnimationFrame !== "function") { g.cancelAnimationFrame = (id) => clearTimeout(id as ReturnType); } -import { ChatProvider, openAIMessageFormat, openAIReadableStreamAdapter } from "@openuidev/react-headless"; import { render } from "ink"; import { createElement } from "react"; import { App } from "./app.js"; @@ -25,11 +24,4 @@ if (!process.env.OPENAI_API_KEY) { const systemPrompt = tuiLibrary.prompt(); -render( - createElement(ChatProvider, { - processMessage: makeProcessMessage(systemPrompt), - streamProtocol: openAIReadableStreamAdapter(), - messageFormat: openAIMessageFormat, - children: createElement(App), - }), -); +render(createElement(App, { processMessage: makeProcessMessage(systemPrompt) })); From 69132eec63541798f51cde593c8566ce5076888b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 10:21:16 +0000 Subject: [PATCH 03/12] Improve TUI chat interface: history, bubbles, spinner, composer - Preserve full conversation history via Ink (completed turns are emitted once to scrollback), fixing the tall-output/composer-scroll issue and keeping the composer anchored at the bottom. - Split interactive components into interactive vs display-only variants (via a context flag) so finalized turns don't pollute keyboard focus. - Add chat framing: header bar, user bubbles, assistant labels, welcome/empty state, an animated streaming spinner, and a bordered composer with hints. - Add a test covering display-only (finalized) rendering. Co-authored-by: Ankit Das --- examples/openui-tui-chat/README.md | 9 +- .../src/__tests__/genui.test.tsx | 12 + examples/openui-tui-chat/src/app.tsx | 221 ++++++++++++++---- .../openui-tui-chat/src/genui/components.tsx | 55 ++++- .../openui-tui-chat/src/genui/context.tsx | 2 + examples/openui-tui-chat/src/genui/state.tsx | 5 +- 6 files changed, 248 insertions(+), 56 deletions(-) diff --git a/examples/openui-tui-chat/README.md b/examples/openui-tui-chat/README.md index f2b422bfa..15982e649 100644 --- a/examples/openui-tui-chat/README.md +++ b/examples/openui-tui-chat/README.md @@ -58,8 +58,15 @@ pnpm --filter openui-tui-chat test # vitest + ink-testing-library pnpm --filter openui-tui-chat typecheck ``` +## Chat UI + +- A header, a welcome/empty state with example prompts, and a bordered composer with key hints. +- User messages render as bubbles; assistant turns render as live generative UI. +- Completed turns are written to the terminal scrollback via Ink's ``, so full history stays visible and the composer stays anchored at the bottom (no viewport clobbering on tall output). +- An animated spinner shows while the assistant is streaming. + ## Limitations (POC) - Read-oriented charts/tables render as ASCII; not pixel-faithful. -- Interactivity targets the latest assistant message; prior turns show as compact prompt lines. +- Interactivity targets the **latest** assistant turn; completed turns become display-only once they scroll into history. - Queries/`$state` two-way binding beyond simple form fields are out of scope for v1. diff --git a/examples/openui-tui-chat/src/__tests__/genui.test.tsx b/examples/openui-tui-chat/src/__tests__/genui.test.tsx index b9378fb6f..ab5895697 100644 --- a/examples/openui-tui-chat/src/__tests__/genui.test.tsx +++ b/examples/openui-tui-chat/src/__tests__/genui.test.tsx @@ -32,6 +32,7 @@ function evalProgram(src: string) { const noopCtx: TuiContextValue = { library: tuiLibrary, + interactive: true, triggerAction: () => {}, getFieldValue: () => undefined, setFieldValue: () => {}, @@ -80,6 +81,17 @@ describe("TUI renderer", () => { ); expect(lastFrame() ?? "").toContain("hello world"); }); + + it("renders finalized turns display-only (buttons are not interactive)", () => { + const root = evalProgram( + 'root = Card([btns])\nbtns = Buttons([b1])\nb1 = Button("Retry", Action([@ToAssistant("retry")]))', + ); + const staticCtx: TuiContextValue = { ...noopCtx, interactive: false }; + const { lastFrame } = render( + createElement(TuiProvider, { value: staticCtx }, createElement(RenderValue, { value: root })), + ); + expect(lastFrame() ?? "").toContain("[ Retry ]"); + }); }); describe("TUI interactivity", () => { diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx index 98c3396b7..9b0164b51 100644 --- a/examples/openui-tui-chat/src/app.tsx +++ b/examples/openui-tui-chat/src/app.tsx @@ -1,5 +1,6 @@ -import { Box, Text, useFocus, useInput } from "ink"; -import { useState } from "react"; +import type { Message } from "@openuidev/react-headless"; +import { Box, Static, Text, useFocus, useInput } from "ink"; +import { useEffect, useState } from "react"; import { useLocalChat, type ProcessFn } from "./chat.js"; import { RenderValue } from "./genui/components.js"; import { TuiProvider } from "./genui/context.js"; @@ -16,22 +17,133 @@ function messageText(content: unknown): string { return ""; } -export function App({ processMessage }: { processMessage: ProcessFn }) { - const { messages, isRunning, send } = useLocalChat(processMessage); - const [draft, setDraft] = useState(""); +const firstLine = (s: string) => s.split("\n")[0] ?? ""; + +const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +// ─────────────────────────── chrome ─────────────────────────── + +function Header() { + return ( + + + {" ◆ OpenUI TUI Chat "} + + {" generative UI, streamed into your terminal"} + + ); +} - const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); - const response = lastAssistant ? messageText(lastAssistant.content) : null; +function Welcome() { + return ( + + Ask for UI and it renders live, right here in your terminal. Try: + · Compare the 4 largest countries by population as a bar chart + · Build a contact form with a name field and a topic dropdown + · Show the top 5 programming languages by popularity in a table + + ); +} + +function UserBubble({ text }: { text: string }) { + return ( + + + {text} + + + ); +} + +function Thinking() { + const [frame, setFrame] = useState(0); + useEffect(() => { + const t = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), 90); + return () => clearInterval(t); + }, []); + return ( + + {SPINNER[frame]} + OpenUI is thinking… + + ); +} + +function Composer({ + draft, + focused, + isRunning, +}: { + draft: string; + focused: boolean; + isRunning: boolean; +}) { + return ( + + + {"❯ "} + {draft} + {focused ? : null} + {draft.length === 0 ? ( + {isRunning ? "waiting for response…" : "Message OpenUI…"} + ) : null} + + {" Enter send · Tab focus UI · ↑↓ choose · Ctrl+C quit"} + + ); +} - const onSend = (content: string) => send(content); +// ─────────────────────────── assistant message ─────────────────────────── +function AssistantMessageView({ + message, + interactive, + isStreaming, + onSend, +}: { + message: Message; + interactive: boolean; + isStreaming: boolean; + onSend: (content: string) => void; +}) { + const content = messageText(message.content); const { result, ctx } = useGenUi( tuiLibrary, - lastAssistant?.id ?? null, - response, - isRunning, + message.id, + content, + isStreaming, onSend, + interactive, + ); + + if (!result?.root) { + if (isStreaming) return null; + return ( + + {content ? content : "(no renderable UI)"} + + ); + } + + return ( + + + ◆ OpenUI + + + + + ); +} + +// ─────────────────────────── app ─────────────────────────── + +type StaticItem = { kind: "header" } | { kind: "message"; message: Message }; + +export function App({ processMessage }: { processMessage: ProcessFn }) { + const { messages, isRunning, send } = useLocalChat(processMessage); + const [draft, setDraft] = useState(""); const { isFocused: composerFocused } = useFocus({ id: "composer", autoFocus: true }); useInput( @@ -39,7 +151,7 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { if (key.return) { const text = draft.trim(); if (text && !isRunning) { - onSend(text); + send(text); setDraft(""); } return; @@ -53,48 +165,57 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { { isActive: composerFocused }, ); - const userMessages = messages.filter((m) => m.role === "user"); + const last = messages[messages.length - 1]; + const liveAssistant = last && last.role === "assistant" ? last : null; + const finalized = liveAssistant ? messages.slice(0, -1) : messages; + const liveContent = liveAssistant ? messageText(liveAssistant.content) : ""; + const showThinking = isRunning && liveContent.trim() === ""; + + // Completed turns are emitted once into scrollback via , keeping the + // live/interactive region small so the composer never scrolls off screen. + const staticItems: StaticItem[] = [ + { kind: "header" }, + ...finalized.map((message) => ({ kind: "message" as const, message })), + ]; return ( - - - - OpenUI TUI Chat{" "} - · streamed OpenUI Lang, rendered in your terminal - - - {userMessages.map((m) => ( - - {"› "} - {messageText(m.content).split("\n")[0]} - - ))} - - {result?.root ? ( - - - - ) : messages.length === 0 ? ( - - Ask for a chart, a table, or a form. Try: - · "Compare the 4 largest countries by population as a bar chart" - · "Build a contact form with name, email and a topic dropdown" - - ) : null} + + + {(item, index) => + item.kind === "header" ? ( + +
+ + ) : ( + + {item.message.role === "user" ? ( + + ) : ( + {}} + /> + )} + + ) + } + - {isRunning ? ( - {"\n"}◐ thinking… + + {messages.length === 0 ? : null} + {liveAssistant ? ( + ) : null} - - - {composerFocused ? "❯ " : " "} - {draft || (composerFocused ? "" : "")} - {composerFocused ? "▏" : ""} - {draft.length === 0 && composerFocused ? ( - type a message · Enter to send · Tab to focus UI · Ctrl+C to quit - ) : null} - + {showThinking ? : null} + - + ); } diff --git a/examples/openui-tui-chat/src/genui/components.tsx b/examples/openui-tui-chat/src/genui/components.tsx index e0516c210..47caa4554 100644 --- a/examples/openui-tui-chat/src/genui/components.tsx +++ b/examples/openui-tui-chat/src/genui/components.tsx @@ -169,9 +169,18 @@ function FollowUpBlockView({ props, renderNode }: ViewProps) { } function FollowUpItemView({ props }: ViewProps) { + const { interactive } = useTui(); + const text = str(props.text); + return interactive ? ( + + ) : ( + {"• "}{text} + ); +} + +function FollowUpItemInteractive({ text }: { text: string }) { const { isFocused } = useFocus(); const { triggerAction } = useTui(); - const text = str(props.text); useInput( (_input, key) => { if (key.return) triggerAction(text); @@ -199,13 +208,22 @@ function ButtonsView({ props, renderNode }: ViewProps) { } function ButtonView({ props }: ViewProps) { + const { interactive } = useTui(); + const label = str(props.label) || "Button"; + return interactive ? ( + + ) : ( + {`[ ${label} ]`} + ); +} + +function ButtonInteractive({ label, action }: { label: string; action: unknown }) { const { isFocused } = useFocus(); const formName = useFormName(); const { triggerAction } = useTui(); - const label = str(props.label) || "Button"; useInput( (_input, key) => { - if (key.return) triggerAction(label, formName, props.action); + if (key.return) triggerAction(label, formName, action); }, { isActive: isFocused }, ); @@ -242,6 +260,19 @@ function FormControlView({ props, renderNode }: ViewProps) { } function InputView({ props }: ViewProps) { + const { interactive } = useTui(); + if (!interactive) { + return ( + + {" "} + {str(props.placeholder) || str(props.name)} + + ); + } + return ; +} + +function InputInteractive({ props }: { props: Record }) { const { isFocused } = useFocus(); const formName = useFormName(); const { getFieldValue, setFieldValue } = useTui(); @@ -270,6 +301,24 @@ function InputView({ props }: ViewProps) { } function SelectView({ props }: ViewProps) { + const { interactive } = useTui(); + const items = Array.isArray(props.items) ? props.items : []; + if (!interactive) { + return ( + + {items.map((it, i) => ( + + {" ( ) "} + {str((it as ElementLike)?.props?.label ?? (it as ElementLike)?.props?.value)} + + ))} + + ); + } + return ; +} + +function SelectInteractive({ props }: { props: Record }) { const { isFocused } = useFocus(); const formName = useFormName(); const { getFieldValue, setFieldValue } = useTui(); diff --git a/examples/openui-tui-chat/src/genui/context.tsx b/examples/openui-tui-chat/src/genui/context.tsx index 547ea9ffd..ebe44174d 100644 --- a/examples/openui-tui-chat/src/genui/context.tsx +++ b/examples/openui-tui-chat/src/genui/context.tsx @@ -7,6 +7,8 @@ import { createContext, useContext } from "react"; */ export interface TuiContextValue { library: Library; + /** When false, components render display-only (no focus/keyboard) — used for finalized turns. */ + interactive: boolean; /** Fire an action (button / follow-up / form submit) → sends a message to the assistant. */ triggerAction: ( userMessage: string, diff --git a/examples/openui-tui-chat/src/genui/state.tsx b/examples/openui-tui-chat/src/genui/state.tsx index 0152615a3..73d19bb92 100644 --- a/examples/openui-tui-chat/src/genui/state.tsx +++ b/examples/openui-tui-chat/src/genui/state.tsx @@ -35,6 +35,7 @@ export function useGenUi( response: string | null, isStreaming: boolean, onSend: (content: string) => void, + interactive = true, ): GenUiState { const onSendRef = useRef(onSend); onSendRef.current = onSend; @@ -139,8 +140,8 @@ export function useGenUi( ); const ctx = useMemo( - () => ({ library, triggerAction, getFieldValue, setFieldValue }), - [library, triggerAction, getFieldValue, setFieldValue], + () => ({ library, interactive, triggerAction, getFieldValue, setFieldValue }), + [library, interactive, triggerAction, getFieldValue, setFieldValue], ); // isStreaming currently only affects display in the app; kept for parity/future use. From 3d24200b3abe85e9fd29703241e0a6e7e816a5d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 11:57:06 +0000 Subject: [PATCH 04/12] Fix form Select: select on arrow with instant feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropdown required arrow-then-Enter and gave no immediate visual feedback (the (•) only appeared after focus moved away), so selection felt broken. Now arrow keys select immediately (radio-group style) and a local state bump guarantees an instant repaint even when the cursor index is unchanged. Also initialize the cursor to the current selection and add a form usage hint. Co-authored-by: Ankit Das --- examples/openui-tui-chat/README.md | 4 +- .../src/__tests__/genui.test.tsx | 64 +++++++++++++++++++ .../openui-tui-chat/src/genui/components.tsx | 35 +++++++--- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/examples/openui-tui-chat/README.md b/examples/openui-tui-chat/README.md index 15982e649..d9fea229f 100644 --- a/examples/openui-tui-chat/README.md +++ b/examples/openui-tui-chat/README.md @@ -39,8 +39,8 @@ or _"Build a contact form with name, email and a topic dropdown"_. - Type + **Enter** — send a message. - **Tab / Shift+Tab** — move focus between the composer and interactive UI (follow-ups, buttons, form fields). -- **Enter** — activate the focused follow-up/button, or (in a Select) choose the highlighted option. -- **↑ / ↓** — move within a focused Select. +- **Enter** — activate the focused follow-up/button (also confirms the highlighted Select option). +- **↑ / ↓** — choose an option in a focused Select; the highlighted option is selected immediately. - **Ctrl+C** — quit. ## Supported components (v1) diff --git a/examples/openui-tui-chat/src/__tests__/genui.test.tsx b/examples/openui-tui-chat/src/__tests__/genui.test.tsx index ab5895697..38ec04fda 100644 --- a/examples/openui-tui-chat/src/__tests__/genui.test.tsx +++ b/examples/openui-tui-chat/src/__tests__/genui.test.tsx @@ -113,6 +113,70 @@ describe("TUI interactivity", () => { expect(sent).toContain("Show this as a table"); }); + it("shows a Select choice immediately after Enter (no extra keypress needed)", async () => { + const src = [ + "root = Card([form])", + 'form = Form("f", btns, [topicField])', + 'topicField = FormControl("Topic", topic)', + 'topic = Select("topic", [o1, o2])', + 'o1 = SelectItem("sales", "Sales")', + 'o2 = SelectItem("support", "Support")', + "btns = Buttons([submit])", + 'submit = Button("Send", Action([@ToAssistant("go")]))', + ].join("\n"); + + const { stdin, lastFrame } = render(createElement(Harness, { src, onSend: () => {} })); + await delay(40); + stdin.write("\t"); // focus the Select (first focusable) + await delay(30); + stdin.write("\u001B[B"); // Down arrow → move cursor to Support + await delay(30); + stdin.write("\r"); // Enter → select Support + await delay(40); + + const frame = lastFrame() ?? ""; + expect(frame).toContain("(•) Support"); + expect(frame).not.toContain("(•) Sales"); + }); + + it("selects a Select option immediately on arrow (no Enter needed)", async () => { + const src = [ + "root = Card([form])", + 'form = Form("f", btns, [topicField])', + 'topicField = FormControl("Topic", topic)', + 'topic = Select("topic", [o1, o2])', + 'o1 = SelectItem("sales", "Sales")', + 'o2 = SelectItem("support", "Support")', + "btns = Buttons([submit])", + 'submit = Button("Send")', + ].join("\n"); + const { stdin, lastFrame } = render(createElement(Harness, { src, onSend: () => {} })); + await delay(40); + stdin.write("\t"); // focus the Select + await delay(30); + stdin.write("\u001B[B"); // Down arrow only — should select immediately + await delay(40); + expect(lastFrame() ?? "").toContain("(•) Support"); + }); + + it("shows typed Input text immediately", async () => { + const src = [ + "root = Card([form])", + 'form = Form("f", btns, [nameField])', + 'nameField = FormControl("Name", nameInput)', + 'nameInput = Input("name", "Your name")', + "btns = Buttons([submit])", + 'submit = Button("Send")', + ].join("\n"); + const { stdin, lastFrame } = render(createElement(Harness, { src, onSend: () => {} })); + await delay(40); + stdin.write("\t"); + await delay(30); + stdin.write("Hi"); + await delay(40); + expect(lastFrame() ?? "").toContain("Hi"); + }); + it("collects form field values and submits them via the button's action", async () => { const sent: string[] = []; const src = [ diff --git a/examples/openui-tui-chat/src/genui/components.tsx b/examples/openui-tui-chat/src/genui/components.tsx index 47caa4554..2dcc03860 100644 --- a/examples/openui-tui-chat/src/genui/components.tsx +++ b/examples/openui-tui-chat/src/genui/components.tsx @@ -240,6 +240,7 @@ function FormView({ props, renderNode }: ViewProps) { return ( + Tab between fields · type to fill · ↑↓ to choose · Enter on a button to submit {fields.map((f, i) => ( {renderNode(f)} ))} @@ -329,31 +330,49 @@ function SelectInteractive({ props }: { props: Record }) { label: str((it as ElementLike)?.props?.label ?? (it as ElementLike)?.props?.value), })); const current = str(getFieldValue(formName, name)); - const [cursor, setCursor] = useState(0); + const initialIndex = Math.max( + 0, + options.findIndex((o) => o.value === current), + ); + const [cursor, setCursor] = useState(initialIndex); + // Bumped on every choice so the row re-renders immediately, even when the + // cursor index is unchanged (e.g. pressing Enter) where a store-only update + // may not repaint on the next frame. + const [, bump] = useState(0); + + // Arrow keys select immediately (radio-group behaviour) for instant feedback; + // Enter also confirms the current row. + const choose = (index: number) => { + const clamped = Math.max(0, Math.min(options.length - 1, index)); + const option = options[clamped]; + setCursor(clamped); + bump((n) => n + 1); + if (option) setFieldValue(formName, "Select", name, option.value); + }; + useInput( (_input, key) => { - if (key.upArrow) setCursor((c) => Math.max(0, c - 1)); - else if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1)); - else if (key.return) { - const o = options[cursor]; - if (o) setFieldValue(formName, "Select", name, o.value); - } + if (key.upArrow) choose(cursor - 1); + else if (key.downArrow) choose(cursor + 1); + else if (key.return) choose(cursor); }, { isActive: isFocused }, ); + return ( {options.map((o, i) => { const isSel = o.value === current; const isCursor = isFocused && i === cursor; return ( - + {isCursor ? "❯ " : " "} {isSel ? "(•) " : "( ) "} {o.label} ); })} + {isFocused ? ↑↓ to choose : null} ); } From bace70dc9f5f475b5120da7232b62eaee83eba0e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 12:00:23 +0000 Subject: [PATCH 05/12] Add number-key selection for Select (no cursor needed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Options are numbered (1., 2., …) and pressing the matching digit selects that option directly — no arrow navigation or Enter required. Arrow keys and Enter still work. Updates the focused-select and form hints accordingly. Co-authored-by: Ankit Das --- .../src/__tests__/genui.test.tsx | 28 +++++++++++++++++-- .../openui-tui-chat/src/genui/components.tsx | 14 +++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/examples/openui-tui-chat/src/__tests__/genui.test.tsx b/examples/openui-tui-chat/src/__tests__/genui.test.tsx index 38ec04fda..21ea092f9 100644 --- a/examples/openui-tui-chat/src/__tests__/genui.test.tsx +++ b/examples/openui-tui-chat/src/__tests__/genui.test.tsx @@ -135,8 +135,30 @@ describe("TUI interactivity", () => { await delay(40); const frame = lastFrame() ?? ""; - expect(frame).toContain("(•) Support"); - expect(frame).not.toContain("(•) Sales"); + expect(frame).toContain("(•) 2. Support"); + expect(frame).not.toContain("(•) 1. Sales"); + }); + + it("selects a Select option by number key (no cursor movement)", async () => { + const src = [ + "root = Card([form])", + 'form = Form("f", btns, [topicField])', + 'topicField = FormControl("Topic", topic)', + 'topic = Select("topic", [o1, o2])', + 'o1 = SelectItem("sales", "Sales")', + 'o2 = SelectItem("support", "Support")', + "btns = Buttons([submit])", + 'submit = Button("Send")', + ].join("\n"); + const { stdin, lastFrame } = render(createElement(Harness, { src, onSend: () => {} })); + await delay(40); + stdin.write("\t"); // focus the Select + await delay(30); + stdin.write("2"); // press "2" → pick the 2nd option directly + await delay(40); + const frame = lastFrame() ?? ""; + expect(frame).toContain("(•) 2. Support"); + expect(frame).not.toContain("(•) 1. Sales"); }); it("selects a Select option immediately on arrow (no Enter needed)", async () => { @@ -156,7 +178,7 @@ describe("TUI interactivity", () => { await delay(30); stdin.write("\u001B[B"); // Down arrow only — should select immediately await delay(40); - expect(lastFrame() ?? "").toContain("(•) Support"); + expect(lastFrame() ?? "").toContain("(•) 2. Support"); }); it("shows typed Input text immediately", async () => { diff --git a/examples/openui-tui-chat/src/genui/components.tsx b/examples/openui-tui-chat/src/genui/components.tsx index 2dcc03860..ca39d9835 100644 --- a/examples/openui-tui-chat/src/genui/components.tsx +++ b/examples/openui-tui-chat/src/genui/components.tsx @@ -240,7 +240,7 @@ function FormView({ props, renderNode }: ViewProps) { return ( - Tab between fields · type to fill · ↑↓ to choose · Enter on a button to submit + Tab between fields · type to fill · number keys or ↑↓ to choose · Enter on a button to submit {fields.map((f, i) => ( {renderNode(f)} ))} @@ -351,7 +351,13 @@ function SelectInteractive({ props }: { props: Record }) { }; useInput( - (_input, key) => { + (input, key) => { + // Number keys pick an option directly — no cursor movement needed. + if (/^[1-9]$/.test(input) && !key.ctrl && !key.meta) { + const index = Number(input) - 1; + if (index < options.length) choose(index); + return; + } if (key.upArrow) choose(cursor - 1); else if (key.downArrow) choose(cursor + 1); else if (key.return) choose(cursor); @@ -368,11 +374,11 @@ function SelectInteractive({ props }: { props: Record }) { {isCursor ? "❯ " : " "} {isSel ? "(•) " : "( ) "} - {o.label} + {i + 1}. {o.label} ); })} - {isFocused ? ↑↓ to choose : null} + {isFocused ? press 1-{options.length} or ↑↓ to choose : null} ); } From b5dda02b081883fa94e5da07865e0b45a230f859 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 13:02:38 +0000 Subject: [PATCH 06/12] Add mouse click support for form elements Adds a click-only SGR mouse layer (src/genui/mouse.tsx): enables ?1000/?1006 once at the root, parses left-button presses, and hit-tests them against the bottom-anchored live region via Yoga layout offsets (offset recomputed per click so it adapts as content changes). Clicking selects a dropdown option, activates a button/follow-up, or focuses a text field. Text inputs and the composer now filter escape/mouse sequences out of stdin so mouse bytes don't leak into fields. Keyboard remains the primary, fully-portable interaction. Co-authored-by: Ankit Das --- examples/openui-tui-chat/README.md | 13 +- examples/openui-tui-chat/src/app.tsx | 88 +++++---- .../openui-tui-chat/src/genui/components.tsx | 56 +++--- examples/openui-tui-chat/src/genui/mouse.tsx | 171 ++++++++++++++++++ 4 files changed, 266 insertions(+), 62 deletions(-) create mode 100644 examples/openui-tui-chat/src/genui/mouse.tsx diff --git a/examples/openui-tui-chat/README.md b/examples/openui-tui-chat/README.md index d9fea229f..a13583dbe 100644 --- a/examples/openui-tui-chat/README.md +++ b/examples/openui-tui-chat/README.md @@ -40,9 +40,20 @@ or _"Build a contact form with name, email and a topic dropdown"_. - Type + **Enter** — send a message. - **Tab / Shift+Tab** — move focus between the composer and interactive UI (follow-ups, buttons, form fields). - **Enter** — activate the focused follow-up/button (also confirms the highlighted Select option). -- **↑ / ↓** — choose an option in a focused Select; the highlighted option is selected immediately. +- **↑ / ↓** or **number keys** — choose an option in a focused Select; the highlighted option is selected immediately. +- **Mouse click** — click a dropdown option to select it, a button/follow-up to activate it, or a text field to focus it (see caveats below). - **Ctrl+C** — quit. +### Mouse support (form elements) + +Clicking works on the **latest** turn's interactive elements (dropdown options, buttons, follow-ups, text fields). It uses click-only SGR mouse tracking (`?1000`/`?1006`) enabled once at the root; clicks are hit-tested against the bottom-anchored live region using Yoga layout offsets. + +Caveats (inherent to terminal mouse tracking): + +- While mouse tracking is active, the terminal's native click-drag **text selection/copy is disabled** (hold Shift in most terminals to bypass and select text). +- Only the current (bottom) turn is clickable; earlier turns scrolled into history are keyboard-recallable but not click targets. +- Requires an xterm-style terminal; through tmux you must set `set -g mouse on`. Keyboard remains the fully-portable path. + ## Supported components (v1) `Card`, `CardHeader`, `TextContent`, `Table`/`Col`, `BarChart`/`Series`, diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx index 9b0164b51..021f1fdb1 100644 --- a/examples/openui-tui-chat/src/app.tsx +++ b/examples/openui-tui-chat/src/app.tsx @@ -1,10 +1,11 @@ import type { Message } from "@openuidev/react-headless"; -import { Box, Static, Text, useFocus, useInput } from "ink"; -import { useEffect, useState } from "react"; +import { Box, Static, Text, useFocus, useInput, type DOMElement } from "ink"; +import { useEffect, useRef, useState } from "react"; import { useLocalChat, type ProcessFn } from "./chat.js"; import { RenderValue } from "./genui/components.js"; import { TuiProvider } from "./genui/context.js"; import { tuiLibrary } from "./genui/library.js"; +import { isTypedText, MouseProvider } from "./genui/mouse.js"; import { useGenUi } from "./genui/state.js"; function messageText(content: unknown): string { @@ -144,6 +145,7 @@ type StaticItem = { kind: "header" } | { kind: "message"; message: Message }; export function App({ processMessage }: { processMessage: ProcessFn }) { const { messages, isRunning, send } = useLocalChat(processMessage); const [draft, setDraft] = useState(""); + const dynamicRef = useRef(null); const { isFocused: composerFocused } = useFocus({ id: "composer", autoFocus: true }); useInput( @@ -160,7 +162,11 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { setDraft((d) => d.slice(0, -1)); return; } - if (input && !key.ctrl && !key.meta && !key.tab) setDraft((d) => d + input); + // Accept printable text but drop escape/mouse sequences that share stdin + // when mouse tracking is enabled. + if (isTypedText(input) && !key.ctrl && !key.meta) { + setDraft((d) => d + input); + } }, { isActive: composerFocused }, ); @@ -179,43 +185,45 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { ]; return ( - - - {(item, index) => - item.kind === "header" ? ( - -
- - ) : ( - - {item.message.role === "user" ? ( - - ) : ( - {}} - /> - )} - - ) - } - - - - {messages.length === 0 ? : null} - {liveAssistant ? ( - - ) : null} - {showThinking ? : null} - + + + + {(item, index) => + item.kind === "header" ? ( + +
+ + ) : ( + + {item.message.role === "user" ? ( + + ) : ( + {}} + /> + )} + + ) + } + + + + {messages.length === 0 ? : null} + {liveAssistant ? ( + + ) : null} + {showThinking ? : null} + + - + ); } diff --git a/examples/openui-tui-chat/src/genui/components.tsx b/examples/openui-tui-chat/src/genui/components.tsx index ca39d9835..d80d1d30a 100644 --- a/examples/openui-tui-chat/src/genui/components.tsx +++ b/examples/openui-tui-chat/src/genui/components.tsx @@ -1,7 +1,8 @@ -import { Box, Text, useFocus, useInput } from "ink"; +import { Box, Text, useFocus, useFocusManager, useInput } from "ink"; import { Component, Fragment, type ReactNode, useState } from "react"; import { renderBars } from "./chart.js"; import { FormNameProvider, useFormName, useTui } from "./context.js"; +import { Clickable, isTypedText } from "./mouse.js"; /** The render contract each library component receives (matches lang-core's ComponentRenderProps). */ interface ViewProps { @@ -188,10 +189,12 @@ function FollowUpItemInteractive({ text }: { text: string }) { { isActive: isFocused }, ); return ( - - {isFocused ? "❯ " : "• "} - {text} - + triggerAction(text)}> + + {isFocused ? "❯ " : "• "} + {text} + + ); } @@ -228,9 +231,11 @@ function ButtonInteractive({ label, action }: { label: string; action: unknown } { isActive: isFocused }, ); return ( - - {` ${label} `} - + triggerAction(label, formName, action)}> + + {` ${label} `} + + ); } @@ -274,10 +279,12 @@ function InputView({ props }: ViewProps) { } function InputInteractive({ props }: { props: Record }) { - const { isFocused } = useFocus(); const formName = useFormName(); const { getFieldValue, setFieldValue } = useTui(); const name = str(props.name); + const focusId = `input:${formName ?? ""}:${name}`; + const { isFocused } = useFocus({ id: focusId }); + const { focus } = useFocusManager(); const value = str(getFieldValue(formName, name)); useInput( (input, key) => { @@ -287,17 +294,22 @@ function InputInteractive({ props }: { props: Record }) { } if (key.return || key.tab || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return; - if (input && !key.ctrl && !key.meta) setFieldValue(formName, "Input", name, value + input); + // Accept printable text; drop mouse escape sequences that share stdin. + if (isTypedText(input) && !key.ctrl && !key.meta) { + setFieldValue(formName, "Input", name, value + input); + } }, { isActive: isFocused }, ); const shown = value.length ? value : str(props.placeholder); return ( - - {isFocused ? "❯ " : " "} - {shown} - {isFocused ? "▏" : ""} - + focus(focusId)}> + + {isFocused ? "❯ " : " "} + {shown} + {isFocused ? "▏" : ""} + + ); } @@ -371,14 +383,16 @@ function SelectInteractive({ props }: { props: Record }) { const isSel = o.value === current; const isCursor = isFocused && i === cursor; return ( - - {isCursor ? "❯ " : " "} - {isSel ? "(•) " : "( ) "} - {i + 1}. {o.label} - + choose(i)}> + + {isCursor ? "❯ " : " "} + {isSel ? "(•) " : "( ) "} + {i + 1}. {o.label} + + ); })} - {isFocused ? press 1-{options.length} or ↑↓ to choose : null} + {isFocused ? click, press 1-{options.length}, or ↑↓ to choose : null} ); } diff --git a/examples/openui-tui-chat/src/genui/mouse.tsx b/examples/openui-tui-chat/src/genui/mouse.tsx new file mode 100644 index 000000000..27be4a06e --- /dev/null +++ b/examples/openui-tui-chat/src/genui/mouse.tsx @@ -0,0 +1,171 @@ +import { appendFileSync } from "node:fs"; +import { Box, measureElement, useStdin, useStdout, type DOMElement } from "ink"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + type ReactNode, + type RefObject, +} from "react"; + +// Click-only SGR mouse tracking (?1000) + SGR extended coords (?1006). We use +// click-only (not all-motion ?1003) to avoid flooding stdin on every move. +const SGR_ENABLE = "\u001B[?1000h\u001B[?1006h"; +const SGR_DISABLE = "\u001B[?1000l\u001B[?1006l"; +// Press events end with "M"; release with "m". We only act on press. +const PRESS_RE = /\u001B\[<(\d+);(\d+);(\d+)M/g; + +const DEBUG = process.env.TUI_MOUSE_DEBUG === "1"; + +interface Zone { + ref: RefObject; + onClick: () => void; +} + +interface MouseContextValue { + register: (zone: Zone) => () => void; +} + +const MouseContext = createContext(null); + +/** Position of a node relative to `rootNode`, by summing Yoga offsets up the tree. */ +function positionRelativeTo(node: DOMElement, rootNode: DOMElement | null) { + const yoga = (node as unknown as { yogaNode?: any }).yogaNode; + const rootYoga = (rootNode as unknown as { yogaNode?: any } | null)?.yogaNode; + if (!yoga) return null; + let left = 0; + let top = 0; + let current: any = yoga; + while (current && current !== rootYoga) { + left += current.getComputedLeft?.() ?? 0; + top += current.getComputedTop?.() ?? 0; + current = current.getParent?.(); + } + return { + left, + top, + width: yoga.getComputedWidth?.() ?? 0, + height: yoga.getComputedHeight?.() ?? 0, + }; +} + +/** + * Enables click-only mouse tracking once, parses press events from stdin, and + * dispatches to the topmost registered zone. `rootRef` must point at the + * bottom-anchored dynamic region so we can map absolute screen rows to it. + */ +export function MouseProvider({ + rootRef, + children, +}: { + rootRef: RefObject; + children: ReactNode; +}) { + const { stdin, setRawMode, isRawModeSupported } = useStdin(); + const { stdout } = useStdout(); + const zones = useRef>(new Set()); + + const register = useCallback((zone: Zone) => { + zones.current.add(zone); + return () => { + zones.current.delete(zone); + }; + }, []); + + useEffect(() => { + if (!stdin || !isRawModeSupported) return; + setRawMode(true); + stdout.write(SGR_ENABLE); + + const dispatch = (cx: number, cy: number) => { + const rows = stdout.rows ?? 24; + // The dynamic region is anchored to the bottom of the terminal, so its + // top screen row is rows - height. Zone tops are measured relative to it. + let offsetY = 0; + if (rootRef.current) { + const { height } = measureElement(rootRef.current); + offsetY = Math.max(0, rows - height); + } + + let hit: Zone | null = null; + let hitInfo = "miss"; + for (const zone of zones.current) { + const node = zone.ref.current; + if (!node) continue; + const p = positionRelativeTo(node, rootRef.current); + if (!p) continue; + const top = p.top + offsetY; + if (DEBUG) hitInfo += ` | zone[l${p.left},t${top},w${p.width},h${p.height}]`; + if (cx >= p.left && cx < p.left + p.width && cy >= top && cy < top + p.height) { + hit = zone; + hitInfo = `hit@[l${p.left},t${top},w${p.width},h${p.height}]`; + break; + } + } + + if (DEBUG) { + try { + appendFileSync("/tmp/mouse-debug.log", `click(${cx},${cy}) rows=${rows} offY=${offsetY} ${hitInfo}\n`); + } catch { + // ignore + } + } + if (hit) hit.onClick(); + }; + + const onData = (data: Buffer) => { + const s = data.toString("utf8"); + PRESS_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = PRESS_RE.exec(s)) !== null) { + const cb = Number(m[1]); + if ((cb & 3) !== 0 || (cb & 64) !== 0) continue; // left-button presses only + dispatch(Number(m[2]) - 1, Number(m[3]) - 1); + } + }; + + stdin.on("data", onData); + const cleanupOnExit = () => stdout.write(SGR_DISABLE); + process.once("exit", cleanupOnExit); + + return () => { + stdin.off("data", onData); + stdout.write(SGR_DISABLE); + process.off("exit", cleanupOnExit); + }; + }, [stdin, setRawMode, isRawModeSupported, stdout, rootRef]); + + return {children}; +} + +/** + * True for ordinary typed text; false for escape/control/mouse sequences that + * land on the same stdin when mouse tracking is enabled. + */ +export function isTypedText(input: string): boolean { + if (!input) return false; + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f]/.test(input)) return false; // control chars incl. ESC + if (input.includes("[<")) return false; // SGR mouse marker (ESC already stripped) + return true; +} + +/** Register a click zone for the given ref. No-op when there is no MouseProvider. */ +export function useClickable(ref: RefObject, onClick: () => void) { + const ctx = useContext(MouseContext); + const cbRef = useRef(onClick); + cbRef.current = onClick; + useEffect(() => { + if (!ctx) return; + return ctx.register({ ref, onClick: () => cbRef.current() }); + }, [ctx, ref]); +} + +/** Wrap children in a Box that fires `onClick` when clicked (mouse). */ +export function Clickable({ onClick, children }: { onClick: () => void; children: ReactNode }) { + const ref = useRef(null); + useClickable(ref, onClick); + return {children}; +} From a6c2a03a74934fb59cfa5f43cfb5f5d982e314b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 13:40:16 +0000 Subject: [PATCH 07/12] Bottom-anchor the live region so mouse hit-testing is reliable Mouse clicks were only landing when the conversation had filled the screen; a short/fresh form rendered near the top, but hit-testing assumed the live region was bottom-anchored, so clicks missed. Give the live region the full terminal height with justifyContent=flex-end so it always fills the screen (top at row 0). The click offset then computes to 0 and zone positions equal screen coordinates, so clicks land correctly regardless of content length. Co-authored-by: Ankit Das --- examples/openui-tui-chat/src/app.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx index 021f1fdb1..dad7541a1 100644 --- a/examples/openui-tui-chat/src/app.tsx +++ b/examples/openui-tui-chat/src/app.tsx @@ -1,5 +1,5 @@ import type { Message } from "@openuidev/react-headless"; -import { Box, Static, Text, useFocus, useInput, type DOMElement } from "ink"; +import { Box, Static, Text, useFocus, useInput, useStdout, type DOMElement } from "ink"; import { useEffect, useRef, useState } from "react"; import { useLocalChat, type ProcessFn } from "./chat.js"; import { RenderValue } from "./genui/components.js"; @@ -146,6 +146,8 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { const { messages, isRunning, send } = useLocalChat(processMessage); const [draft, setDraft] = useState(""); const dynamicRef = useRef(null); + const { stdout } = useStdout(); + const rows = stdout?.rows ?? 24; const { isFocused: composerFocused } = useFocus({ id: "composer", autoFocus: true }); useInput( @@ -210,7 +212,13 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { } - + {messages.length === 0 ? : null} {liveAssistant ? ( Date: Thu, 30 Jul 2026 15:18:04 +0000 Subject: [PATCH 08/12] Fix flicker/jumping while typing: single full-height frame, drop The full-height live region plus made Ink repaint and scroll the whole screen on every keystroke (flicker + jumping). Render the current exchange in a single fixed full-height frame instead: Ink updates it in place without scrolling, so typing is stable, and because the frame fills the screen from the top, mouse clicks still map 1:1 to screen rows. Trade-off: only the current exchange is shown on screen (no scrollback history of prior turns). Co-authored-by: Ankit Das --- examples/openui-tui-chat/src/app.tsx | 75 ++++++++-------------------- 1 file changed, 21 insertions(+), 54 deletions(-) diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx index dad7541a1..e5c98a5df 100644 --- a/examples/openui-tui-chat/src/app.tsx +++ b/examples/openui-tui-chat/src/app.tsx @@ -1,5 +1,5 @@ import type { Message } from "@openuidev/react-headless"; -import { Box, Static, Text, useFocus, useInput, useStdout, type DOMElement } from "ink"; +import { Box, Text, useFocus, useInput, useStdout, type DOMElement } from "ink"; import { useEffect, useRef, useState } from "react"; import { useLocalChat, type ProcessFn } from "./chat.js"; import { RenderValue } from "./genui/components.js"; @@ -140,8 +140,6 @@ function AssistantMessageView({ // ─────────────────────────── app ─────────────────────────── -type StaticItem = { kind: "header" } | { kind: "message"; message: Message }; - export function App({ processMessage }: { processMessage: ProcessFn }) { const { messages, isRunning, send } = useLocalChat(processMessage); const [draft, setDraft] = useState(""); @@ -175,62 +173,31 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { const last = messages[messages.length - 1]; const liveAssistant = last && last.role === "assistant" ? last : null; - const finalized = liveAssistant ? messages.slice(0, -1) : messages; const liveContent = liveAssistant ? messageText(liveAssistant.content) : ""; const showThinking = isRunning && liveContent.trim() === ""; + const lastUser = [...messages].reverse().find((m) => m.role === "user") ?? null; - // Completed turns are emitted once into scrollback via , keeping the - // live/interactive region small so the composer never scrolls off screen. - const staticItems: StaticItem[] = [ - { kind: "header" }, - ...finalized.map((message) => ({ kind: "message" as const, message })), - ]; - + // A single fixed-height frame (no ): the current exchange is anchored + // to the bottom. A stable, full-height frame lets Ink update in place without + // scrolling the terminal, so typing no longer flickers/jumps — and because the + // frame fills the screen from the top, mouse clicks map 1:1 to screen rows. return ( - - - {(item, index) => - item.kind === "header" ? ( - -
- - ) : ( - - {item.message.role === "user" ? ( - - ) : ( - {}} - /> - )} - - ) - } - - - - {messages.length === 0 ? : null} - {liveAssistant ? ( - - ) : null} - {showThinking ? : null} - - + +
+ + {messages.length === 0 ? : null} + {lastUser ? : null} + {liveAssistant ? ( + + ) : null} + {showThinking ? : null} + ); From 50fb7dd90287a8dda6d539a164c67e705ab6d540 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 15:21:58 +0000 Subject: [PATCH 09/12] docs: update README for single-frame layout (no scrollback history) Co-authored-by: Ankit Das --- examples/openui-tui-chat/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/openui-tui-chat/README.md b/examples/openui-tui-chat/README.md index a13583dbe..9bb963359 100644 --- a/examples/openui-tui-chat/README.md +++ b/examples/openui-tui-chat/README.md @@ -51,8 +51,7 @@ Clicking works on the **latest** turn's interactive elements (dropdown options, Caveats (inherent to terminal mouse tracking): - While mouse tracking is active, the terminal's native click-drag **text selection/copy is disabled** (hold Shift in most terminals to bypass and select text). -- Only the current (bottom) turn is clickable; earlier turns scrolled into history are keyboard-recallable but not click targets. -- Requires an xterm-style terminal; through tmux you must set `set -g mouse on`. Keyboard remains the fully-portable path. +- Run the app **directly in a terminal** so it receives mouse events; through tmux you must `set -g mouse on` (otherwise tmux captures the mouse). Keyboard remains the fully-portable path. ## Supported components (v1) @@ -72,12 +71,12 @@ pnpm --filter openui-tui-chat typecheck ## Chat UI - A header, a welcome/empty state with example prompts, and a bordered composer with key hints. -- User messages render as bubbles; assistant turns render as live generative UI. -- Completed turns are written to the terminal scrollback via Ink's ``, so full history stays visible and the composer stays anchored at the bottom (no viewport clobbering on tall output). +- The current exchange (your last prompt + the live assistant UI) renders in a single fixed full-height frame anchored to the bottom. Ink updates this frame in place, so typing stays stable (no flicker/scroll-jumping) and mouse clicks map cleanly to screen rows. - An animated spinner shows while the assistant is streaming. +- Trade-off of the fixed-frame approach: only the current exchange is shown on screen (there is no scroll-back log of earlier turns). ## Limitations (POC) - Read-oriented charts/tables render as ASCII; not pixel-faithful. -- Interactivity targets the **latest** assistant turn; completed turns become display-only once they scroll into history. +- Only the current exchange is shown (no on-screen scroll-back of earlier turns). - Queries/`$state` two-way binding beyond simple form fields are out of scope for v1. From 66aced5ed1b95e150ab1af341da97bbc14dcb9ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 20:18:12 +0000 Subject: [PATCH 10/12] Restore on-screen message history (revert to scrollback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring back the multi-turn history that was dropped when fixing the flicker. Render finished turns via Ink (terminal scrollback) with a small natural-height live region — the combination that was smooth in earlier versions. Trade-off: mouse hit-testing becomes best-effort (reliable once the conversation fills the screen); keyboard works everywhere. Documented the history-vs-mouse trade-off in the README. Co-authored-by: Ankit Das --- examples/openui-tui-chat/README.md | 18 ++++--- examples/openui-tui-chat/src/app.tsx | 71 +++++++++++++++++++--------- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/examples/openui-tui-chat/README.md b/examples/openui-tui-chat/README.md index 9bb963359..042d4095e 100644 --- a/examples/openui-tui-chat/README.md +++ b/examples/openui-tui-chat/README.md @@ -46,12 +46,17 @@ or _"Build a contact form with name, email and a topic dropdown"_. ### Mouse support (form elements) -Clicking works on the **latest** turn's interactive elements (dropdown options, buttons, follow-ups, text fields). It uses click-only SGR mouse tracking (`?1000`/`?1006`) enabled once at the root; clicks are hit-tested against the bottom-anchored live region using Yoga layout offsets. +Clicking targets the **latest** turn's interactive elements (dropdown options, buttons, follow-ups, text fields). It uses click-only SGR mouse tracking (`?1000`/`?1006`) enabled once at the root; clicks are hit-tested against the live region using Yoga layout offsets. -Caveats (inherent to terminal mouse tracking): +Caveats (inherent to terminal mouse tracking + the scrollback layout): +- Click hit-testing is **best-effort**: it's reliable once the conversation has filled the screen (so the live region sits at the bottom). On the very first short turn — when there's empty space below the live region — clicks may miss; use the keyboard (Tab + number keys/arrows) there. Getting pixel-perfect mouse on every turn would require a full-screen layout that gives up scrollback history — see below. - While mouse tracking is active, the terminal's native click-drag **text selection/copy is disabled** (hold Shift in most terminals to bypass and select text). -- Run the app **directly in a terminal** so it receives mouse events; through tmux you must `set -g mouse on` (otherwise tmux captures the mouse). Keyboard remains the fully-portable path. +- Run the app **directly in a terminal** so it receives mouse events; through tmux you must `set -g mouse on` (otherwise tmux captures the mouse). Keyboard is the fully-portable path. + +### History vs. reliable mouse (design trade-off) + +Ink can't cleanly give all three of rendered scrollback history, zero typing flicker, and pixel-perfect mouse at once. This example prioritizes **history + smooth typing**, with best-effort mouse. A full-screen (alternate-screen) layout could make mouse pixel-perfect everywhere, but it gives up terminal scrollback history. ## Supported components (v1) @@ -71,12 +76,11 @@ pnpm --filter openui-tui-chat typecheck ## Chat UI - A header, a welcome/empty state with example prompts, and a bordered composer with key hints. -- The current exchange (your last prompt + the live assistant UI) renders in a single fixed full-height frame anchored to the bottom. Ink updates this frame in place, so typing stays stable (no flicker/scroll-jumping) and mouse clicks map cleanly to screen rows. -- An animated spinner shows while the assistant is streaming. -- Trade-off of the fixed-frame approach: only the current exchange is shown on screen (there is no scroll-back log of earlier turns). +- Completed turns are written to the terminal scrollback via Ink's ``, so the full conversation history stays on screen (scroll up to see earlier turns) while the latest assistant turn renders live and interactive at the bottom. Typing is smooth (the live region is small, so Ink updates it without repainting the whole screen). +- User messages render as bubbles; the assistant turn renders as generative UI. An animated spinner shows while streaming. ## Limitations (POC) - Read-oriented charts/tables render as ASCII; not pixel-faithful. -- Only the current exchange is shown (no on-screen scroll-back of earlier turns). +- Mouse hit-testing is best-effort (see the trade-off above); keyboard works everywhere. - Queries/`$state` two-way binding beyond simple form fields are out of scope for v1. diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx index e5c98a5df..021f1fdb1 100644 --- a/examples/openui-tui-chat/src/app.tsx +++ b/examples/openui-tui-chat/src/app.tsx @@ -1,5 +1,5 @@ import type { Message } from "@openuidev/react-headless"; -import { Box, Text, useFocus, useInput, useStdout, type DOMElement } from "ink"; +import { Box, Static, Text, useFocus, useInput, type DOMElement } from "ink"; import { useEffect, useRef, useState } from "react"; import { useLocalChat, type ProcessFn } from "./chat.js"; import { RenderValue } from "./genui/components.js"; @@ -140,12 +140,12 @@ function AssistantMessageView({ // ─────────────────────────── app ─────────────────────────── +type StaticItem = { kind: "header" } | { kind: "message"; message: Message }; + export function App({ processMessage }: { processMessage: ProcessFn }) { const { messages, isRunning, send } = useLocalChat(processMessage); const [draft, setDraft] = useState(""); const dynamicRef = useRef(null); - const { stdout } = useStdout(); - const rows = stdout?.rows ?? 24; const { isFocused: composerFocused } = useFocus({ id: "composer", autoFocus: true }); useInput( @@ -173,31 +173,56 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { const last = messages[messages.length - 1]; const liveAssistant = last && last.role === "assistant" ? last : null; + const finalized = liveAssistant ? messages.slice(0, -1) : messages; const liveContent = liveAssistant ? messageText(liveAssistant.content) : ""; const showThinking = isRunning && liveContent.trim() === ""; - const lastUser = [...messages].reverse().find((m) => m.role === "user") ?? null; - // A single fixed-height frame (no ): the current exchange is anchored - // to the bottom. A stable, full-height frame lets Ink update in place without - // scrolling the terminal, so typing no longer flickers/jumps — and because the - // frame fills the screen from the top, mouse clicks map 1:1 to screen rows. + // Completed turns are emitted once into scrollback via , keeping the + // live/interactive region small so the composer never scrolls off screen. + const staticItems: StaticItem[] = [ + { kind: "header" }, + ...finalized.map((message) => ({ kind: "message" as const, message })), + ]; + return ( - -
- - {messages.length === 0 ? : null} - {lastUser ? : null} - {liveAssistant ? ( - - ) : null} - {showThinking ? : null} - + + + {(item, index) => + item.kind === "header" ? ( + +
+ + ) : ( + + {item.message.role === "user" ? ( + + ) : ( + {}} + /> + )} + + ) + } + + + + {messages.length === 0 ? : null} + {liveAssistant ? ( + + ) : null} + {showThinking ? : null} + + ); From ce60cf2a7b942fd5f506e462d235365d80ca0913 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 21:19:43 +0000 Subject: [PATCH 11/12] Full-screen mouse-first mode + richer gradient component library - Full-screen: render on the terminal alternate screen (cli.tsx) with a single full-height frame showing the current exchange (no scrollback history). This gives reliable pixel-perfect mouse (offset 0) and minimal flicker. - Richer visuals via the Ink ecosystem: a self-contained truecolor GradientText (gradient.tsx) on the header, headings, chart bars and the assistant label; ink-spinner for the streaming indicator; ink-big-text for the welcome splash. - New OpenUI components: Callout (colored banner) and TagBlock (colored pills), wired into the library so the model can emit them. Tests strip ANSI and cover the new components. Co-authored-by: Ankit Das --- examples/openui-tui-chat/package.json | 2 + .../src/__tests__/genui.test.tsx | 37 +++- examples/openui-tui-chat/src/app.tsx | 119 ++++-------- examples/openui-tui-chat/src/cli.tsx | 18 +- .../openui-tui-chat/src/genui/components.tsx | 61 +++++- .../openui-tui-chat/src/genui/gradient.tsx | 56 ++++++ examples/openui-tui-chat/src/genui/library.ts | 21 +++ pnpm-lock.yaml | 175 +++++++++++++++--- 8 files changed, 362 insertions(+), 127 deletions(-) create mode 100644 examples/openui-tui-chat/src/genui/gradient.tsx diff --git a/examples/openui-tui-chat/package.json b/examples/openui-tui-chat/package.json index 9f690f4cc..bc19fb89e 100644 --- a/examples/openui-tui-chat/package.json +++ b/examples/openui-tui-chat/package.json @@ -17,6 +17,8 @@ "@openuidev/lang-core": "workspace:*", "@openuidev/react-headless": "workspace:*", "ink": "^5.1.0", + "ink-big-text": "^2.0.0", + "ink-spinner": "^5.0.0", "openai": "^6.22.0", "react": "^18.3.1", "zod": "^4.3.6", diff --git a/examples/openui-tui-chat/src/__tests__/genui.test.tsx b/examples/openui-tui-chat/src/__tests__/genui.test.tsx index 21ea092f9..fa716d2d1 100644 --- a/examples/openui-tui-chat/src/__tests__/genui.test.tsx +++ b/examples/openui-tui-chat/src/__tests__/genui.test.tsx @@ -13,6 +13,11 @@ import { useGenUi } from "../genui/state.js"; const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); +// Strip ANSI SGR codes so assertions match text even when it's colored/gradient +// (gradient text inserts a color code between every character). +// eslint-disable-next-line no-control-regex +const plain = (s: string | undefined) => (s ?? "").replace(/\u001B\[[0-9;]*m/g, ""); + /** Parse + evaluate an OpenUI Lang program with the TUI library. */ function evalProgram(src: string) { const sp = createStreamingParser(tuiLibrary.toJSONSchema(), tuiLibrary.root); @@ -65,7 +70,7 @@ describe("TUI renderer", () => { createElement(TuiProvider, { value: noopCtx }, createElement(RenderValue, { value: root })), ); - const frame = lastFrame() ?? ""; + const frame = plain(lastFrame()); expect(frame).toContain("Setup Status"); expect(frame).toContain("All green"); expect(frame).toContain("█"); // chart bars @@ -74,12 +79,30 @@ describe("TUI renderer", () => { expect(frame).toContain("react-headless"); }); + it("renders rich Callout and TagBlock components", () => { + const src = [ + "root = Card([c, tags])", + 'c = Callout("success", "All set", "Your Pro plan is active")', + 'tags = TagBlock(["Pro", "Fast", "New"])', + ].join("\n"); + const root = evalProgram(src); + const { lastFrame } = render( + createElement(TuiProvider, { value: noopCtx }, createElement(RenderValue, { value: root })), + ); + const frame = plain(lastFrame()); + expect(frame).toContain("All set"); + expect(frame).toContain("Your Pro plan is active"); + expect(frame).toContain("Pro"); + expect(frame).toContain("Fast"); + expect(frame).toContain("New"); + }); + it("renders unknown components as a visible marker instead of crashing", () => { const root = evalProgram('root = Card([x])\nx = TextContent("hello world")'); const { lastFrame } = render( createElement(TuiProvider, { value: noopCtx }, createElement(RenderValue, { value: root })), ); - expect(lastFrame() ?? "").toContain("hello world"); + expect(plain(lastFrame())).toContain("hello world"); }); it("renders finalized turns display-only (buttons are not interactive)", () => { @@ -90,7 +113,7 @@ describe("TUI renderer", () => { const { lastFrame } = render( createElement(TuiProvider, { value: staticCtx }, createElement(RenderValue, { value: root })), ); - expect(lastFrame() ?? "").toContain("[ Retry ]"); + expect(plain(lastFrame())).toContain("[ Retry ]"); }); }); @@ -134,7 +157,7 @@ describe("TUI interactivity", () => { stdin.write("\r"); // Enter → select Support await delay(40); - const frame = lastFrame() ?? ""; + const frame = plain(lastFrame()); expect(frame).toContain("(•) 2. Support"); expect(frame).not.toContain("(•) 1. Sales"); }); @@ -156,7 +179,7 @@ describe("TUI interactivity", () => { await delay(30); stdin.write("2"); // press "2" → pick the 2nd option directly await delay(40); - const frame = lastFrame() ?? ""; + const frame = plain(lastFrame()); expect(frame).toContain("(•) 2. Support"); expect(frame).not.toContain("(•) 1. Sales"); }); @@ -178,7 +201,7 @@ describe("TUI interactivity", () => { await delay(30); stdin.write("\u001B[B"); // Down arrow only — should select immediately await delay(40); - expect(lastFrame() ?? "").toContain("(•) 2. Support"); + expect(plain(lastFrame())).toContain("(•) 2. Support"); }); it("shows typed Input text immediately", async () => { @@ -196,7 +219,7 @@ describe("TUI interactivity", () => { await delay(30); stdin.write("Hi"); await delay(40); - expect(lastFrame() ?? "").toContain("Hi"); + expect(plain(lastFrame())).toContain("Hi"); }); it("collects form field values and submits them via the button's action", async () => { diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx index 021f1fdb1..0ebd058ff 100644 --- a/examples/openui-tui-chat/src/app.tsx +++ b/examples/openui-tui-chat/src/app.tsx @@ -1,9 +1,12 @@ import type { Message } from "@openuidev/react-headless"; -import { Box, Static, Text, useFocus, useInput, type DOMElement } from "ink"; -import { useEffect, useRef, useState } from "react"; +import BigText from "ink-big-text"; +import Spinner from "ink-spinner"; +import { Box, Text, useFocus, useInput, useStdout, type DOMElement } from "ink"; +import { useRef, useState } from "react"; import { useLocalChat, type ProcessFn } from "./chat.js"; import { RenderValue } from "./genui/components.js"; import { TuiProvider } from "./genui/context.js"; +import { GradientText } from "./genui/gradient.js"; import { tuiLibrary } from "./genui/library.js"; import { isTypedText, MouseProvider } from "./genui/mouse.js"; import { useGenUi } from "./genui/state.js"; @@ -20,16 +23,12 @@ function messageText(content: unknown): string { const firstLine = (s: string) => s.split("\n")[0] ?? ""; -const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - // ─────────────────────────── chrome ─────────────────────────── function Header() { return ( - - - {" ◆ OpenUI TUI Chat "} - + + {" generative UI, streamed into your terminal"} ); @@ -37,11 +36,12 @@ function Header() { function Welcome() { return ( - + + Ask for UI and it renders live, right here in your terminal. Try: · Compare the 4 largest countries by population as a bar chart · Build a contact form with a name field and a topic dropdown - · Show the top 5 programming languages by popularity in a table + · A pricing callout with tags for a Pro plan ); } @@ -57,15 +57,12 @@ function UserBubble({ text }: { text: string }) { } function Thinking() { - const [frame, setFrame] = useState(0); - useEffect(() => { - const t = setInterval(() => setFrame((f) => (f + 1) % SPINNER.length), 90); - return () => clearInterval(t); - }, []); return ( - {SPINNER[frame]} - OpenUI is thinking… + + + + ); } @@ -81,15 +78,15 @@ function Composer({ }) { return ( - - {"❯ "} + + {"❯ "} {draft} - {focused ? : null} + {focused ? : null} {draft.length === 0 ? ( {isRunning ? "waiting for response…" : "Message OpenUI…"} ) : null} - {" Enter send · Tab focus UI · ↑↓ choose · Ctrl+C quit"} + {" Enter send · Tab/click focus · ↑↓ or number keys choose · Ctrl+C quit"} ); } @@ -98,24 +95,15 @@ function Composer({ function AssistantMessageView({ message, - interactive, isStreaming, onSend, }: { message: Message; - interactive: boolean; isStreaming: boolean; onSend: (content: string) => void; }) { const content = messageText(message.content); - const { result, ctx } = useGenUi( - tuiLibrary, - message.id, - content, - isStreaming, - onSend, - interactive, - ); + const { result, ctx } = useGenUi(tuiLibrary, message.id, content, isStreaming, onSend, true); if (!result?.root) { if (isStreaming) return null; @@ -128,9 +116,7 @@ function AssistantMessageView({ return ( - - ◆ OpenUI - + @@ -140,12 +126,12 @@ function AssistantMessageView({ // ─────────────────────────── app ─────────────────────────── -type StaticItem = { kind: "header" } | { kind: "message"; message: Message }; - export function App({ processMessage }: { processMessage: ProcessFn }) { const { messages, isRunning, send } = useLocalChat(processMessage); const [draft, setDraft] = useState(""); const dynamicRef = useRef(null); + const { stdout } = useStdout(); + const rows = stdout?.rows ?? 24; const { isFocused: composerFocused } = useFocus({ id: "composer", autoFocus: true }); useInput( @@ -162,8 +148,6 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { setDraft((d) => d.slice(0, -1)); return; } - // Accept printable text but drop escape/mouse sequences that share stdin - // when mouse tracking is enabled. if (isTypedText(input) && !key.ctrl && !key.meta) { setDraft((d) => d + input); } @@ -173,56 +157,25 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { const last = messages[messages.length - 1]; const liveAssistant = last && last.role === "assistant" ? last : null; - const finalized = liveAssistant ? messages.slice(0, -1) : messages; const liveContent = liveAssistant ? messageText(liveAssistant.content) : ""; const showThinking = isRunning && liveContent.trim() === ""; + const lastUser = [...messages].reverse().find((m) => m.role === "user") ?? null; - // Completed turns are emitted once into scrollback via , keeping the - // live/interactive region small so the composer never scrolls off screen. - const staticItems: StaticItem[] = [ - { kind: "header" }, - ...finalized.map((message) => ({ kind: "message" as const, message })), - ]; - + // Single full-height frame on the alternate screen (see cli.tsx): the current + // exchange is pinned to the bottom, the frame fills the screen from row 0, so + // Ink repaints in place (minimal flicker) and mouse clicks map 1:1 to rows. return ( - - - {(item, index) => - item.kind === "header" ? ( - -
- - ) : ( - - {item.message.role === "user" ? ( - - ) : ( - {}} - /> - )} - - ) - } - - - - {messages.length === 0 ? : null} - {liveAssistant ? ( - - ) : null} - {showThinking ? : null} - - + +
+ + {messages.length === 0 ? : null} + {lastUser ? : null} + {liveAssistant ? ( + + ) : null} + {showThinking ? : null} + ); diff --git a/examples/openui-tui-chat/src/cli.tsx b/examples/openui-tui-chat/src/cli.tsx index 26042c436..c70a52857 100644 --- a/examples/openui-tui-chat/src/cli.tsx +++ b/examples/openui-tui-chat/src/cli.tsx @@ -24,4 +24,20 @@ if (!process.env.OPENAI_API_KEY) { const systemPrompt = tuiLibrary.prompt(); -render(createElement(App, { processMessage: makeProcessMessage(systemPrompt) })); +// Use the terminal's alternate screen buffer: a stable, full-screen canvas that +// doesn't scroll the main buffer, so full-height repaints don't flicker/jump and +// mouse clicks map 1:1 to screen rows. (Trade-off: no scrollback history.) +const ENTER_ALT_SCREEN = "\u001B[?1049h"; +const EXIT_ALT_SCREEN = "\u001B[?1049l"; +process.stdout.write(ENTER_ALT_SCREEN); +const restoreScreen = () => { + try { + process.stdout.write(EXIT_ALT_SCREEN); + } catch { + // ignore + } +}; +process.on("exit", restoreScreen); + +const app = render(createElement(App, { processMessage: makeProcessMessage(systemPrompt) })); +app.waitUntilExit().then(restoreScreen, restoreScreen); diff --git a/examples/openui-tui-chat/src/genui/components.tsx b/examples/openui-tui-chat/src/genui/components.tsx index d80d1d30a..d80a29355 100644 --- a/examples/openui-tui-chat/src/genui/components.tsx +++ b/examples/openui-tui-chat/src/genui/components.tsx @@ -2,6 +2,7 @@ import { Box, Text, useFocus, useFocusManager, useInput } from "ink"; import { Component, Fragment, type ReactNode, useState } from "react"; import { renderBars } from "./chart.js"; import { FormNameProvider, useFormName, useTui } from "./context.js"; +import { GradientText } from "./gradient.js"; import { Clickable, isTypedText } from "./mouse.js"; /** The render contract each library component receives (matches lang-core's ComponentRenderProps). */ @@ -101,7 +102,7 @@ function CardView({ props, renderNode }: ViewProps) { function CardHeaderView({ props }: ViewProps) { return ( - {props.title ? {str(props.title)} : null} + {props.title ? : null} {props.subtitle ? {str(props.subtitle)} : null} ); @@ -109,8 +110,56 @@ function CardHeaderView({ props }: ViewProps) { function TextContentView({ props }: ViewProps) { const size = str(props.size); - const heavy = size.includes("heavy") || size === "large"; - return {str(props.text)}; + const heavy = size.includes("heavy"); + const large = size === "large" || size === "large-heavy"; + const text = str(props.text); + // Headline-sized text gets a gradient for a richer look. + if (large) { + return ; + } + return ( + + {text} + + ); +} + +const CALLOUT_STYLES: Record = { + info: { color: "cyan", icon: "ℹ" }, + success: { color: "green", icon: "✓" }, + warning: { color: "yellow", icon: "⚠" }, + error: { color: "red", icon: "✕" }, + neutral: { color: "gray", icon: "•" }, +}; + +function CalloutView({ props }: ViewProps) { + const variant = str(props.variant) || "info"; + const style = CALLOUT_STYLES[variant] ?? CALLOUT_STYLES.info!; + return ( + + + + {style.icon} {str(props.title)} + + + {props.description ? {str(props.description)} : null} + + ); +} + +const TAG_COLORS = ["cyan", "magenta", "green", "yellow", "blue", "red"] as const; + +function TagBlockView({ props }: ViewProps) { + const tags = Array.isArray(props.tags) ? props.tags.map(str) : []; + return ( + + {tags.map((t, i) => ( + + {` ${t} `} + + ))} + + ); } function TableView({ props }: ViewProps) { @@ -146,9 +195,7 @@ function BarChartView({ props }: ViewProps) { {props.yLabel ? {str(props.yLabel)} : null} {lines.map((ln, i) => ( - - {ln} - + ))} {props.xLabel ? {str(props.xLabel)} : null} @@ -406,6 +453,8 @@ export const views: Record = { Card: CardView, CardHeader: CardHeaderView, TextContent: TextContentView, + Callout: CalloutView, + TagBlock: TagBlockView, Table: TableView, Col: StructuralView, BarChart: BarChartView, diff --git a/examples/openui-tui-chat/src/genui/gradient.tsx b/examples/openui-tui-chat/src/genui/gradient.tsx new file mode 100644 index 000000000..c53fc2036 --- /dev/null +++ b/examples/openui-tui-chat/src/genui/gradient.tsx @@ -0,0 +1,56 @@ +import { Text } from "ink"; + +type RGB = [number, number, number]; + +// Two-stop gradient presets (start → end). +const PRESETS: Record = { + mind: [ + [0, 224, 255], + [180, 90, 255], + ], // cyan → violet + pastel: [ + [255, 140, 200], + [255, 214, 120], + ], // pink → gold + ocean: [ + [0, 170, 255], + [0, 255, 190], + ], // blue → aqua +}; + +function toHex([r, g, b]: RGB): string { + const h = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0"); + return `#${h(r)}${h(g)}${h(b)}`; +} + +function lerp(a: RGB, b: RGB, t: number): RGB { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +} + +/** + * Gradient text using Ink's own truecolor support — one per + * character. Reliable (no external gradient dep) and degrades to plain text on + * terminals without color. + */ +export function GradientText({ + text, + preset = "mind", + bold, +}: { + text: string; + preset?: keyof typeof PRESETS | string; + bold?: boolean; +}) { + const [from, to] = PRESETS[preset] ?? PRESETS.mind!; + const chars = [...text]; + const n = Math.max(1, chars.length - 1); + return ( + + {chars.map((ch, i) => ( + + {ch} + + ))} + + ); +} diff --git a/examples/openui-tui-chat/src/genui/library.ts b/examples/openui-tui-chat/src/genui/library.ts index 0493a95bb..ee541b6c4 100644 --- a/examples/openui-tui-chat/src/genui/library.ts +++ b/examples/openui-tui-chat/src/genui/library.ts @@ -138,6 +138,25 @@ const CardHeader = defineComponent({ component: views.CardHeader, }); +const Callout = defineComponent({ + name: "Callout", + description: + "A colored callout banner for highlights, tips, or status. Choose a variant that matches the tone.", + props: z.object({ + variant: z.enum(["info", "success", "warning", "error", "neutral"]), + title: z.string(), + description: z.string().optional(), + }), + component: views.Callout, +}); + +const TagBlock = defineComponent({ + name: "TagBlock", + description: "A row of short colored tags/pills (keywords, categories, or labels).", + props: z.object({ tags: z.array(z.string()) }), + component: views.TagBlock, +}); + const FollowUpBlock = defineComponent({ name: "FollowUpBlock", description: "A list of follow-up suggestions shown at the end of a response.", @@ -157,6 +176,8 @@ export const tuiLibrary = createLibrary({ Card, CardHeader, TextContent, + Callout, + TagBlock, Table, Col, BarChart, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a2885d4d..b337db1ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -714,6 +714,12 @@ importers: ink: specifier: ^5.1.0 version: 5.2.1(@types/react@19.2.14)(react@18.3.1) + ink-big-text: + specifier: ^2.0.0 + version: 2.0.0(ink@5.2.1(@types/react@19.2.14)(react@18.3.1))(react@18.3.1) + ink-spinner: + specifier: ^5.0.0 + version: 5.0.0(ink@5.2.1(@types/react@19.2.14)(react@18.3.1))(react@18.3.1) openai: specifier: ^6.22.0 version: 6.34.0(ws@8.20.0)(zod@4.3.6) @@ -9004,6 +9010,10 @@ packages: resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} engines: {node: '>=12'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -9464,6 +9474,11 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfonts@3.3.1: + resolution: {integrity: sha512-ZGEmN3W9mViWEDjsuPo4nK4h39sfh6YtoneFYp9WLPI/rw8BaSSrfQC6jkrGW3JMvV3ZnExJB/AEqXc/nHYxkw==} + engines: {node: '>=10'} + hasBin: true + chai@5.2.0: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} @@ -10232,6 +10247,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + define-property@1.0.0: + resolution: {integrity: sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==} + engines: {node: '>=0.10.0'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -11424,6 +11443,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} @@ -11683,6 +11706,20 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ink-big-text@2.0.0: + resolution: {integrity: sha512-Juzqv+rIOLGuhMJiE50VtS6dg6olWfzFdL7wsU/EARSL5Eaa5JNXMogMBm9AkjgzO2Y3UwWCOh87jbhSn8aNdw==} + engines: {node: '>=14.16'} + peerDependencies: + ink: '>=4' + react: '>=18' + + ink-spinner@5.0.0: + resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} + engines: {node: '>=14.16'} + peerDependencies: + ink: '>=4.0.0' + react: '>=18.0.0' + ink-testing-library@4.0.0: resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} engines: {node: '>=18'} @@ -11749,6 +11786,10 @@ packages: iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-accessor-descriptor@1.0.2: + resolution: {integrity: sha512-AIbwAcazqP3R65dGvqk1V+a+vE5Fg1yu/ZKMOiBWSUIXXiwQkYmXQcVa2O0nh0tSDKDFKxG2mY7dB1Sr4hEP1g==} + engines: {node: '>= 0.4'} + is-alphabetical@1.0.4: resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} @@ -11785,6 +11826,9 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + is-bun-module@2.0.0: resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} @@ -11796,6 +11840,10 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-data-descriptor@1.0.1: + resolution: {integrity: sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==} + engines: {node: '>= 0.4'} + is-data-view@1.0.2: resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} engines: {node: '>= 0.4'} @@ -11810,6 +11858,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-descriptor@1.0.4: + resolution: {integrity: sha512-bv5z95W0dDtLfKwDfkTNxaRxmISBD3eQBKJeVxv2AQ7MjuUnDNG7cIQqvFtMOUYhsILWHhMayWdoGqNqYYYjww==} + engines: {node: '>= 0.4'} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -11895,6 +11947,10 @@ packages: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} + is-number@3.0.0: + resolution: {integrity: sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==} + engines: {node: '>=0.10.0'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -12177,6 +12233,10 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kind-of@3.2.2: + resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==} + engines: {node: '>=0.10.0'} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -15017,6 +15077,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom-string@1.0.0: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} @@ -16269,6 +16333,11 @@ packages: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} + window-size@1.1.1: + resolution: {integrity: sha512-5D/9vujkmVQ7pSmc0SCBmHXbkv6eaHwXEx65MywhmUMsI8sGqJ972APq1lotfcwMKPFLuCFfL8xGHLIp7jaBmA==} + engines: {node: '>= 0.10.0'} + hasBin: true + wonka@6.3.5: resolution: {integrity: sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==} @@ -16318,18 +16387,6 @@ packages: utf-8-validate: optional: true - ws@8.18.2: - resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -18948,7 +19005,7 @@ snapshots: dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 @@ -25775,6 +25832,8 @@ snapshots: ansi-regex@6.1.0: {} + ansi-regex@6.2.2: {} + ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -26327,6 +26386,11 @@ snapshots: ccount@2.0.1: {} + cfonts@3.3.1: + dependencies: + supports-color: 8.1.1 + window-size: 1.1.1 + chai@5.2.0: dependencies: assertion-error: 2.0.1 @@ -26524,7 +26588,7 @@ snapshots: cliui@9.0.1: dependencies: string-width: 7.2.0 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 wrap-ansi: 9.0.2 clone@1.0.4: {} @@ -27095,6 +27159,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + define-property@1.0.0: + dependencies: + is-descriptor: 1.0.4 + defu@6.1.4: {} delaunator@5.1.0: @@ -27550,8 +27618,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.6 eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.29.0(jiti@2.6.1)) @@ -27570,8 +27638,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.3 eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.29.0(jiti@2.6.1)) @@ -27597,7 +27665,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -27608,22 +27676,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -27634,7 +27702,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -28641,6 +28709,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-from-dom@5.0.1: dependencies: '@types/hast': 3.0.4 @@ -28983,6 +29055,19 @@ snapshots: ini@4.1.1: {} + ink-big-text@2.0.0(ink@5.2.1(@types/react@19.2.14)(react@18.3.1))(react@18.3.1): + dependencies: + cfonts: 3.3.1 + ink: 5.2.1(@types/react@19.2.14)(react@18.3.1) + prop-types: 15.8.1 + react: 18.3.1 + + ink-spinner@5.0.0(ink@5.2.1(@types/react@19.2.14)(react@18.3.1))(react@18.3.1): + dependencies: + cli-spinners: 2.9.2 + ink: 5.2.1(@types/react@19.2.14)(react@18.3.1) + react: 18.3.1 + ink-testing-library@4.0.0(@types/react@19.2.14): optionalDependencies: '@types/react': 19.2.14 @@ -29072,6 +29157,10 @@ snapshots: iron-webcrypto@1.2.1: {} + is-accessor-descriptor@1.0.2: + dependencies: + hasown: 2.0.4 + is-alphabetical@1.0.4: {} is-alphabetical@2.0.1: {} @@ -29118,6 +29207,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@1.1.6: {} + is-bun-module@2.0.0: dependencies: semver: 7.7.4 @@ -29128,6 +29219,10 @@ snapshots: dependencies: hasown: 2.0.2 + is-data-descriptor@1.0.1: + dependencies: + hasown: 2.0.2 + is-data-view@1.0.2: dependencies: call-bound: 1.0.4 @@ -29143,6 +29238,11 @@ snapshots: is-decimal@2.0.1: {} + is-descriptor@1.0.4: + dependencies: + is-accessor-descriptor: 1.0.2 + is-data-descriptor: 1.0.1 + is-docker@2.2.1: {} is-docker@3.0.0: {} @@ -29204,6 +29304,10 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-number@3.0.0: + dependencies: + kind-of: 3.2.2 + is-number@7.0.0: {} is-path-inside@4.0.0: {} @@ -29457,7 +29561,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.18.2 + ws: 8.20.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -29517,6 +29621,10 @@ snapshots: khroma@2.1.0: {} + kind-of@3.2.2: + dependencies: + is-buffer: 1.1.6 + kind-of@6.0.3: {} kleur@3.0.3: {} @@ -33629,7 +33737,7 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 string-width@7.2.0: dependencies: @@ -33710,7 +33818,11 @@ snapshots: strip-ansi@7.1.0: dependencies: - ansi-regex: 6.1.0 + ansi-regex: 6.2.2 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 strip-bom-string@1.0.0: {} @@ -35158,6 +35270,11 @@ snapshots: dependencies: string-width: 7.2.0 + window-size@1.1.1: + dependencies: + define-property: 1.0.0 + is-number: 3.0.0 + wonka@6.3.5: {} word-wrap@1.2.5: {} @@ -35172,7 +35289,7 @@ snapshots: dependencies: ansi-styles: 6.2.1 string-width: 5.1.2 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 wrap-ansi@9.0.2: dependencies: @@ -35193,8 +35310,6 @@ snapshots: ws@7.5.10: {} - ws@8.18.2: {} - ws@8.20.0: {} wsl-utils@0.1.0: From 059bdedb862615ddc40a98310ce61ef3a3603620 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 00:14:26 +0000 Subject: [PATCH 12/12] Never break on long content: natural-height rendering (drop fixed frame + DSR) A form taller than the terminal broke the fixed-height/alt-screen layout (overflow corrupted the frame; Ink can't reliably clip). Render the exchange at natural height instead, so long content scrolls in the terminal's native scrollback and the layout stays intact. Removed the alternate screen (native scrollback is needed for long forms) and the DSR cursor-position probe (its responses leaked into the composer as garbage). Mouse hit-testing bottom-anchors to the live region, which is correct for screen-filling content incl. long forms. Co-authored-by: Ankit Das --- examples/openui-tui-chat/README.md | 30 ++++++++++---------- examples/openui-tui-chat/src/app.tsx | 14 ++++----- examples/openui-tui-chat/src/cli.tsx | 18 +----------- examples/openui-tui-chat/src/genui/mouse.tsx | 13 ++++----- 4 files changed, 28 insertions(+), 47 deletions(-) diff --git a/examples/openui-tui-chat/README.md b/examples/openui-tui-chat/README.md index 042d4095e..b637316d2 100644 --- a/examples/openui-tui-chat/README.md +++ b/examples/openui-tui-chat/README.md @@ -48,23 +48,21 @@ or _"Build a contact form with name, email and a topic dropdown"_. Clicking targets the **latest** turn's interactive elements (dropdown options, buttons, follow-ups, text fields). It uses click-only SGR mouse tracking (`?1000`/`?1006`) enabled once at the root; clicks are hit-tested against the live region using Yoga layout offsets. -Caveats (inherent to terminal mouse tracking + the scrollback layout): +Caveats (inherent to terminal mouse tracking + natural-height layout): -- Click hit-testing is **best-effort**: it's reliable once the conversation has filled the screen (so the live region sits at the bottom). On the very first short turn — when there's empty space below the live region — clicks may miss; use the keyboard (Tab + number keys/arrows) there. Getting pixel-perfect mouse on every turn would require a full-screen layout that gives up scrollback history — see below. +- Hit-testing anchors to the bottom of the terminal (where the live region ends), so it's reliable for content that fills the screen — including a long form's visible fields. For a short exchange with empty space below, clicks may be slightly off; keyboard (Tab + number keys/arrows) is exact everywhere. - While mouse tracking is active, the terminal's native click-drag **text selection/copy is disabled** (hold Shift in most terminals to bypass and select text). - Run the app **directly in a terminal** so it receives mouse events; through tmux you must `set -g mouse on` (otherwise tmux captures the mouse). Keyboard is the fully-portable path. -### History vs. reliable mouse (design trade-off) +## Supported components -Ink can't cleanly give all three of rendered scrollback history, zero typing flicker, and pixel-perfect mouse at once. This example prioritizes **history + smooth typing**, with best-effort mouse. A full-screen (alternate-screen) layout could make mouse pixel-perfect everywhere, but it gives up terminal scrollback history. +`Card`, `CardHeader`, `TextContent`, `Callout` (colored banner), `TagBlock` (colored pills), +`Table`/`Col`, `BarChart`/`Series` (gradient bars), `FollowUpBlock`/`FollowUpItem`, +`Form`/`FormControl`/`Input`/`Select`/`Buttons`/`Button`. -## Supported components (v1) - -`Card`, `CardHeader`, `TextContent`, `Table`/`Col`, `BarChart`/`Series`, -`FollowUpBlock`/`FollowUpItem`, `Form`/`FormControl`/`Input`/`Select`/`Buttons`/`Button`. - -Follow-ups, buttons and form submits drive the assistant loop via the OpenUI -`@ToAssistant` action. +Headings, chart bars and the header use a truecolor gradient; `ink-spinner` shows while +streaming and `ink-big-text` renders the welcome logo. Follow-ups, buttons and form submits +drive the assistant loop via the OpenUI `@ToAssistant` action. ## Test @@ -75,12 +73,14 @@ pnpm --filter openui-tui-chat typecheck ## Chat UI -- A header, a welcome/empty state with example prompts, and a bordered composer with key hints. -- Completed turns are written to the terminal scrollback via Ink's ``, so the full conversation history stays on screen (scroll up to see earlier turns) while the latest assistant turn renders live and interactive at the bottom. Typing is smooth (the live region is small, so Ink updates it without repainting the whole screen). -- User messages render as bubbles; the assistant turn renders as generative UI. An animated spinner shows while streaming. +- A gradient header, a welcome splash (big-text logo) with example prompts, and a bordered composer with key hints. +- The current exchange renders at **natural height**. Content taller than the terminal (e.g. a long form) scrolls in the terminal's native scrollback rather than corrupting the layout — the composer stays intact and the app never "breaks". Short exchanges stay compact. +- User messages render as bubbles; the assistant turn renders as generative UI with an animated spinner while streaming. ## Limitations (POC) - Read-oriented charts/tables render as ASCII; not pixel-faithful. -- Mouse hit-testing is best-effort (see the trade-off above); keyboard works everywhere. +- On a form taller than the screen, upper fields scroll out of view — reach them with keyboard focus (Tab) or by scrolling the terminal; mouse clicks target the visible (on-screen) fields. +- Mouse hit-testing is best-effort for short exchanges (see caveats above); keyboard works everywhere. +- Only the current exchange is shown; there is no in-app multi-turn history log. - Queries/`$state` two-way binding beyond simple form fields are out of scope for v1. diff --git a/examples/openui-tui-chat/src/app.tsx b/examples/openui-tui-chat/src/app.tsx index 0ebd058ff..d8e9372a0 100644 --- a/examples/openui-tui-chat/src/app.tsx +++ b/examples/openui-tui-chat/src/app.tsx @@ -1,7 +1,7 @@ import type { Message } from "@openuidev/react-headless"; import BigText from "ink-big-text"; import Spinner from "ink-spinner"; -import { Box, Text, useFocus, useInput, useStdout, type DOMElement } from "ink"; +import { Box, Text, useFocus, useInput, type DOMElement } from "ink"; import { useRef, useState } from "react"; import { useLocalChat, type ProcessFn } from "./chat.js"; import { RenderValue } from "./genui/components.js"; @@ -130,8 +130,6 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { const { messages, isRunning, send } = useLocalChat(processMessage); const [draft, setDraft] = useState(""); const dynamicRef = useRef(null); - const { stdout } = useStdout(); - const rows = stdout?.rows ?? 24; const { isFocused: composerFocused } = useFocus({ id: "composer", autoFocus: true }); useInput( @@ -161,14 +159,14 @@ export function App({ processMessage }: { processMessage: ProcessFn }) { const showThinking = isRunning && liveContent.trim() === ""; const lastUser = [...messages].reverse().find((m) => m.role === "user") ?? null; - // Single full-height frame on the alternate screen (see cli.tsx): the current - // exchange is pinned to the bottom, the frame fills the screen from row 0, so - // Ink repaints in place (minimal flicker) and mouse clicks map 1:1 to rows. + // Natural-height frame (no fixed height): the current exchange renders at its + // true size. Content taller than the terminal scrolls natively instead of + // corrupting the layout, so a long form never breaks. Mouse hit-testing tracks + // the region via a cursor-position query (see mouse.tsx). return ( - +
- {messages.length === 0 ? : null} {lastUser ? : null} {liveAssistant ? ( diff --git a/examples/openui-tui-chat/src/cli.tsx b/examples/openui-tui-chat/src/cli.tsx index c70a52857..26042c436 100644 --- a/examples/openui-tui-chat/src/cli.tsx +++ b/examples/openui-tui-chat/src/cli.tsx @@ -24,20 +24,4 @@ if (!process.env.OPENAI_API_KEY) { const systemPrompt = tuiLibrary.prompt(); -// Use the terminal's alternate screen buffer: a stable, full-screen canvas that -// doesn't scroll the main buffer, so full-height repaints don't flicker/jump and -// mouse clicks map 1:1 to screen rows. (Trade-off: no scrollback history.) -const ENTER_ALT_SCREEN = "\u001B[?1049h"; -const EXIT_ALT_SCREEN = "\u001B[?1049l"; -process.stdout.write(ENTER_ALT_SCREEN); -const restoreScreen = () => { - try { - process.stdout.write(EXIT_ALT_SCREEN); - } catch { - // ignore - } -}; -process.on("exit", restoreScreen); - -const app = render(createElement(App, { processMessage: makeProcessMessage(systemPrompt) })); -app.waitUntilExit().then(restoreScreen, restoreScreen); +render(createElement(App, { processMessage: makeProcessMessage(systemPrompt) })); diff --git a/examples/openui-tui-chat/src/genui/mouse.tsx b/examples/openui-tui-chat/src/genui/mouse.tsx index 27be4a06e..ceaed788e 100644 --- a/examples/openui-tui-chat/src/genui/mouse.tsx +++ b/examples/openui-tui-chat/src/genui/mouse.tsx @@ -81,13 +81,12 @@ export function MouseProvider({ const dispatch = (cx: number, cy: number) => { const rows = stdout.rows ?? 24; - // The dynamic region is anchored to the bottom of the terminal, so its - // top screen row is rows - height. Zone tops are measured relative to it. - let offsetY = 0; - if (rootRef.current) { - const { height } = measureElement(rootRef.current); - offsetY = Math.max(0, rows - height); - } + const height = rootRef.current ? measureElement(rootRef.current).height : 0; + // The live region's last line (composer) sits at the bottom of the terminal + // output, so its top screen row is (rows - height). This is correct once the + // content fills the screen (including long forms); for a short exchange it's + // approximate, so keyboard remains the exact path there. + const offsetY = height > 0 ? rows - height : 0; let hit: Zone | null = null; let hitInfo = "miss";