Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions examples/openui-tui-chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 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 (also confirms the highlighted Select option).
- **↑ / ↓** 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 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 + natural-height layout):

- 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.

## Supported components

`Card`, `CardHeader`, `TextContent`, `Callout` (colored banner), `TagBlock` (colored pills),
`Table`/`Col`, `BarChart`/`Series` (gradient bars), `FollowUpBlock`/`FollowUpItem`,
`Form`/`FormControl`/`Input`/`Select`/`Buttons`/`Button`.

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

```sh
pnpm --filter openui-tui-chat test # vitest + ink-testing-library
pnpm --filter openui-tui-chat typecheck
```

## Chat UI

- 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.
- 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.
35 changes: 35 additions & 0 deletions examples/openui-tui-chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"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",
"ink-big-text": "^2.0.0",
"ink-spinner": "^5.0.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"
}
}
253 changes: 253 additions & 0 deletions examples/openui-tui-chat/src/__tests__/genui.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
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));

// 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);
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,
interactive: true,
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 = plain(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 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(plain(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(plain(lastFrame())).toContain("[ Retry ]");
});
});

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("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 = plain(lastFrame());
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 = plain(lastFrame());
expect(frame).toContain("(•) 2. Support");
expect(frame).not.toContain("(•) 1. 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(plain(lastFrame())).toContain("(•) 2. 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(plain(lastFrame())).toContain("Hi");
});

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"');
});
});
Loading