Skip to content

Terminal client

A full-screen terminal client for a harness you are already running. Same conversation as the web UI — the same streaming, the same tool cards, the same approvals — driven from the directory you are working in.

┌─ threads ──┬─ felix ─────────────────────┐
│ • refactor │ › explain the proxy worker │
│ docs sync│ ⏺ The proxy strips /api … │
│ │ ⎿ read_file worker/index.ts │
├────────────┴─────────────────────────────┤
│ > _ esc·stop ⇥ │
└──────────────────────────────────────────┘

Running it

Terminal window
pnpm tui:dev # from a checkout
pnpm tui:build && bun apps/tui/dist/felix.js

It needs Bun. The client draws through a native renderer reached over FFI, which Bun has built in and Node does not — the rest of this repo is unchanged and still runs on Node.

It needs a TTY, and it needs a harness. “Needs a TTY” usually means the command was run somewhere that captures output — a CI step, an editor’s task pane, a coding agent’s shell — rather than that anything is wrong with your terminal. Run it in one directly. With neither flag nor environment it talks to http://localhost:8080 with the quick manifest.

Option Environment Meaning
--origin <url> FELIX_ORIGIN Where the harness is. Default http://localhost:8080.
--key <key> FELIX_API_KEY Bearer token. FELIX_AUTH_API_KEYS is accepted too.
--manifest <name> FELIX_MANIFEST Which agent. Default quick.
--thread <id> Resume a thread instead of starting one.
--yes Do not ask before a client tool writes a file.
--insecure Allow the key over plaintext http to a remote origin.

Anything left over is sent as the first message: felix "explain the proxy worker".

A config file covers the common case — ~/.config/felix/config.json (or under $XDG_CONFIG_HOME):

{ "origin": "https://felix.example.com", "apiKey": "sk-felix-…", "manifest": "deep" }

Running from a checkout needs no configuration at all. apps/chat-ui/.dev.vars is this repo’s one local secrets file — wrangler dev reads it, the Vite dev proxy reads it, and so does this client — so pnpm tui:dev picks up the key make up generated. It reads FELIX_API_KEY, FELIX_AUTH_API_KEYS (the harness’s own spelling) and FELIX_ORIGIN, and ignores everything else in the file.

That file is located from the client’s own source, not from the working directory. The TUI runs wherever you happen to be, and walking up from cwd looking for a file full of credentials would read whatever an unrelated parent directory happened to contain.

Precedence is narrowest first, by how many runs a source affects: a flag (this run), then the environment (this shell), then .dev.vars (this clone), then ~/.config (this machine).

Prefer the environment or the file over --key: an argument is visible in ps to every user on the machine. A key is refused outright over plaintext http to anything but loopback, because it would otherwise cross the network in the clear — the mirror of the harness’s own refusal to run FELIX_AUTH_MODE=none on a bind reachable off-host. --insecure overrides that.

Keys

Key
enter Send. While a run is live this steers it rather than starting a turn.
esc Stop the run (POST /chat/abort).
/ Walk back through prompts you have sent; past the newest returns the line you were typing.
ctrl+e Open the line in $VISUAL or $EDITOR. Saving brings the text back to the prompt; it is not sent until you press enter.
tab Focus the thread rail; / and enter to switch, tab again to leave.
ctrl+n New thread.
ctrl+c Stop a live run; again to quit.
y / n Answer whatever is blocking the run.

What these keys do at any moment is written along the bottom of the screen, beside the manifest and origin — the rail’s bindings included, which is where it matters most, because the prompt’s own hint is not drawn while the rail has focus.

The rail takes the keyboard whole while it has focus, so moves its cursor rather than recalling a prompt, and enter picks a thread rather than sending what is typed. Typing filters it, over titles, locally and instantly — esc clears the filter and a second esc leaves the rail. It opens on the thread you have open rather than at the top, and scrolls to keep the cursor on screen however many threads the harness returns. esc does not stop a run while the rail is focused; ctrl+c still does, from anywhere.

The rail is only drawn on a terminal at least 90 columns wide, and tab does nothing below that.

Slash commands

Command
/new Start a thread.
/clear Forget this one (DELETE /chat/history/{id}).
/continue Let the agent keep going.
/think <level> off minimal low medium high xhigh max.
/manifest [name] Switch agent, or list what the harness serves.
/rename <name> A durable name, replacing the title derived from the first message.
/fork Copy this thread into a new one and switch to it. The original is untouched.
/compact Summarise older context now instead of waiting for the window to fill.
/export [file] Write the active branch as JSONL. Defaults to felix-<thread>.jsonl in the working directory, and refuses to overwrite an existing file.
/rewind [n] Move the active leaf back n turns (default 1). Later turns come off the branch rather than being deleted.
/search <text> Full-text across every thread the harness holds.
/open <n|id> Open a numbered hit from the last /search, or a thread by id.
/refresh Re-read the thread list from the harness.
/quit

/search and the rail’s filter answer different questions. /search is the harness’s full-text search over message bodies — a round trip, and the way to find a conversation by something said in it. The filter is over the titles already on screen, needs nothing from the network, and answers “which of these”.

The thread list is read at startup and after anything that changes it here. A thread started in another client — the web app, a second terminal — exists, but this process has no way to hear about it until /refresh.

/rewind needs server event ids, which only arrive with a session snapshot, so it hydrates the thread from the harness before it moves anything — a thread streamed entirely in this process has no ids to rewind to until it does.

Writing more than a line

shift+enter opens a second line. The prompt is a real editor — a cursor, word motion (ctrl+←/ctrl+→), line kills (ctrl+u/ctrl+k), selection and undo — so a paragraph can be written where you are standing. Enter sends.

shift+enter needs a terminal that speaks the kitty keyboard protocol, which is the only way one can report that modifier on enter at all. In a terminal without it, enter still sends and you lose only the second line — ctrl+e is the way through.

ctrl+e hands the terminal to $VISUAL or $EDITOR on a temp Markdown file seeded with whatever is already typed, and takes it back when the editor exits. Quitting without saving, or saving an empty file, leaves the prompt as it was — neither should turn into a sent message.

Pasted text is joined into one line, newlines becoming spaces and the trailing newline most copied blocks carry dropped: a paste is prose you copied, not keys you pressed, and the newlines you type deliberately are the ones that are kept. A paste never sends on its own either — it lands in the prompt and waits for enter, because what reaches the model should be what you read on screen.

When you look away

A run keeps going when the window does not have focus, and can block on an approval minutes later. So two signals, the same split the web UI makes:

  • The window title always reflects the run — (...) Working, (!) Approve, or Felix. It costs nothing and it is what you see in a tab strip. The title stack is pushed on start and popped on exit, so your shell’s own title survives.
  • A desktop notification (OSC 9) fires only once the terminal has reported that it lost focus. A terminal that never reports focus at all gets the title and nothing else: “we do not know” is treated as “you might be watching”, because the alternative is a bell every time a run ends under your nose.

Focus is tracked with DECSET 1004, which most modern terminals support and the rest ignore. FELIX_NO_NOTIFY=1 turns the whole thing off — no title, no reporting, no bell.

When the run is waiting

Three frames pause a run until something answers, and all three take the keyboard:

  • approval_required — a gated tool. y approves, n denies (POST /approvals/{id}/decide). These also arrive by polling GET /approvals, because the harness does not reliably announce them on the stream.
  • ui_request — the agent is asking you something. Select, confirm or type (POST /chat/ui).
  • tool_request — the harness handed a tool back for this process to run. See below.

Client tools run against your working directory

tool_request means the client executes the tool and posts the result back. The browser answers those from an in-tab virtual filesystem; here they run against process.cwd().

Three rules hold:

  • Everything resolves under one root. The model chooses these paths — sometimes from text that came out of an earlier tool result — so anything that climbs out of the working directory is refused. The check compares real paths, and a broken symlink is refused outright: it has no real path to check, and a write would follow it wherever it pointed.
  • Some in-root paths are refused too. Nothing is written under .git/, .husky/ or node_modules/, and no existing executable file is overwritten. Those are the places where “write a file” quietly means “run a command” the next time you use git or enter the directory, and one line of confirmation text is not enough to catch it.
  • Reads are silent; writes ask. A write prints the absolute path it would touch and waits for y — absolute because the relative spelling is the model’s, and echoing it back cannot be checked. --yes answers yes to everything. The prompt expires on its own, and is cancelled if you stop the run, so a write can never land after the turn that asked for it has gone.

The supported verbs are pwd, ls, cat, echo, mkdir, touch, tree, help. This is not a shell: nothing is spawned as a process. local_open has no terminal equivalent and is refused.

What it does not do

The inspector surfaces — memory, the activity feed, plans, usage, manifest versions, evals, scheduled jobs — are the web UI’s. So are image attachments. This client is the conversation.

Threads are cached under $XDG_STATE_HOME/felix (default ~/.local/state/felix), written 0600 inside a 0700 directory — a transcript carries whatever the agent read, and so does the last fifty prompts, kept beside them in prompt-history.jsonl for . The harness remains authoritative for which threads exist and what they are named; the local copy is what keeps a transcript readable when the harness rejects anonymous history reads, and the only record of which manifest a thread used.

Each client takes an exclusive lease on the session it opens, so two clients on one thread is reported rather than raced — open the same thread in the web UI and the second one says so.

On exit the screen goes with the client, so the last thing printed — into normal scrollback, after the terminal has been restored — is the thread you were in and the command that returns to it:

felix: Proxy worker routing
rejoin with: felix --thread 0f9c1e7a-…