voxal docs
Guides

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.

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

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

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

app.jsx
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 deploy

Anyone can now reach your chat with:

ssh <name>@voxal.sh

Next steps

On this page