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

Source: https://controlroom.interlace.tools/docs/recipes/chat-cli

[`examples/chat-cli`](https://github.com/ofri-peretz/burgee/tree/main/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.

```bash
npx degit ofri-peretz/burgee/examples/chat-cli my-chat
cd my-chat && npm install && node chat.mjs
```

| Key | What it does |
| :-- | :-- |
| `Enter` | send; `Meta+Enter` or `Ctrl+J` for a new line |
| `↑` `↓` | history |
| `Tab` | complete a `/command` |
| `Esc` | interrupt the reply |
| `Ctrl+C` | quit, 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:

```js
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](/docs/guides/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:

```text title="printf 'hello\n/help\nlist the files\ny\n' | node chat.mjs" in="examples/chat-cli"
> 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:

```text title="printf 'hello\n' | node chat.mjs --json" in="examples/chat-cli"
{"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`](https://github.com/ofri-peretz/burgee/tree/main/examples/chat-cli-ink)
is the same boilerplate written for Ink, running on `controlroom/ink`
([Coming from ink](/docs/coming-from/ink)).
