controlroom
Recipes

A chat CLI

A chat CLI in the shape of Claude Code on the native API: an inline screen whose transcript flows into scrollback, a streamed markdown reply, slash commands, a permission prompt, Esc to interrupt — and one prompt per stdin line in a pipe.

examples/chat-cli is a chat CLI on controlroom's native API. It runs inline: the transcript flows into the terminal's own scrollback, and a live region under it shows the reply as it streams, a status line, and the input line.

npx degit ofri-peretz/burgee/examples/chat-cli my-chat
cd my-chat && npm install && node chat.mjs
KeyWhat it does
Entersend; Meta+Enter or Ctrl+J for a new line
↑ ↓history
Tabcomplete a /command
Escinterrupt the reply
Ctrl+Cquit, and hand the terminal back as it was

A reply that wants to run a tool asks first: type y or n. The model is scripted, so the boilerplate runs with no key and no network.

How it is built

An inline screen with four parts, each sized to fit what it shows, so the live region is only as tall as the reply being streamed:

const LAYOUT = {
  direction: 'column',
  parts: [
    { size: 'fit', content: 'reply' },
    { size: 'fit', content: 'status' },
    { size: 'fit', content: 'prompt' },
    { size: 1, content: 'hints' },
  ],
};

The transcript is committed. Each prompt and each finished reply goes through screen.commit(), which writes it above the live region, once, into scrollback: ink's <Static>. The reply pane, the spinner status line and the hint line are liveOnly, so a pipe and an agent get the transcript itself.

The input line is caique's editor in the prompt pane, with slash-command completion through its complete option (The input line). Esc is not a key the editor uses while no menu is open, so it falls through to the keymap, where it is bound to interrupt.

A reply streams word by word on the runtime's clock, updating the reply pane and a status line with the elapsed time and a token count. On a terminal the committed reply is the markdown as it was drawn; off a terminal it is the markdown text.

Every caller

In a pipe, each stdin line is one prompt, and the program ends when stdin does:

printf 'hello\n/help\nlist the files\ny\n' | node chat.mjs
> hello
You said: *hello*.

This reply is **scripted**. Replace `MODEL` in `chat.mjs` with a real client.
> /help
/help  show the commands
/clear  start a new conversation
/exit  leave
> list the files
Allow ls? Type y or n.
⏺ ran ls
There are **three** files:

- `chat.mjs`
- `README.md`
- `package.json`

Under --json, each transcript entry is one commit event on stderr:

printf 'hello\n' | node chat.mjs --json
{"event":"commit","state":"> hello"}
{"event":"commit","state":"You said: *hello*.\n\nThis reply is **scripted**. Replace `MODEL` in `chat.mjs` with a real client."}

The example's own npm test checks both, and on a simulated terminal that a reply streams under a status line, Esc interrupts it, Tab completes a slash command and Ctrl+C quits.

What to change first

  1. MODEL: replace reply(prompt) with your client, and stream its tokens into stream() instead of the scripted words.
  2. COMMANDS: the slash commands, and what submit() does for each.
  3. complete: completion for the input line. @file completion goes here.

Coming from an Ink chat app instead? examples/chat-cli-ink is the same boilerplate written for Ink, running on controlroom/ink (Coming from ink).

On this page