Skip to content

Latest commit

 

History

History
457 lines (279 loc) · 75.4 KB

README.md

File metadata and controls

457 lines (279 loc) · 75.4 KB

t3term

T3 Code without the window.

A terminal client in Rust. One 5.4 MB binary: the whole interface with no arguments,
a CLI you can script with a subcommand. Your threads are the ones the app already shows.

Try it · Why it exists · What you get · CLI · How it works

Rust macOS MIT Unofficial

Opening a thread in t3term, sending a prompt, watching the agent run a command and answer, then pressing t to open the tool call and see its output

New to T3 Code? It runs coding agents on your machine. Claude Code, Codex, Cursor, Grok and others, each working in its own thread on one of your projects, with approvals, permission modes and branches. It ships as an Electron desktop app, plus a web app and a phone app. t3term is the same thing as a terminal program. It connects to the T3 server you already run rather than starting one, so a thread you open here is the same thread the desktop app shows.

Try it

There are no prebuilt binaries yet, so build it:

git clone https://github.com/krishhgg/t3term
cd t3term
cargo build --release

./target/release/t3term doctor   # does it see your server?
./target/release/t3term          # open the TUI

cargo install --path . puts t3term on your PATH instead.

You need T3 Code already running, either the desktop app or t3 from their installer, plus a Rust toolchain. t3term finds the server on its own and logs itself in, so there is nothing to configure. If doctor fails it names the step that failed. It cannot create a thread yet, so start one in the app and open it here.

Tested against T3 Code 0.0.46-nightly.20261007.2787 on orchestration protocol 2, on macOS. Linux and Windows have not been tried.

Why it exists

It's a TUI alternative to the desktop app. Everything runs the same, you're just interacting through a terminal rather than a window.

It's way lighter because it isn't a browser. The desktop app is Electron, which means it ships Chromium, so a whole browser engine runs your window, with another process for the GPU and a few helpers around it, and one more every time you open a second window. That came out to about 1.1 GB in the run below. t3term is one Rust process drawing text cells, so it sits at 53 MB.

Both clients open on the same thread, on the same Mac, against one server holding 14 projects and 123 threads. Sampled every 5 seconds across a minute:

Memory CPU
T3 Code desktop windows (5 processes) 1161 MB 11.1% of one core
t3term (1 process) 53 MB 0.8% of one core

Run python3 benchmarks/compare_clients.py with both clients open and it prints that table for your own machine. Memory is a sum of RSS, which overcounts pages the processes share, so read it as an upper bound.

Neither row counts what runs whichever client you use: T3's server itself used 449 MB, and the agents it had spawned used 2774 MB across 63 to 66 processes. The agents are the expensive part, and nothing here changes that. t3term replaces the window, not the engine.

Rust is a big part of why it stays that low, since there's no runtime or garbage collector underneath it and the whole binary is 5.4 MB. It only redraws when something actually changed, and never more than 30 times a second, so leaving a thread open costs you almost nothing. Long threads stay cheap too, because it only draws the lines that are on your screen instead of the whole history, which in the benchmark was around 12 times faster at 50,000 lines.

It's scriptable. Every subcommand takes --json, and t3term threads --limit 1 answers in 30 to 50 ms using the saved login. Send a prompt from a git hook, wait for the turn, approve what it asks, read the result. One gap: when the agent asks a question rather than for approval, requests lists it but only the TUI can answer it.

It's a client, not a fork. t3term opens no database and starts no server. A thread you open here is the thread the desktop app and the phone app show.

The T3 Code desktop app on the left and t3term on the right, showing the same thread with the same answer and code block

The same thread in both. The desktop app is on the left, t3term on the right.

What you get

The TUI follows the desktop app closely, because muscle memory is worth more than a new idea here. The sidebar lists threads as cards with the project monogram, a status word or the thread's age, title, branch and provider glyph. The status words, their order and their colors are the desktop's. Approval and Input mean the agent waits on you, and they come first. Working has a spinner and a clock, and reads Goal while a /goal is active. Waiting means background work will wake the agent. Limited is a run that hit a usage limit and Failed is any other failure. Woke marks a snooze that ended, until someone dismisses it in the desktop app or the thread moves on. Done marks a finished run nobody has looked at since. A card with no word shows the time of its last message. The cards sit on the desktop's Pinned, Active, Snoozed and Settled shelves, in the desktop's order, each under its own heading. A Working shelf between Active and Snoozed is off unless you turn it on, as below. A snoozed thread comes back to its shelf when the snooze ends, and the Settled shelf stays closed until you open it. Forks are listed like any other thread. Archived threads and subagents are not. Your prompts are right-aligned bubbles. Approvals and questions open a panel above the composer with their keys printed on the buttons.

t3term with a thread open: the sidebar on the left, a turn showing a command and its output, the answer, and the composer with model, effort and mode chips

Where Keys
Anywhere Tab / Shift+Tab move focus, Ctrl+B hide or show the sidebar, Alt+B open or close the thread's details, PgUp/PgDn scroll, Ctrl+X interrupt, Ctrl+C quit
Anywhere, approval pending Alt+A accept, Alt+S accept for session, Alt+D decline. Alt+↑/↓ or the wheel scrolls a long request
Anywhere, thread open Alt+M model, Alt+E reasoning effort and other model options, Alt+P access, and plan mode once it's turned on
Anywhere, tasks drawer showing Alt+T open or close the task list. Alt+↑/↓ or the wheel scrolls a long list
Anywhere, provider banner showing Alt+N dismiss, Alt+O open or close the whole notice. Alt+↑/↓ or the wheel scrolls an open notice too long to show
Anywhere, error banner showing Alt+W dismiss, Alt+I open or close the whole error. Alt+↑/↓ or the wheel scrolls an open error too long to show
Open menu ↑/↓ choose, Enter select, Esc close. In the model menu, type to search
Sidebar ↑/↓ or j/k select, Enter open, w open or close the Working shelf once it's turned on, e show or hide the Settled shelf, [ and ] narrow or widen the sidebar, 0 back to its usual width, q quit
Composer Enter send (queues if the thread is busy), Alt+Enter or Ctrl+J newline, Ctrl+R swap in an unsent message, Esc to transcript
Transcript ↑/↓ scroll, g/G top/bottom, t open or close every row of tool calls, p expand or collapse the first long plan in view, w open or close the worktree setup's details while its card is in view, Enter compose, Esc, ← or h sidebar, showing it if hidden
Thread details ↑/↓ or j/k scroll, PgUp/PgDn page, Home/g top, End/G bottom, Esc close the dialog or leave the pane

The wheel scrolls the transcript and the sidebar. Clicking ◧ at the top left of the conversation hides or shows the sidebar, clicking ◨ at the top right opens or closes the thread's details, and dragging the sidebar's right edge resizes it. Clicking a thread opens it, clicking the Working heading or the Settled footer opens or closes that shelf, clicking a chip under the composer opens its menu, clicking the top row of the tasks drawer opens or closes its list, clicking × on a provider or error banner dismisses it, clicking elsewhere on a long notice or error opens or closes it, and clicking a row of tool calls opens that row. Clicking the top edge of a long plan, or its Expand plan or Collapse plan button, expands or collapses that plan. Clicking a worktree setup card's Details row opens or closes its details.

On macOS the Alt keys need Option to send Meta: "Use Option as Meta key" in Terminal, "Esc+" for the Option key in iTerm2, or macos-option-as-alt = true in Ghostty.

Picking a model

A menu choice turns its chip blue and goes to T3 with the thread's next message, as in the desktop app.

Choosing a model, effort and mode in the TUI, then sending

The chips and menus follow T3's provider list as it changes. The TUI subscribes to T3's server config, so when T3 adds or drops a provider, renames a model or gives a provider a status such as error, the chips and any open menu show it without reading the list again. The model menu shows a status after the provider's name. An open menu keeps its highlight on the same model as rows move. When a draft's provider goes away, its chip shows the thread's model, and the draft keeps the choice until the provider comes back. If T3 refuses the subscription, each menu reads the list with server.getConfig when it opens.

Ultrathink applies to that one message and comes back only if that message fails to send. T3 refuses a mode change while a run is active, so the TUI leaves the message in the composer to send once the run ends. That includes the switch back to Build below. A message that fails after you have typed something else or opened another thread is kept: the status line says so, Ctrl+R swaps it with the composer's text, and opening its thread with an empty composer brings it back. A send that times out can still reach T3, so if the thread later shows it, the TUI drops its kept copy rather than send it twice.

Plan mode

Build and Plan are hidden by default, as in the nightly desktop app, which moved them behind a legacy setting. To bring them back, add "planModeEnabled": true to ~/.config/t3term/settings.json and restart t3term. The desktop's "Plan mode (legacy)" switch uses the same key, though each app keeps its own copy. The Alt+P menu then has a Plan mode section, and a Plan chip shows while a thread plans. Providers without plan mode, such as Pi and Grok, never show either.

While Plan is hidden, the TUI sends every message in Build. A thread that the CLI, the desktop or an earlier t3term left in Plan goes back to Build with its next message from the TUI, as it would in the desktop. A model's Plan agent, such as OpenCode's, is hidden the same way. Alt+E leaves it out, and a thread saved on it sends its next message with the agent Alt+E shows, or with no agent when Plan was the only one. The setting changes nothing in the CLI, where --plan, --no-plan and --option agent=plan work either way.

Proposed plans

A plan an agent proposes appears in the transcript as a card, like the nightly desktop's plan card. Its top edge has a Plan chip and the plan's title, which is the first Markdown heading in it, or "Proposed plan" when it has none. Inside is the plan as Markdown, without its title line or a Summary heading right under it. Cards show whether or not planModeEnabled is on. The checklist an agent keeps while it works is a different item. It stays a plain Plan row in the transcript, and the running turn's checklist also shows in the tasks drawer below.

A plan longer than 900 characters or 20 lines starts collapsed, as on the desktop. Characters here are UTF-16 code units, the desktop's measure, so an emoji counts as two. A collapsed plan shows its first ten lines with text, then ..., and has an Expand plan button in its bottom edge. A click on the button or on the card's top edge expands it, and Collapse plan folds it again. With the transcript focused, p does the same for the first long plan in view, reading down from the top of the screen. A plan counts as in view while any of it shows, even after its top edge has scrolled off. With no long plan in view p does nothing, and the status line offers p only when it would act. A shorter plan shows in full and has no button.

Expanding or collapsing a plan keeps its top edge on the same screen row, so the text above it stays where it was. A collapse whose top edge had scrolled above the screen brings that edge back into view instead, as near the top row as the thread allows, so the shorter card doesn't end up out of view. Either way the view never scrolls past the end of the thread. When the card and what follows it are too short to reach the bottom of the screen from that row, the view stops at the end of the thread and the top edge sits lower. Expanding a plan near the bottom of the thread scrolls the view off the bottom, so new output stops pulling it down until you press G. A scroll that arrives before the screen redraws, such as G pressed right after p, wins, and the top edge moves with it. Each plan expands on its own. t3term remembers which are expanded only until you open another thread, and sends nothing to T3 or the settings file. The CLI prints plans as it did before.

The frame needs 17 columns, the width of the Collapse plan button and its two corners. A narrower card drops it. The Plan chip and as much of the title as fits take the top row, the plan follows at the full width, and a long plan ends with its button, cut short when the label doesn't fit. A click on either of those rows works as it does on the edges. Text too long for a row breaks onto the next one, mid-word if it has to, so none of the plan is cut off.

Tasks

While a turn runs, the checklist its agent keeps shows in a drawer on the composer's top edge, as in the nightly desktop. Its top row names the step in progress, or the next one to do, or the last step once all are done. The row ends with the number of steps done, such as 2/5, which turns green when every step is done. In a drawer 60 columns or wider, a list of 2 to 10 steps adds a bar with a segment per step, colored by its state. Alt+T or a click on the top row opens the list under it. Each step has a mark, ✓ done, ◉ running or ○ to do, then its text and, on the right, a time. A finished step shows how long it took as T3 recorded it, and the running step shows now.

Alt+T is the drawer's only while the drawer shows. With no drawer, the key goes where it went before. Terminals send Esc then a quick t as Alt+T, so in the transcript that sequence still opens or closes every row of tool calls. While a drawer shows, the same Esc and t open its list instead, and a plain t still works.

The drawer shows the newest list written by the run that owns the thread's work, while that run hasn't ended. A new turn that hasn't written a list yet shows nothing, rather than the last turn's list. A queued message doesn't take the drawer from the run still working, and a list with no steps shows nothing. The drawer goes away when the run ends, while a request waits for an answer, and while the thread's watch connects or reconnects. When it comes back, its list starts closed, however briefly it was gone, as it does when you open another thread. Whether the list is open stays in this window, and t3term sends nothing to T3 or the settings file.

The open list takes at most 15 rows, the desktop's height, and at most 40% of the terminal. A longer list ends with a row such as Lines 1-14 of 30 · Alt+↑/↓ scroll, and Alt+↑/↓ or the wheel scroll it. With less room the list shrinks, and with none the drawer isn't drawn. The Alt+T label leaves a drawer under 40 columns, and a very narrow one shows only the count and the arrow. A long step breaks onto more rows, mid-word if it has to. CJK characters and emoji count two columns each, and an accented letter or an emoji such as 👩‍💻 or ⚠️ stays on one row.

In the transcript and in t3term read, a checklist shows as Plan rows marked [x] done, [>] running and [ ] to do.

An agent writes each step's text, so the drawer, the transcript and t3term read drop its control characters, such as Esc and the C1 codes, and a step can't move the cursor, clear the screen or set the clipboard. What followed an Esc stays as plain text. A carriage return starts a new row, and a tab or another blank control shows as a space. t3term --json read keeps the text as T3 sent it and writes each control character as a JSON escape, such as \u001b for Esc or \u009b for the C1 CSI. A terminal shows the escape as text, and a JSON parser reads back the original step.

Worktree setup

While T3 sets up a worktree for the open thread, a card under the thread's first prompt shows how far it got, as the nightly desktop's worktree setup card does. The card only reads. It has none of the desktop's Cancel, Work locally or Open terminal buttons, and t3term never starts, retries or cancels a setup.

Under the thread's first prompt, the card reads Setting up worktree… with 1m 53s on the right. Fetch base branch and Check out files are done, and the Install script has run for 1m 37s, with its last four output lines under it. Start agent is pending. Details is open with the branch t3code/add-search, the base main, the worktree's path and the command bun install --frozen-lockfile, and the status line offers w hide setup details

The thread and its setup are invented and come from a fake T3 server, and the image renders t3term's captured terminal output in truecolor. Worktree setup verification lists what was checked, with captures of a checkout at 47%, a failed setup script in truecolor and 256 colors, a script still running after the agent started, a 42x18 screen and made-up Unicode and control text. A capture slideshow steps through six of the captures. It is a slideshow of the captures, not a recording.

The TUI subscribes to T3's subscribeWorktreeSetup for the open thread while the thread has a worktree path or its latest run is preparing a worktree, which is when the desktop subscribes. Each open thread has one subscription. Opening another thread drops it, and so does the thread's watch closing. The TUI checks whether to subscribe when a thread opens, when T3 changes the thread or one of its runs, and when the sidebar's copy of the thread changes. A frame, a key or a piece of a streamed answer doesn't check, and the card never asks T3 for anything else.

The card's first row names the setup's state, such as Setting up worktree…, Worktree ready, setup script failed or Worktree setup failed, with the total time on the right. A row follows for each stage: fetching the base branch, checking out files, initializing submodules, the setup script under its name, and starting the agent. Each has a status mark, ✓ done, a spinner while running, ✕ failed, ! warning, − skipped or ○ pending, and its time. A running checkout shows its percentage, a skipped stage says why or skipped, and other stages show T3's note on what they did. While the script runs with output, and after it fails, four rows under it show its last four lines, so the card grows once and not with each line. A failed setup shows T3's error in up to six rows.

The card follows the desktop's rules for when it shows:

  • A running setup always shows.
  • A setup that finished cleanly shows until the agent's turn starts.
  • A failed or cancelled setup, or one whose script failed, stays through the first turn. Once the agent has started and the turn is live, it shrinks to one row that says how it went.
  • A setup script still running after the agent started is one row, such as Install is still running, with its time. The desktop shows a chip in its working header that opens the stages in a popover. t3term draws the row where the card was, and its Details row opens the stages under it.
  • A second prompt in the thread retires any card except a running setup's.

w in the transcript opens or closes the setup's branch, base, path and script command under the card's Details row. It works while any row of a card with a Details row is in view, even after the Details row has scrolled off, and the status line offers w then. A click on the Details row does the same while that row is on screen. Opening keeps the Details row on the screen row it was on, on screen or off. Closing does too, except that a Details row above the screen comes to the top, so the shorter card doesn't leave an unrelated block in view. In the composer w types a w, in the sidebar it stays the Working shelf's key, and Alt+W stays the error banner's. A click acts on the row as the last frame drew it, so a click after a scroll, a resize, Ctrl+B or a new setup does nothing until the next frame. Whether Details is open stays in this window, and t3term sends nothing to T3 or the settings file.

T3 sends each setup whole, and the card shows the latest one for the open thread. A setup for another thread, or one that doesn't parse, changes nothing. The card drops control characters as the tasks drawer does a step's, so a script's output can't move the cursor or set the clipboard. It keeps at most 200 characters of a stage's note, 400 of an output line and 1,000 of the error, and a cut keeps an accent on its letter and an emoji whole. CJK characters count two columns, and a long branch or path is cut in the middle so both ends show.

T3 numbers a thread's setup changes and forgets a setup 30 seconds after it ends, sending null. Within one subscription a setup with a lower number than the one shown is older and is ignored. Null leaves the card as it was, as on the desktop. T3 counts from zero again after null and after it restarts, so after null or a reconnect the next setup replaces the shown one whatever its number. The desktop keeps comparing across those, and can keep an older setup on screen until the new one's number passes it.

If T3 refuses the subscription, which it does for a session or a server, or answers it with any other error, the TUI doesn't ask again for any thread until it restarts, and says nothing. The thread works as before, and the card keeps the last setup it had. If T3 ends a subscription without an error or the connection drops, the TUI subscribes again after a short wait, and the card keeps the setup it had until the new subscription sends one.

A card kept through a refusal, a reconnect, null or the thread's watch closing doesn't wake the TUI once a second, even if its setup was running when it came. The tick starts again once an open subscription sends a running setup and a frame draws its card. The card's times still move when something else draws a frame in a new second, such as a key, a server event, or the tick for a run on the open thread or a Working card in the sidebar. Each such frame lays a running setup's card out again at the current time.

A few things differ from the desktop:

  • t3term's card always has its own first row. The desktop puts the label of a running or cleanly finished setup in the working row above its card.
  • Details opens below its row. The desktop shows the details above its Details button, and always shows the button, where t3term leaves the row out when T3 sent no branch, base, path or command.
  • With no prompt in the thread, the card follows the last row of the transcript.
  • The card waits for the thread's state to load, and a follow-up prompt counts once T3 shows it in the thread.

Provider status

When the provider the composer would send the next message with isn't ready, or runs a version T3 knows is broken or doesn't support, a banner at the top of the conversation says so, as the nightly desktop's does. It lies over the transcript like the thread error banner, and above it when both show.

An amber banner reads Codex provider status, with a warning that the provider answers slowly right now, × and Alt+N dismiss. Under it, a red banner shows the thread's provider session error, with × and Alt+W dismiss. The transcript shows around both, and the composer holds the draft draft stays here

The thread and its errors are invented and come from a fake T3 server, and the image renders t3term's captured terminal output in truecolor. Provider status verification lists what was checked, with captures of an unsupported version, a sign-in error, a long notice, 256 colors and a 42x18 screen. A capture slideshow steps through six of the captures. It is a slideshow of the captures, not a recording.

The provider is the one the model chip names. That is the model you picked for the thread and haven't sent yet, while T3 would still take it, else the thread's own model selection. The banner looks that provider instance up in T3's config by its id, so two instances of one driver, such as Codex and a second Codex for work, each have their own notice. It finds the instance even when T3 lists no models for it. With no thread open, or once T3's config no longer lists the instance, there is no banner, and t3term never shows another provider's status in its place.

What it says follows the desktop's ProviderStatusBanner:

  • A provider in error that is signed out reads Codex is unauthenticated.
  • A version T3 marks broken, or one it doesn't support on a provider that is otherwise ready, reads Codex 1.2.3 is known to be broken or Codex 1.2.3 is unsupported, with T3's advice as the message. Advice for a broken version comes before the startup failure it causes.
  • Any other provider that isn't ready reads Codex provider status, with T3's message.
  • A ready provider whose version T3 hasn't marked that way, a disabled one and an installed Antigravity whose sign-in T3 hasn't checked yet show nothing.

The name is the one T3 gives the instance, else the driver's id in words. The border is amber for a warning or an unsupported version, and red for an error or a broken version. When T3 sends no message, the banner gives the desktop's advice, with one change. t3term has no provider setup, so the advice names T3 Code's, as in Open provider setup in T3 Code to sign in. A provider whose setup can't sign in reads Sign in via the CLI to authenticate again. t3term doesn't install a provider or sign in to one.

Alt+N or a click on × dismisses the banner. As in the desktop, the window remembers one dismissed notice, until t3term quits, and sends nothing to T3 or the settings file. The notice stays hidden on every thread that uses that provider. A different notice shows, and if the provider then goes back to the dismissed one, it stays hidden. A provider with nothing to say clears the dismissal, and so does a selection whose provider T3's config doesn't list, so the notice shows again if it comes back after that. Dismissal goes by the provider instance, its state, its sign-in status and T3's whole message, or for a version, the instance, the verdict, the version and T3's whole advice. A message that differs from a dismissed one only past what the banner prints still shows.

The banner draws as the error banner does. A closed notice shows its title and at most three rows of its message, and its last row ends with … when there is more. Alt+O or a click on the banner opens the whole notice and closes it again, and an open notice too long for its rows scrolls with Alt+↑/↓ or the wheel. Opening one banner closes the other, so only one is ever open, and Alt+↑/↓ scroll that one before a request panel or the task list. The two banners share the top half of the transcript. While the thread has an error, the closed notice leaves the error banner at least three rows of that half, or half of it on a tall transcript. On a transcript under eight rows, the closed notice gives way to the error and doesn't show. An open notice takes the transcript's height, less the rows it leaves the error.

Alt+N and Alt+O are the banner's only while it shows, as Alt+W and Alt+I are the error banner's. The banner drops control characters, prints at most the first 4,096 bytes of the title and of the message, cut where a character starts, and says when it cut the rest.

The TUI works out the composer's provider only after something that can change it: a new config from T3, opening a thread, a change from T3 to the thread, a fresh copy of the thread after a reconnect, a send, and a pick from the model menu. Except after a new config, it first compares the thread, its model selection, its provider instance and the unsent pick with the ones it used last, and stops if they match. The banner then compares the notice with the one it has. Only a new notice is checked against the dismissed one and cleaned for printing, so T3 checking a provider and finding it as it was does no more than that comparison. A streamed answer, a redraw, and opening or scrolling the banner don't look at the provider at all, and the banner never asks T3 for anything, the model list included. A model list that comes back after a newer config can't bring back an older status, and while the config subscription reconnects, the notice stays as it was until the new snapshot.

Thread errors

When a thread has an error, a banner at the top of the conversation shows it, as the nightly desktop's does. The banner lies over the transcript's first rows rather than pushing them down, so the text you are reading stays where it is. Its border is red, or amber for a usage limit. It is as wide as its text, up to 96 columns, and leaves a column of the conversation on each side.

A provider session's error A long error, open and scrolled to the end
Err A: session open with the sidebar shown: a red banner over the transcript's top rows reads Fixture provider session error: the invented connection to the provider closed, with × on the right and Alt+W dismiss in the bottom edge, and the composer holds the draft draft stays here Err: long text with its error open: the red banner fills the transcript with detail lines, a word of y wrapped onto two rows and … the error goes on past 4096 bytes, and its bottom edge reads Lines 22-55 of 55 · Alt+↑/↓ · Alt+I less · Alt+W dismiss

The threads are invented and come from a fake T3 server. The images render t3term's captured terminal output in truecolor. Thread error verification lists what was checked, with captures of a usage limit in 256 colors and of a 30x10 screen. A screen tour shows a long error closed, open and dismissed. It is a slideshow of the captures, not a recording.

The TUI picks the error as the desktop does. A send from this window that failed comes first, until the next send to that thread starts or the thread shows the message after all. Otherwise it is the error T3 holds for the thread: the last error of the thread's provider session, else the failure of the thread's latest run. A run's failure counts only when the run failed on its root node, so a failed tool call, a subagent's error, an older run's error and an error a fork inherited from its parent never show. A newer run replaces a failure as soon as it is queued, unless the queue is held. A usage limit is the exception: it stays the thread's error while newer messages wait behind it, until one of them starts. The banner is amber only when it shows the usage limit itself, so a different error from the provider session shows in red.

A send error belongs to the thread it went to. If it comes back after you have opened another thread, the banner waits for that thread, and the message is kept as before.

Alt+W or a click on × dismisses the banner. t3term remembers the dismissal until it quits, for that thread and that exact text, and sends nothing to T3 or the settings file. The error stays dismissed when you leave the thread and come back, the same text on another thread still shows, and a different error on the same thread shows again. Dismissing a send error shows the thread's own error if it has one.

A closed banner shows at most three rows of the error and at most half of the transcript, and its last row ends with … when there is more. Alt+I or a click on the banner opens the whole error, up to the transcript's height, and Alt+I or another click closes it. An open error too long for its rows shows a count such as Lines 1-12 of 40, and Alt+↑/↓ or the wheel scroll it. The banner's bottom edge names the keys that apply, as many as fit. A banner with fewer than 12 columns, or fewer than three rows it may take, has no border and shows only the text and ×. The wheel over a closed banner scrolls the transcript under it, and a click on the banner never reaches the row under it.

Alt+W and Alt+I are the banner's only while it shows. Terminals send Esc then a quick w or i as Alt+W or Alt+I, so while a banner shows, that sequence acts on the banner. Without one it does what it did before, and a plain w or i always does. Alt+↑/↓ go to an open error too long to show before a request panel or the task list. While a menu is open the banner's keys still work, and the first click closes the menu.

An error comes from a provider or a server, so the banner drops its control characters as the drawer does a step's. It prints at most the first 4,096 bytes of the error, cut where a character starts, and its last line says when it cut the rest. An error with nothing left to print, such as only spaces, shows no banner. Dismissal goes by the whole error, so an error that differs from a dismissed one only past those 4,096 bytes still shows.

A provider session's error can be any length, so the TUI reads it only after something that can change it. That is when you open the thread, when T3 sends a change to a run, a provider session, the thread or an error item, when T3 sends a fresh copy of the thread after a reconnect, when a send error comes or goes, and when you dismiss an error. A change from T3 copies the error out of the thread once. The banner then compares the whole error with the one it has. Only a new error is checked against the ones you dismissed and cleaned for printing, and the cleaning reads at most 4,096 bytes of it. A streamed answer, a redraw, and opening or scrolling the banner don't read the error at all.

The banner shows the thread's error only, and the provider banner above it shows the provider's status. A few things differ from the desktop. t3term shows a failed send's error as the client reported it, where the desktop sanitizes it first. A usage limit always shows in the banner, because t3term has no notice of its own for the latest run. A check that fails before a message goes out, and a failed interrupt or answer, still show only on the status line. The desktop also keeps a thread's error while a pull request watch holds the thread, and t3term doesn't.

Context compaction

When an agent compacts its context, the transcript and t3term read show a marker for it, with or without a title from T3. The marker names the compaction's state: Compacting context while it is pending, running or waiting, Context compaction failed, Context compaction stopped once it was cancelled or interrupted, and Context compacted otherwise. The nightly desktop's timeline calls a failed or pending compaction compacted, where t3term names its state.

A compaction that finished with both token counts shows them in the marker, such as Context compacted 899K → 19K tokens. Otherwise the counts T3 sent go on the line under it, with ? for one it didn't send, such as 899K → ? tokens. t3term writes counts as the desktop does, so 1,500 tokens reads 1.50K.

The provider's summary comes next, which the desktop's timeline leaves out. t3term shows up to twelve lines with text from its first 4,096 bytes, and a last line of … when there was more. It drops the summary's control characters as it does a checklist step's, and t3term --json read keeps the summary as T3 sent it. In the TUI the marker is a rule across the transcript with the label in the middle, blue while the compaction is under way, and the summary wraps under it in grey. When T3 updates the item, such as when a running compaction finishes, the same marker changes in place. The TUI cleans the summary and writes the counts once each time T3 changes the item, not on every redraw.

Three compaction markers in one thread: Context compacted 899K → 19K tokens over a two-line summary, Context compaction failed over 1.23M → ? tokens and its summary, and Context compaction stopped over 999 → ? tokens

Context handoff

When T3 hands a thread's context from one provider to another, the transcript and t3term read show a Context handoff marker, with or without a title from T3. The line under it lists the source models, then an arrow and the target model, such as gpt-5.5, gpt-5.4 → claude-fable-5. Sources keep T3's order, and past twelve a count such as +3 more stands for the rest. t3term send --wait and t3term wait print the marker and that line on stderr once, when a handoff in the turn they wait on has finished or failed.

Newer T3 servers stamp the models on the handoff. For an older handoff, t3term reads them from the thread's runs as the nightly desktop does. The target's model is that of the handoff's run, and each source's is that of the latest earlier run on its provider. An end with no known model shows its provider id, such as codex_personal. So does each end of a handoff that a fork inherited, because the fork doesn't have its parent's runs. The desktop shows names from T3's model and provider list, which t3term read doesn't fetch, so t3term shows the ids T3 sent.

Each end shows at most 64 columns and ends with … when cut. t3term drops its control characters as it does a checklist step's, and t3term --json read keeps the item as T3 sent it. In the TUI the marker is a rule like a compaction's, with the endpoints wrapped under it in grey and the label in red when the handoff failed. t3term read prints no colors, so a failed handoff reads the same there. The TUI works out the endpoints again when T3 changes the item and, for an older handoff, when a run is added or its model, provider or order changes, not on every redraw. A handoff stamped at both ends never reads the runs, and while no handoff in the transcript reads them, a run event doesn't make the TUI look the run up.

Two Context handoff markers in one thread: a grey one over gpt-5.5, gpt-5.4 → claude-fable-5, and a failed one with a red label over composer-2, gpt-5.5, opencode, composer-2 → claudeAgent

The thread is invented and comes from a fake T3 server. The image renders t3term's captured terminal output.

The Working shelf

The nightly desktop app has a "Working shelf" setting, off by default, that moves threads busy without you out of Active. To turn it on in t3term, add "sidebarWorkingShelfEnabled": true to ~/.config/t3term/settings.json and restart t3term. The desktop's switch uses the same key, though each app keeps its own copy. With it off, the sidebar is as described above.

With it on, a thread on the Active shelf moves to Working while a run is under way, or while background work will wake the agent, as long as nothing waits on you. An approval, a question, a failure or a plan waiting for your reply keeps it in Active. Pinned, snoozed and settled threads stay on their own shelves while they work. Working sits between Active and Snoozed and lists the thread you last sent a message to first, so a run finishing or waking again doesn't move a card. Active then lists the thread that most recently came back to you first. That is the latest of when it was created or reopened, when its latest run was requested or finished, and when it left Working, such as for an approval partway through a run. No server field records when a thread left Working, so t3term notes the moment it sees it happen. It forgets those moments when it quits, and it notes none for threads that were already waiting when it started.

The shelf starts closed, with its heading counting the threads in it. The open thread's card stays under the heading while it works, so sending a message doesn't hide it. w in the sidebar, or a click on the heading, opens or closes the shelf. t3term saves that choice in the same file, under sidebarWorkingShelfExpanded.

Hiding the sidebar

Ctrl+B hides the sidebar and gives its columns to the conversation. It works from any pane, with a menu open too, and never types into the composer. Ctrl+B again, or a click on ◧ at the top left of the conversation, brings it back. On the desktop, Mod+B and the panel button in the title bar do the same.

Shown Hidden
t3term with the sidebar shown: shelves of threads on the left, and on the right ◧ before the breadcrumb, a running turn's plan, the tasks drawer and the draft hd in the composer The same thread after Ctrl+B: the conversation takes the full width, ◧ still leads the header, the draft reads hid after an i typed at the kept cursor, and the status line's key hints start with Ctrl+B sidebar

The threads are invented and come from a fake T3 server. The images render t3term's captured terminal output. A screen tour shows these two and a hidden sidebar at 60x20 in 256 colors, for three seconds each. It is a slideshow of the captures, not a recording.

While the sidebar is hidden, the key hints on the status line start with Ctrl+B sidebar, and Tab and Shift+Tab move between the composer and the transcript. Esc, ← or h in the transcript show the sidebar and focus it. Hiding it while it has focus moves focus to the transcript when a thread is open, or else to the composer. The open thread, the draft and its cursor, and how far you have scrolled stay as they were. The sidebar keeps up with T3 while hidden, so it comes back with any new or renamed threads and with your highlight where you left it.

t3term saves the choice in ~/.config/t3term/settings.json under sidebarHidden, so the next run starts the same way. A file without the key shows the sidebar. The desktop writes its choice to a cookie that it never reads back, so it opens with the sidebar shown every time, and this key is t3term's.

tmux uses Ctrl+B as its prefix. Inside tmux, press Ctrl+B twice to send one to t3term, or click ◧.

Resizing the sidebar

Drag the sidebar's right edge, the one-column strip between it and the conversation, with the left mouse button. Or, with the sidebar focused, press [ to narrow it and ] to widen it by two columns. 0 puts back the width t3term picks, a quarter of the screen from 26 to 34 columns. On a screen at least 40 columns wide, the sidebar can be from 20 columns wide up to half the screen. A screen from 20 to 39 columns wide keeps 20 columns for the conversation and gives the sidebar the rest. Under 20 columns, the sidebar gets none and the conversation takes the whole screen. On the desktop you drag the edge of the sidebar and double-click it to reset. t3term has no double-click there, so 0 does the reset.

The edge follows the pointer while the button is down, and t3term saves the width when you let go. A click on the edge, or a drag that ends where it started, saves nothing, so the width still follows the screen. A key, Ctrl+B or a terminal resize during a drag puts the edge back where it was, and Esc does nothing else. With a menu open, the first press on the edge only closes the menu. A drag needs a terminal that reports the mouse moving while a button is held. Where it doesn't, [ and ] still work.

t3term saves the width in columns in ~/.config/t3term/settings.json under sidebarWidth. A file without the key gives the width t3term picks. A screen too narrow for the saved width draws the sidebar narrower and keeps the saved width for when the screen grows again. The desktop keeps its width in pixels in the browser's storage, so this key is t3term's.

Width t3term picks, 140x44 Dragged to 51 columns, 140x44
t3term with no width saved: a 34-column sidebar of shelves, the thread Tasks: live run open with its plan and tasks drawer, and the draft hd in the composer The same thread with the sidebar dragged to 51 columns: a card title cut short before now fits, the transcript wraps in the narrower conversation and the draft still reads hd
Saved 59, drawn at 40 on 80x24 Saved 48, 140x44 in 256 colors
On an 80x24 screen the saved 59 columns draw as 40, half the screen, beside the same thread's plan and tasks drawer, and the draft reads hid A 48-column sidebar in 256 colors with Parser cleanup (fork) open and keep []0 draft unsent typed in the composer

The threads are invented and come from a fake T3 server. The images render t3term's captured terminal output, the first three in truecolor. A screen tour shows the four in this order for three seconds each. It is a slideshow of the captures, not a recording, so it shows neither the pointer nor the edge moving.

The environment and its connection

The row under the sidebar's wordmark names the T3 environment t3term is connected to. It shows the label from the environment's descriptor, which the desktop's connection settings show too, then the host and port t3term reached, such as Studio Mac · 127.0.0.1:3773. t3term reads both once, when it starts, so a label changed while it runs shows after a restart. The address is only the host and the port. A user name, password, path, query or fragment in the origin, such as one set in T3TERM_ORIGIN, never shows, and neither does a port the scheme implies. The label loses its control characters, its line breaks read as spaces, and it ends with … after 64 characters. Without a label the address shows alone, and an origin that doesn't parse shows no address. When the row is too narrow for both, a domain name is cut in the middle in front of its whole port, down to three characters at each end, as in stu…com:3773 for studio.example.com:3773. An IP address is never cut, so no digit of it or of its port goes missing. An address that doesn't fit is left out, and then the label is cut. At 80 columns the sidebar's row has 10 columns after Studio Mac · , too few for 127.0.0.1:3773, so the label shows alone until the sidebar is dragged wider.

The dot by the wordmark shows the sidebar's own feed, T3's orchestration.subscribeShell, in the desktop's colors. It is amber and reads connecting until T3 says the feed has caught up, and then turns green. After a drop it is amber and reads reconnecting while the feed subscribes again, and the sidebar keeps its threads until T3 says the feed has caught up again. When T3 refuses the feed it turns red and reads closed. The watcher stops then, so the dot and its word stay after the error message goes, and the threads stay as they were until t3term restarts. A sidebar too narrow for the word shows only the dot's color.

The feed shares the TUI's one WebSocket with the open thread's watch, so a dropped connection usually shows in both. Nothing watches the socket itself, though, so the dot is the feed's state and not the connection's. The open thread's watch keeps its own state at the end of the breadcrumb and at the start of the status line. A thread whose watch closes doesn't turn the dot red, and a live thread doesn't hide a feed that dropped or closed. With no thread open, the status line names the feed's state.

The sidebar's first row reads T3 Code, then closed and a dot in red, over Fixture Mac · 127.0.0.1:51024. On the right the open thread Live: replies coming shows its latest replies, the status line starts with a green dot and live, and the composer holds the draft demo draft kept

The environment and its threads are invented and come from a fake T3 server, and the image renders t3term's captured terminal output in truecolor. Environment chrome verification lists what was checked, with captures of the identity row, the sidebar hidden, a reconnecting feed, a thread whose watch closed beside a live feed, a hostile label, a 40x12 screen and 256 colors. A capture slideshow steps through six of the captures. It is a slideshow of the captures, not a recording.

While the sidebar is hidden, or too narrow for its dot, the end of the breadcrumb's row shows the dot, the feed's word while it isn't live, and as much of the environment as fits. It takes at most a third of the row and leaves the thread's title its first 16 columns. As the row narrows the environment is cut and then left out, then the word goes, then the dot. The environment and the dot take no clicks and have no key, and t3term asks T3 for nothing more to show them.

Thread details

Alt+B, or a click on ◨ at the right end of the title row, opens the open thread's details. Either one closes them again. The button is ◨ with a space on each side, three columns, and a blank column separates it from the breadcrumb, so the breadcrumb ends four columns sooner than it did. The details show four things:

  • Project: the title of the thread's project, from the sidebar's feed.
  • Workspace: Worktree · feature-a and the path under it when T3 recorded a worktree path for the thread. Otherwise Local checkout · demo and the project's folder. The name is the path's last folder, as the desktop names it.
  • Recorded branch: the branch T3 recorded for the thread. t3term reads no Git, so it can differ from the branch checked out. A thread with no branch recorded reads None recorded, never main.
  • Environment: the environment's name and address, as the sidebar shows them, when t3term has either.

Loading… stands in while the thread or its project is on its way. Unavailable replaces it once it won't come, and stands for a field that is missing or isn't text. A project without a folder reads Project folder unavailable, and so does a blank one, as a blank worktree path reads Unavailable. A path longer than 1,024 characters shows at most its first 256 and its last 768 characters with … between, and counts as blank when both of those parts are. Each part keeps whole letters and emoji, so it can hold fewer. The values come from what the thread's watch and the sidebar's feed already sent. While the thread's watch is open, its copy of the thread comes first, and after it closes the feed's copy stands in. The values change as T3 sends changes, and opening another thread shows its details from the next frame. t3term asks T3 for nothing more to show them. Text from T3 loses its control characters, and long values wrap to a few rows and end with ….

When the main column has room for a 36-column pane and still keeps 60 columns for the conversation, and the pane gets at least 12 rows, the details show in a pane at the right, from under the title row to the bottom of the composer. The conversation narrows to make room, and the transcript keeps its place. Otherwise they show as a dialog at the top right, up to 56 columns wide. At 120 columns that means a pane with the sidebar hidden and a dialog with it shown. The desktop's card works much the same way: it pins to the right of the chat while the chat keeps a readable width, and turns into a popover when it wouldn't.

The sidebar hidden at 120x36. The details pane at the right reads Project Details Desk, Workspace Worktree · feature-a over its path, Recorded branch feature/a and Environment Fixture Mac · 127.0.0.1:59258. The conversation keeps its replies on the left, and the composer holds draft XYZkept

The project, its threads and every path are invented and come from a fake T3 server. The image renders t3term's captured terminal cells in truecolor, not a screenshot of a terminal app. Thread details verification lists what was checked, with captures of the pane at 200x50, a dialog with the sidebar shown, live updates, a missing project, the model menu, a 40x12 screen and hostile fields in 256 colors. A capture slideshow steps through six of the captures. It is a slideshow of still captures, not a recording.

A pane leaves the keyboard where it was, so typing in the composer goes on. Tab reaches the pane after the transcript, and a click on the pane focuses it. Esc there gives the keyboard back to the pane that had it and leaves the details open. A dialog takes the keyboard and the mouse while it shows. Esc closes it before Esc does anything else, and so does a click outside it or on its ×. Ctrl+B, Ctrl+X, Ctrl+C and Alt+B still work over it, and opening one closes an open menu. When a dialog turns into a pane because the screen grew, the keyboard goes back to the pane that had it. The arrows or j and k, the page keys, Home or g and End or G scroll the text, as the wheel over it does.

The details start closed every time t3term starts. Whether they are open isn't saved and doesn't change with the thread.

Tool calls and reasoning

Reasoning is always there to read, laid out as prose in grey so the model's own answers stay the brightest text on screen. Tool calls are not: a run of them folds into one row saying how many there were and which tools ran, which keeps a turn short without hiding what it did.

A turn with its tool calls folded into one row, the reasoning in grey, then the answer

Click the row to open it. Each call is then one row with its icon, what it did and a chip holding the file, command or query it did it to, with the output quoted under it. A call that failed is red, and carries exit N when T3 reports the code. Clicking the row again closes it, and what you are reading stays where it is on screen while the rows above it grow.

The same turn with the row open: a file read and a search, each with its output

t opens every row at once and keeps new turns open, so a long run reads as it happens with no clicking. t again closes them all. The setting is saved in ~/.config/t3term/settings.json, so it survives a restart. That file is t3term's own, not T3's. Each save reads the file again while it holds a lock on settings.json.lock beside it, then replaces the file whole, so a second t3term or a quick second t can't save over a change it hasn't read. If the file doesn't parse, t3term starts with the defaults and leaves the file as it is, so t saves nothing until you fix or delete it.

T3 leaves tool output out of a thread's projection so a large result can't stall the socket, and marks the item instead. t3term asks for it with orchestration.getTurnItem, only for the rows on screen, and keeps the answer until the item changes. Each row shows twelve lines: the first twelve of a file or a search, the last twelve of a command, where its result is.

Colors come from T3's dark theme. When COLORTERM reports truecolor the TUI uses the exact values. Otherwise, or when T3TERM_COLOR=256 is set, it maps each one to the nearest entry in the 256-color palette.

File changes

An edit's row reads ✎ Edit with a chip holding the file and T3's line counts, such as src/main.rs +3 -1. When T3 lists more than one operation in the edit, the chip counts the files instead, such as 2 files +4 -2, as the nightly desktop's Changed 3 files does. T3 counts lines for the whole edit and sends no count per file, so t3term never puts a count beside one path.

Open the edit's group and each operation T3 listed gets a line under the row. A move shows the old path and an arrow, and a file or MIME type follows in brackets when T3 sends one, such as move /workspace/old.ts → /workspace/new.ts (text). The row lists at most twelve operations, and a line such as … 988 more files counts the rest. An entry without a path is left out and counted with the rest. When T3 sends no list, an empty one or one with no entry t3term can read, the row shows the file name alone.

When an edit fails, T3 sends the provider's error where a patch would be. The error comes first under the row, in red, and the operations follow, the order the desktop's item inspector uses.

The pinned nightly sends no patch for an edit that didn't fail, because the desktop reads full diffs another way. When a server does send one, t3term shows its start under the operations: hunk headers in blue, added lines in green, removed lines in red, file headers in bold grey and other lines in grey. The counts in each hunk's header say where the hunk ends, so a removed line that starts with -- stays a removed line, and a text that isn't a patch reads as plain grey text. t3term never builds a patch of its own from the item's other fields.

An error or a patch shows at most twelve lines from its first 4,096 bytes, and a last line says what it left out, such as … 18 more lines or … the text goes on past 4096 bytes. t3term reads at most 1,024 bytes of a path, an operation or a file type, and ends one it cut with …. A line too long for the transcript ends in … at its edge, and a wide character or joined emoji that doesn't fit before it is left out whole. t3term drops the control characters of all of it as it does a checklist step's, and a tab shows as one space.

T3 sends the whole edit again each time it changes, such as when a running edit fails. The TUI describes the edit once for each change and draws it again in place, so a reader scrolled up stays on the same text. t3term read, t3term send --wait and t3term wait print the row alone, such as edit src/main.rs +3 -1, with the file name cleaned the same way. t3term --json read keeps the item as T3 sent it.

There is no turn diff yet, and nothing opens an edit's full diff. Paths show as T3 sent them, not relative to the workspace, and the TUI doesn't show oldStr or newStr.

A running turn's open group: Move the session store in red with 3 files after it failed, two red error lines, then a move, an add and a delete in grey, and two more edits below

The thread is invented and comes from a fake T3 server that sends what the pinned nightly sends. The image renders t3term's captured terminal output. File change verification lists what was checked, with captures of the bounds, 256 colors, a narrow screen and a patch that only the fake server sends. A screen tour shows the live edit's captures in order. It is a slideshow of the captures, not a recording.

It's a CLI too

t3term doctor                       check discovery, protocol, auth and the WebSocket
t3term projects
t3term threads [--project P] [--all] [--limit N]
t3term read <thread> [--last N] [--reasoning]
t3term watch <thread>               live events; --json prints one item per line
t3term send <thread> [prompt] [--wait] [--timeout S] [--if-busy refuse|queue|steer] [choices]
t3term wait <thread>                stream the current turn until it ends
t3term requests <thread>            pending approvals and questions
t3term approve <thread> [--request ID] [--decision accept|accept-for-session|decline|cancel]
t3term interrupt <thread>
t3term models [--all]               providers, models and each model's options
t3term settings <thread> [choices]  show a thread's model and modes, or change them
t3term logout                       revoke the saved login and remove it from the Keychain

<thread> takes a full id, the 8-character prefix threads prints, or an exact title. send reads stdin when you leave the prompt out. Add --json to any command for machine-readable output. Inside its strings, every control character, DEL and the C1 codes included, is a JSON escape such as \n or \u009b.

Without --json, t3term drops control characters from what it prints on stdout and stderr: text from T3, the ids and names you pass and its error messages. A carriage return or \r\n prints as one line break, and a tab or another control that spaces text prints as a space. It drops every other control, C0, DEL or C1, so a thread title or a reply can't clear the screen, move the cursor or set the clipboard. What followed an Esc, such as [2J, stays as plain text, and accents, joined emoji and no-break spaces print as T3 sent them. doctor prints each check's details as JSON, escaped as --json escapes them. A usage error is the exception, because the argument parser quotes the command line as you typed it. --json keeps every value as T3 sent it.

Exit codes: 0 success, 1 failure or a turn that ended without completing, 2 usage, 3 not found, 4 rejected or unsupported protocol, 5 server unavailable, 6 timeout, 7 the turn is waiting for an approval or answer.

Choosing a model, effort and mode from the command line

The choices on send and settings are --model, --effort, --option ID=VALUE, --mode and --plan or --no-plan. Anything you leave out keeps the thread's current value.

  • --model takes provider/model (for example claudeAgent/claude-opus-5-5), a model id, or a model name. A model id that two providers share needs the provider/ part.
  • --effort sets whichever reasoning option the model has: effort on Claude, reasoningEffort on Codex and Grok, reasoning on Cursor. --effort ultrathink adds Ultrathink: to the start of the message, as the desktop app does, and leaves the thread's effort alone.
  • --option sets any other option the model lists, such as fastMode=on or contextWindow=1m.
  • --mode is approval-required (also supervised), auto-accept-edits, auto or full-access.

t3term models lists the values each model accepts and marks defaults with *. t3term reads them from the server, so new models need no update here. Like the desktop app, send changes modes with their own commands just before the message and carries the model on the message itself, while settings applies the change right away. During an active run, settings refuses any change and send refuses a mode change, because T3 can restart the agent's session to apply them. send can still carry a new model on a queued or steered message.

How it works

  • Discovery. The client reads ~/.t3/userdata/server-runtime.json (or $T3CODE_HOME, or T3TERM_ORIGIN). It checks /.well-known/t3/environment and refuses any server that is not on orchestration protocol 2.
  • Auth. On macOS, t3term keeps one login per T3 server in the login Keychain under the service t3term. It lasts 30 days, the same as T3's own default, and holds only orchestration:read and orchestration:operate. Each run checks it against /api/auth/session, and if it expires within a day or the server rejects it, t3term issues a new one and revokes the old one, so you never log in by hand. t3term logout revokes and deletes it.
  • RPC. Effect RPC over one /ws connection. The client acks every stream chunk and pings every 10 seconds. If no frame arrives for 30 seconds it treats the socket as dead.
  • State. Each V2 event carries the whole updated entity, so the reducer upserts it by id. After a dropped connection the client resubscribes with afterSequence and skips any replayed event it already applied.
  • Server config. The TUI subscribes to subscribeServerConfig with an empty payload. Each subscription opens with a snapshot of the whole config and goes on with changes to the provider list, the settings or the keybindings. The stream has no cursor, so after a dropped connection the TUI keeps the config it has until the new subscription's snapshot replaces it. Retries wait 250 ms at first, doubling up to 10 seconds, and a snapshot brings the wait back to 250 ms. A server that refuses the subscription, because it lacks the method or the session lacks orchestration:read, gets a server.getConfig read each time a menu opens. The CLI reads server.getConfig when a command needs the model list.
  • Rendering. The TUI draws only after input or a server event, at most 30 times a second. The open thread keeps its transcript blocks between frames and describes an item again only after T3 changes it, so a frame drawn for a key or the clock describes nothing. Each block keeps its wrapped lines until it changes or the width does, and only the visible rows are copied into a frame. While the open thread has a run going, the frame shows the card of a running setup that the open subscription sent since it last connected or sent null, or a card on screen reads Working or Goal, a once-a-second tick advances the spinners and clocks, and that tick stops when none does. The setup card is laid out again only when the setup, how it shows, Details or the width changes, or when a frame is drawn in a new second while the setup it holds is running. While a thread is snoozed, one timer waits for the soonest snooze to end, because no server event marks that moment. The same redraw moves the card back and shows Woke. Otherwise an idle TUI wakes only for input or server events.
More on the saved login

doctor --json reports the session's scopes and where its login came from: saved, newly saved or temporary. With the saved login a CLI command uses under 20 ms of CPU, and the session check against the server takes about 27 ms. Issuing a new session costs about 0.8 s, because it runs t3 auth session issue through the running server's own binary. t3term finds that binary from the server pid, since the t3 on your PATH can be a different version. Override it with T3TERM_T3_COMMAND.

Set T3TERM_NO_SAVED_LOGIN=1 to use a session that lasts one run instead. t3term revokes it on exit, including after Ctrl+C, kill or a closed terminal window.

Tests

cargo test

The unit tests cover the reducers, Markdown wrapping, the composer, the model menus, the transcript, the cleaning of a reply that arrives in pieces and the lines under a file change, the tasks drawer, the thread error and provider status banners, hiding and resizing the sidebar, the environment's name and address, the thread details and auth command parsing. The TUI tests draw frames on ratatui's test backend with a client that never connects, and a test build keeps saved settings in memory, so no test writes ~/.config/t3term/settings.json. tests/fake_server.rs drives the real RPC client against a fake Effect RPC server, dropping the socket mid-stream to check the resume cursor, chunk acks, batched frames, duplicate suppression and error decoding. Its config tests check the subscription's exact request, a fresh snapshot with no cursor after each drop, the backoff, stopping when the TUI goes while a watch connects or waits, and both ways T3 refuses the subscription. Its worktree setup tests check the exact request for one thread and that nulls and whole setups pass on as T3 sent them. They check that each reconnect sends the same request with no cursor, even after T3's count of a setup's changes starts over, and they check the backoff and both ways T3 refuses the subscription. Dropping a setup watch stops it while it waits to retry, and interrupts its own stream while the shell watch on the same connection goes on. The TUI's config tests run on a runtime that never polls its tasks, so they can count each model-list read the TUI starts and show which changes reach the chips and menus without one. The TUI's worktree setup tests stand in for the subscription, so they count each one the TUI opens and send setups down it. They take a thread through progress, the agent starting, a script still running and a clean finish, through failed, cancelled and script-failed setups before and after a follow-up, and through nulls, lower numbers, reconnects, refusals, thread switches and malformed setups. They check w and clicks, the Details row staying in place, the rows you are reading staying put while the card above grows, cleaned text, narrow windows and that only a running setup's card in view keeps the clock ticking. The sidebar's feed tests take it through connecting, a snapshot, catching up, a drop and a refusal, beside a thread whose watch is live or closed. They check that the dot and its word follow the feed, that the status line and the breadcrumb follow the thread, that a closed feed stays closed after its message goes, and that none of it starts a read or the tick. The environment's tests check that a label's control characters and an origin's credentials, path, query and fragment never reach the screen, that an address shows its whole port or doesn't show, that it fits from 24 to 200 columns with the sidebar shown or hidden, and that it takes no clicks. The thread details' tests take them through a pane and a dialog at sizes from 200x50 down to an empty screen, Tab, Esc, clicks and the wheel, a thread switch, a watch that closes before it answers, events from the feed and the watch, and hostile and huge fields. They check that the draft, its cursor and the transcript's place stay as they were, that a click lands only where the last frame drew, that the last thread's values never come back, that only an event that can change the values works them out again, and that none of it starts a read or the tick. tests/cli.rs runs the built binary with a temporary home and checks the --json error and exit code when the server is gone, when it isn't on protocol 2 and when the TUI has no terminal. It also runs t3term read against a fake server and a fake t3 that issues a made-up session. It checks that the plain output drops the control characters of a checklist, a compaction summary and an edit's file name, and that --json writes them as escapes that decode to the text T3 sent. Each edit prints as one row, without its operations, error or patch. Another test runs t3term send --wait against a fake server that also plays T3's side of the WebSocket. It checks that each finished handoff in the turn prints its marker and endpoints on stderr once, even when T3 sends the item again, and that --json prints only the result. A test of t3term wait checks that each edit in the turn prints its row on stderr once, when it finishes, however often T3 sends it again. Three more check how a wait ends. t3term wait exits 7 with the outcome needs-attention when T3 asks for an approval, and 1 with the outcome failed when the run fails, with the reply so far on stdout. t3term send --wait exits 6 with THREAD_WAIT_TIMEOUT when the turn outlasts --timeout, and the error names the message it sent. Six more send made-up control characters through read, projects, threads, wait, send --wait, watch, requests, approve, interrupt, models, settings, doctor and the error report. They check that the plain output drops them and measures columns without them, that a reply printed as it streams matches the whole reply once cleaned, and that --json keeps every value as T3 sent it, as do the ids t3term sends back. None of them reach a real T3 server or the Keychain, and CI runs them on macOS for every pull request.

docs/screenshots/ also holds before-tui.png, after-tui.png, approval.png, streaming.gif, picker-model.png and tool-calls-failed.png.

Not done yet

  • Answering a question from the CLI. requests lists one and the TUI can answer it, but there is no answer subcommand yet, so a script that hits a question has to hand over to a person.
  • Loading older history for long threads. The TUI opens a bounded recent window.
  • New threads, diffs and checkpoints, worktrees, attachments, embedded terminals and queue management. The TUI shows a worktree setup's progress, but can't cancel or retry it, work locally instead or open the setup script's terminal.
  • A check of the reducer's output against T3's TypeScript reducer on recorded event streams.
  • Linux and Windows. The code has Linux pid lookup, but only macOS has been tested, and the saved login needs the macOS Keychain, so other systems would issue a new session every run.
  • Syntax highlighting in code blocks and the project and git panel.
  • The rest of the thread details. t3term shows the project, the workspace, the recorded branch and the environment, but not the desktop's Git status, its actions such as copying a path or opening an editor, its scripts or its tabbed right panel.
  • More than one environment. t3term connects to the one T3 server discovery finds and names it in the sidebar. The desktop's connection settings list each environment it knows with its label, its address and a dot for its connection.
  • Provider setup. The provider banner names T3 Code's provider setup, but t3term can't install a provider, sign in to one or update it. The banner follows the provider the model chip names. The desktop also weighs the thread's running session and the project's default model, and falls back to another provider when the chosen one is off or unavailable. t3term has none of those fallbacks, so where they apply the banner can name a different provider from the desktop's, or none.
  • Acting on a proposed plan. The desktop's card menu copies a plan, downloads it as Markdown or saves it to the workspace, and its composer offers Implement and Implement in a new thread. t3term only shows the plan. Its collapsed card also ends at ten lines with text and ..., where the desktop clips the preview to a fixed height and fades it out.
  • Pinning, snoozing, settling and reordering threads. The sidebar shows the shelves, but moving a thread between them still takes the desktop app. The Snoozed shelf also stays open, where the desktop starts it closed. Snoozed and settled threads are full cards, where the desktop shows a one-line row with the wake or settle time.
  • Marking threads seen. The sidebar reads Done and Woke against the visit time the server keeps for each thread, but t3term doesn't report a visit when you open one, so only the desktop or another client clears those words. A server too old to keep visit times gets no Done from t3term at all, because the desktop's fallback is a visit time saved in the browser.

Credit

T3 Code is by T3 Tools Inc. and is MIT licensed. t3term exists because they built something worth writing a second client for, and because they made the protocol readable.

t3term is an unofficial, independent project. It is not made by, endorsed by or affiliated with T3 Tools Inc. It copies none of their code: it is Rust that speaks their WebSocket API. The dark palette is read from T3 Code's MIT-licensed theme so the two look like the same product, and the screenshot above shows their desktop app for comparison. "T3" and "T3 Code" are theirs.

MIT, see LICENSE.