A terminal chat client for T3 Code, written in Rust with ratatui. It stays small: a thread picker, a chat view, a composer, and the thread's terminals, driven with Vim-style keys. No diff viewer, browser, or settings. Use the desktop, web, or mobile app for those.
- A T3 Code server, either the desktop app or
npx t3@latest, on the same machine or reachable over the network. It need not already be running: see Starting the server. - A Rust toolchain, to build it yourself.
rust-toolchain.tomlnames the version this is built and checked with, and rustup installs that one for you. Older ones may work and nothing checks. The prebuilt binaries need none of this.
A built binary, without a Rust toolchain:
cargo binstall triaReleases carry one for Linux and macOS, on x86-64 and arm64. Anywhere else, and with
cargo install, it is compiled from source:
cargo install triaFrom a clone:
cargo install --path .Run tria and choose Local or Remote to add your first project. Local browses this
machine; Remote accepts an SSH config alias or host, with optional user, port, and identity
file overrides. Selecting a directory connects to its machine and opens a new-thread draft.
Local setup issues a session automatically. SSH setup issues it on the remote host.
For a direct network connection, mint a pairing credential on the machine that runs the server. The desktop app offers this under Connections; the CLI prints one with:
t3 pairExchange it once for a stored bearer token. Either the raw token or the full /pair#token=...
URL works:
tria pair <credential>The token is stored in $XDG_CONFIG_HOME/tria/config.toml (default ~/.config/tria/config.toml)
with mode 0600. tria finds a local server through the runtime file the server writes under the
T3 home directory. For a remote server pass --url https://host:port to tria pair and it is
remembered.
Then run tria for the thread list, or, in a project you want to work on:
tria openwhich opens that project with a new thread ready to write, and adds the project to the server first where there is none for it yet.
tria is a client, so it needs a server to talk to. If none is answering on this machine when
you run tria, it starts one with t3 serve and waits for it to come up — the desktop app
does not have to be open. It says what it is doing, then connects:
No T3 Code server running. Starting one with `t3 serve`.
Server at http://127.0.0.1:3773. It keeps running after tria exits.
The server is left running on purpose. It owns provider sessions, background tasks, and
terminals, all of which outlive a chat window, so quitting tria is not a reason to take them
down. The next tria finds it and connects straight away. Stop it like any other process, or
install it as a background service with t3 service install and it will always be up.
If local discovery names another live server while Tria's server is still answering, Tria shows a persistent warning with both addresses and the other server's PID when available. It checks at startup and every five seconds, keeps its current connection, and clears the warning when one server stops. Two servers using the same T3 home can execute the same threads twice; stop one server to resolve that. SSH and remote connections do not check this machine's runtime file.
Set server_command in the config file to start it some other way:
server_command = "npx t3@latest serve"It runs through a shell, so a full command line works. Set it to "" to never start a server;
tria then reports that nothing is running and exits. Output from a start goes to
server-start.log beside the config file, and a command that exits without serving is reported
with the first lines of it.
Direct connections made with --url never start a server on another host. The SSH project
browser can start one remotely: it reuses the host's running T3 server, or starts a loopback
server in its T3 home directory. If t3 is absent, it downloads and verifies an official
standalone release. That requires Linux or an Apple Silicon Mac, curl or wget, tar, and
sha256sum or shasum. Provider CLIs such as Codex must already be installed and signed in
on the host, and available to its non-interactive login shell.
SSH uses OpenSSH, ~/.ssh/config, and key or agent authentication. Password prompts are not
supported; test access with ssh <host> first if authentication fails. New host keys are
accepted; changed keys are refused. Tria restores a dropped forward automatically and closes
it on exit; a server it started keeps running. SSH profiles and the last selected machine
are remembered, and the next tria reconnects to that machine. Selecting Local switches back.
Server paths on SSH connections are always treated as remote, including through localhost
forwards. Direct forwards configured manually with --url still need this distinction.
Press ? inside the app for the full list.
| Normal mode | |
|---|---|
Ctrl-d Ctrl-u Ctrl-f Ctrl-b Ctrl-e Ctrl-y |
scroll the conversation: half page, page, line |
J K |
next / previous thread |
/ |
fuzzy thread picker, which also searches what the threads said |
Tab / Shift-Tab |
cycle focus: composer, chat, thread list. Esc returns to the composer |
s S |
toggle the sidebar / the settled shelf |
H L |
thread list focused: the tab to the left or right — threads, pull requests, subagents |
n |
new thread (pick a project; ^R renames the one under the cursor) |
gw |
new thread: start it in a fresh worktree, or the project's checkout |
m |
change model |
i a I A o O or Enter |
write a message (Vim insert entry) |
za |
fold or unfold the tool group or row under the cursor; za on a row inside an open group opens what that call kept |
zr zm |
open or shut one level everywhere: the groups, then what every call in them kept |
zR zM |
open every level at once, or shut them all and let go of the folds opened by hand |
1..9 |
answer a pending approval, with nothing written in the composer; otherwise a count for the next motion |
ga |
answer the agent's question: digits pick, Space toggles, c types a custom answer, Enter advances |
gy |
copy the last assistant message (OSC 52) |
gI |
copy the open thread's ID; in the thread list, the selected thread's ID (OSC 52) |
gY |
chat focused: copy the link destination under the cursor, or the first link on its line (OSC 52) |
gs |
toggle the thread between settled and active; in the thread list, the selected row |
gT |
list the agent's background tasks that are still running; Enter reads one, s stops them, S the session, after asking |
gA |
list the subagents the thread has run; Enter reads one's work as it goes (r re-reads a transcript file), y yanks its report |
gS |
list the thread's terminals; attach to one, or close, restart, open a new one |
gW |
list the worktrees threads are holding; remove the ones that are done with |
gl |
open lazygit in a terminal popup for the thread; [programs] in the config file binds more keys |
g! |
a shell in the popup, in the thread's directory |
ge |
composer focused: edit the draft in your editor, read back on exit. Chat focused: view the message, plan, tool row, or tool group under the cursor |
gE |
view the whole conversation in your editor |
gr |
chat focused: rewind to before the message of yours under the cursor (:rewind for the last turn) |
gx |
open the link on the cursor's line, else the picture under the cursor, else the thread's pull request |
gp |
read the thread's pull request in place of the chat (:pr) |
gt |
switch to the tmux session named after the thread's directory, creating it if needed |
gP |
split a tmux pane beside tria, in the thread's directory (:split) |
gN |
open a tmux window in this session, in the thread's directory (:window) |
gD |
show the thread's directory in the machine's file browser (:reveal) |
h l 0 $ w b |
chat focused: move along the line under the cursor |
v V |
start a selection by character or by line, in the chat or in the composer; y copies it |
. |
composer focused: the last change again |
gJ |
composer focused: join lines, since J opens the next thread |
Ctrl-c |
interrupt the running turn, or stop the background work left running without one. Anywhere something is being typed or a list is open, it is Esc instead |
| mouse wheel | scroll the conversation, or the thread list when the pointer is over it |
| left click | in the thread list: open a thread, or fold and unfold a section; in the chat: open a link, or fold and unfold a tool group or row; in the composer: put the cursor there and write |
| drag | select text in the conversation; releasing copies it (OSC 52) |
With the chat focused, a cursor moves through the conversation: j k with counts,
{ } to the previous or next message, gg and G (which also resumes following new
output), Ctrl-d Ctrl-u Ctrl-f Ctrl-b Ctrl-e Ctrl-y to scroll with the cursor.
za, Enter, or Space folds the tool group or row under the cursor. The cursor is a
block on one character: h l 0 $ w b move along the line, and j k keep the
column.
v starts a selection by character and V by line; y copies it, and yy or 3y copy
from the cursor without one. Clicking a chat line focuses the chat and puts the cursor on
the character clicked.
What a copy gives you is what was written rather than what was drawn: the marks and indents the chat decorates its lines with are left out, and a message broken over several rows comes back as the one line it was written as.
Clicking in the composer puts the cursor where you clicked and leaves you writing, without a trip through normal mode. Past the end of a line is the end of it, and below the last line is the last line, so a click anywhere in the box lands somewhere you can type.
/ and ? search the conversation forward or backward. The cursor previews the first match
while you type, Enter accepts, Esc goes back to where you were. n and N step to the
next and previous match, wrapping around with a notice. Matches are highlighted while the
chat has focus. Queries are case-insensitive unless they contain an uppercase letter. In the
chat, ? searches; help is ? from the composer or :help.
In the thread list, j/k move, Enter opens a thread or folds a section.
The / thread picker filters titles as you type. Once you pause, it also asks the server
which threads said what you typed, and lists them under the titles with the words in place:
yours marked you:, the agent's agent:. The server matches the query as written, ignoring
case, in your messages and each turn's final answer, and names each thread once, by its best
match. Enter on one opens the thread at that message, loading older turns until it is there,
and leaves the query as the chat's search, so n and N go on to the rest. Archived threads
are not searched.
With the composer focused, normal mode edits it with Vim semantics. Motions: h j k l w b e W B E 0 ^ $ gg G f F t T ; ,, all taking counts. Operators d c y combine with a motion or a
text object (iw aw iW aW, i" a", i( a( i[ a[ i{ a{ i< a<), plus dd cc yy D C Y x X. p P
paste from the single register, r replaces a character, ~ toggles case, u and Ctrl-r
undo and redo. v and V select by character or by line — o swaps which end moves, the
operators take the selection, and iw and its kind select an object outright. . makes the
last change again, typing what was typed into it. Yanks also go to the system clipboard. The
status bar shows a partially typed command.
Eight letters are the app's rather than the editor's — s S J K n m / ? — and so are the
digits, which answer an approval when there is one and nothing is written. They are the
app's only while nothing is half typed at the composer: a count, an operator waiting for its
motion, or a selection being made gives every one of them back to Vim, so 3J joins three
lines and viws substitutes a word. What they displace has another way in: s is cl, S is cc, and J is
gJ. The emulation stops there — no marks, no search within the composer, no macros, no
registers beyond the unnamed one, and no visual block.
g and z are keys that have not finished being pressed, and the status bar shows them
too, beside the rest of a half-typed command. They wait 1.2 seconds for the key that
completes them and then give it back, so a g pressed by mistake does not swallow the
next key. The config file says how long:
prefix_timeout_ms = 3000Zero waits for as long as it takes, which is what Vim calls notimeout: gA is then gA
however long the pause in the middle, and the g on the status bar is what says one is
still waiting.
| Insert mode | |
|---|---|
Enter |
send; while a turn runs, steer it |
Ctrl-s |
while a turn runs, queue the message until it ends |
/ at the start of a line, or $ |
list the provider's commands and skills; Tab takes the row, Up Down move, Esc hides the list |
Alt-Enter or Ctrl-j |
newline |
Ctrl-q |
the next key as the character it stands for: Ctrl-q Enter is a newline, Ctrl-q Tab a tab |
Ctrl-v |
paste the clipboard: an image goes with the message, text is pasted as text |
Backspace on an empty message |
drop the last image attached |
Up Down or Ctrl-p Ctrl-n |
prompt history; on an empty composer, Up takes a queued message back first |
← → Home End, Ctrl-a Ctrl-e |
move the cursor; Alt with an arrow, or Alt-b Alt-f, moves by word |
Backspace Delete, Ctrl-w Ctrl-k Ctrl-u |
delete a character, the word before the cursor, to the end, to the start |
Esc or Ctrl-c |
back to normal mode |
Quitting is :q, and only :q. No key does it on its own, because the composer holds
unsent messages that nothing writes to disk, and a chord that throws them away is not one
to find by accident. Ctrl-q means in insert mode what it means in Vim, which is why it is
not a quit: the next key goes in as the character it stands for. Alt-Enter is the usual
way to write a newline where Enter sends, but a terminal has to be willing to send it;
Ctrl-q Enter needs nothing of the terminal and always works. Vim's other name for it,
Ctrl-v, pastes instead, as it does in most agents' composers.
A line that starts with / lists what the provider can run: its commands, which only mean
anything as the first line of a message, and its skills. A word that starts with $ lists the
skills alone, anywhere in the message. Typing narrows the list and Tab writes the row in. A
skill always goes in as $name, however it was found, since that is how the server knows to
run one; a command goes in as /name. Enter still sends with the list up. The lists are the
ones the provider found for the thread's directory, so a project's own commands and skills are
there with the rest.
gr on one of your messages in the chat rewinds the thread to before it, and :rewind does
the same for the last turn. The turn goes, with every turn after it, and what you sent in that
turn comes back to the composer to be written again: the prompt and anything that steered it,
since a steer is part of the turn it joined. The status bar asks first. Enter rewinds the
conversation and leaves the files as they are; f also puts the files back as the turn before
left them, from the checkpoint the server keeps of each turn. A running turn has to be
interrupted first, and a provider that cannot drop turns from its history cannot rewind.
A message sent while a turn runs goes straight to the agent and joins the turn it is on:
that is steering, and the server has no way to take such a message back. Ctrl-s keeps the message in tria instead, shown on the
composer's lower border, and sends it as the next turn once the running one completes.
Queuing another joins it to the first. Until then Up on an empty composer takes it back
to edit or drop. A turn that is interrupted or fails does not send it; it comes back to the
composer to be looked at again.
The custom answer typed after ga then c is edited with those same keys, less the ones
that only mean something in a message: it is one line, so there is no newline to write. A
long answer scrolls inside its row rather than wrapping, Enter confirms it, and Esc
goes back to the options without keeping it.
So is every other line in the client: the : command line, a picker's query, and the /
search over the chat. All of them are the same one-line editor, so the cursor moves, a
word goes with Ctrl-w, and a long one scrolls inside its row. A picker keeps Ctrl-k
for itself — in a list, moving the cursor up a row is worth more than killing to the end
of a line that short — and Ctrl-j, Ctrl-n, Ctrl-p with it.
A paste arrives as one event and goes wherever typing would: the composer in either mode, the command line, a picker's query, the chat search, the custom answer. The fields that hold one line fold the paste onto one rather than keeping only what came before the first newline.
Commands, entered after : in normal mode: new [project], model, effort [level],
mode plan|default, perm <runtime mode>, rename [title], project rename <name>,
archive, delete!, approve [n], stop, stop!, implement [new],
older, rewind, answer, dismiss, pr, checkout, labels, git, shell, edit, view, tasks, terminals, tmux,
reveal,
worktree, worktrees, settle, unsettle, wake, settled, sidebar, agents,
usage, split, window, reconnect, help, q.
implement builds the plan a thread in plan mode has proposed: it sends the plan back as the
next turn, out of plan mode, and the server marks the plan built. implement new does it in a
thread of its own that works on the same branch and checkout, and tria moves there once the
server has made it. The composer says when a plan is waiting.
The line is the same one-line editor as the rest of the client, so it takes the same keys:
Ctrl-w rubs out a word and Ctrl-u the line, Ctrl-a and Ctrl-e go to the ends, and
the arrows, Alt-b and Alt-f move by character and by word. Backspace on an empty line
still leaves. Up and Down, or Ctrl-p and Ctrl-n, walk back through the commands run
this session.
Tab completes the command's name, and the names of the programs [programs] binds. The
candidates are listed beside the line, each Tab takes the next and Shift-Tab the one
before, and tabbing past the last gives back what was typed rather than leaving a wrong
guess in the line. An argument that comes from a fixed list completes the same way —
mode, perm, project, and whichever efforts the model offers. The ones that take a
title or a name do not: there is nothing here to complete those against.
An image goes with the message being written in either of two ways. A file dropped on the
terminal, the screenshot preview macOS shows after a capture included, arrives as a paste of
its path, and a paste that is nothing but paths to image files attaches them instead of
typing them. A picture on the clipboard is attached with Ctrl-v, since a terminal's own
paste carries text alone; this reads the clipboard of the machine tria runs on, so it finds
nothing over SSH. Either way the image is read where tria runs and sent with the message,
so it reaches a remote server too.
The images wait above the composer until the message goes, and go with it whether it is
sent, queued, or the first message of a new thread; one that comes back unsent brings its
images back with it. Backspace on an empty message drops the last one. The server takes
up to 10 MB an image and 80 MB a message; a larger image is sent as a JPEG when that is
small enough.
The agent stops for an approval whenever its permissions say it should, and the panel
that appears numbers the answers. 1..9 answer it — but only with nothing written in
the composer. One of those answers grants a permission for the rest of the session, and
an approval arrives when the agent reaches one rather than when you are ready for it, so
a count typed at a half-written message would otherwise answer it: 2w is two words, not
"allow this for the session". With a draft in the composer the digits stay the
composer's and the panel says so; :approve <n> answers it whatever is written there.
tria open starts tria on the project for the directory you are in, with a draft ready to
write: it is the way in from a checkout rather than from the thread list. tria open <path>
names another directory. It reuses a project registered for that exact directory, or
registers the directory as a new project named after it. This also works inside a registered
home directory or another project. The directory has to exist: a missing path is reported
on the terminal before the screen is taken over. A project belongs to the server, so adding
one adds it for the desktop app too.
Inside tmux, tria open first looks for a running tria on the same tmux server and T3
server, preferring one in the current session. It opens the directory's draft there and
switches to that instance's session, window, and pane. If none is available, it starts a
new tria. The running instance must have been started with a version that supports this
handoff. Unsent text in an existing thread stays with that thread; an unsent new-thread
draft must be finished first.
n picks a project and opens a draft; Add project… in that list (or :project add)
opens the local/SSH directory browser. Use arrows and Enter to browse, Backspace for the
parent, Tab to edit a path, and Ctrl-o or F2 to use the directory being displayed. Esc
returns to machine selection or cancels setup. A registered project for the exact directory
is reused; otherwise it is added before the draft opens. Switching machines requires unsent
and queued messages to be finished or cleared. With a saved SSH connection,
tria open /absolute/remote/path also opens a server-side directory without checking it here.
For a project selected from the list, the message you write starts the thread. The header says
where it will run: ⌂ project checkout on <branch>, or ⌂ new worktree off <branch>, which
the server creates when the thread starts. gw or :worktree switches between them. The
default comes from the project's own setting, then the server's, and the server's default is
the current checkout.
Directory-based commands work before the first message too: gl, custom program bindings,
g!, the terminal panel, the editor, reveal, and tmux commands use the draft's project
checkout or its prepared pull-request worktree. A fresh worktree requested with gw is
created when you send, so these commands use the project checkout until then. Opening a
program leaves your message unsent; its terminal identity carries through to the new thread.
A worktree branches off whatever the project's checkout has at the time, or off the remote's copy of it where the server is set to start new worktrees from origin. Its branch is named after the thread rather than the message, since the server names the worktree's directory after the branch and a message is not a branch name.
Worktrees outlive the threads that used them, so gW or :worktrees lists the ones threads
are still holding: the thread, its project and branch, and whether the checkout has anything
uncommitted in it. x removes the selected one and X removes it anyway — git refuses a
worktree with modified or untracked files in it, which is the check worth having, and X is
how you say you meant it. A worktree the server made for a thread is the only kind offered:
one that a project is rooted in is a place to work rather than something left over.
X is the one key in that list that destroys work, and it sits on the shift of the one that
does not, so it asks first: a box naming the worktree and listing every uncommitted and
untracked file in it, re-read from the checkout as it opens rather than taken from the cache.
Enter removes it, Esc or q keeps it, and j/k move through the list when it is longer
than the box. A worktree with nothing uncommitted has nothing to lose and nothing for git to
refuse over, so X on one of those removes it without asking. The branch is named in the box
too, because it stays: what goes is the checkout.
Removing a worktree leaves its branch, so nothing committed is lost by clearing them out.
Threads that are done with a worktree still on the disk are marked ⌂ in the sidebar, and the
settled section says how many there are.
If a thread's worktree directory has gone, :worktree recreate restores its recorded
path from its recorded branch, then you can send the failed message again. The operation
runs on the server, including for SSH and direct remote connections. It retains the branch's
committed work; deleted uncommitted files cannot be recovered this way. Git refuses a path
that is occupied or a branch checked out elsewhere. The thread must not be running. If the
directory was deleted outside Git, stale worktree metadata may need git worktree prune
in the server's project checkout before recreating it.
If its branch was also deleted, :worktree recreate main creates the recorded branch
again from the server's local main, at the recorded path. It does not fetch or recover
the old branch's commits, and refuses to overwrite an existing branch.
The model for a new thread is the last one you picked with m, remembered in the config file
between runs. Without one it falls back to the project's default model, then the server's.
^R in that list renames the project under the cursor: the name it has takes the search's
place, ready to be typed over, Enter renames it and Esc keeps the old one. :project rename <name> does the same for the project the open thread belongs to. The name is the
server's, so it is the name everywhere — this sidebar, the desktop app, the next client to
connect. It is ^R rather than r because the letters in that list go to the search.
The project list n opens draws each project with what it is known by, in the order the
desktop app uses it: the emoji it was given, the icon it was named from the Lucide set, the
icon its checkout carries, and failing all three a badge with initials from its name.
The initials and their colour follow the desktop app's rules: Kindra is KA,
github_ui is GU, and qk is QK. The badge uses terminal text, so it works without
image support too. A custom monogram chosen in the desktop's project settings uses your
letters and colour instead, taking priority over the checkout's icon.
A named icon arrives as a name and a colour, since the picture itself is nobody's to send. Lucide publishes the set as a font, so tria looks the name up there and draws the character into pixels at the size of the room it has. A name from a set newer than the one tria was built against is not drawn, and the project falls back to the icon its checkout carries.
The looking is the server's: the icon the project names, then the one its t3.json names in
iconPath, then the usual places a favicon lives, then whatever the project's index.html
links to. The bytes come over HTTP, so a server on another machine works the same as one
here. A repository that keeps its icon somewhere of its own — a monorepo, say — is worth
naming in t3.json, which the whole team then shares. SVG icons are not drawn.
The header shows the thread's branch, the name of its worktree after ⌂ when it has one of
its own, and, when the server has linked a pull request, its number, title, draft or merged
state, and a checks glyph (✓ passing, ✗ failing, ○ pending).
After the branch comes the state of the checkout: ● with + and − line counts when the
working tree is dirty, ↑ commits ahead of the upstream and ↓ commits behind.
The thread list only carries a branch for threads the server created one for, so for the rest tria watches the thread's checkout and shows the branch that is actually checked out there. Threads sharing a project's checkout therefore show the same branch, which is the truth: they share it.
The server caches its git status and does not report every edit, so tria asks it to re-read the checkout when the thread opens, when a turn finishes, when you leave the terminal popup, and every twenty seconds otherwise.
gx opens the thread's pull request in the browser through the platform's URL opener. gp
reads it in tria instead, and :pr does too, offering a picker when several are linked.
A thread can have several pull requests linked, as a stack or side by side. They are grouped into stacks the way the desktop app groups them: a stack the host keeps is taken in the host's order, and the rest are chained where one pull request's base branch is another's head branch in the same repository. Two pull requests from the same head branch say nothing about which is the parent, so neither is chained to it. A layer taken out of a stack in the desktop app stays linked so the stack does not bring it back, and tria does not show it.
The header shows one of them: the one on the branch checked out where the thread works, so
moving through the layers of a stack moves the header with it; else the one the server finds on
the thread's branch; else the only open one, the highest open layer of the stack linked last,
once everything has landed, the top of the stack, or the one updated last. When they are all one stack, the header
says where the one shown sits in it, stack 2/3; otherwise it counts the rest, +2. Either way
it adds the worst checks among the others still open, since a failure elsewhere in a stack holds
up the one shown.
The :pr picker lists them stack by stack, the header's first, each stack bottom to top with
↳ on the layers that build on another. Each row has its
state, checks, and review decision, and its repository when they are not all from one.
Enter reads the one under the cursor, Ctrl-y copies its link, and Ctrl-d unlinks it from
the thread, after asking.
Enter in the :pr picker, or gp, reads a pull request in place of the conversation, as a
subagent's transcript is read: the same view with the same keys for scrolling, search, and
yanking, and q or Esc to go back to the conversation where it was. It is fetched with
pullRequests.detail, which the server answers by asking the host, so the host's CLI has to be
installed and signed in where the server runs.
A pull request that is a layer of a stack says so across the top: every layer by number, bottom
to top, in the colour of where it stands — merged, open, draft, closed — with the one being read
picked out. [ and ] read the layer below and above. The strip is the thread list's, so it
changes as soon as a layer lands or another is linked.
The top says where it stands: state, author, the branch and the one it merges into, the size of the change, the labels in the host's colours, and whatever stands between it and landing — a conflict with its base, commits it is behind by, workflow runs waiting for approval — and whether auto-merge is on.
Then the checks, counted by state, with every one that is failing, needs action, or is still
running listed on its own row and the ones that passed or were skipped folded into a count;
za on the count lists them. gx on a check's row opens that check; anywhere else it opens
the pull request.
Then the reviews, read with pullRequests.activity, which the server answers separately and
more slowly, so the section says loading… until it arrives. Each reviewer has a row saying
where they stand, the way the host decides it: their latest approval or request for changes,
and only when they have given neither, that they commented. Those asked to review and not
answered since are waiting, with what they said before if they were asked again. Requests for
changes come first, then those waiting, and the count of unresolved review threads is beside
the heading. The latest review is open with what it said; za on any other reviewer's row
opens theirs, and gx opens it on the host. The author's own replies are not counted as
reviews.
Then the description, rendered like a message and folded to its opening lines when it is long.
The description and the reviews are drawn as the host shows them: HTML comments, such as a
template's instructions or a bot's bookkeeping, are left out, and each <details> is its
summary on a row of its own, with za opening it unless it was written <details open>.
Pictures in the description and the reviews are drawn under their captions, as they are in a
message, and gx on one opens it. That covers the <img> tags GitHub writes when a picture is dropped into a
pull request as well as markdown images. They are fetched by tria itself, since the server has
no way to hand them over; the caption says loading… until one arrives. A picture on the pull
request's own host is fetched with the token gh auth token gives for that host, which is what
a private repository's uploads need, so gh has to be installed and signed in where tria runs
for those.
L, or :labels, lists the repository's labels to change the pull request's: the ones on it
first, marked ✓, then the rest with what each is for. Enter puts the one under the cursor
on or takes it off, and the list stays open for the next. A change shows at once and goes back
if the host refuses it. It is only offered where the server says the host can change labels
and nothing says the viewer may not; the header lists L where that is so.
:checkout starts a new thread in the pull request's own code. The server checks it out in a
worktree — reusing one that already has its branch, and bringing it up to the pull request's
head unless it holds changes or commits of its own — and the new thread starts there, with the
pull request linked to it once the thread exists. The header says so, and says when a reused
worktree was not brought up to date. With no argument it is the pull request being read, the
one picked in the sidebar, or the one the header shows; :checkout 123 or :checkout <link>
names another, in the open thread's project. c on a pull request in the sidebar does the
same. A pull request whose branch is checked out in the project's own checkout is refused:
two checkouts cannot have one branch, so the project's checkout has to move off it first.
It keeps itself up to date while it is open, reading the pull request again without a word
and without moving you: when a turn ends, which the server announces on
pullRequests.subscribeRefreshes because the agent may have pushed, commented, or merged, and
when the server's own sync with the host brings news of it to the thread list — checks
finishing, a review, a merge. r reads it again by hand. Each of these asks the server to drop
its copy first, since it keeps what the host said until it has reason to doubt it. A message written while it
is open goes to the main agent, after asking, starting with Sent looking at pull request <repository>#<number> (<link>):.
g and one more key run a program in a terminal of the thread's own, as a popup inside tria:
the chat stays on screen around it and tria keeps running. The popup closes when the program
exits. gl is lazygit until you say otherwise, and what is on the other keys is the config
file's to decide:
[programs]
b = "yazi"
l = "gitui"
d = "lazydocker"The key is the one pressed after g, and the value is the command, which should be
interactive: one that prints and exits takes the popup with it. A key set to "" takes its
binding away, including l. Each binding also answers to the program's own name on the
command line, so :yazi is gb; :git stays the name for whatever is on l, as does the
older git_command setting, which programs.l overrides. The keys tria answers itself —
g a e s t w x y A D E N P S T W ! — are refused, and tria says which
when it starts rather than binding a key that would never arrive.
While a program is up, Ctrl-\ leaves it running and its key comes back to it exactly as it
was. Each binding has a terminal of its own, so a file manager and a git client left open in
one thread do not take turns in the same shell.
For a local server, program bindings run in a PTY owned by tria. A plain executable such as
lazygit starts directly; command strings with arguments or shell syntax run through
non-interactive /bin/sh -c. They inherit tria's environment without loading interactive
shell configuration, running prompt hooks such as direnv, or adding commands to shell history.
If a program needs the checkout's direnv environment, bind it to direnv exec . lazygit.
Detached programs keep running and appear in the terminal list, where they can be attached,
closed, or restarted. They end when tria exits or changes server connections. Shell terminals
(g! and :shell) still belong to the server. For a remote server, program bindings also use
server terminals and their usual shell startup, so the program runs where the repository is.
The pane answers the capability queries a full-screen program sends on startup, including while a local program is detached, so nothing waits on a timeout.
A paste in the pane reaches the program as a paste. One that has asked for bracketed paste gets the text between the markers, so a shell holds a multi-line paste at the prompt instead of running every line but the last; one that has not gets it plain. Line endings arrive as carriage returns, which is what Return sends, and the other control bytes are dropped, since nothing in pasted text is meant as an escape sequence.
g! or :shell opens a plain shell in the same popup, in the thread's directory. Exiting it,
with Ctrl-d or exit, closes the popup. Both of these are scratch sessions: tria closes the
ones it opened and leaves the desktop app's own alone.
URLs in the conversation are underlined, and clicking one opens it in the browser. With the
chat focused, gx opens the link on the cursor's line, falling back to the picture under the
cursor — the one its tool row has, or the one its message shows — and then to the thread's
pull request.
gY copies the link's destination to the clipboard, including when Markdown shows only
its label. Both gx and gY prefer the link under the cursor when a line has several
links, and use the first link on the line otherwise. A toast shows the copied destination;
if there is no link on the line, gY says so.
Links are found on the drawn screen rather than in the message text, so one that wraps across two lines is still whole, and the same goes for a URL inside a tool row or a plan. Trailing sentence punctuation is left out of the link; brackets are kept when the URL opened them itself.
The agent runs monitors and background commands that outlive the turn that started them.
gT or :tasks lists the ones still running, as the server keeps them, and the status bar
shows a count. Work the agent delegated to a subagent is not background work and is listed by
gA instead.
Enter on a task reads it in place of the conversation: the command that started it, drawn
open, with what it has written so far as its output. A command sent to the background
answers only with where its output goes, a file named after the task, so that file is what
is read, with projects.readFile, which works against a remote server too. Nothing ties the
task to the command that launched it but that answer, so the thread's commands are looked
through, newest first, asking with orchestration.getTurnItem for any whose output the
stream left out; once found, it is remembered for the session. The file is read again every
five seconds while the task runs, and at once on r, and stays readable once it has ended.
The header says where the file is, and when the launch is older than the loaded history.
q or Esc goes back to the list.
A pull request the agent asked the server to watch is listed as a task while the watch lasts.
The watch has nothing of its own to read, since it is the server polling the host, so Enter
reads the pull request it is waiting on instead, picked by the watch the pull request carries.
There is no per-task stop in the protocol. What stops a watcher is an interrupt of the turn
that left it running — Ctrl-c, :stop, or s in the list, which is what the desktop
app's Monitoring · Stop sends. A pull request watch is the server's own and outlives every
turn, so once no turn is running the same keys turn each of the thread's watches off
instead; the server refuses to interrupt a turn that has ended when nothing of it is still
going. S or :stop! is the harder one: it ends the provider
session, and every process it started goes with it. Either way the conversation stays, and
the next message starts a session again.
S asks before it does it. It is s with a finger on shift, it takes down work the list
is not showing, and nothing takes it back: S again or y agrees, and any other key —
Esc included — leaves the session running and the list where it was. :stop! carries
its answer in the !, as :delete! does, so it goes straight through.
A connection that drops is asked for again with a backoff, and the streams resume from where they got to, so a server restarting under you costs a moment and nothing else.
A server that refuses tria is the other case: a token that has been revoked or has
expired is not going to be accepted by asking again every few seconds. That stops the
asking, and the line under the header says what the server said and what to do about
it: mint another credential, tria pair <credential> again, then :reconnect. Only the
server actually refusing this client counts as that — a 500 or a 503 is a server having
a bad minute and is asked again as anything else is.
:reconnect throws the connection away and makes another, whatever state it was in.
A thread's updates come over a stream of its own. If it ends, tria asks for it again from where it got to, and a stream that comes back is a moment of trouble nobody needs told about. One that will not come back is another matter: the conversation stops moving and looks exactly like one nobody is writing to, which is the worst thing a chat window can be quietly wrong about.
So it says so, on a line under the header, for as long as it lasts — ⚠ this thread has stopped updating — and keeps asking every ten seconds until the thread speaks again.
Opening the thread again, or the next reconnection, also starts a new stream.
A turn takes minutes, which is the whole reason not to sit and watch one. When a thread stops working — finished, failed, waiting on an approval or a question, or holding a plan — the terminal is asked to say so, and it turns that into whatever this desktop calls a notification. Which thread it was does not come into it: the open one finishing while you are in another window is the case this is for.
By default it only speaks up when the terminal does not have the focus, since a notification for something already on the screen in front of you is an interruption to tell you what you are looking at. The config file says otherwise:
notify = "always" # or "unfocused", the default, or "never"Two things this leans on the terminal for. The notification itself is OSC 9, which most
terminals understand and some do not. The focus is the terminal reporting it, which fewer
do — and inside tmux it arrives only with focus-events on, as the notification itself
arrives only with allow-passthrough on. A terminal that never mentions focus is treated
as one that does not have it, so notifications arrive rather than not: an interruption you
did not need is something you can see and turn off, and one that never came looks like a
feature that does not work. notify = "never" turns them off, and "always" is the
setting for a terminal that will not report focus but will notify.
:usage shows what each signed-in account has left of its subscription: a bar per
rolling window — Claude's five-hour session, the weekly allowance — with how full it is
and when it comes back. The account the next message would be spent from is marked, which
is worth knowing on a server with more than one signed in.
The figures are the provider's own, taken by the server when it last probed, so the panel
says how old they are and r asks again. An account with no quota to report — an API key,
a cloud endpoint — is not listed; one whose quota could not be read says so.
The agent delegates work to subagents that run their own conversation out of sight of the
thread. gA or :agents lists them: what each was asked for, the agent definition it runs
as, how long it has been going or how long it took, what it is doing now or the first line
of the report it came back with, and its model, tokens, and tool count. The status bar
carries a count while any are working.
They come from the thread's own history, where each subagent has an entry, and the
background tasks from the server's list of them. So gT lists monitors and backgrounded
commands, gA lists subagents, and neither shows the other. Like a background
task, a subagent has no stop of its own in the protocol: Ctrl-c interrupts the turn that
started it, and stopping the session ends everything running in it.
A subagent can run more than once under the same identity. The row then reads run 2, and
shows the current run rather than the last one's outcome. If the provider session dies with
subagents still working, they are reported as stopped rather than left spinning, since the
processes that would have finished them died with it.
y copies the report. Enter reads the whole transcript.
Enter on a row opens the subagent's own conversation in place of the thread's: the brief it
was given, everything it said, and every tool call it made, in the same view with the same
keys. Scrolling, folding, search, ge, gE, and yanking all work as they do in the chat,
because it is the same renderer — the transcript's rows are translated into the messages and
tool calls the server would have sent for the same work. q or Esc goes back to the list,
and the conversation returns to where it was.
There is no sending to a subagent: the protocol has nowhere to address one. A message written
while a transcript is open therefore goes to the main agent, and because it reads like a reply
to the transcript on screen, tria asks first — Enter (or Ctrl-s when queueing) again or y
sends, anything else goes back to writing. What is sent starts with Sent looking at the transcript for subagent <id>:, so the agent knows what the message was written against.
Servers that keep a subagent's work as a thread of its own say which thread it is, and that thread is what opens: tria subscribes to it as it does to the open thread, so what the agent does next arrives while it is read, with nothing to read again. Reading further up stays put; reading at the end follows what is new. Going back lets the thread go.
Work from before the server kept such threads is read from its transcript instead. The
transcript is a file the provider wrote on the machine that ran the agent, fetched with
projects.readFile, which takes an absolute path for exactly this. It therefore works against
a remote server too. The server stops reading at a megabyte and the header says so when the
tail is missing.
A subagent that is still working can be read as far as it has got: the provider writes the
file from the moment the agent starts, but it only reports the path once the task is over, so
the path is taken from another task in the same thread — they are written side by side in one
directory, each named after its task. The header says the run is still going, and the
transcript keeps itself up to date while it is open: the server has no way to say a file
changed, so it is read again whenever the thread says the agent did something, and every five
seconds between those, for a stretch of writing that says nothing. Reading further up stays
put; reading at the end follows what is new. Once the agent has finished it is read one last
time and left alone. r reads it again by hand. In a thread where no task has finished yet
there is nothing to take the directory from, and the row says it has no transcript.
Unlike the tool rows in the thread, these are not projected down to a summary on the way out: the file has the whole input and the whole output, so an expanded row shows the command it ran and what came back.
The format is the provider's rather than the protocol's, so this reads what Claude's agent sessions record. A row that does not fit the shape is skipped rather than fatal, which is why the header counts what was read.
Each thread can have shells running on the server, the same ones the desktop app shows in its
terminal tabs. gS or :terminals lists them with their status, working directory, process
id, and whether a command is running right now. x closes the selected one, r restarts
it in the same directory, and c opens a new one in the thread's working directory.
Enter attaches: the shell opens as a popup over the chat and every key goes to it, Ctrl-c
included. Ctrl-\ detaches and leaves the shell running, and exiting the shell closes the
popup. A program that asks for the mouse gets it, so clicking and scrolling work inside lazygit
and anything else full screen; otherwise the wheel moves through the pane's own scrollback, as
does Shift with the wheel while a program is using the mouse. Typing returns to the live
screen, and resizing the window resizes the pty. The pane is a real terminal emulator, so
full-screen programs work: this is the other way to reach lazygit, and unlike gl it stays
inside tria and survives detaching.
The server owns the pty, so a shell you start here also appears in the desktop app's terminal tabs, keeps running after you detach or quit, and picks up where it left off when you attach again.
A program in the pane can draw a picture: tria answers the kitty graphics protocol, so chafa,
timg, viu and anything else that asks the terminal whether pictures work is told that they
do, and what it sends is drawn over the pane. An image is placed where the cursor is and rides
the text as that scrolls, and it goes when the screen it was drawn over does — a clear, a
full-screen program taking over, or a resize. This needs a terminal underneath that draws
pictures too; where there is none, the pane draws in half-blocks like the chat does. A program
that decides by $TERM alone rather than asking will not try, because the pty is a plain
xterm-256color and saying otherwise would name a terminfo entry that may not be installed.
Animation and the unicode placeholder scheme are not answered, and a program asking for them is
told so rather than left waiting.
ge with the composer focused writes the draft to a private temp file, opens it in your
editor, and reads it back when the editor exits. With the chat focused, ge opens the block
under the cursor read only: a message as markdown, or a tool call with its input, output
summary, and changed files. gE opens the whole loaded conversation. The editor is editor
from the config file, else $VISUAL, else $EDITOR, else nvim; Vim-like editors get -R
for read-only views. Like gl, this uses a tmux popup inside tmux and takes over the
terminal otherwise. :edit and :view are the command forms.
Unsent composer text stays with its thread for as long as tria is running: switch away and back and the half-written message is still there. New-thread drafts have a slot of their own. Nothing is written to disk, so quitting is quitting.
Sending or queueing a message empties the composer and returns to normal mode. A message
the server will not take never went anywhere, so it comes back: into the composer if that is still where
you are and nothing has been written since, and into the thread's parked draft if you have
gone elsewhere. Where neither is free it is not lost either — every sent message goes into
the composer's history, and the toast says to reach for it with Ctrl-p. The same holds
for the message that would have started a new thread: the draft comes back with it, so the
project, the model and the worktree choice are as you left them.
A thread you come back to after a long gap says what it is still carrying, where the
composer's usual hint goes: 101k tokens from earlier · /compact resumes with less context. It appears for a thread whose last context count was at least 100k tokens and
was made over an hour ago, whose provider has a compact command to send, and which is
not running or waiting on an answer — the same rule the desktop app offers it by. Typing
takes it off the screen, and ignoring it costs nothing.
gD or :reveal hands the thread's working directory — its worktree when it has one, else
the project root — to whatever this machine browses files with: the Finder, the explorer,
the desktop's file manager, through the same platform opener a link goes to. The path is the
server's, so where the server runs on another machine tria says so instead of opening a
directory of that name here, which would be somebody else's.
When tria runs inside tmux, gt or :tmux switches the client to the session named after
the thread's working directory: the worktree when the thread has one, else the project root.
The name is the directory's last path component, with . and : replaced by _ because
tmux reserves them. If no such session exists, tria creates it in that directory first. This
suits a one-session-per-checkout layout.
gP or :split stays where you are instead: it splits tria's own pane and opens a shell in
the other half, in the same directory, in the session and window already on screen. tmux
puts the cursor in a pane it has just made, so the shell has the keys as soon as it is there.
The split is side by side, since tria is a tall window and a shell under it would have a
dozen rows. Nothing is said afterwards — the new pane is on screen with the cursor in it.
gN or :window opens a window instead of a pane: a new tab along the bottom of the
screen, in the same session, in the thread's directory. It is prefix c with somewhere to
start — no position is asked for, so the tab lands where this session puts a new one.
All three want the directory to be one this machine has. The path is the server's, so where the server runs elsewhere tria says so rather than handing tmux a path that is somebody else's directory here.
Completed turns end with a dim ◷ Worked for 2m 3s row, using elapsed wall time
(including tool execution and waits). Interrupted and failed turns say so on that row.
Historical turns whose start time is not retained use the preceding prompt as an estimate,
marked ~; a turn with missing or invalid timing has no duration row. Loading older turns
also loads their completion timestamps.
Tool calls fold in two levels. A run of them collapses to one line saying how many there are
and what the last one was; opening that gives a line per call; opening a call gives what the
server kept of it. za works on whatever is under the cursor, a group or a row. zr and zm
open and shut a level across the whole conversation, so zr zr is every call and everything
in it, which zR does in one. zM shuts them all, and is the one key that also lets go of
the folds opened by hand.
Expanding a tool row starts with what the server sends: the command or tool input, an
output summary, changed files, and the status. File changes show each file's retained
diff below its path, with green additions and red deletions; those patches also appear in
copies and editor exports. For local servers, opening folds recovers patches stripped
from the stream from the server's read-only state.sqlite activity records, matched by
thread and tool-call ID. Calls without retained patches show their file paths.
When connected to a local server, opening
folds also looks for the full Claude tool inputs and results in the checkout's saved
transcripts under ~/.claude/projects (or CLAUDE_CONFIG_DIR/projects). Calls are matched
by their tool-use IDs. Recovered content appears in expanded rows, searches, yanks, and
editor exports; the server's records are unchanged. Reading happens in the background. JSON inputs and outputs are syntax highlighted; unified
diffs use green additions, red deletions, and cyan headers. Copying and exports retain the
original text.
For a background Bash command, recovery also reads the output file referenced by its saved result, labelled as current background output. These file previews are limited to 2 MiB and explicitly marked if cut off. Reopening folds retries recovery and refreshes that output. Missing transcripts or results are labelled, with the server summary kept as a fallback. Full tool input/result recovery supports Claude; file-patch recovery also supports Codex. Both require a local server; recovery does not resume a session or rerun a command. A subagent's tool calls already come from its transcript file and keep what they sent and what they got back.
An image the agent read is drawn rather than named: unfolding the row puts the picture under
the line, as wide as the chat and at most half as tall, scrolling with the conversation like
any other lines. It is fitted to that room rather than cropped, so a tall screenshot comes
out whole and small; gx on the row hands the picture to whatever this machine opens
pictures with, which is where to read one the terminal has shrunk past reading. That works
from a folded row too, and on a terminal that cannot draw pictures at all. A picture that
came inside a subagent's transcript is not a file anywhere, so it is written to a temporary
one first, named after the bytes so the same picture is always the same file. The summary the server sends holds the path the tool read and not the
picture, so the file is read from disk — which works when the server is on this machine, and
is why an image row against a remote server has nothing to unfold. A subagent's transcript is
the exception again: the provider's file carries its pictures inside it, and those draw
wherever the server runs.
Pictures attached to your messages appear below your text, with their filenames as captions.
They are downloaded through the server's asset API, so this works with a remote server too.
gx on the caption or picture opens it in your machine's image viewer. While a picture is
being fetched its caption says loading…; if it cannot be fetched, the filename stays with
could not be fetched beside it.
A picture the agent points at in what it writes is drawn as well. An agent that takes a
screenshot writes the file out and then shows it, which in markdown is an image: what it
called the picture is kept as the caption and the picture goes under it, in the same room and
by the same rules as a tool row's. There is nothing to unfold — a message's picture is part of the message
— and gx on the caption or on the picture itself opens the file. The path is read from the
disk under us, as a tool row's is, so the caption says whose disk it was when that is somewhere
else, and says nothing at that path now when the file has been cleaned up since. A source
that is not a path in full is left as the marker the markdown reader wrote: a relative one has
no directory here to be relative to, and the chat does not fetch over the web.
How it is drawn is up to the terminal, which is asked once at startup: the kitty, iTerm2, or
sixel graphics protocol where it speaks one, and half-blocks where it speaks none, which is
coarse but is still the picture. PNG, JPEG, GIF, and WebP are read. A row says [image] in
place of the picture when the file is something else, or when the terminal answered with no
way to draw one, and says so with nothing at that path now when what the tool read was a
temporary file that has since been cleaned up.
A table wider than the chat is narrowed to fit rather than wrapped across its borders. Each column keeps its longest word where there is room for that, and every column gives up the same share of its width past that; the text then wraps inside its cells, which grow taller instead. A pull request's description and reviews are drawn the same way. A view too narrow for even one letter per column leaves the table as it is.
The sidebar mirrors the desktop app's sections: pinned, active, snoozed, and settled. Settled
threads are ones the server has parked after their turn finished; they render grayed out in a
shelf that starts folded (113 of them is normal). Press S, :settled, or Enter on the shelf
header to unfold it. J/K and the picker only cycle through visible threads.
Each row shows the project on the right and a status glyph on the left: ! needs an approval
or an answer, a spinner is working, ◔ is monitoring (background work still running after the
turn), ▤ has a plan ready, ✗ failed, and a red ⊘ was interrupted, its last turn stopped
before it finished. The status bar spells the status out for the open
thread. :settle and :unsettle move the current thread between the active list and the
shelf; :wake ends a snooze.
A thread that has finished a turn, been written to, or settled into monitoring since you last
had it open shows ● in place of the quiet ·; opening it clears the mark. Monitoring keeps
its ◔ and turns green instead, because a watcher sits there for hours and the glyph alone
cannot say whether anything has happened in them. The other statuses are left alone, since
each already says more than "look at me". This is only for as long as tria is running:
what you have read is not written down, so a thread is never unread because of something that
happened before tria started.
The list draws a thread on one line. The config file can give it two instead, which buys room for the title by putting what the thread is working in under it, and draws the project as an icon spanning both lines rather than a label competing with the title for the same row:
sidebar_layout = "two-line"The second line is the thread's branch; a thread working in the project's own checkout has no
branch of its own recorded, so it says the project's name instead, and the open thread falls
back to the ref its checkout is on. ⌂ in front of it means a settled thread still holds a
worktree. The other value is one-line, which is the default.
The sidebar has three tabs, named across its top: the threads, and the open thread's pull
requests and subagents. H and L move between them with the sidebar focused, a click on a
name does too, and each tab is where it was left. The threads are what tria starts on. The
other two follow the open thread, so moving through threads with J and K shows each one's
pull requests or subagents; a new thread's list starts at its top. Their names count what the
thread has, and say when something there wants looking at: ✗ beside the pull requests while
an open one is failing its checks, a spinner beside the subagents while one is working.
The pull requests are listed as :pr lists them, stack by stack with ↳ on the layers that
build on another. Each row leads with where the pull request stands — ● open, ◌ draft, ◆
merged, ⊘ closed — and then its title. Under it the two-line layout puts its number, how its
checks stand (✓ passing, ✗ failing, ○ running), what its review decided (✔ approved,
✎ changes requested, … waiting on a reviewer), and its branch last, which is what gives
way when the sidebar is narrow. The one-line layout puts the two marks before the number. The subagents are listed newest first, with how
long each has taken, and in two lines what it is doing or how it came out.
Enter reads the one under the cursor in place of the conversation, as :pr and gA do,
and leaves the keys in the sidebar, so the next is j and Enter away; the one being read is
marked there. r reads it again, y copies a pull request's link or a subagent's report, and
d unlinks a pull request from the thread, after asking, and c starts a thread in a pull
request's own code, as :checkout does.
tria open [path]opens the project for a directory, adding it when there is none. It is the chat, not a subcommand that prints and exits.tria probeconnects, prints the server config keys and the thread list, and exits.tria dump <thread-id>opens a thread through the same connection code as the UI and prints reduced state for a few seconds. Both are useful when checking a new server release.
Logs go to tria.log next to the config file. Each start begins a new log and keeps the
previous start's as tria.log.1. Set TRIA_LOG=debug for more detail.
See PLAN.md for the protocol notes: pairing and WebSocket tickets, the Effect RPC JSON framing, the shell and thread subscriptions, and the dispatch commands this client uses.
Releases are cut with cargo-release from main:
cargo release minor # shows what it would do
cargo release minor --executeIt bumps the version in Cargo.toml and Cargo.lock, commits that as Release <version>,
tags the commit v<version>, and pushes both. The tag starts the release workflow, which
builds the binaries, attaches them to a GitHub release, and then publishes the crate to
crates.io. Nothing is published from the machine that runs cargo release.
Publishing uses crates.io trusted publishing, so the repository holds no registry token. The
crate's settings on crates.io name this repository and release.yml as a trusted publisher.