Skip to content
PajnPublic

About

TUI T3 Code client with support for images, mouse and other modern terminal features

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

tria

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.

Requirements

  • 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.toml names 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.

Setup

A built binary, without a Rust toolchain:

cargo binstall tria

Releases carry one for Linux and macOS, on x86-64 and arm64. Anywhere else, and with cargo install, it is compiled from source:

cargo install tria

From 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 pair

Exchange 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 open

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

Starting the server

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.

Keys

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 = 3000

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

Images

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.

Approvals

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.

New threads

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.

Branch and pull request

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.

Reading a pull request

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>):.

Programs

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.

Links

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.

Background tasks

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.

When the connection goes

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.

When the updates stop

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.

Notifications

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 limits

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

Subagents

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.

Subagent transcripts

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.

Terminals

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.

Editor

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.

The thread's directory

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.

tmux

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.

Tool output

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.

Thread list

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.

Tabs

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.

Other subcommands

  • 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 probe connects, 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.

How it talks to the server

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.

Releasing

Releases are cut with cargo-release from main:

cargo release minor            # shows what it would do
cargo release minor --execute

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

About

TUI T3 Code client with support for images, mouse and other modern terminal features

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages