Documentation
A light, AI-first terminal. Everything is a terminal command and every setting is a TOML file — there is no settings UI. This page is the complete reference.
Install & first launch
One command installs aiTerminal on macOS 12+ — it clones the source at the newest release
tag, installs Rust if you don't have it, builds the app and puts aiTerminal.app
in /Applications:
❯ curl -fsSL https://mourad-ghafiri.github.io/aiTerminal/install.sh | sh
The same command updates it later, to whatever the newest release is by then. Set
AITERMINAL_REF to build something else — AITERMINAL_REF=v0.4.0 for an
older release, AITERMINAL_REF=main for the tip. To uninstall, add
-s -- remove (your settings in ~/.aiTerminal are kept unless you add
--purge):
# update
curl -fsSL https://mourad-ghafiri.github.io/aiTerminal/install.sh | sh
# uninstall (add --purge to delete your settings too)
curl -fsSL https://mourad-ghafiri.github.io/aiTerminal/install.sh | sh -s -- remove
It builds for the Mac you are on — Apple Silicon or Intel — so there is no build to choose. Or do it by hand:
# zero external crates — this is the whole build
git clone https://github.com/mourad-ghafiri/aiTerminal.git
cd aiTerminal
cargo build --release
./target/release/aiTerminal
Prefer a real macOS app? One script bundles a self-contained
aiTerminal.app (binary + the builtin/ data + icon):
./tools/bundle-macos.sh # this Mac's architecture → dist/aiTerminal.app (+ a .zip)
open dist/aiTerminal.app # run it — or install it:
cp -R dist/aiTerminal.app /Applications/
# the release set: dist/aiTerminal-macos-{arm64,x86_64,universal}.zip
./tools/bundle-macos.sh all # needs: rustup target add aarch64-apple-darwin x86_64-apple-darwin
First launch seeds ~/.aiTerminal/ with the default config, 19 themes, 30 plugins,
keymaps, agents, flows and skills — every one an editable file. Nothing is ever overwritten
after that: your edits win.
Enable AI
AI is off until you declare a model — no vendor is assumed. Add one
[[ai.model]] to ~/.aiTerminal/config.toml:
[[ai.model]]
provider = "anthropic" # or openai · openrouter · deepseek · groq · grok
id = "claude-opus-4-8" # qwen · kimi · minimax · ollama · lmstudio · local
api_key = "sk-…" # or "$MY_VAR", or omit → $ANTHROPIC_API_KEY
Declare several models and they form a weighted pool — every request draws one from it. aiTerminal never scans your machine for keys: only your config or an environment variable supplies them. See Models & providers for pools, strategies and per-model tuning.
The ~/.aiTerminal folder
~/.aiTerminal/
├── config.toml # the one config file (+ per-profile overlays)
├── profiles/ # <id>/{profile.toml, config.toml, workspace.toml}
├── themes/ # 19 builtin + yours
├── keymaps/ # keybinding layers
├── plugins/ # user-installed plugins (builtins ship with the app)
├── i18n/ # locale overrides
├── ai/
│ ├── aiTerminal.md # the global AI instructions — edit the persona
│ ├── agents/ skills/ prompts/ flows/ mcp/
│ ├── models/ # provider catalogs (12 ship)
│ ├── memory/ # global BM25-ranked markdown memories
│ ├── jobs/ # <id>/{job.toml, runs/<n>.md}
│ └── sessions/ # <id>/ per-folder AI memory (digest + memory/)
├── logs/ cache/ shell/ crash.log
Without writing code
None of this needs a repo. If you never open a source file, most of it is still for you — and the table says exactly what each one needs, so nothing here fails on your machine for a reason you could not have known.
| You want to | Type | Needs |
|---|---|---|
| understand a long PDF, or a photo of a page | @ai summarise @~/Downloads/lease.pdf | a model whose entry declares enable_document (images: enable_vision) — otherwise the file is dropped |
| research a decision, with real sources | @flow research "what changed in the tokio 1.42 release" | [ai] network = true. Search is keyless — no extra account |
| tidy or rename a pile of files | @ai move every screenshot into folders by month | a model. @ai proposes the command and preloads it for you to read |
| read a long web page | @researcher "what does this say — <url>" | [ai] network = true |
| draft something, saved to a file | @writer "turn @notes.md into a brief at brief.md" | a model |
| hold writing under a word count | @loop "cut intro.md under 200 words" --agent writer --check "test $(wc -w < intro.md) -lt 200" | a model, and a command whose exit status decides |
| a weekly digest, in plain English | @job "every Monday at 9, summarise ~/Documents/inbox" | a model to read the schedule once |
| write and preview at the same time | @md edit letter.md | nothing — no model, no network |
| ask your terminal from your phone | @gate telegram start | a bot token |
@ai has no tools — it answers, or proposes one command for you to review.
It cannot open a file, fetch a URL or read your clipboard. What it can see is an
attachment: any @<path> naming a real file rides along with the
question. To have something read a page or a folder, use an agent
(@researcher, @explorer) — those have tools. And an attachment can
be silently dropped: a PDF only reaches a model that declares
enable_document; if yours does not, the question still goes, without the file.These need no model at all: @md · @theme ·
@profile · @config · @plugin · @agent ·
@flow check · @flow graph · @job -- <command>.
@ai — a command to run, or an answer
❯ @ai list files
du -sh . # the proposed command
❯ press Enter to run (or edit) # preloaded — edit or Enter
❯ @ai why is my docker build slow?
The most common causes are cache invalidation and copying node_modules…
@ai is dual-mode: it turns your request into one shell command — proposed
at your prompt to review, edit, and run — or, for a question, a prose answer. It never runs
anything itself. The contract is deliberately tiny and streamable: either the whole
reply is one RUN: <command> line, or it is prose. Only the undecided first
few characters are held back, so an answer starts rendering while it is still arriving — and
a model that keeps talking after the command cannot get a second line onto your prompt.
- Command — collected fully and checked by the guard before your shell
sees it: manual (default) preloads it for review (Enter runs it); auto runs
a guard-allowed command immediately (confirm-tier —
rm -rf,sudo— always drops to review; denied prints as a#comment). - Answer — a question is rendered as styled Markdown (headings, lists, tables, boxed code, links) by a from-scratch engine, and preloads nothing. Piped output stays raw Markdown.
- It grounds on your recent terminal session (shared as a redacted context file) and recalled memory; reasoning stays hidden behind the spinner.
@<agent> · @agent — the specialists
❯ @agent # everything you have
❯ @agent researcher # one in full: tools, skills, what it returns
❯ @coder "fix the failing parser test"
✦ @coder · claude-opus-4-8
⠹ thinking… · a cached prompt prefix costs about a tenth of a fresh one
⚙ fs.search "parse_line" · 18ms · 6 results
⚙ fs.edit src/parser.rs · 6ms · 1 replaced
⋯ sys.run cargo test --workspace
The fix: the parser dropped the final line …
✓ 8.4s · 3 tools · 12.3k in / 1.8k out
One thing writes to that region. The answer repaints in place as it streams while the
tool trace has to stay where it was printed, and those used to be two writers on two streams
that knew nothing about each other — so the next repaint climbed back over lines it had never
painted and ate them. Everything now goes through one sink that takes the live tail off,
writes the line, and puts the tail back. @agent, @loop and a
foreground @job all draw through it, so all three look alike; a tracked
@job keeps a copy of the text in its log, and only the text.
A call still running after a moment says so (the ⋯ row) and its line is replaced
when it lands — a forty-second cargo test used to print nothing at all until it
was over, which on screen is indistinguishable from a hang. And while you wait, one dim line
keeps you company: see a wait says what it is for.
A tool line says what the call was acting on, not the argument JSON it arrived as —
fs.read {"path":"crates/framework/src/cli/runn is the wire format cut at a fixed
width, and it reliably takes the one part you were reading. A long subject is elided in the
middle, because a path's last component is what you were looking for. What came back
is read off the JSON's shape: an array is a count of results, a listing counts its entries, and
multi-line output counts its lines. Nothing in that knows a tool by name, so a tool an MCP server exposes
that this build has never heard of still gets a readable line.
An agent is a Markdown file with TOML frontmatter — system prompt, allowed tools, step
budget, skills. @agent lists the eight that ship; @agent <name>
shows one in full.
| Agent | Role |
|---|---|
@planner | Turns a goal into a small plan with a concrete "done when" — reads, never writes. |
@explorer | Fast read-only scout — maps relevant code, reports tightly. |
@researcher | Finds sources, reads them, and reports what they actually say — with links. |
@coder | Senior engineer + orchestrator — explores, makes the smallest correct edit, verifies, delegates sub-agents. |
@tester | Finds and runs the project's own tests; reproduces a failure, then fixes it. |
@reviewer | Read-only review — correctness, security, tests, design. |
@writer | Documentation and reports for the person who will read them — and saves the file. |
@ai | General assistant — proposes a command to review/run, or answers a question. |
A turn may carry several tool calls — one @tool line each, up to
eight. They run in the order written and every result comes back together, so four file
reads cost one model round trip instead of four. A round trip is the expensive part: each
one re-sends the whole transcript, which is longer than the last. The one rule is that a
batch must be independent — a call whose arguments come from an earlier call's
result belongs on the next turn. Other dialects are accepted for models that will not emit
ours — including a bare <tool> {json} line with no marker at all,
recognised only when the leading token is a tool this agent declared, so prose that
mentions one is still prose — and each is read for every call it carries rather than
the first.
A run that hits a bound still answers. When max_steps runs out — or the
stuck-loop breaker fires — the loop spends one more turn with the tools withdrawn, asking for
the best answer the transcript supports. The outcome is unchanged (⚠ in the
footer, exit 1), because the bound really did fire; what changes is that you get
the findings rather than a sentence about a counter. This matters most inside
@flow: a node whose agent ran out of steps used to fail, block everything
downstream of it and end the run — after doing most of the work.
Every agent ends by stating what it returns. That is not a style rule: a flow node
chains on that text, so {{explore.output}} is only as good as the agent's
discipline. @tester and @reviewer go further and promise a final
VERDICT: PASS / VERDICT: FAIL line — an agent that reports
a failure has still finished its run successfully, so that line is how a workflow tells the
difference.
Live chrome rides stderr, content rides stdout — so piping stays clean:
@explorer "map the auth flow" > auth-map.md works.
A cheap model is a first-class target. An agent run sends its instructions in the request's system slot and the conversation as real role-tagged turns, because role separation is the strongest structural signal a model gets about who said what — and the weaker the model, the more of the work that signal does. A turn that botches the tool-call format is corrected and retried rather than accepted as the answer; three identical calls in a row stop the run; and the context is measured against the model's own window and compacted before that model would refuse a turn.
@flow — a workflow declared as a graph
❯ @flow # the installed flows
❯ @flow check build # prove it runs — no model needed, nothing spent
❯ @flow graph review # draw it, with what each node reaches
❯ @flow review "this branch" # three reviewers, at the same time
❯ @flow build --bg "add a --json flag"
❯ @flow nodes last # every node of a run, side by side
❯ @flow resume last # run only what did not finish
Five flows ship, and none of them names a build tool, a test command or a language —
@tester finds the project's own runner, which is what makes them yours rather
than somebody else's. research is the one that is not about code at all: it
answers any question, needs [ai] network = true, and its search is
keyless — there is no second account to create.
| Flow | What it does |
|---|---|
build | plan → map the code and its conventions in parallel → implement → test → fix until green → review → summarise |
fix | reproduce the failure first → find its cause → patch → prove it is gone |
review | map once, then three reviewers at the same time → one merged verdict |
research | break the question into sub-questions → research each in parallel → compare → report, with sources |
document | read the real code → write the file → check every claim against the source → revise until it holds |
A flow is a TOML file of [[node]] entries — each one an agent run, a shell
command, or a pause for you — and needs names what must finish first. That is
the whole idea, and it buys four things a chain cannot do.
- Nodes that need nothing from each other run at the same time. Three reviews cost one review's wall clock.
whenputs the decision on the edge, as data this tool parses — not as an instruction a model interprets, differently, next time.- A
runnode costs no tokens. It is a command through the same guard as everything else, and its exit status is a fact the graph branches on. The model is spent only where judgement is needed. gotopoints one edge backwards, bounded bymax— so "test, fix, test again" is a flow rather than something you sit and supervise.
# ~/.aiTerminal/ai/flows/ship.toml — the whole vocabulary in one file
[[node]]
id = "build"
agent = "coder"
prompt = "Implement: {{input}}"
[[node]]
id = "verify"
run = "cargo test" # a command node — zero tokens
needs = ["build"]
[[node]]
id = "fix"
agent = "coder"
needs = ["verify"]
when = "verify.failed" # a conditional edge
prompt = "Fix:\n{{verify.output}}"
goto = "verify" # a bounded retry loop
max = 3
Nothing runs until the graph is proved. @flow check costs nothing and
needs no model: it refuses a dangling needs, a reference to a node that does not
run first, a condition naming a node that does not exist, an agent that is not installed, and
a command the guard would refuse. A node cannot read {{other.output}} unless
other is ordered ahead of it — otherwise what it reads depends on which thread
finished first, and that is a race, not a workflow.
The graph is a document, not just a picture
@flow graph builds a Markdown document — a heading, the diagram as a
mermaid fence, and a table of the facts — and hands it to the renderer
@md already uses. So inside aiTerminal the diagram is drawn by the same GPU
renderer that draws every other diagram, and in a pipe it degrades to box art and a plain
table. Neither is a special case; both fall out of it being ordinary Markdown.
A picture answers "what runs after what" and nothing else. The questions people arrive with are which agent is behind that box, what can it reach, and what is the condition on that arrow — so those are the columns.
❯ @flow graph review
review
────────────────────────────────────────────────────────────
Map the code, review it three ways in parallel, then merge into one verdict
5 nodes · 3 parallel · 20m · 4 at a time · needs an input
┌───────────────┐
│ map @explorer │
└───────────────┘
│
┌───────────────────────┴──┬───────────────────────┐
▼ ▼ ▼
┌───────────┴───────────┐ ┌──────────┴─────────┐ ┌─────────┴────────┐
│ correctness @reviewer │ │ security @reviewer │ │ design @reviewer │
└───────────────────────┘ └────────────────────┘ └──────────────────┘
│ │ │
└───────────────────────┬──┴───────────────────────┘
▼
┌────────┴─────────┐
│ report @reviewer │
└──────────────────┘
╭─────────────┬───────────┬──────┬────────────────────╮
│ node │ runs │ when │ reaches │
├─────────────┼───────────┼──────┼────────────────────┤
│ map │ @explorer │ — │ 7 tools · 1 skill │
│ correctness │ @reviewer │ — │ 7 tools · 5 skills │
│ security │ @reviewer │ — │ 7 tools · 5 skills │
│ design │ @reviewer │ — │ 7 tools · 5 skills │
│ report │ @reviewer │ — │ 7 tools · 5 skills │
╰─────────────┴───────────┴──────┴────────────────────╯
@flow show <id> prints the same document after a run, with each node's real
state, model and cost written over it. A window too narrow to draw the diagram in gets the
outline instead — never raw diagram syntax.
Every node is written down, so a run can be picked back up. Each result lands in
ai/flow-runs/<id>/ the moment it happens, and @flow resume
replays the finished nodes from disk — a six-node flow that died at node five costs one node
to finish, not six. A failed node stops its own branch and nothing else, so the independent
work is kept. An approve node asks on a terminal; detached, it parks the run as
waiting instead of deadlocking a background job.
A goal on its own — the graph gets built
❯ @flow explain how the export command works
◈ no flow named — building a graph for this goal
◈ built a 3-node graph: read the code, then explain it · @flow show 1785371201-90257
▸ explain-how-the-export · explain how the export command works
You do not have to already have the right flow. Name none and one is written for this goal — by the model, out of the agents this machine actually has — and then held to every check a graph you wrote by hand is held to: it goes through the same parser and the same verifier, so an agent that does not exist, an edge that points nowhere and a command the guard refuses are all caught before a token is spent running it. When the checker refuses it, its own errors are handed back for one more attempt; a second failure prints them and stops, having spent two small calls and no agent runs.
The graph lives in the run's own record (ai/flow-runs/<id>/flow.toml), so
@flow show, node, log, retry and
resume all work on it and you can read exactly what was made for you — and
@flow keeps listing the five flows you meant to have rather than filling up with
one-off graphs. A build the checker refused is kept too: seeing what it tried to make of your
sentence is the fastest way to understand what it misread.
The first word decides, against what you have installed:
| You typed | What happens |
|---|---|
@flow document this project | document is a flow you have → it runs, with this project as its input |
@flow revieew the parser | close enough to review to be a typo → refused, with the suggestion |
@flow explain this project | neither → the whole line is a goal, and a graph is built for it |
The typo guard is why the third row is safe. @flow revieew the parser must never
quietly become a different flow — and it must not become a goal either, because
building and running a graph for a misspelling is the same footgun in a newer coat.
Watching it run
A chain can narrate itself; a graph cannot. Four nodes start together and finish in whatever order they finish, so a stream of start/done lines hides the most useful thing about the run. So you watch it as the graph it is — and the layout is the graph, not the file: a rank is a column. What runs first is on the left, what runs at the same time stacks in one column, and the arrows only ever point the way the work moves.
▸ build · add a --json flag to the export command
8 nodes · 6 agents · 69 tools · 24 skills · 4 at a time · slowest path plan→explore→apply→verify→review→summary
╭────────────────╮ ╭────────────────╮ ╭────────────────╮ ╭────────────────╮ ╭────────────────╮ ╭────────────────╮
│ ✓ plan ⚙3 │ │ ✓ explore ⚙12 │ │ ⠼ apply ⚙4 ×2 │ │ ○ verify │ │ ○ fix │ │ ○ summary │
│ @planner │───▸│ @explorer │───▸│ @coder │───▸│ @tester │───▸│ @coder │╌╎ ▸│ @writer │
│ 4.2s · 3.1k │ ││ 8.1s · 9.4k │ ││ ⚙ fs.edit src… │ ╎ │ │ ││ when verify.o… │ ╎ ││ │
╰────────────────╯ │╰────────────────╯ │╰────────────────╯ ╎ ╰────────────────╯ │╰────────────────╯ ╎ │╰────────────────╯
│ │ ╎ │ ╎ │
│╭────────────────╮ │ ╎ │╭────────────────╮ ╎ │
││ ✓ conventions │ │ ╎ ││ ○ review │ ╎ │
▸│ @explorer │───┘ ╎ ▸│ @reviewer │───┘
│ 7.6s · 8.8k │ ╎ │ when verify.o… │ ╎
╰────────────────╯ ╎ ╰────────────────╯ ╎
╎ ╎
╎╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╎
⠋ apply · @coder · claude-sonnet-5
running · attempt 2 · 4 tool calls · needs explore, conventions
⚙ fs.read src/export.rs · 9ms · 8.2KB
⚙ fs.edit src/export.rs · 12ms · 1 replaced
⚙ sys.run cargo test · 2.1s · 48 lines
3/8 done · 1 running · 21.3k tokens · 24.6s
The shape. explore and conventions share a column because
they run at the same time; apply is to their right because it waits for both.
Depth is horizontal position and nothing else — it used to be reading order, so two cards side
by side meant only that they were declared next to each other. The ranking and the crossing
reduction are the same
layered-graph
passes the diagram renderer uses, so @flow graph <name> and the live board
agree about the shape.
What is not drawn. An edge the graph already implies is left off. If d
needs a, b and c where b and c
both need a, the direct a → d says nothing new, and drawing it puts
three arrows into one card where one carries the meaning. The dependency is untouched — the
scheduler still honours it; what is dropped is saying it twice. A goto travels in
the band under the whole board and turns in the gaps, so a loop never runs through the cards it
loops over.
The slowest path on the header line is the chain that decides the wall clock. On a graph that overlaps work that is not the slowest node — a slow node with three fast ones beside it costs nothing extra — and it cannot be read off the picture, so it is stated.
A run that goes wrong says so, on the card and in the tally. A settled failure's
third line is why it stopped rather than what it cost — the cost of a failure is the
least interesting thing about it. A node that could never run because something it needed
failed is drawn ⊘ blocked, in amber, distinct from the · of a node
its own condition ruled out; nothing is left reading "waiting" on a run that has finished. And
the tally names every state, not only the two that are going well:
0/5 done · 1 failed · 2 blocked · 2 skipped · 30.0k tokens · 11.5s
The footer under the board then names the first node that failed and the first line of what it said, because by the time you read a footer the board has usually scrolled.
Under the cards, a pane follows whichever node is working — and when something breaks,
the one that broke: its agent,
model, state, attempt, elapsed, cost, what it needs, and the last few tool calls it made. A
card is three lines and has to hold a name; these are the questions asked of a run that is
going wrong, which is the only time anybody watches a board closely. There is no selection
because the board does not read your keyboard — for the whole story of one node,
@flow node <id> and @flow log <id> are the commands.
An edge takes the colour of the node it leaves once that node has settled, so the path that actually ran lights up behind the board and stops exactly where the run did — in the theme's own green, red and amber, alongside the accent the running card pulses in.
Two views. [flow] view = "list" — or --view list for one
command — puts every node back on a single dense row in file order. It is the shortest board
that can exist, which is what a twenty-node flow in a six-line split wants; and the graph view
hands over to it by itself when the cards will not fit the window, in either
direction — depth costs width now, so a nine-deep flow asks for more columns than a terminal
has. Off a terminal — --bg, a pipe, CI — neither view applies: the same state
machine prints [node] event lines instead, nothing is overwritten, and the
attribution a plain stream could never give is still there.
Your keyboard is left alone. While the board owns a region of the screen it asks the
terminal to stop echoing, because the board repaints by climbing back over the block it drew
and an echoed keystroke moves the cursor out from under it — which used to strand a copy of the
board on screen for every Enter pressed during a run. Ctrl-C still works, and an
approve node gets echo and the cursor back for as long as its question is on
screen.
Node control
show is the whole run and log is one node's text. Between them sits
the question people actually ask when an answer looks wrong — what is this node.
❯ @flow nodes last
✗ 900-1 failed · flow 'build'
✓ plan done @planner · claude-sonnet-5 · 4.2s · 9000 tokens · 3 tool call(s)
✓ explore done @explorer · claude-sonnet-5 · 8.1s · 6900 tokens · 12 tool call(s)
✗ apply failed @coder · claude-opus-5 · ×2 · 12.3s · 4 tool call(s)
⊘ verify blocked
· review skipped
left to do apply, fix, summary
| Command | What it does |
|---|---|
@flow nodes [<id>] | every node of a run, side by side: state, agent, model, attempts, time, tokens, tool calls, exit |
@flow node [<id>] <node> | one node in full — its edges and condition, then what it was asked and what it answered, rendered as the Markdown it is |
@flow watch [<id>] | attach to a run that is still going, from any pane — including a --bg run with no terminal of its own. No id attaches to the newest live run; Ctrl-C detaches and the run keeps going |
@flow retry [<id>] <node> | run one node again and everything built on it, printed before anything starts |
Which model served a node is a fact the record has to keep: a pool that picks per run cannot
be read backwards for it. And the cascade in retry is the point — re-running
apply while verify keeps the answer it derived from the old one is
not a retry, it is a record that contradicts itself, where a downstream
{{apply.output}} names text that no longer exists. What came before is
exactly what a resume keeps.
@loop — iterate until it verifies
❯ @loop "make the config tests pass"
🔁 loop 'coder' — up to 5 iteration(s)
verifier: cargo test -p framework config:: — proposed from the goal
▶ iteration 1/5 … ▶ iteration 2/5 …
✓ goal reached after 2 iteration(s)
The verifier is the whole game. --check "<cmd>" is a binary stop
condition — exit 0 = done, no judgement involved. Give none and the AI reads the goal
once and proposes a real command, because the alternative — a model deciding for
itself whether it is finished — is the single most common way agent loops fail. The
proposal is printed before anything runs and the command guard still adjudicates it: a
"verifier" that would deploy, push or install is a side effect, not a measurement, and is
refused. Only if nothing verifiable turns up does an independent reviewer agent grade each
iteration.
And it is proven before it costs anything. The check runs once before iteration 1: guard-refused or unrunnable → exit 2 with nothing spent; already exits 0 → the goal was already met, zero iterations; fails → that failure seeds iteration 1, so the maker starts on the real error instead of a guess.
| Flag | Meaning |
|---|---|
--check "<cmd>" | the verifier; killed if it hangs past [loop] check_timeout |
--no-check | skip inference — grade with a reviewer agent |
--max N | iteration cap (default 5) |
--budget TOKENS | total token budget across iterations |
--timeout 30m | wall clock for the whole run |
--agent <name> | the maker (default coder) |
--bg · --dry-run | detach it · show the plan and the proven verifier, run nothing |
Iterations, tokens and wall clock are three different ways to run away, so all three are bounded — and a flag value that can't be read is an error, never a silent default. No progress is detected: the loop remembers its last few verifier observations, so a run that repeats itself and one that oscillates between two bad states both count. The first time, the maker gets one more iteration, told what was already tried and asked for a materially different approach; the second time ends the run.
❯ @loop # recent runs: verifier · iterations · outcome
❯ @loop show last # goal, bounds, what was already tried
❯ @loop log 4310 -f # the newest iteration, live
❯ @loop resume 4310 --budget 200000 # carry on — with more rope
Every iteration is written to ai/loops/<id>/ — loop.toml
(goal, verifier, bounds, progress) plus iterations/<n>.md. A run stopped
by Ctrl+C, a timeout or the cap resumes from where it stopped, attempt log and all, rather
than paying for the whole thing again. Exit codes: 0 reached ·
1 a bound stopped it · 2 setup error · 130 interrupted.
@job — say what to do, and when
❯ @job "check the logs at midnight"
⧖ every day at 00:00 — check the logs · job 1753112100-4310
fires in 7h · list: @job · cancel: @job cancel 1753112100-4310
❯ @job "run ./backup.sh every weekday at 6pm" # a command job
❯ @job "remind me to stretch in 20 minutes" # one-shot
❯ @job "post the update every monday morning" --dry-run # plan only
❯ @job --every 15m -- ./sync.sh # explicit: no model consulted
❯ @job # list
background jobs (3):
⧖ 1753112100-4310 scheduled check the logs (fires in 7h)
cron 0 0 * * * · 12 run(s) · last ok
▶ 1753112000-4242 running audit the deps … (2m ago · 2m)
✓ 1753111800-4101 done create a CHANGELOG … (9m ago · 45s)
❯ @job show last · @job log 4310 -f · @job cancel 4310
Write the request the way you'd say it. The AI reads when out of the sentence
once, at creation, and writes its answer into the record as a cron
expression — so occurrence #47 of an hourly job costs nothing and behaves exactly like
#1. The interpretation is printed before you accept it, and --dry-run shows
it without scheduling anything. Explicit
--every/--cron/--at/--in skip the model
entirely, and everything after -- is a command job that
needs no AI at all to run — checked by the same command guard as everything else, where
"ask first" is a refusal because a detached job has nobody to ask.
It survives the machine: the sleeper that waits for a fire-time dies with a reboot, so on
the next launch the supervisor re-arms anything still ahead and runs anything overdue
once — an hourly job that missed six hours runs once, not six times. Each
job is a folder — job.toml (what to run, when, and how the last run went) plus
runs/<n>.md, one log per occurrence, pruned and size-capped so an hourly
job can't fill the disk. Statuses are always honest:
scheduled · running · done · failed · cancelled · died · missed — a crashed
job's record heals on the next list, and @job clear prunes everything that is
neither running nor scheduled.
A wait says what it is for
Most of what a command does is instant — a job's record, its arming, its spawn are all under
a millisecond. What is not instant is a model call, and there are four made outside a
run's own loop: the planner reading a @job request, the verifier proposal that
opens a @loop, the graph built for a @flow goal, and a run folding
its own history when the window fills. Every one of them used to happen with nothing on
screen — @job "summarise the logs every morning" sat on a dead terminal until the
model answered.
❯ @job summarise the logs every morning
⠹ reading when to run this…
⧖ every day at 09:00 — summarise the logs · job 1785371201-90257
fires in 14h · list: @job · cancel: @job cancel 1785371201-90257
Not "thinking": these happen before a run exists, and why you are waiting is the
useful part. The spinner also holds its first frame for a moment, so work that finishes
at once draws nothing at all — @job -- echo hi and @job --every 15m …
never consult a model and show nothing new.
@job also says what it understood — but only when that differs from what
you typed. The planner does not just pick a schedule: it strips the timing words out of the
task, and may turn a sentence into a shell command. That rewrite changes what the job
is. An echo of your own sentence is noise, so there is none. And a planner that was
asked and could not answer says so rather than falling silently back to the word
parser — you waited for that call.
Something to read while you wait
⠹ thinking… · @flow retry <node> re-runs it and everything built on it
⠹ thinking… · "Simplicity is prerequisite for reliability" — Dijkstra
One dim line keeps you company. It costs no rows — it rides inside the spinner's own
line, or one constant row under a flow board — it only appears while nothing else is
happening (silent until a wait has lasted after, gone the instant the answer
starts, so a run that answers in three seconds never shows one), and it cannot reach
anything but a screen: a pipe, a --bg job, a job log and CI never see one.
The lines are written by the model, once, into cache/motivation.toml and
reused — in the background, so no run ever waits for them. Tips are drawn from this tool's own
command list rather than imagined, which is what stops a "tip" teaching you a flag that does
not exist. With no model configured there is no cache and the feature is simply absent.
[motivation] turns it off, picks which kinds to draw from, and sets both timings.
@md — read & live-edit Markdown
Needs nothing — no model, no network, no config.
| Command | Does |
|---|---|
@md render <file.md> | pretty-print it in the pane; a long file opens a scrollable pager |
@md edit <file.md> | a full-screen split — Markdown left, live preview right |
Anything else prints the usage and exits 2.
@md render
This is the real output, at 72 columns — headings get a rule, tables are drawn, and the mermaid block becomes a diagram:
❯ @md render release.md
Release plan
────────────────────────────────────────────────────────────
• cut the branch
• run the suite
╭──────┬───────╮
│ step │ owner │
├──────┼───────┤
│ cut │ ada │
│ ship │ grace │
╰──────┴───────╯
┌───┐ ┌───┐ ┌───┐
│ A │──▶┤ B │──▶┤ C │
└───┘ └───┘ └───┘
GitHub-flavored Markdown plus the HTML subset (alerts, footnotes,
<details>, centered blocks, HTML tables), syntax-highlighted code, images
drawn as pixels, and every mermaid diagram type — flowchart, sequence, class, state,
ER, gantt, pie, journey, mindmap, git graph and the rest. Inside aiTerminal a diagram is
drawn as real pixels; piped or in another terminal it is the same picture in Unicode box
art, which is what you see above.
A file taller than the pane opens a pager that starts at the top and reflows on resize:
↑↓/j k scroll · Space/b page · g/G top/bottom · ←→ pan · wheel · q quit
@md edit
A full-screen split: the raw Markdown with a gutter on the left, the rendered preview on the right, re-rendered as you type. The status bar carries the file, a ● when there are unsaved edits, and the line count; the bar at the foot is the whole keymap.
❯ @md edit release.md
release.md ● (10L) saved release.md
1 │ # Release plan │ Release plan
2 │ │ ────────────
3 │ - cut the branch │
4 │ - run the suite │ • cut the branch
5 │ │ • run the suite
6 │ ```mermaid │
7 │ flowchart LR │ ┌───┐ ┌───┐ ┌───┐
8 │ A --> B --> C │ │ A │──▶┤ B │──▶┤ C │
9 │ ``` │ └───┘ └───┘ └───┘
^S save ^W focus:editor ^Q quit · scroll: ↑↓ ←→ · wheel · shift+wheel = horizontal
| Key | Does |
|---|---|
| Ctrl+S | save — the status bar confirms saved <file> |
| Ctrl+W | move focus between editor and preview (the bar shows which has it) |
| Ctrl+Q · Esc | quit — with unsaved edits it asks (y) save · (n) discard · (esc) cancel |
| ↑↓←→ · PageUp/PageDown | scroll and pan the focused half |
| wheel · Shift+wheel | scroll · scroll horizontally; click to focus a half and place the caret |
aiTerminal reports SGR mouse events to whatever is running, so vim and
less get the mouse inside aiTerminal too — not just @md.
@gate — drive your terminal from a chat app
Setting up Telegram, step by step
Five minutes, and four of them are Telegram's.
1 — Create a bot. Open Telegram and message
@BotFather — it is
Telegram's own bot for making bots. Send /newbot. It asks for a display name
(anything, e.g. My Terminal), then a username that must be unique and end in
bot (e.g. mourad_term_bot). It replies with a token — a run of digits,
a colon, then a long random tail. Treat it as a password: anyone holding it can drive your bot.
2 — Put the token in your environment, not in the config file. Add this to your shell
profile (~/.zshrc), so it is not sitting in a file you might share or commit:
❯ export TELEGRAM_BOT_TOKEN='<paste the token BotFather sent you>'
3 — Turn gates on in ~/.aiTerminal/config.toml. The
"$VAR" form is read from the environment at start:
[gates]
enabled = true
[gates.telegram]
token = "$TELEGRAM_BOT_TOKEN"
4 — Start it in whichever tab or split you want to share:
❯ @gate telegram start
⬤ telegram gate live · @mourad_term_bot
pair from the chat: /pair 418-207 (nothing runs until you do)
5 — Pair. Open a chat with your bot and send the six-digit code exactly as
the pane printed it. Until you do, the gate ignores every message. After that, send
git status and it runs.
curl, so it
is visible in the process list on your own machine. It never leaves your machine except to
Telegram's API, and nothing else is sent — but if that matters where you work, know it before
you start.If something is missing, @gate says which piece and how to fix it — it will not
start half-configured.
What it looks like
❯ @gate telegram start # hand this pane to Telegram
⬤ telegram gate live · @mourad_term_bot
pair from the chat: /pair 418-207 (nothing runs until you do)
❯ ls # you, still typing normally
README.md crates docs
▸ Mourad: cargo build # arrived from the chat
❯ cargo build
Compiling aiTerminal…
◂ sent 12 lines
@gate telegram start turns a tab or split into a shell you share. You keep
typing locally while a paired chat drives the same shell — same working directory, same
history, same running program. Every remote action prints a dim line in the pane first, so
nothing anyone does from the chat is invisible.
In a paired chat a plain message is a command; the reply comes back as a code block with its
exit status and duration. /shot sends a PNG of the live terminal, which is
worth asking for when a heavily-styled TUI is on screen: the live text frame keeps the box
drawing but loses the colours. If a command goes quiet waiting for
a password, your next message is routed to its stdin. A long build sends progress notes
and still reports its real exit status. And if you have a half-typed line at the prompt, a
command from the chat waits its turn rather than being spliced into it.
| From the chat | Does |
|---|---|
<anything> · /run <cmd> | run it in the shared shell (through the command guard) |
/sh <cmd> | run out-of-band — works even while a full-screen app owns the shell |
/shot | a screenshot of the terminal as it looks right now |
/key · /keys · /cancel | press a key, type text without Enter, or Ctrl-C the running command |
/ai <prompt> | ask this terminal's own AI, from the chat |
/full · /status · /help · /stop | untruncated output as a file · what's running · the menu · end the gate |
Driving a program — Claude Code, Codex, vim, a REPL
Start something interactive in the gated shell and the gate attaches to it. The chat becomes that program's screen: one message that keeps updating as it redraws, with buttons for whatever it is currently asking. Anything you type is typed into the program and submitted. When it exits, the gate detaches and you are back at the shell.
You ▸ claude
Bot ◂ ▶ attached to claude — it has taken over the terminal.
You ▸ add a --json flag to the export command
Bot ◂ ┌ claude ────────────────────────────┐
│ Edit src/export.rs │
│ │ + #[arg(long)] json: bool │
│ │
│ Do you want to make this edit? │
│ ❯ 1. Yes │
│ 2. Yes, and don't ask again │
│ 3. No, tell Claude what to do │
└────────────────────────────────────┘
[ 1 · Yes ] [ 2 · Yes, and… ] [ 3 · No, tell… ]
[ ↑ ] [ ↓ ] [ ⏎ ] [ esc ] [ ^C ] [ 📷 ]
Tap 1. The same message updates in place — taps add nothing to the conversation, so the live screen stays at the bottom where you can see it.
vim, htop, python3, psql —
and whatever ships next month. The terminal protocol is the signal: a program that
manages the screen says so in DEC private modes. The catch is that a shell's line editor arms
two of them at every prompt (zle_bracketed_paste and smkx), so
bracketed paste and application cursor keys mean nothing on their own — only the alternate
screen and mouse reporting are shell-proof. For the ambiguous pair the shell integration
already tells us the truth, since a command runs between the preexec and precmd marks:
alt || mouse || ((bracketed || app_cursor) && command_running). At a prompt
nothing is running; once claude starts, the same modes become decisive.
A REPL sets no modes at all, so the fallback is the shape of a prompt — quiet output and
the cursor parked after a prompt character, which is also what tells it from a download that
stalled mid-line. Buttons follow the shape of a question, so an agent listing its plan is
left alone.Reading those modes is not only detection — it is also how input is encoded. Arrows go
out as ESC O A when the program asked for application cursor keys and
ESC [ A when it did not; many programs accept only the form they requested.
A multi-line prompt is wrapped in ESC[200~ … ESC[201~ when the program
enabled bracketed paste, so it arrives as one paste instead of the first line being
submitted and the rest becoming follow-ups. That is what makes sending a real prompt from a
phone work at all.
While attached, /run is refused rather than queued — there is no shell to
run it in, and firing it minutes later when the program exits would be worse; use
/sh <cmd> for an out-of-band shell. The command guard does not apply to
program input: that is the honest description, and also the point — the program's own
confirmations, which you now answer from your phone, are the control. Frames wait for the
screen to settle and never redraw more than once every couple of seconds, so a program
streaming a long answer cannot flood the chat. [gates] attach = false turns it
all off.
[gates] enabled = false), and a bot accepts messages from anyone who learns its
@name — so a chat id is an address, not a credential. The real control is pairing: a
six-digit code printed in your terminal, which only someone looking at your screen can
read. Until a chat pairs it gets no reply at all. Five wrong codes close pairing for the
session, only one chat may be paired at a time, and every command still passes your
guard, with your secret rules applied on the way out.
Read docs/gate.md
— including what it does not protect against — before enabling it.@profile — identities that switch everything
Needs nothing. A profile is a named terminal identity: its own config overlay and its own saved tabs, splits, working directories and terminal content.
| Command | Does |
|---|---|
@profile | list them — ● marks the active one |
@profile <id> | switch by id or name; a running window follows within a second |
@profile current | print the active id (one word — made for scripts) |
@profile create "<name>" [emoji] | create one and print its config-overlay path |
@profile rename <id> "<new name>" [emoji] | rename it |
@profile delete <id> | delete it |
@profile edit [<id>] | open its config overlay in $EDITOR; saving applies live |
❯ @profile
profiles in ~/.aiTerminal/profiles (1):
● 🚀 Default (default)
switch: @profile <id> · settings: @profile edit (a running window follows live)
❯ @profile create "Work" 💼
created profile 'Work' (work) — switch with: aiTerminal profile switch work
its config overlay: ~/.aiTerminal/profiles/work/config.toml
❯ @profile
profiles in ~/.aiTerminal/profiles (2):
○ 🚀 Default (default)
● 💼 Work (work)
switch: @profile <id> · settings: @profile edit (a running window follows live)
❯ @profile default
switched to profile 'default' — a running window applies it within a second
Every profile owns profiles/<id>/config.toml — an all-commented template
where only the keys you uncomment override the global config. A different theme, a different
AI pool, different keybindings per profile, all TOML. Its open tabs and splits persist to
workspace.toml on quit and autosave, including each pane's working directory,
zoom and terminal content with its colours — so switching back restores what you
left, not an empty shell.
@theme — 19 themes, live
Needs nothing. Switching restyles the window, its panes and the running shells' colours within a second — there is no settings window in this product.
| Command | Does |
|---|---|
@theme | list all 19 — ● marks the active one |
@theme <name> | apply it to the active profile, live |
@theme path | print the themes directory |
@theme export <name> | print that theme's full normalized TOML, every token resolved |
❯ @theme
themes in ~/.aiTerminal/themes (19):
○ alpine
○ coral
○ cosmic-orange
…
● midnight
…
○ sunset
○ titanium
switch: @theme <name> · export a reference: @theme export <name>
❯ @theme sunset
theme 'sunset' applied to profile 'default' — a running window restyles within a second
❯ @theme export nebula
name = "Nebula"
dark = true
bg = "#0B0E14"
surface = "#161A23"
fg = "#F2F4F8"
muted = "#8A90A0"
accent = "#D85BFF"
…
A theme is one TOML of semantic tokens. Themes are seeded into
~/.aiTerminal/themes/ on first launch and read from there, so dropping a file in
adds a theme — @theme export gives you a complete, correct starting point rather
than a blank page.
@config — the one file
Needs nothing. Everything is TOML and there is no settings UI: inspect here, edit the file, reload with ⌘, or restart.
| Command | Does |
|---|---|
@config | the effective settings — after the profile overlay is applied |
@config path | print the config file's path (for $EDITOR "$(@config path)") |
❯ @config
config: ~/.aiTerminal/config.toml
theme = sunset
font_family = Menlo
font_size = 13
zoom = 1
tab_bar = top
shell = $SHELL
scrollback = 10000
edit the file, then reload in the app with Cmd-, (or restart)
What it prints is the effective value, so it is the fastest way to find out whether a profile overlay is overriding what you think it is. The full key reference is below.
@plugin — what is running, and what you add
Needs nothing. A plugin is a folder with a plugin.toml — declarative
data, not code. 31 ship with the app and load from the bundle; the ones you add live in
~/.aiTerminal/plugins/. Both are listed, because both are running.
| Command | Does |
|---|---|
@plugin · @plugin list | every plugin — bundled and installed |
@plugin info <name> | one in full: version, description, whether it is bundled or yours |
@plugin install <path> | copy a plugin folder into the plugins dir |
@plugin enable <name> · disable | turn one on or off — bundled ones included |
@plugin remove <name> | delete an installed plugin (bundled ones cannot be removed, only disabled) |
❯ @plugin
plugins (30 bundled · 0 installed):
● ai-guard 1.0.0 Default rules for the AI guard: what may run, be touched, leave
● ai-terminal 1.0.0 @ai · @<agent> · @flow · @loop · @job · @md · @gate …
● alias-hints 1.0.0 Suggests the shortest alias for any command you type
● autosuggest 1.0.0 Inline suggestions from history as you type — → accepts
…
bundled plugins live in the app; yours go in ~/.aiTerminal/plugins
one in full: @plugin info <name> · turn one off: @plugin disable <name>
❯ @plugin info git
git v1.2.0
Comprehensive git integration: status segment (staged + unstaged dots), 100+ aliases,
branch-aware helpers, abbreviations, completions
bundled with the app · enabled: true
Only trusted plugins — the bundled ones — may contribute exec providers
or shell snippets, because a snippet is shell code your shell runs. An installed third-party
plugin still supplies aliases, abbreviations, completions and status segments, but its
exec providers are skipped. Disabling a plugin takes effect on the next shell.
Exit codes & scripting
Every AI command tells the shell the truth, so $?, && and CI compose:
| Code | Meaning |
|---|---|
0 | completed (for @loop: the goal verified) |
1 | failed — model/transport error, step limit, tool stall; loop stalled/exhausted/out of budget |
2 | setup error — unknown agent/flow, AI not configured, guard-blocked check |
130 | interrupted — Ctrl+C cancelled cleanly (the in-flight request is killed mid-token) |
Tabs, splits & the quick switcher
- ⌘T new tab · ⌘W close · ⌘1…⌘9 jump · ⌃Tab cycle.
- ⌘D split right · ⌘⇧D split down · ⌘⌥arrows move focus · ⌘↩ zoom a pane full.
- Per-pane font zoom (⌘=/⌘-/⌘0), 10,000-line scrollback, char/word/line mouse selection, ⌘-click links and paths. ↩ with a live mouse selection copies it instead of executing.
- Command-line editing like a text field (the
lineeditplugin): ⌥/⌘ arrows jump by word / to the line ends, ⇧+arrows select on a light band (text keeps its colors), typing replaces the selection, ⌫/⌦ delete it, Esc cancels, ⌘C copies (OSC 52). Cursor: block (default), bar or underline. - ⌘P (or ⌘K) opens the switcher: type a number or fuzzy text over titles and working directories. Tabs auto-name from the running program, folder and shell — including the remote host over SSH.
- The tab bar docks top, bottom, left or right (
[behavior] tab_bar).
Profiles & session restore
A profile = a config.toml overlay (theme, AI pool, plugins, locale…) + a saved
workspace (tabs, splits, focus, per-pane zoom and cwd, window size — and the styled pane
content itself). Switching restores exactly the state you left, colors included,
silently. Autosave: 5 s after a structure change, every 30 s for content (skipped when
nothing changed).
Themes
19 ship: Midnight (default) · Graphite · Alpine · Nebula · Deep Purple · Lavender · Pink ·
Product RED · Sage · Gold · Mist Blue · Titanium · Coral · Cosmic Orange · Solar Flare ·
Sunset · Sky Blue · Starlight · Light Gold. A theme is one TOML of semantic tokens
(bg surface fg muted accent success warn error), a 16-color ANSI palette and
filetype colors that drive theme-matched ls output. Drop your own in
~/.aiTerminal/themes/ — @theme export gives you a starting point.
Keybindings
Layering, later wins: builtin default → plugin keybindings → your
~/.aiTerminal/keymaps/*.toml → config.toml [[keybinding]]:
[[keybinding]]
key = "cmd+shift+enter"
action = "zoom_pane"
Chords follow the keycap, not the position: a binding on ⌘⇧M matches whichever key types M on your layout. ⌘⇧←/→ are deliberately unbound at the window level — they reach the shell as select-to-line-edge, like every macOS text field. The full action list ships in the docs folder of the repo.
Closing things
Needs nothing. ⌘W, ⌘⇧W and ⌘Q can ask before they act — because ⌘Q sits beside ⌘W and takes every tab, split and running shell with it.
[behavior]
confirm_close_pane = false # a split is cheap to reopen
confirm_close_tab = true
confirm_quit = true
The dialog takes keyboard and mouse: esc cancels, ←/→ (or Tab) move between the buttons, ↵ chooses the focused one, and either button is clickable. A click on the backdrop cancels; ⌘Q pressed again while it is open confirms.
Cancel holds focus when it opens. A hand that hit ⌘Q by accident hits ↵ next, and that has to keep the session rather than end it. Confirming is deliberate — →, a click, or the chord again.
Closing the last split, or the last tab, ends the session — so it asks the quit
question and obeys confirm_quit, whatever the other two say. It tells you what is
at stake, counted from the live window rather than guessed:
Quit aiTerminal?
3 tabs · 5 splits will close
[ Cancel ] [ Quit ]
esc cancel · ←→ move · ↵ choose
Languages (i18n)
English and French ship; select with [appearance] locale = "fr" — per profile if
you like. Every chrome/CLI string flows through the catalog; add
~/.aiTerminal/i18n/<locale>.toml to translate or override anything.
Missing keys fall back locale → en → key, and CI enforces en/fr parity.
Shell integration
Non-destructive, and your rc files always win: zsh rides a generated ZDOTDIR
that sources your own .zshrc first; bash uses --rcfile. The master
switch is [shell] integration = false. What rides in:
- Plugin aliases, abbreviations and tab-completions
- Theme colors as
TT_*vars + matchingLS_COLORS— re-sourced live on@theme - The
@-command handlers viacommand_not_found(fires only for@-words) TT_SESSION_LOG— the redacted session context@aigrounds on
config.toml reference
| Section | Keys |
|---|---|
[appearance] | theme · locale · font_family (default Menlo) · font_size (default 15) · cursor_style (block / bar / underline) |
[behavior] | zoom · tab_bar = top|bottom|left|right · shell · scrollback (default 10000) · confirm_close_pane (default false) · confirm_close_tab · confirm_quit (default true) — see Closing things |
[ai] | share_terminal_context · memory · show_reasoning (show thinking text; default off) · mode = manual|auto · network · budget (USD cost soft-cap) · context_window (tokens to budget against; 0 = the serving model's own) · compact_at (fraction of the window that triggers compaction; default 0.75) — then [ai.balance] strategy + the [[ai.model]] blocks, last |
[[ai.model]] | provider · id · api_key · weight · temperature · top_p · top_k · max_tokens · thinking |
[gates] | enabled · require_pairing · plain_text = run|ignore · screenshot = document|photo · max_reply_messages · idle_timeout_minutes — then a [gates.<channel>] table (token · allow), last |
[jobs] | max_concurrent · keep_runs · max_log_bytes |
[loop] | max · timeout · check_timeout · keep_runs · propose_check (let the AI infer a verifier command) |
[flow] | concurrency (nodes in flight at once) · timeout · node_timeout · keep_runs · max_map (the ceiling on a fan-out) · view (graph | list — how a run is watched and drawn) |
[motivation] | enabled · kinds (tips | facts | quotes | encouragement) · after · every — one dim line beside the spinner while a run waits, written by the model into cache/motivation.toml and reused; absent with no model |
[md] | syntax · image_max_rows · remote_images |
[plugins] | enabled · disabled = [...] |
[shell] | integration |
[logging] | level = off|error|warn|info|debug|trace · retention_days (default 7) |
[[guard.command]] | pattern · rule = deny|confirm|allow|auto |
[[guard.path]] | pattern · rule = deny|read-only|allow |
[[keybinding]] | key · action |
[[guard.secret]] | pattern · name · scope = ai|terminal|all · literal |
@theme within a second — and ⌘, reloads instantly.Plugins
A plugin is a folder with a plugin.toml — declarative data over generic core
primitives; no plugin code runs inside the terminal process:
[plugin]
name = "my-tools"
description = "my aliases and status segment"
[aliases]
gs = "git status -sb"
[[segment]]
align = "right"; template = "🔧 {my.var}"
# plus: [[abbr]] [[completion]] [[keybinding]]
# [[guard.command]] [[guard.path]] [[guard.secret]] shell.zsh shell.bash
30 builtins ship (git, docker, kubernetes, rust, python, node, github, extract, jump,
autosuggest, syntax-highlight, history, lineedit, sudo, clipboard, encode, weather, web-search,
ai-guard, …).
Trusted plugin shell snippets are sourced into your shell; third-party installs go under
~/.aiTerminal/plugins/.
Models & providers
12 provider catalogs ship as TOML under ai/models/ — Anthropic, OpenAI,
OpenRouter, DeepSeek, Groq, Grok (xAI), Qwen, Kimi (Moonshot), MiniMax, plus local Ollama,
LM Studio and a generic OpenAI-compatible local. Any model id a known provider
serves works, even if not pre-declared. Per-model capabilities (vision,
document, thinking, tools) gate what each request
carries; per-model pricing powers the token accounting in every run footer.
[[ai.model]] blocks LAST in the [ai] section.
config.toml is plain TOML: every bare key = value belongs to the
table header above it. A model block opens a new table, so an [ai]
setting written below one silently joins that model. The seeded file is already
laid out this way, and aiTerminal warns at startup if it ever happens.
Getting started — one model
There is one pool and one strategy: every request (@ai, agents, flows,
loops) draws a model from it. No separate “fast” tier, no global key.
[[ai.model]]
provider = "openrouter" # any provider (see ai/models/*.toml)
id = "deepseek/deepseek-chat" # any id that provider serves
api_key = "sk-or-v1-…"
That is the whole setup — weight is optional, a lone model serves every
request.
API keys — three ways
Keys belong to the model that needs them, so a mixed pool carries one key per provider.
In config.toml | Resolves to |
|---|---|
api_key = "sk-…" | the key itself |
api_key = "$MY_KEY" or "${MY_KEY}" | that environment variable's value |
api_key omitted | the provider's own variable — $ANTHROPIC_API_KEY, $OPENAI_API_KEY, $OPENROUTER_API_KEY, $DEEPSEEK_API_KEY, … |
Expansion happens at request time, so exporting or rotating a key takes effect
without touching config.toml. An unset variable resolves to nothing (never
the literal "$MY_KEY") and you get a hint naming the variable to set. Local
providers need no key at all:
[[ai.model]]
provider = "ollama"
id = "llama3.1"
A pool of several models
Add more blocks. weight is each model's share of requests; omit it and a
model gets a full 100. Sampling settings are optional per model.
[[ai.model]] # ~10% — keep the pricey one rare
provider = "anthropic"
id = "claude-opus-4-8"
api_key = "$ANTHROPIC_API_KEY"
weight = 10
temperature = 0.3 # 0.0–1.0, lower is more deterministic
max_tokens = 8000 # response cap (clamped 1–200000)
thinking = true # force extended thinking on/off
[[ai.model]] # ~90% — the everyday workhorse
provider = "openrouter"
id = "deepseek/deepseek-chat"
api_key = "sk-or-v1-…"
weight = 90
top_p = 0.95 # nucleus sampling
top_k = 40 # top-k sampling
How the pool picks
Weighted by default — omit [ai.balance] unless you want another
strategy, and keep it above your model blocks.
[ai.balance]
strategy = "weighted" # weighted (default) | round_robin | cost | failover
weighted picks at random proportional to weight;
round_robin cycles the entries; cost always takes the cheapest;
failover uses the first with the rest as ordered fallbacks the agent path
retries on a hard error.
Agents, skills & prompts
All plain files under ai/. 8 agents are Markdown + frontmatter (tools,
max_steps, skills) — run @agent to see them, @agent
<name> for one in full. 12 skills ship. The global
ai/aiTerminal.md is the system-prompt base for every run — edit it to
change the persona. Project-local ai/ folders shadow the global set, so a repo
can ship its own agents and flows.
A skill is a Markdown file of standing instructions for one kind of work. An agent
names the ones it wants in its frontmatter, and they splice into its system prompt under
## Skill: <name> — in the order the agent declared them, so the list
reads as a priority and the same agent always sends the same prompt.
| Skill | What it holds | Agents that name it |
|---|---|---|
concise | answer first, cut the hedging and the preamble | all seven specialists (ai carries its own instructions inline) |
planning | smallest checkable steps, name the acceptance check, say what is out of scope | planner · coder |
research | a snippet is not a source — open it; cross-check; record dates and versions | researcher · planner |
writing | lead with the answer, concrete over abstract, never describe behaviour you have not checked | writer · researcher · reviewer |
verification | reproduce before fixing, re-run after changing, never weaken a check to make it pass | tester · coder · reviewer |
testing | cover the edges, keep tests hermetic, add a regression test for every bug | tester · coder |
debugging | reproduce → isolate → root cause, not the first plausible guess | tester · coder |
code-review | correctness, then security, then tests, then design — with file:line | reviewer · coder |
security-review | the classes worth looking for, and what silence about them means | reviewer |
orchestration | when to delegate breadth to a sub-agent and when it is slower | coder · planner |
git | the commands and the conventions around a change | coder |
refactoring | behaviour-preserving change, one thing at a time | none by default — attach it yourself |
Add your own by dropping a file in ai/skills/ and naming it in an agent's
skills = [...]. @agent <name> prints the skills an agent
carries, in the order they splice. prompts = [...] (ai/prompts/)
is the same mechanism under a second name, for your own blocks — nothing ships in it,
because one bundled answer to "how do I reuse a block of prompt" is enough.
Every agent's frontmatter is checked at build time: a tool it declares must exist in the
capability registry, a skill or prompt it names must be installed, and its
max_steps must be sane. A misspelled tool used to reach the model with a
generic description and fail three minutes into a run.
Attachments
Any @<path> token that names a real file becomes an attachment — in
@ai, agents, flows and loops alike. Images (png/jpg/gif/webp, ≤4 MB) ride as
vision blocks; PDFs as document blocks; text files inline fenced (≤48 KB, truncated
beyond); up to 16 per prompt; everything passes redaction first.
Context & compaction
Needs a model. Every run measures its own context against the window of the model
serving that run, and gives context back before that model would refuse the turn. The
window comes from the model's own context_window in
ai/models/*.toml — so a 32k local model gets a 32k budget and a 1M model gets a
1M one, out of the same code. Nothing here is per-vendor.
[ai]
context_window = 0 # 0 = use the serving model's own; >0 overrides it
compact_at = 0.75 # fraction of the usable window that triggers compaction
context_window is for the case a model file cannot know about: a local model
served with a smaller window than its card claims. For a mixed pool, set it on the
entry instead, so a 32k local model and a 200k hosted one each budget against their own:
[[ai.model]]
id = "my-local-model"
context_window = 16000
The reply's reservation is capped at half the window. context_window is a
setting you type and max_tokens comes from a model file, so the two routinely
disagree — and a 16k reply declared against an 8k window used to leave a quarter of the window
for everything else, which meant compacting on turn one and buying a summary on every turn
after.
A big tool result never enters the transcript
A tool result does not cost its tokens once. It is stored, and the transcript is re-sent on
every remaining turn — so a 200 KB build log carried inline is paid for again and
again, and it crowds out the reasoning it was fetched to support. So a result over
8 KB is written to cache/offload/<run>/ the moment it arrives,
and the model is handed a preview plus the path.
Compiling framework v0.0.0 (/work/crates/framework)
…
[full output saved to …/cache/offload/1785-42/003-sys-run.txt] — 4000 lines, 214887 bytes.
Read it with fs.read when you need more.
Lossless in the way that matters: fs.read is not workspace-confined (only writes
are), so the agent can pull any of it back when it turns out to matter. The threshold is
deliberately generous — every source file you read and every short command is under it.
The ladder — cheapest rung first
When something still grows past the line — a long conversation rather than a large result — compaction runs cheapest rung first and stops as soon as the transcript fits.
| Rung | What it does | Costs |
|---|---|---|
| offload | Any large tool result that slipped through is written out and replaced by a preview plus its path. | nothing — no model call |
| summarize | The oldest turns fold into one ## Earlier work (compacted) block, sized from the budget so the result actually fits. | one model call |
Most runs now finish without the ladder running at all — results never enter the transcript at full size in the first place, and the cheapest compaction is the one that does not have to happen. A run that does compact says so; it never shrinks its own history silently.
Paying for the prompt once
A turn re-sends everything before it: the agent's system prompt — its instructions, its skills, its whole tool catalogue — and the entire conversation so far. Left unmarked, a twelve-step run pays full price for that prompt twelve times.
So every turn declares what is settled: the system block is fixed for the run, and
every message but the newest has already been sent. That is a fact about the conversation
rather than a vendor feature, so it rides on the neutral request and each provider decides
what to do with it — Anthropic gets two cache_control breakpoints (one static,
one rolling), and OpenAI-compatible endpoints cache a matching prefix by themselves and need
only that we do not disturb the order.
The saving is a number, not a claim:
❯ @coder "add a --json flag to the export command"
⚙ fs.read src/cli.rs · 9ms · 6KB
⚙ fs.edit src/cli.rs · 12ms · 1 replaced
Added --json to the export command, with a test.
✓ 8.4s · 3 tools · 12.3k in / 1.8k out (11.1k cached, 90%) · ~$0.004
The first turn of a run writes the cache; every turn after reads it, and the share climbs as the run goes on. A run showing no cached share is the signal that something in the prompt has stopped being stable — a tool list that changed order, a timestamp somebody added. The transcript is append-only and the MCP tool catalogue is sorted for exactly this reason, and both are held in place by tests.
ctx.* — an agent managing its own context
Two tools every agent has, answered by the run loop itself rather than by a capability (they read and rewrite the run's transcript, which is loop state). An agent does not declare them, and they never reach the tool runner.
| Tool | Does |
|---|---|
ctx.status {} | {used, window, usable, pct, turns} — check before a big read |
ctx.compact {"keep": "…"} | run the ladder now; keep names what must survive |
@ai has no transcript — it is a single turn — so it gets no
ctx.* tool. What it gets is the budget: its grounding preamble is trimmed to fit
before egress, dropping whole blocks from the least valuable end (the terminal snapshot, then
the session digest, then recalled memory). Your standing instructions and the files you
attached are the last things to go, and a trim is announced rather than silent.
Sessions, memory & MCP
Folder sessions: every AI feature (@ai, @<agent>,
@flow, @loop, @job) persists a session for the folder it
runs in, so returning to a project restores what the AI knows about it — no flags. A folder
maps to its project root (the git top-level if you're in a repo, so subdirectories
share one session; else the working dir). Each session lives under
ai/sessions/<id>/: session.md (a byte-capped rolling digest of
recent runs), a folder-scoped memory/, and meta.toml. It's lean —
a run appends one digest line with no extra model call.
Memory: plain Markdown notes ranked by a from-scratch BM25 retriever — no database, no
embeddings service. There are two stores: global (ai/memory/, durable
across every project) and per-folder (inside the session). With
[ai] memory = true, relevant memories auto-inject each run — folder-first,
then global — and agents curate their own via memory.* tools, writing to the
folder store during a folder run.
One fact, one memory. memory.add reinforces instead of duplicating. An
agent re-learns the same thing constantly — it reads a config, saves what it found, and does
it again next run. When what it is saving already exists, the stored note is reinforced
(salience up, tags merged) and returned, rather than a near-copy being written; two files
saying the same thing both ranked and both got recalled, so the model paid twice to be told
once.
Links. A note can name related ones — links = ["…"] in the frontmatter,
or [[id]] written in the body, both read and merged — and recall follows them
one hop. This is what lexical ranking structurally cannot do alone: a decision ranks
because it shares words with your question, while the reason it was made usually
shares none. memory.link {from, to} relates two notes in both directions.
Ranking is Okapi BM25 over body + tags + kind, re-ranked by salience (reinforced on every recall and re-learn), recency (decayed per day), and an exact-tag boost — a tag is a deliberate act, while the same word in a body may be an aside, and flat BM25 cannot tell them apart.
MCP: declare Model-Context-Protocol servers under ai/mcp/; agent runs
launch them and expose their tools as mcp.<server>.<tool> beside the
native catalog.
The tool catalog
The full native catalog (each agent declares its own allowlist; undeclared tools are
refused): fs.* read/search/glob/write/edit (writes sandboxed to your invocation
directory) · sys.run (through the guard, output capped) · diag.check
(native cargo check/ruff → structured file:line
diagnostics) · web/net/http (SSRF-guarded, off with [ai] network=false) ·
git.* · memory.* (incl. memory.link) ·
todo.* · task.run
(parallel read-only sub-agents) · data.*/queue.*/store.*
(a sandboxed scratch database, queues and KV for agents) · codec.* (hash, uuid,
base64/hex/url, JSON, CSV) · time.* date math · clipboard.
What each bundled agent holds (write your own ai/agents/*.md to grant more):
@coder — fs read+write, diag.check, sys.run,
web.read, memory.*, todo.*, task.run;
@explorer/@reviewer — read-only (fs read/search, web.read, memory);
@tester — read + write + sys.run. The catalog above is what a
custom agent can additionally opt into (e.g. http.* for an API-calling
agent, data.* for a scratch DB).
Security
The AI proposes, the guard disposes.
- Commands — three regex tiers, deny > confirm > allow, judged over the whole line, every pipeline segment and each segment's program name. Defaults deny fork bombs,
rm -rf /,mkfs, raw disk writes; confirmsudo, force-pushes,rm -rf,curl | sh. Auto mode additionally runs only a safe-list of read-only commands. - Files and folders — the same three tiers, as regex over the resolved absolute path: one line covers a folder and everything under it (
(^|/)\.ssh/), another a kind of file wherever it lives (\.(pem|key|p12)$). They reach the file tools and the paths a shell command names. A small floor (~/.ssh,~/.aws,~/.gnupg, this terminal's own config and gate records, private-key files) is native and cannot be switched off by disabling a plugin. - Secrets — nine scoped rules swap a value for a placeholder on its way out and swap the real value back in the moment the text returns. Full reference below.
- A refusal is information — the model is told the rules before it starts, works around what it cannot have, and stops with a reason (
refused, exit 1) when it cannot. - Agent confinement — per-agent tool allowlists; file writes sandboxed to the invocation directory; secret-path reads blocked; sub-agents are read-only and cannot re-delegate; agent names and job ids are validated before touching the filesystem.
- Keys — never searched for on your machine; only config/env supply them; request bodies never appear in process argv.
Secrets — out as a placeholder, back as themselves
A secret you cat is yours: it is already on your screen, your disk, your
environment. The boundary that matters is egress — the moment text is about to leave for
a model, a tool, or a chat app. The guard sits exactly there and swaps each match for a
placeholder named after its rule («aws-key-1») — then swaps the real value back in
the moment the text returns to this machine, as a command about to run or a tool's arguments.
So an agent can use a database password it was never shown.
Before this, redaction was one-way: the model saw «redacted» and the command it
wrote back could not connect to anything, so people turned redaction off and their keys went to
a model. The round trip is what makes the safe thing also the useful thing. Nothing is written
down — the values live in memory for one run, and a placeholder from another run is refused
rather than run.
❯ cat .env
DATABASE_URL=postgres://db.internal/prod
AWS_ACCESS_KEY_ID=AKIA…
ANTHROPIC_API_KEY=sk-ant-…
LOG_LEVEL=debug
# what actually leaves for the model
DATABASE_URL=postgres://db.internal/prod ← untouched
AWS_ACCESS_KEY_ID=«redacted»
ANTHROPIC_«redacted»
LOG_LEVEL=debug ← untouched
Two things to read off that. Redaction is targeted — the connection string and the log
level pass through, so the model keeps enough context to be useful. And rules compose:
each runs over the previous one's output, so a key caught by the sk- rule can be
caught again by the KEY=value rule, which takes the key name with it. That
is why ANTHROPIC_API_KEY=… collapses to ANTHROPIC_«redacted» while
AWS_ACCESS_KEY_ID=… keeps its name. Over-redacting is the safe direction.
Scopes
| Scope | Applied to |
|---|---|
ai | Everything bound for a model — the terminal context @ai grounds on, tool results returned to an agent, @loop/@job context, and the session-context file. |
terminal | Live PTY output as it is displayed — applied per printable run, never to an escape sequence, so colors and cursor moves can't be corrupted. |
all | Both. The default when a rule omits scope. |
@gate deliberately applies both scopes to anything it sends to a chat — a
phone is off-machine either way, so the narrower reading would be the wrong one.
The nine default rules
They ship as the ai-guard plugin, all scoped ai. The mechanism is
native; the plugin only supplies the rules, so you can edit them or
@plugin disable ai-guard outright.
| Catches | Pattern |
|---|---|
| AWS access key ids | AKIA[0-9A-Z]{16} |
| OpenAI / Anthropic keys | sk-[A-Za-z0-9_-]{16,} |
| GitHub tokens | gh[pousr]_[A-Za-z0-9]{20,} |
| Slack tokens | xox[abps]-[A-Za-z0-9-]{8,} |
| Google API keys | AIza[A-Za-z0-9_-]{20,} |
| Bearer tokens | (?i)bearer\s+[A-Za-z0-9._~+/-]+=* |
| JWTs | eyJ….eyJ….… |
Sensitive KEY=value | (?i)(api_key|access_key|client_secret|token|secret|password|credential|auth)\s*[:=]\s*\S+ |
| PEM private-key blocks | -----BEGIN … PRIVATE KEY----- … -----END …----- |
scope = "ai", so cat .env still shows you your own values — only what
leaves is rewritten. Add scope = "terminal" rules to mask them in the
display too, which is what you want before screen-sharing or recording.Your own rules
The same three tables work in config.toml, in a profile, and in any plugin's
plugin.toml — one parser reads all three. Config rules run before plugin
rules, so yours are the one a refusal names.
[[guard.command]]
pattern = "^docker\s+ps\b"
rule = "auto" # deny | confirm | allow | auto
[[guard.path]]
pattern = "/clients/"
rule = "deny" # deny | read-only | allow
[[guard.secret]]
pattern = "acme_[a-z0-9]{32}" # regex by default
name = "acme-key" # names its placeholder: «acme-key-1»
scope = "all" # terminal | ai | all
[[guard.secret]]
pattern = "10.0.42.17" # an exact string
literal = true # skip the regex engine entirely
Properties worth knowing
- Step-budgeted engine — a pathological pattern fails fast instead of hanging your terminal. There is no ReDoS surface, even from a plugin you installed.
- A bad pattern is skipped, not fatal — it prints
guard rule skipped — …and the rest of the policy still loads. - One value, one placeholder — the same secret always reads back the same, so a model can carry it from a file it read into a command it writes and reason about it as one thing. A different value gets a different name.
- Anything crossing a process boundary is masked instead — the window's session file is read by the CLI, which has a different vault, so a placeholder there could never come back.
- Clean text costs nothing — each rule carries its literal head (
sk-,AKIA) as acontainsprefilter, and a rule that changes nothing never reallocates. This runs on every PTY chunk. - Agents can call it —
guard.mask(text, scope)is in the tool catalog, so an agent can scrub something before writing it into a file or a report. - It is not a secret scanner — it rewrites text in flight; it does not search your disk, and it cannot mask a secret that never passes through it. API keys are never hunted for on your machine: only config and env supply them.
Logging
A leveled async diagnostic logger writes one file per day under
~/.aiTerminal/logs/, auto-pruned after retention_days (default 7).
Default level is error — silence by default. Panics also append to
crash.log (rotated at 1 MiB), and the event loop drops the offending frame
instead of crashing the app.
Architecture
A four-layer Rust workspace — corelib < platform < framework < app —
with four CI gates: zero external crates in the lockfile, strict layer edges,
unsafe confined to platform/src/os/ (every other crate root forbids
it), and no source file over 1000 lines. ~78k lines of Rust, and a test suite covered
in Testing below.
That last gate is aimed at contributors rather than at the binary. A file nobody reads end to
end is a file nobody can change safely, so a module that outgrows the limit is split along a
boundary it already has — cli/ into one file per surface, caps/backends/
by tool family, term/ by what the VT engine does. Unit tests live in
<module>/tests/ beside the code, never inline, so reading the production
source means reading only the production source.
The performance model: the event loop idles (~0% CPU, one wake per real change, renders paced to 60 Hz and damage-tracked to the GPU); every stream, subprocess and transcript carries a named byte cap with an over-cap regression test; every deadline kills its child; the regex engine is step-budgeted; a panic degrades to one dropped frame.
Testing
1441 unit tests and 315 scenarios, running in a few seconds with no network and no API key. A unit test proves a function; a scenario proves a product — a real user journey written as TOML and played against the real code.
❯ cargo test --workspace # everything
❯ cargo test -p framework scenario -- --nocapture # the scenario report
ai scenarios
✓ a destructive suggestion is blocked before it can reach the shell
✓ a chatty model cannot smuggle a second command into your shell
27/27 passed
The distinction earns its keep. When @gate shipped with a large unit suite and
users still hit bugs, 35 scenarios against that same code found 22 defects — failures
of product behaviour that a unit test is structurally unable to see. There is now a folder of
scenarios per feature: gate 35 · flow 29 · markdown 28 ·
ai 28 · cli 24 · terminal 22 · jobs 20 ·
config 16 · security 15 · plugins 14 ·
loop 13 · shell 10 · keymap 10 · theme 8 ·
memory 8. Each folder asserts a minimum count, so coverage cannot silently shrink.
The suite is hermetic and harmless by design. All AI is mocked — scenarios write what
the model replies, encode it as the provider's real SSE wire format and decode it with the
real decoder, so the streaming path runs with no socket and no key. Nothing spawns a process,
opens a socket or touches a PTY, which is what makes it safe to write a test about
rm -rf /: the string exists only as text asserted to go nowhere. The one
deliberate exception is zsh -n/bash -n on the generated shell init —
parse-only, no command executed — because a quoting slip there would break every new pane.
docs/ folder carries the full engineering documentation — architecture,
per-cap performance model, testing policy, packaging.
Read it on GitHub →