Build a chat app
Build a complete React chat TUI with a scrolling transcript, a live input box, a fetch command, and deploy it
In this guide you will build a complete chat app as a terminal UI: a transcript that scrolls into history, a live input box you can type into, a /fact command that calls an external API, and a /quit command to exit. By the end you will have a runnable app.jsx and the one command to ship it.
What you will build
A single screen with two parts. The top is the transcript, rendered with <Static> so each message is printed once and scrolls naturally into the terminal's scrollback. The bottom is a bordered input box, redrawn on every keystroke, showing your draft and a block cursor.
Set up the messages and draft
Start with serve() and two pieces of per-connection state: the list of messages and the current draft. A small push helper appends a message.
import React, { useState } from 'react';
import { serve, Box, Text, Static, Spacer, useInput, useApp } from '@voxalsh/sdk/ui';
import { fetch } from '@voxalsh/sdk';
function Chat() {
const [messages, setMessages] = useState([
{ id: 0, role: 'bot', text: 'hi! type a message and press enter. /fact for a cat fact, /quit to leave.' },
]);
const [draft, setDraft] = useState('');
const push = (role, text) =>
setMessages((m) => [...m, { id: m.length, role, text }]);
// ...
}Edit the draft and submit on Enter
useInput drives the input box. key.return submits, key.backspace (or key.delete) erases the last character, and any plain typed character is appended. Guard against modifier combinations with !key.ctrl && !key.meta.
useInput((input, key) => {
if (key.return) {
const value = draft;
setDraft('');
submit(value);
} else if (key.backspace || key.delete) {
setDraft((d) => d.slice(0, -1));
} else if (input && !key.ctrl && !key.meta) {
setDraft((d) => d + input);
}
});Add commands and a fetch
submit decides what to do with a line. Plain text is echoed back. /quit calls exit() from useApp(). /fact sets a busy flag, calls fetch for a cat fact, and clears the flag in a finally so it resets even on error.
const { exit } = useApp();
const [busy, setBusy] = useState(false);
const submit = async (value) => {
const text = value.trim();
if (!text) return;
push('you', text);
if (text === '/quit') return exit();
if (text === '/fact') {
setBusy(true);
try {
const res = await fetch('https://catfact.ninja/fact');
push('bot', '🐱 ' + JSON.parse(res.body).fact);
} catch (err) {
push('bot', '(fetch failed: ' + err.message + ')');
} finally {
setBusy(false);
}
return;
}
push('bot', 'you said: ' + text);
};<Static> renders its items once and never repaints them, which is exactly what you want for a chat transcript or a log. New messages append below; old ones stay put in scrollback. Only the input box below it is redrawn each keystroke.
The full app
Here is the complete app.jsx, ready to run.
import React, { useState } from 'react';
import { serve, Box, Text, Static, Spacer, useInput, useApp } from '@voxalsh/sdk/ui';
import { fetch } from '@voxalsh/sdk';
function Message({ role, text }) {
const isYou = role === 'you';
return (
<Box>
<Text color={isYou ? 'cyan' : 'green'} bold>
{isYou ? 'you' : 'bot'}
</Text>
<Text>{' '}</Text>
<Text>{text}</Text>
</Box>
);
}
function Chat({ id }) {
const [messages, setMessages] = useState([
{ id: 0, role: 'bot', text: 'hi! type a message and press enter. /fact for a cat fact, /quit to leave.' },
]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const { exit } = useApp();
const push = (role, text) =>
setMessages((m) => [...m, { id: m.length, role, text }]);
const submit = async (value) => {
const text = value.trim();
if (!text) return;
push('you', text);
if (text === '/quit') return exit();
if (text === '/fact') {
setBusy(true);
try {
const res = await fetch('https://catfact.ninja/fact');
push('bot', '🐱 ' + JSON.parse(res.body).fact);
} catch (err) {
push('bot', '(fetch failed: ' + err.message + ')');
} finally {
setBusy(false);
}
return;
}
push('bot', 'you said: ' + text);
};
useInput((input, key) => {
if (key.return) {
const value = draft;
setDraft('');
submit(value);
} else if (key.backspace || key.delete) {
setDraft((d) => d.slice(0, -1));
} else if (input && !key.ctrl && !key.meta) {
setDraft((d) => d + input);
}
});
return (
<>
{/* Transcript: each message printed once, scrolls into terminal history. */}
<Static items={messages}>
{(m) => <Message key={m.id} role={m.role} text={m.text} />}
</Static>
{/* Live input box, redrawn every keystroke. */}
<Box borderStyle="round" borderColor="gray" paddingX={1}>
<Text color="cyan">{'❯ '}</Text>
<Text>{draft}</Text>
<Text inverse> </Text>
<Spacer />
<Text dimColor>{busy ? '…thinking' : `#${String(id).slice(0, 4)}`}</Text>
</Box>
</>
);
}
serve((conn) => <Chat id={conn.id} />);Run and deploy
Preview locally with voxal dev, then ship it:
voxal deployAnyone can now reach your chat with:
ssh <name>@voxal.sh