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
Section titled “Running it”pnpm tui:dev # from a checkoutpnpm tui:build && bun apps/tui/dist/felix.jsIt 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.
| 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. |
pgup / pgdn |
Read back through the conversation. Paging down to the bottom returns to the live end, so there is no mode to leave. |
tab |
Open the thread picker; ↑/↓ and enter to switch, esc to close. |
shift+tab |
Open the inspector — what the harness recorded, planned, spent, remembers and can retrieve. |
ctrl+n |
New thread. |
ctrl+c |
Stop a live run; again to quit. |
ctrl+d |
Open the debug console, when the client was started with FELIX_DEBUG=1. |
y / n |
Answer whatever is blocking the run. |
e |
Approve a gated call with different arguments — opens them in your editor. |
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.
/help prints the whole set, grouped by which surface owns the key, because the same key means
different things in each: esc aborts a run in the chat, clears the filter in the thread list, and
leaves the search in the inspector. That listing is generated from the table the router is
tested against, so a binding the client has and the help does not is a failing test rather than a
discovery.
The picker opens over the conversation rather than beside it, so it costs nothing when closed and
is the same on a narrow terminal as on a wide one. It takes the keyboard whole while it is open, 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.
It works at any terminal width.
Replies are rendered as the markdown they are — headings, tables, blockquotes, nested lists, links, and fenced code coloured by tree-sitter. Parsers for JavaScript, TypeScript and Markdown are built in; a fence in any other language renders as plain text inside a code block, because fetching a parser would mean this client talking to something other than your harness.
Clicking a thread in the picker opens it. Selecting text with the mouse copies it to your system clipboard, over OSC 52 — which also works
across ssh, where nothing on the remote machine can reach your clipboard directly. Terminals
that do not implement it, or have it switched off, are told to you once in the status line
rather than on every drag.
Colours follow your terminal. The client uses its own palette only when the terminal both reports true-colour support and has said whether it is light or dark; otherwise every colour is an ANSI index, which is a reference into the sixteen your theme defines rather than a colour painted over them. Syntax highlighting inside a code block is always indexed, so a fenced block looks like the rest of the code on your machine.
Slash commands
Section titled “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. |
/plan |
Plan mode on or off, from the next run: the agent only reads and proposes until you approve its plan. The status line says when a thread is in a mode other than its default. |
/ask <question> |
A side question about this conversation, answered in a notice and not added to it — so it works mid-run, or while another client drives the thread. |
/style [name] |
How the agent writes, from the next run: one of the output styles it declares, or default. With no name, lists them and marks the thread’s. |
/manifest [name] |
Switch agent, or list what the harness serves. |
/start <n> |
Put the agent’s nth starter prompt in the composer, to read and send with Enter. The empty thread numbers them; they come from the manifest’s metadata.starters, and an agent that declares none has none here. |
/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. |
/version <n> |
Switch the newest edited message to its nth version, oldest first — the line under it reads version 2 of 3. A switch is a rewind to the newest event under that version, so its replies come back with it. |
/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. |
/artifact <n> [file] |
Read a spilled tool output — n is the handle on the card. With a path, writes it there instead of paging it; refuses to overwrite. |
/skills |
The tenant’s skill library in $PAGER: drafts waiting for review, oldest first, with where each came from and its scores, then the skills in use. |
/skill <name> |
One skill in $PAGER: its versions newest first, who decided each, whether an imported skill’s source has moved on, and the publish gate’s verdict on its newest draft, with the reasons it would refuse. |
/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.
An edited message keeps its other versions on the session: editing rewinds to the message’s parent
and sends the new text beside the old, which is what the web app’s edit does. The version line is
read off the snapshot, so a thread shows it once it has been hydrated — on opening it, or on any
/rewind or /version. /version acts on the newest edited message on the active branch, the one
you are most likely reading.
When the agent plans — the deep pattern’s plan_create, plan_update_step and plan_get — the
transcript draws the plan as one block where it first appeared: its title, how many steps are done,
and each step with the agent’s own word for its status. Every plan call answers with the whole plan,
so the block shows the newest answer and the later calls are folded into it rather than drawn as a
run of near-identical tool cards.
Inspecting the harness
Section titled “Inspecting the harness”shift+tab opens a read-only view of what the harness has been doing. ←/→ move between
sections, ↑/↓ and pgup/pgdn scroll, r re-reads now, and esc closes.
| Section | |
|---|---|
Audit |
What the harness recorded — user_input, tool_call, policy_deny, final_response — newest first. The web app calls this Activity; the tab strip has seven columns per tab at eighty, so this client takes the shorter word. |
Waiting |
Gated tool calls waiting on a person. The same queue the banner draws from, so it is populated whether or not you are watching. |
Plans |
Plans the agent wrote for itself, with how many steps are done. |
Tools |
Per-tool calls, errors and mean latency over the harness’s window. |
Usage |
Spend by thread over the last 24 hours, newest activity first: calls, tokens in and out, and what each thread cost, from GET /usage/threads. The thread on screen is marked ›; calls made outside any thread — and every call from before the harness recorded threads on spend — are one row, outside a thread. “In” is the whole prompt, cached parts included. A blank cost is metered but unpriced — the model has no entry in the pricing catalog, so its tokens count against the caps while its spend records as zero and limits.max_cost_usd fails open for it. Blank rather than $0, because a configuration gap is not a free turn. Against a harness without /usage/threads, the tab shows the raw meter, newest call first. |
Memory |
Facts kept across sessions. / searches them, and the via column names which retriever found each hit — the reason a result looks wrong is usually which channel produced it. Listing instead of searching, that column answers the opposite question: lexical means no embedder ran for the row, so the vector channel cannot reach it at all. |
Corpus |
Documents the agent retrieves from. / searches them, and searching swaps the listing for the matching chunks — the passage is what was retrieved, so the passage is what you read. The via column carries the chunk count when listing and the retriever when searching. |
Skills |
Declared and active, as the agent last listed them. Empty until the agent calls list_skills during a run, and it says so rather than showing an empty list. |
Only the section you are looking at is polled, so the client makes one request every three seconds while the overlay is open and none while it is closed. Unlike the web app it does not stop polling when the terminal loses focus: focus reporting is something plenty of terminals never answer, and a panel showing numbers from ten minutes ago with nothing saying so is worse than one extra request.
shift+tab and tab swap between the inspector and the thread list rather than stacking — one
overlay owns the keyboard at a time. Neither opens while the agent is waiting on you: an approval or
a question keeps the keyboard until it is answered, and ctrl+c still stops the run from anywhere.
Nothing here writes. Memory and the corpus can be read and searched but not added to or removed, which is the web UI’s job — both write paths put text in front of the model that it reads back later, and the forms there say so.
The tenant’s skill library is read with /skills and /skill <name> rather than a ninth tab: eight
tabs already fill the strip at eighty columns, and a report you can scroll, search and copy belongs in
your pager. They are read-only too. Publishing, rejecting and editing a draft are the web client’s,
where every publish states the gate’s verdict and the live version it replaces before it asks.
Output too large to inline
Section titled “Output too large to inline”A manifest with artifacts.enabled replaces any oversized tool result with a preview plus a
reference, and keeps the rest in the object store. The card shows the preview and how much more
there is:
⎿ read_file path=big.log 2026-09-01T04:12:88Z starting run… · 48k more [a1]/artifact 1 fetches the whole thing and opens it in $PAGER (falling back to $VISUAL/$EDITOR,
then less), which is why it is not drawn in the transcript: tens of thousands of characters is a
second transcript, and you already have a pager. /artifact 1 out.log writes it to a file instead,
refusing to overwrite one that exists — the same rule /export follows.
The reference is only read from the end of a tool’s output. A tool that mentions an artifact mid-sentence is talking about one rather than returning one, and does not get a handle.
Writing more than a line
Section titled “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
Section titled “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,(!) Waiting on you(an approval or an agent’s question), orFelix. 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
Section titled “When the run is waiting”Three frames pause a run until something answers, and all three take the keyboard:
approval_required— a gated tool.yapproves,ndenies (POST /approvals/{id}/decide). Against a harness newer thanv0.2.2the frame lands about a second after the tool card appears, while the run is still blocked; an older one delivered it after the decision it was asking for (felix-run/felix#210). Either way the client pollsGET /approvalsas well, and needs to. A durable run’s agent is in the worker while its stream is served by the API, and side events are an in-process queue, so no side event can cross; since felix-run/felix#246 the durable stream rebuildsapproval_requiredfrom the pending row instead, for a caller holdingapprovals:read. The poll is still what makes an unwatched run answerable — nothing is streamed to a client that was never attached — and what keeps this client usable against an older harness.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.
When the harness asks you to approve a file write, the banner shows the change as a diff —
both sides of it, with line numbers — rather than a count of the characters it would replace.
A large diff is cut, and says how many lines it cut, because the y/n keys sit below the
payload and an uncapped diff would push them off the screen.
The banner also names the rule that gated the call and counts down: the harness waits a few minutes for you and then denies on your behalf, so once that passes the prompt says so and stops accepting an answer rather than pretending the decision is still yours. And because approving authorises every identical call to that tool until the same moment — not just the one in front of you — it says that too.
There is a third answer besides yes and no: e opens the call’s arguments in your editor, and
approving then runs the version you saved. Useful when the intention is right and a detail is
wrong — the path is off by a directory, a command has the wrong flag — where denying would throw
the whole thing away and make the agent guess again. Note that it is a substitution rather than a
one-off correction: the same call is rewritten your way until the approval expires.
An agent question that offers choices is a real list you arrow through; one that wants text is a real input with a cursor, word motion and undo, and it takes a paste whole.
Client tools run against your working directory
Section titled “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/ornode_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.--yesanswers 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.
A blocked run costs real time, so every request settles: a refusal, a path outside the root, an
unknown tool and an unanswered prompt all come back as a result, never a hang, because the harness
parks the model loop until it hears back (spec.client_tools[].timeout_seconds, default 120s).
What it does not do
Section titled “What it does not do”Image attachments are the web UI’s, and so is every surface that writes to the harness’s configuration: manifest versions and canaries, eval datasets, scheduled jobs. Those are operator tools with a form each, and a form is what a browser is for.
Reading is a different question, and it is answered here now — see Inspecting the harness above. The split is deliberate rather than a staging post: this is the client with a real working directory, so “why did it do that” gets asked here first.
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. The web UI then watches the thread read-only, and takes it over on its own once the terminal’s lease is released or lapses.
The terminal renews its lease at half the TTL for as long as the thread is open, so a web tab
watching it does not take over while the terminal is still there; quitting or switching threads
releases it. Every message it sends carries the lease token, so if another client does take the
thread — the machine slept past the TTL, say — the harness refuses the terminal’s sends rather than
taking messages from two drivers, and the notice line says another client took over this thread.
When another client already drives the thread, the terminal watches it, as the web UI does.
Opening a held thread — or losing one mid-session — it takes an observer hold of its own: the notice
line says another client holds this thread — following read-only, and the composer’s hint says it
is watching. A message typed then is not sent; it goes back into the field with
another client is driving this thread — watching read-only, your message is back in the composer,
so nothing is lost. Slash commands still work, and a write one makes — /think, say — carries the
observer’s token, which the harness refuses as lease_read_only. Before this, a terminal refused at
startup held no token at all, so its writes went out bare: refused with a confusing lease_held
under FELIX_LEASE_ENFORCE=strict, and accepted
as a second driver under the default.
While watching, the terminal asks for the thread once per renewal interval and renews its observer
hold when the answer is no. As soon as the thread is free it takes it, drops the observer hold, and
says this terminal drives the thread again. Quitting or switching threads releases whichever hold
it has, each with its own token.
The status line says what the client believes about the harness. Where the host normally sits it
reads unreachable when a request never arrived anywhere — usually the harness is not running, since
it is a process on the same machine — or key rejected after a 401. There is no re-prompt: this
process holds a bearer token from a flag, the environment or a config file, and has nowhere to put a
new one, so it names the problem and leaves the fix to you.
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-…