# Introduction
URL: https://tuios.dev/docs
> TUIOS is a terminal multiplexer and window manager with workspaces, tiling, persistent sessions and a vim-style modal interface.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
TUIOS (Terminal UI Operating System) runs many shells inside one terminal. Each shell gets its own window. You arrange windows with tiling or by hand, group them into nine workspaces, and detach and come back later without losing anything.
## Install
```bash
brew install tuios
```
```bash
yay -S tuios-bin
```
```bash
curl -fsSL https://raw.githubusercontent.com/Gaurav-Gosain/tuios/main/install.sh | bash
```
```bash
go install github.com/Gaurav-Gosain/tuios/cmd/tuios@latest
```
Nix, Docker, release binaries and building from source are covered in [Getting Started](/docs/getting-started#install).
## Run it
```bash
tuios
```
Press n to open a window, i to type in it, and Alt +Esc to get back to window management. Ctrl +P opens the command palette, which lists every command. [Getting Started](/docs/getting-started) walks through the rest.
`tuios` starts a background daemon and attaches to a session in it. Close the terminal and the session keeps running. Run `tuios` again to get it back.
## What you get
**Windows and layout**
* Two modes, modelled on vim: window management mode for arranging windows, terminal mode for typing into one.
* BSP tiling by default, with explicit splits, preselection, swapping and resizing. Master-stack and a niri-style scrolling layout are one palette command away.
* Nine workspaces, zoom, minimize, layout templates and optional shared borders.
* A session rail on the right lists your sessions, panes, files and agents.
* A command palette (Ctrl +P ) and an app launcher (Alt +Space ).
**Sessions**
* A daemon owns the shells, so detaching costs nothing and several terminals can attach to one session at once.
* Window layout and working directories come back after a daemon restart or a reboot. Running programs and scrollback do not. See [Sessions](/docs/sessions).
* Sessions and single panes can live on other machines, reached over ssh.
**Scripting**
* A JSON [control protocol](/docs/control-protocol) on the daemon socket, with blocking waits and an event stream.
* CLI commands for the same things: `send-text`, `capture-pane`, `wait-for`, `new-window` and more.
* [Tape scripts](/docs/tape-scripting) to record and replay workflows, and [hooks](/docs/hooks) that run shell commands on window, session and agent events.
**Agents**
* Panes that run coding agents report whether the agent is working, idle or waiting for you. See [Agents](/docs/agents).
* One [Inbox](/docs/agent-inbox) lists every approval, question and finished turn waiting for you, in every session, and answers them from where you are.
* Review what an agent changed, leave notes on its lines, and send them back. Fan one prompt out to several agents in git worktrees and keep the best result.
**Terminal**
* Vim-style copy mode over 10,000 lines of scrollback per window, plus mouse wheel scrolling and selection.
* [Multi copy mode](/docs/layout-modes#multi-copy-mode) searches every pane of the multifocus set at once and copies the selection of each pane.
* [Hints](/docs/hints) put a label on each URL, path and hash in a pane. Type the label to copy the text.
* Kitty graphics (including `mpv --vo=kitty` video), sixel passthrough, the kitty keyboard protocol and synchronized output.
* 342 built-in themes with a live picker, and an in-app settings page.
**Remote access**
* `tuios ssh` serves TUIOS over SSH with public key authentication.
* `tuios-web`, a separate binary, serves it to a browser. See [Web Terminal](/docs/web).
## Where to go next
## Community
* Source, issues and discussions: [Gaurav-Gosain/tuios](https://github.com/Gaurav-Gosain/tuios) on GitHub.
* License: MIT.
* [tuios-courier](https://github.com/Gsirawan/tuios-courier) by [Gsirawan](https://github.com/Gsirawan): agent mail between machines through a relay over HTTPS, for networks that block ssh. It is a separate community project. See [Agent messaging](/docs/agent-messaging#without-ssh).
# Getting Started
URL: https://tuios.dev/docs/getting-started
> Install TUIOS, open your first windows, and learn the keys you need on day one.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
This page takes you from nothing to a working session in about five minutes.
[Learn tuios](/learn) runs the real app in your browser, with a pretend shell. Press the keys yourself, five minutes, nothing to install.
You need a terminal with true color support. A [Nerd Font](https://www.nerdfonts.com) makes the icons render; without one, start TUIOS with `--ascii-only`.
## Install
macOS and Linux:
```bash
brew install tuios
```
On Linux, the `tuios-ghostty` cask installs `tuios` built with the [libghostty-vt](/docs/architecture#terminal-emulation-internalvt) emulator. The cask is available from the next release. Both casks install a `tuios` binary. Uninstall one before you install the other. There is no macOS ghostty cask.
```bash
brew install gaurav-gosain/tap/tuios-ghostty
```
```bash
yay -S tuios-bin
```
Linux and macOS. Installs the `tuios` binary into `/usr/local/bin`, `~/.local/bin` or `~/bin`, whichever it can write to.
```bash
curl -fsSL https://raw.githubusercontent.com/Gaurav-Gosain/tuios/main/install.sh | bash
```
```bash
nix run github:Gaurav-Gosain/tuios/v0.8.5#tuios # a release
nix run github:Gaurav-Gosain/tuios#tuios # the newest commit on main
```
Put a release tag after the repo name to build that release. Without a tag, Nix builds the newest commit on `main`.
The flake builds for `x86_64-linux`, `aarch64-linux` and `aarch64-darwin`. On an Intel Mac, use a release binary.
Needs Go 1.26.6 or newer, the version `go.mod` names.
```bash
go install github.com/Gaurav-Gosain/tuios/cmd/tuios@latest
```
```bash
docker run -it --rm ghcr.io/gaurav-gosain/tuios:latest
```
Pre-built binaries for Linux, macOS, Windows and FreeBSD are on the [releases page](https://github.com/Gaurav-Gosain/tuios/releases).
These binaries use the pure Go terminal emulator. Archives named `tuios-ghostty_*` use the [libghostty-vt](/docs/architecture#terminal-emulation-internalvt) emulator backend. From v0.8.1, they are available for Linux, Windows and macOS on Apple silicon and Intel. The v0.8.0 release had no macOS ghostty archive.
Check the install:
```bash
tuios --version
```
The first line names the version and the terminal emulator backend the binary was built with, for example `tuios version 0.8.5 [pure-Go backend]`.
If you installed with the script or a release binary, `tuios update` installs the newest release over the binary (`tuios update --check` only reports). For Homebrew, AUR, Nix and Go installs, use that tool to update. `tuios update` detects them and prints the right command instead.
The browser terminal is a separate binary, `tuios-web`. See [Web Terminal](/docs/web) to install it.
### Build from source
```bash
git clone https://github.com/Gaurav-Gosain/tuios.git
cd tuios
go build -o tuios ./cmd/tuios
```
`./scripts/install.sh` builds and installs into `~/.local/bin`. It builds the pure Go emulator by default, which needs nothing beyond Go. Run `./scripts/install.sh ghostty` for the libghostty-vt emulator backend, which needs `zig`.
## Your first session
### Start TUIOS
```bash
tuios
```
The first run creates a session called `session-0` and shows a welcome screen with four keys on it:
You are in **window management mode**. Keys here arrange windows. Nothing is sent to a shell yet.
The dock runs along the top edge, and the session rail down the right edge lists your sessions, panes and files, and later your agents. Ctrl +B b hides the rail.
### Open a window
Press n . A window opens with your shell in it.
### Type in it
Press i (or Enter ) to enter **terminal mode**. Your keys now go to the shell. Run a command:
```bash
ls
```
### Go back to window management
Press Alt +Esc (Opt +Esc on macOS), or Ctrl +B then Esc .
A bare Esc stays in terminal mode and goes to the program, so vim and other full-screen programs keep working.
### Open two more windows
Press n twice. Tiling is on by default, so each new window takes half of the focused one and all three stay visible.
Move between them with Tab and Shift +Tab , the number keys 1 to 9 , or Alt +arrow keys to move to the neighbour in that direction. You can also click a window to focus it, and double-click it to start typing in it.
### Try another workspace
Press Alt +2 (Opt +2 on macOS) to switch to workspace 2. It is empty. Open a window there, then press Alt +1 to go back.
### Detach, and come back
Press Ctrl +B then d . TUIOS exits, but the session and its shells keep running in the background daemon. Run `tuios` again and everything is where you left it.
Press Ctrl +P in either mode to open the command palette and search every command by name. Press Ctrl +B and wait half a second: a panel lists every key the prefix accepts next. Ctrl +B then ? opens the full help.
## The two modes
| Mode | What your keys do | How to get there |
| ----------------- | -------------------------------------------- | ----------------------------------------------------------------------------- |
| Window management | Create, close, move, tile and switch windows | Alt +Esc , or Ctrl +B Esc |
| Terminal | Go to the program in the focused window | i or Enter |
Ctrl +B is the **prefix** key. It works in both modes: press it, release it, then press the next key. You can change it with `keybindings.leader_key` in the [configuration](/docs/configuration).
## Keys for day one
On macOS, read Alt as Opt . If an Opt chord types a strange character instead, see the [macOS notes](/docs/keybindings#macos).
**Windows**
| Key | Action |
| --------------------------------------------------------- | --------------------------------------------------------------- |
| n | New window (window mode) |
| w or x | Close the focused window (window mode) |
| r | Rename the focused window (window mode) |
| Tab / Shift +Tab | Next / previous window (window mode) |
| Alt +N / Alt +P | Next / previous window (both modes) |
| Alt +arrow | Focus the window in that direction (both modes) |
| 1 to 9 | Focus window by number (window mode) |
| z | Zoom the focused window over the layout, and back (window mode) |
| m | Minimize to the dock (window mode). M restores all. |
The prefix versions work in both modes: Ctrl +B then c (new), x (close), r (rename), n /p (next/previous), an arrow key (focus that way), 0 to 9 (focus by number), z (zoom).
**Tiling**
| Key | Action |
| ------------------------------------------------------------- | ----------------------------------------------------------------------- |
| t or Ctrl +B Space | Turn tiling on or off |
| - / \| | Split the focused window: new window below / to the right (window mode) |
| H J K L | Swap with the neighbour left / down / up / right (window mode) |
| = | Make all splits equal (window mode) |
[BSP Tiling](/docs/bsp-tiling) covers preselection, resizing and the other layouts.
**Workspaces**
| Key | Action |
| ------------------------------------------------------------ | -------------------------------------------- |
| Alt +1 to 9 | Switch to workspace 1 to 9 |
| Alt +Shift +1 to 9 | Move the focused window there, and follow it |
| Ctrl +B w , then a digit | Switch workspace from the prefix menu |
Ctrl +B followed directly by a digit focuses a **window**, not a workspace.
**Scrollback and copying**
| Key | Action |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| Mouse wheel | Scroll the window's history. Typing, or scrolling back to the bottom, returns to live output. |
| Ctrl +B \[ | Enter copy mode: move with vim keys, / to search, v to select, y to copy, Esc to leave |
| Drag with the mouse | Select text. It is copied when you release. |
| Ctrl +click a link | Open it. See [Links](/docs/links). |
**Everything else**
| Key | Action |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Ctrl +P | Command palette |
| Alt +Space or Ctrl +B a | App launcher: start any program on your `$PATH` in a new window |
| Ctrl +B S | Session switcher |
| Ctrl +B , | Settings page (, in window mode) |
| Ctrl +B ? | Help (? in window mode) |
| Ctrl +B d | Detach, leaving the session running |
| Ctrl +B q | Quit menu: detach, switch session, or kill this session |
The full list is on the [Keybindings](/docs/keybindings) page, and `tuios keybinds list` prints the bindings in effect on your machine.
## Sessions
`tuios` on its own attaches to the session you used last, and creates one if there is none. To manage several:
```bash
tuios new work # create a session called "work" and attach to it
tuios ls # list sessions
tuios attach work # attach to "work"
tuios kill-session work # end "work" and every window in it
```
Inside TUIOS, Ctrl +B S switches between sessions and N (window mode) creates one.
If you want a session that lives and dies with this terminal and starts no daemon, run `tuios --standalone`. [Sessions](/docs/sessions) explains both kinds and exactly what survives a restart.
## Make it yours
```bash
tuios config edit # open the config file in $EDITOR
tuios config path # print where it is
tuios --list-themes # list the built-in themes
tuios --theme dracula # start with a theme
```
Most settings can also be changed live on the settings page, Ctrl +B , . See [Configuration](/docs/configuration) for every option.
## Shell completions
```bash
# Bash
tuios completion bash > /etc/bash_completion.d/tuios
# Zsh
tuios completion zsh > "${fpath[1]}/_tuios"
# Fish
tuios completion fish > ~/.config/fish/completions/tuios.fish
# PowerShell
tuios completion powershell > tuios.ps1
```
## Troubleshooting
### Empty space around TUIOS
TUIOS fills every cell that the terminal gives it. A blank border around TUIOS comes from the padding of the terminal window, not from TUIOS. To remove it, set the padding of your terminal to zero:
| Terminal | Setting |
| --------- | --------------------------------------------------------------- |
| kitty | `window_padding_width 0` |
| Ghostty | `window-padding-x = 0` and `window-padding-y = 0` |
| WezTerm | `window_padding = { left = 0, right = 0, top = 0, bottom = 0 }` |
| Alacritty | `padding = { x = 0, y = 0 }` under `[window]` |
Some terminals also leave a thin gap when the window size is not a whole number of cells. Ghostty `window-padding-balance = true` spreads that gap evenly.
### The screen is dim except around the pointer
The spotlight is on. Press Esc in window mode, or Ctrl +B B in any mode. You can also click the Spotlight chip in the dock. See [Spotlight](/docs/themes#spotlight).
### No sound from agent alerts
Alerts are silent until you set `notifications.agent.sound = true`. With `sound_mode = "bell"`, your terminal's bell setting decides if you hear it. See [Hear an alert](/docs/agents#hear-an-alert).
## Next steps
# Keybindings
URL: https://tuios.dev/docs/keybindings
> Every default key in TUIOS for window mode, terminal mode, prefix commands, copy mode and the mouse, grouped by the config section that owns it.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
TUIOS has two modes. **Window mode** is for managing panes. **Terminal mode** sends your keys to the focused pane. A new session starts in window mode.
The leader key is Ctrl +B . Press it, release it, then press a command key. The leader works in both modes.
To send the leader key to the program in the pane, press it two times in terminal mode. The pane gets the leader that you configured, such as Ctrl +A .
Every table on this page names the config section that holds the binding and the action name to use in it. Every action is rebindable. See [Customizing](#customizing) at the end.
**Try it live:** [Learn tuios](/learn) has you press these keys in the real app, in your browser. [Free play](/learn?t=play) has a cheat sheet you can click.
This page lists the defaults. `tuios keybinds list`, `tuios keybinds explain ` and the keybind manager (Ctrl +B k ) show what is bound on your machine.
Search every default binding, or read the tables by section below:
## Modes
Section: `[keybindings.mode_control]`. These keys work in window mode.
| Key | Action | Does |
| ------------------------------ | --------------------- | ----------------------------------------------------- |
| i , Enter | `enter_terminal_mode` | Enter terminal mode |
| Esc | `enter_window_mode` | Enter window mode |
| ? | `toggle_help` | Toggle the help overlay |
| , | `open_settings` | Open the settings page |
| q | `quit` | Quit. In a daemon session it opens the quit menu |
| none | `hold_window_mode` | Window mode while the key is held. Unbound by default |
To leave terminal mode, press Ctrl +B Esc , or the direct key from [Terminal mode](#terminal-mode).
`hold_window_mode` needs a terminal that speaks the Kitty keyboard protocol (Ghostty, kitty, WezTerm, foot, Alacritty), because only that protocol reports key releases. Bind it to an ordinary spare key, such as an unused function key from `f1` to `f12`. Binding a modifier such as `leftalt` also works, but then every keystroke in the session arrives as an escape code.
## Window mode
### Windows
Section: `[keybindings.window_management]`.
| Key | Action | Does |
| -------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| n | `new_window` | New window |
| N | `new_session` | New session. Asks which machine when there is more than one |
| w , x | `close_window` | Close the focused window |
| r | `rename_window` | Rename the focused window |
| m | `minimize_window` | Minimize the focused window |
| M | `restore_all` | Restore all minimized windows |
| z | `toggle_zoom` | Zoom the focused window, or unzoom it |
| p | `toggle_pip` | Pin the focused window as the [picture-in-picture view](/docs/sessions#picture-in-picture), or unpin the pinned one |
| Tab | `next_window` | Focus the next window |
| Shift +Tab | `prev_window` | Focus the previous window |
| ; | `last_pane` | Go back to the pane that had focus before. Press it again to return |
| 1 to 9 | `select_window_1` to `select_window_9` | Focus a window by number |
| c | `copy_selection` | Copy the mouse selection |
| s | `focus_sidebar` | Give the keyboard to the session rail |
| S | `start_screensaver` | Start the screen saver now |
| Alt +Shift +N | `next_session` | Next session |
| Alt +Shift +P | `prev_session` | Previous session |
| none | `switch_session_1` to `switch_session_9` | Attach the session at that position in the rail. Unbound by default. A position with no session shows a message |
Section `[keybindings.restore_minimized]` restores one minimized window by position:
| Key | Action |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| Shift +1 to Shift +9 (also ! to ( ) | `restore_minimized_1` to `restore_minimized_9` |
Section `[keybindings.system]` has one binding: B is `toggle_spotlight`, which lights one part of the screen and dims the rest. See [Spotlight](#spotlight).
### Workspaces
Section: `[keybindings.workspaces]`. There are nine workspaces. The modifier is Option on macOS and Alt elsewhere.
| Key | Action | Does |
| -------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------- |
| Alt +1 to Alt +9 | `switch_workspace_1` to `switch_workspace_9` | Show a workspace |
| Alt +Shift +1 to Alt +Shift +9 | `move_and_follow_1` to `move_and_follow_9` | Move the focused window there and follow it |
`rename_workspace` opens the rename editor on the current workspace. It has no default key. Ctrl +B w r does the same from the prefix.
### Layout and snapping
Section: `[keybindings.layout]`.
| Key | Action | Does |
| ------------------------------------------ | --------------------------- | ------------------------------------------------------------- |
| t | `toggle_tiling` | Turn tiling on or off |
| h | `snap_left` | Snap to the left half. In tiling, focus left |
| l | `snap_right` | Snap to the right half. In tiling, focus right |
| j | `focus_down` | Focus the window below |
| k | `focus_up` | Focus the window above |
| f | `snap_fullscreen` | Fill the screen |
| u | `unsnap` | Restore the size before the snap |
| H , Ctrl +← | `swap_left` | Swap with the window to the left |
| L , Ctrl +→ | `swap_right` | Swap with the window to the right |
| K , Ctrl +↑ | `swap_up` | Swap with the window above |
| J , Ctrl +↓ | `swap_down` | Swap with the window below |
| \< | `resize_master_shrink` | Narrow the master area |
| > | `resize_master_grow` | Widen the master area |
| \{ | `resize_height_shrink` | Shorten the focused window from the bottom edge |
| } | `resize_height_grow` | Lengthen the focused window from the bottom edge |
| . | `resize_master_grow_left` | Widen the master area from the left edge |
| \[ | `resize_height_shrink_top` | Shorten the focused window from the top edge |
| ] | `resize_height_grow_top` | Lengthen the focused window from the top edge |
| none | `resize_master_shrink_left` | Narrow the master area from the left edge. Unbound by default |
`resize_master_shrink_left` has no default because , opens settings. Bind it to a free key if you want it.
Corner snapping is on the [layout prefix](#layout-prefix), not on the bare digits.
### Moving focus
Four keys move focus to the window in a direction. Each direction has a key in window mode, in terminal mode and after the prefix.
| Direction | Window mode | Terminal mode | After Ctrl +B |
| --------- | ------------ | --------------------------- | ---------------------------------- |
| Left | h | Alt +← | ← |
| Down | j | Alt +↓ | ↓ |
| Up | k | Alt +↑ | ↑ |
| Right | l | Alt +→ | → |
Focus goes to the nearest window in that direction that faces the focused window. At the edge of a tiled layout, focus stays where it is. With tiling off, a window that does not face the focused window can also get focus.
With tiling off, h and l snap the focused window to the left or right half of the screen. j and k always move focus.
In the [scrolling layout](/docs/layout-modes#scrolling), left and right move between columns. Up and down move between the windows in one column.
If your config already binds j or k in a window mode section, TUIOS keeps your binding and gives `focus_down` or `focus_up` no key. `tuios keybinds doctor` tells you when this happens.
### Neovim pane navigation
The optional [tuios-nvim-navigator](https://github.com/Tim4c/tuios-nvim-navigator) plugin lets the terminal-mode focus keys move through Neovim splits first. At the edge of a split, TUIOS moves to the next pane. The feature is off by default. Turn it on:
```toml
[appearance]
nvim_navigation = true
```
The plugin tells TUIOS when it is active, so the same keys keep their normal TUIOS action in shells and other programs. By default, both projects use Alt +← , Alt +↓ , Alt +↑ and Alt +→ . If you change the keys on one side, change the other to match `terminal_focus_left`, `terminal_focus_down`, `terminal_focus_up` and `terminal_focus_right`.
The plugin sends private OSC 7777 messages. TUIOS reads them and never shows them in the pane:
```text
OSC 7777 ; tuios-nvim-navigator ; state ; active BEL
OSC 7777 ; tuios-nvim-navigator ; state ; inactive BEL
OSC 7777 ; tuios-nvim-navigator ; focus ; BEL
```
`` is `left`, `down`, `up` or `right`. Another Neovim integration can send the same messages. TUIOS accepts a focus request only from the focused pane in terminal mode, and only just after it sent the matching focus key to that pane. Window mode always keeps the focus keys for TUIOS.
### BSP splits
Also in `[keybindings.layout]`. See [BSP Tiling](/docs/bsp-tiling).
| Key | Action | Does |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| - | `split_horizontal` | Split the focused pane top and bottom |
| \| , \\ | `split_vertical` | Split the focused pane left and right |
| R | `rotate_split` | Flip the split direction |
| = | `equalize_splits` | Make every split equal. In master-stack, also put the master back at `master_ratio` |
| Alt +h / j / k / l | `preselect_left`, `preselect_down`, `preselect_up`, `preselect_right` | Choose where the next window opens |
The preselect keys use Option on macOS. The prefix menu has its own copies of the four split actions, and rebinding one set does not change the other.
## Terminal mode
Section: `[keybindings.terminal_mode]`. These keys work in terminal mode with no prefix. Everything else goes to the pane.
| Key | Action | Does |
| ---------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Alt +n | `terminal_next_window` | Focus the next window |
| Alt +p | `terminal_prev_window` | Focus the previous window |
| Alt +Esc | `terminal_exit_mode` | Go to window mode |
| Alt +arrows | `terminal_focus_left`, `_right`, `_up`, `_down` | Focus the pane in that direction |
| Shift +↑ | `terminal_scroll_up` | Scroll into the pane's scrollback |
| Shift +↓ | `terminal_scroll_down` | Scroll back toward live output |
| Ctrl +Shift +V , Super +V | `terminal_paste_host` | Paste from the host clipboard. An image is pasted as a file path. See [Paste an image](#paste-an-image) |
| Key | Action | Does |
| ------------------------------------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Option +Tab , Alt +n | `terminal_next_window` | Focus the next window |
| Option +Shift +Tab , Alt +p | `terminal_prev_window` | Focus the previous window |
| Option +Esc | `terminal_exit_mode` | Go to window mode |
| Alt +arrows | `terminal_focus_left`, `_right`, `_up`, `_down` | Focus the pane in that direction |
| Shift +↑ | `terminal_scroll_up` | Scroll into the pane's scrollback |
| Shift +↓ | `terminal_scroll_down` | Scroll back toward live output |
| Ctrl +Shift +V , Super +V | `terminal_paste_host` | Paste from the host clipboard. An image is pasted as a file path. See [Paste an image](#paste-an-image) |
Readline, fish and zsh use Alt +← and Alt +→ to move a word at a time. To give them back, run `tuios keybinds free alt+left` and `tuios keybinds free alt+right`. Ctrl +B then an arrow still moves focus.
Plain Ctrl +V is not bound. It reaches the pane, so vim's visual block mode works.
Shift +↑ and Shift +↓ scroll the pane into its history only when the program is on the main screen and did not turn on a mouse mode. In other panes, TUIOS sends the keys to the program, so nvim, less and htop get them. In copy mode the keys scroll. This is the same rule as for the mouse wheel, and the same rule as in kitty.
### Paste an image
Ctrl +B V pastes the image on your clipboard into the focused pane. TUIOS saves the image to a file on the machine where the pane runs. Then it pastes the path of that file into the pane. The pane can run on this machine or on a [host](/docs/remote-hosts). Claude Code, Codex and other agents read an image from a path in the prompt. The action is `paste_image`, and the command palette has it too.
The paste key also pastes an image. When the clipboard holds an image and no text, the paste key pastes the image. When the clipboard holds text, the paste key pastes the text. Some terminals send an empty paste when the clipboard holds only an image. TUIOS then pastes the image.
A browser can put a link or text beside a copied image. The paste key then pastes the text. Ctrl +B V always pastes the image.
The paste key waits at most 0.3 seconds for the list of clipboard types. When the clipboard tool is slower, or cannot list the clipboard, the paste key pastes text. It then does not ask the tool again until TUIOS restarts.
Ctrl +V alone goes to the program in the pane. Claude Code reads the clipboard on Ctrl +V itself, and that only works in a pane on this machine. For a pane on a host, use Ctrl +B V .
TUIOS reads the image with the clipboard tool of your system:
| System | Tool |
| -------------- | ----------------------------------------------- |
| Linux, Wayland | `wl-paste` from wl-clipboard |
| Linux, X11 | `xclip`. `xsel` cannot read an image |
| macOS | `osascript`, or `pngpaste` when it is installed |
| Windows | PowerShell |
The file is a PNG, JPEG, GIF, WebP, BMP or TIFF image of 8 MB or less. Only you can read it. TUIOS deletes it after one hour, or when the daemon stops. The `paste` folder next to the TUIOS socket keeps at most 50 images and 100 MB. When it is full, TUIOS deletes the oldest image.
TUIOS does not read the clipboard when the client runs in a browser, when it runs on another machine through `tuios ssh`, or when the paste comes from `send-keys`. Inside ssh, TUIOS reads only an X display that `ssh -X` forwards. Any other clipboard there is the other machine's.
Keys sent to the pane are encoded the way a real terminal encodes them. When the program in the pane turns on application cursor keys (DECCKM), as zsh's line editor does, the arrows, Home and End are sent in their SS3 form (`ESC O H` for Home ) instead of the CSI form (`ESC [ H`), so the program's bindings for them work. `tuios send-keys` follows the same rule.
`tuios send-keys` also encodes a named key or a chord in the keyboard mode the program asked for. A program that asked for the kitty keyboard protocol or modifyOtherKeys gets that encoding, the same as for a key you press. Vim then reads Ctrl +H as Ctrl +H , not as Backspace .
## Global keys
Section: `[keybindings.global]`. These keys work in window mode and terminal mode.
| Key | Action | Does |
| ------------------------------- | ----------------- | ------------------------ |
| Ctrl +P | `command_palette` | Open the command palette |
| Alt +Space | `launcher` | Open the app launcher |
The **command palette** searches commands, sessions and windows. Start the query with `@` to filter panes by agent state (`@n` for needs input), or with `#` to search actions to rebind.
The **app launcher** lists every executable on `$PATH`, ranked by use. Enter runs the selection in a new pane with no shell in between. Tab types the command into the focused shell instead, so you can add arguments.
These two keys take Ctrl +P from fish and vim and Alt +Space from readline. Set an action to `[]` to give its key back. On macOS, Option +Space types a non-breaking space, so use Ctrl +B a for the launcher.
## Prefix commands
Press Ctrl +B , then the key. Section: `[keybindings.prefix_mode]`.
| Key | Action | Does |
| --------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| c | `prefix_new_window` | New window |
| x | `prefix_close_window` | Close the focused window |
| r | `prefix_rename_window` | Rename the focused window |
| n , Tab | `prefix_next_window` | Next window |
| p , Shift +Tab | `prefix_prev_window` | Previous window |
| 0 to 9 | `prefix_select_0` to `prefix_select_9` | Jump to a window |
| arrows | `terminal_focus_left`, `_right`, `_up`, `_down` | Focus the pane in that direction |
| ) | `next_session` | Next session |
| ( | `prev_session` | Previous session |
| Space | `prefix_toggle_tiling` | Turn tiling on or off |
| z | `prefix_fullscreen` | Zoom the focused window |
| - | `prefix_split_horizontal` | Split top and bottom |
| \| , \\ | `prefix_split_vertical` | Split left and right |
| R | `prefix_rotate_split` | Flip the split direction |
| = | `prefix_equalize_splits` | Make every split equal. In master-stack, also put the master back at `master_ratio` |
| \[ | `prefix_selection` | Enter [copy mode](#copy-mode) |
| F | `hints` | Label the text on the pane to copy it. See [Hints](/docs/hints) |
| none | `hints_all_panes` | Label the text on every pane of the workspace. See [All panes](/docs/hints#all-panes) |
| Q | `display_panes` | Put a label on each pane and focus one by its label. See [Pane labels](/docs/find-a-pane#pane-labels) |
| / | `choose_tree` | Find a pane in every session and go to it. See [The pane navigator](/docs/find-a-pane#the-pane-navigator) |
| s | `prefix_scrollback` | Open the scrollback browser |
| C | `prefix_screenshot` | Take a screenshot |
| P | `prefix_command_palette` | Open the command palette |
| a | `launcher` | Open the app launcher |
| , | `prefix_settings` | Open the settings page |
| k | `prefix_keybinds` | Open the keybind manager |
| b | `prefix_toggle_sidebar` | Show or hide the session rail |
| B | `prefix_toggle_spotlight` | Turn the [spotlight](#spotlight) on or off |
| V | `paste_image` | Paste the clipboard image as a file path. See [Paste an image](#paste-an-image) |
| g | `toggle_scratch` | Show or hide the [scratch terminal](/docs/sessions#scratch-terminal) |
| e | `prefix_explore` | Move the keyboard to the rail, or back |
| S | `prefix_session_switcher` | Open the session switcher |
| W | `prefix_workspace_switcher` | Open the workspace switcher |
| j | `prefix_jump_notif` | Jump to the pane of the newest message |
| N | `prefix_last_message` | Show the last message in full. See [Messages](#messages) |
| i | `prefix_inbox` | Open the [Inbox](/docs/agent-inbox) |
| o | `prefix_next_attention` | Go to the oldest Inbox item that needs you. o again goes to the next |
| O | `prefix_next_finished` | Go to the newest finished turn nobody has seen. O again goes to the next older one |
| v | `prefix_review` | [Review](/docs/agent-inbox#reviewing-an-agents-changes) the focused pane's changes |
| A | `prefix_agents_settings` | Open the [Agents tab](/docs/agents#the-agents-tab) of the settings page |
| M | `prefix_mail` | Open the Inbox on its mail. m there opens the whole mailbox |
| t | `prefix_window` | [Window prefix](#window-prefix) |
| m | `prefix_minimize` | [Minimize prefix](#minimize-prefix) |
| w | `prefix_workspace` | [Workspace prefix](#workspace-prefix) |
| L | `prefix_layout` | [Layout prefix](#layout-prefix) |
| T | `prefix_tape` | [Tape prefix](#tape-prefix) |
| D | `prefix_debug` | [Debug prefix](#debug-prefix) |
| d | `prefix_detach` | Detach. The session keeps running |
| X | `prefix_close_session` | Close the session and its panes |
| Esc | `prefix_exit_mode` | Leave terminal mode. Never detaches |
| q | `prefix_quit` | Quit. In a daemon session it opens the quit menu: detach, switch session, or kill the session |
| ? | `prefix_help` | Toggle help |
In terminal mode, pressing the leader twice sends one literal Ctrl +B to the pane.
The which-key popup that opens after the leader shows the keys of your config. A key that you rebind shows in its new place. An action that you unbind has no row. A row for a range of keys, such as `1-9`, shows only the keys that are bound. For example, if you unbind the key for workspace 5, the row shows `1-4/6-9`. Set `appearance.whichkey_enabled = false` to turn the popup off.
After a command worth repeating, such as an arrow, o or O , the prefix stays armed for 500 ms. Ctrl +B then ← ← ← moves three panes. Change this with `appearance.prefix_repeat_time`. `0` turns it off.
### Scratch terminal
Ctrl +B g shows the scratch terminal, a small layout of panes in a box over the current layout. Press it again, also from inside the box, to hide it. The shells keep running between shows. Inside the box the window keys act on its panes: split, move the focus, resize, swap, zoom and close. `[scratch]` sets the size of the box. See [Scratch terminal](/docs/sessions#scratch-terminal).
### Window prefix
Ctrl +B t , then the key. Section: `[keybindings.window_prefix]`.
| Key | Action | Does |
| ------------------------------- | ---------------------- | ------------------------- |
| n | `window_prefix_new` | New window |
| x | `window_prefix_close` | Close the focused window |
| r | `window_prefix_rename` | Rename the focused window |
| Tab | `window_prefix_next` | Next window |
| Shift +Tab | `window_prefix_prev` | Previous window |
| t | `window_prefix_tiling` | Turn tiling on or off |
| Esc | `window_prefix_cancel` | Cancel |
### Minimize prefix
Ctrl +B m , then the key. Section: `[keybindings.minimize_prefix]`.
| Key | Action | Does |
| ---------------------------- | ---------------------------------------------------------- | -------------------------------------- |
| m | `minimize_prefix_focused` | Minimize the focused window |
| 1 to 9 | `minimize_prefix_restore_1` to `minimize_prefix_restore_9` | Restore a minimized window by position |
| M | `minimize_prefix_restore_all` | Restore all minimized windows |
| Esc | `minimize_prefix_cancel` | Cancel |
### Workspace prefix
Ctrl +B w , then the key. Section: `[keybindings.workspace_prefix]`.
| Key | Action | Does |
| --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------- |
| 1 to 9 | `workspace_prefix_switch_1` to `workspace_prefix_switch_9` | Show a workspace |
| ! @ # $ % ^ & \* ( | `workspace_prefix_move_1` to `workspace_prefix_move_9` | Move the focused window there and follow it |
| r | `workspace_prefix_rename` | Rename the current workspace |
| Esc | `workspace_prefix_cancel` | Cancel |
### Layout prefix
Ctrl +B L , then the key. Section: `[keybindings.layout_prefix]`.
| Key | Action | Does |
| -------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
| l | `layout_prefix_load` | Open the layout picker |
| s | `layout_prefix_save` | Save the current layout as a template |
| 1 to 4 | `snap_corner_1` to `snap_corner_4` | Snap to the top-left, top-right, bottom-left or bottom-right quarter. Floating mode only |
| 5 to 9 | `resize_width_50` to `resize_width_90` | Set the focused window's width to 50% to 90% |
| Shift +5 to Shift +9 | `resize_height_50` to `resize_height_90` | Set its height to 50% to 90% |
| Esc | `layout_prefix_cancel` | Cancel |
Actions `resize_width_10` to `resize_width_40` and `resize_height_10` to `resize_height_40` also exist, with no default key.
Loading a layout moves the existing windows into place. It does not close any. Extra windows are minimized. `tuios layout` manages saved templates from the shell.
### Tape prefix
Ctrl +B T , then the key. Section: `[keybindings.tape_prefix]`. See [Tape Recording](/docs/tape-recording).
| Key | Action | Does |
| -------------- | --------------------- | ----------------------------------------------- |
| m | `tape_prefix_manager` | Open the tape manager |
| r | `tape_prefix_record` | Start recording |
| s | `tape_prefix_stop` | Stop recording |
| t | `tape_prefix_review` | Review the project tape found in this directory |
| Esc | `tape_prefix_cancel` | Cancel |
While a tape plays, section `[keybindings.script]` is active: Ctrl +P is `script_pause` and pauses or resumes playback.
### Debug prefix
Ctrl +B D , then the key. Section: `[keybindings.debug_prefix]`.
| Key | Action | Does |
| -------------- | ------------------------- | --------------------------------------------- |
| l | `debug_prefix_logs` | Toggle the log viewer |
| c | `debug_prefix_cache` | Toggle style cache statistics |
| k | `debug_prefix_showkeys` | Toggle the [showkeys](/docs/showkeys) overlay |
| a | `debug_prefix_animations` | Toggle animations |
| Esc | `debug_prefix_cancel` | Cancel |
## Session rail
Section: `[keybindings.sidebar]`. Active while the rail has the keyboard: press s in window mode, or Ctrl +B e .
| Key | Action | Does |
| ----------------------------------------------- | -------------------- | ------------------------------------------------------------------ |
| j , ↓ | `cursor_down` | Move down |
| k , ↑ | `cursor_up` | Move up |
| g , Home | `first` | First row |
| G , End | `last` | Last row |
| l , → | `expand` | Go to the next section |
| h , ← | `collapse` | Go to the previous section |
| Enter | `activate` | Open the row |
| J , Shift +↓ | `reorder_down` | Move the session or machine down |
| K , Shift +↑ | `reorder_up` | Move the session or machine up |
| Tab , Shift +Tab | `section` | Cycle the sessions, terminals and agents sections |
| 1 to 9 | `jump_1` to `jump_9` | Jump to a row by number |
| n | `new_session` | New session |
| t | `new_window` | New window |
| r | `rename` | Rename the pane or session |
| c | `accent` | Set the session's accent colour |
| x | `kill` | Open the row's close menu |
| m | `menu` | Open the row's menu |
| i | `mail` | Open the mailbox for the pane under the cursor |
| f | `agents_filter` | Agents section: every session, or only this one |
| o | `agents_sort` | Agents section: sort by what needs you, by priority, or by recency |
| / | `palette` | Open the command palette |
| \< | `narrow` | Narrow the rail |
| > | `widen` | Widen the rail |
| ? | `help` | Show the rail's help |
| Esc , s | `exit` | Give the keyboard back |
When the cursor is on a row in the files section, section `[keybindings.sidebar_files]` answers first:
| Key | Action | Does |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------- |
| Enter | `file_open` | Open the folder, or copy the file's path |
| a | `file_create` | New file. End the name with `/` for a folder |
| r | `file_rename` | Rename |
| d | `file_delete` | Delete to the trash |
| D | `file_delete_forever` | Delete with no trash |
| y | `file_copy` | Copy |
| x | `file_cut` | Cut |
| p | `file_paste` | Paste into this folder |
| Y | `file_copy_path` | Copy the absolute path of the file or folder. A folder's context menu has a **Copy path** row too |
See [The files section](/docs/configuration#the-files-section).
When the cursor is on an agent row, section `[keybindings.sidebar_agents]` answers first. So r and x act on the agent there, and keep their rail meaning on every other row:
| Key | Action | Does |
| ------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| r | `agent_reply` | Reply to the agent. The message is queued and typed when the agent is at rest |
| v | `agent_review` | Review the pane's changes |
| u | `agent_unread` | Mark the pane's finished turn unread again, for every client |
| z | `agent_snooze` | Open the Inbox on the pane's item, ready to snooze it |
| x | `agent_cancel_queued` | Drop the newest queued message. u within 10 seconds queues it again. With nothing queued, x opens the row's menu as usual |
Enter on the `+N at rest` line shows the agent rows folded there.
## Inbox and mailbox
The [Inbox](/docs/agent-inbox) lists everything waiting for you in every session. Open it with Ctrl +B i . Its keys are in `[keybindings.inbox]`:
| Key | Action | Does |
| --------------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| j , ↓ , Ctrl +N | `inbox_down` | Move down |
| k , ↑ , Ctrl +P | `inbox_up` | Move up |
| PgDn , PgUp | `inbox_page_down`, `inbox_page_up` | Move a page |
| g , Home / G , End | `inbox_first`, `inbox_last` | First or last item |
| Enter | `inbox_go` | Go to the item's pane, or open a mail thread |
| Space | `inbox_peek` | Read an approval's or a question's prompt and answer it from the Inbox |
| d , Delete | `inbox_dismiss` | Dismiss the item. u undoes it for 10 seconds |
| r | `inbox_reply` | Reply to mail, or to the agent of a finished or errored item |
| y | `inbox_resume` | On a resume row, type the conversation's resume command in its pane |
| p | `inbox_pass_on` | Pass on mail held from another machine |
| f | `inbox_filter` | Show one kind, then the next, then all |
| / | `inbox_select` | Narrow the list with a selector, such as `harness:codex needs:you` |
| m | `inbox_mailbox` | Open the mailbox |
| v | `inbox_review` | Review the changes in the item's pane |
| z | `inbox_snooze` | Snooze the item: then 1 15 minutes, 2 an hour, 3 until 9:00 tomorrow, 4 until it changes. On a snoozed item, wake it |
| u | `inbox_undo` | Undo the last dismiss or snooze, within 10 seconds |
| S | `inbox_show_snoozed` | Show or hide the snoozed items |
| n | `inbox_deny_reason` | Deny a held approval, or keep a plan planning, with a reason you type |
| J , Ctrl +D / K , Ctrl +U | `inbox_detail_down`, `inbox_detail_up` | Scroll the detail under the list, such as a long plan |
| Esc , q | `inbox_close` | Close |
On an approval the Inbox holds for you, 1 allows it once, 2 always allows it and 3 denies it. On a question put with `ask-human`, a digit picks that answer. The digits are not bindings.
The prompt that Space opens has its own section, `[keybindings.inbox_peek]`:
| Key | Action | Does |
| ---------------------------------------------- | --------------------- | ----------------------------------------------------- |
| a | `peek_approve` | Approve |
| A | `peek_approve_always` | Approve and do not ask again, when the menu offers it |
| d | `peek_deny` | Deny |
| Tab | `peek_type` | Type an answer |
| r | `peek_read_again` | Read the prompt again |
| Enter | `peek_go` | Go to the pane instead |
| Esc , q , Space | `peek_back` | Back to the list |
A digit chooses that numbered option of the prompt.
The mailbox (m in the Inbox) uses `[keybindings.mail]`: j /k (`mail_down`, `mail_up`), PgDn /PgUp (`mail_page_down`, `mail_page_up`), Enter opens a thread (`mail_open`), r replies (`mail_reply`), o goes to the pane (`mail_focus_pane`), n writes a new message to an agent (`mail_new`) and Esc or q goes back (`mail_back`).
The footers name whatever key your config binds. Until an agent has been seen, the prefix menu, the help and the palette leave out the agent keys and entries, so a person who runs no agents is not shown them. The keys work before that too, except Ctrl +B O , which types O into the pane until then.
## The review overlay
Ctrl +B v , or v in the Inbox or on a rail agent row, opens the diff of a pane's changes over the whole screen. See [Reviewing an agent's changes](/docs/agent-inbox#reviewing-an-agents-changes). These keys are fixed and are not in the config.
| Keys | Does |
| --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| j k , arrows | Move by line. With the file list focused, move through the files |
| Space , PgDn , Ctrl +D / PgUp , Ctrl +U | Move a page |
| g G | First or last line |
| ] \[ | Next or previous hunk, going on into the next or previous file |
| } \{ | Next or previous file |
| s | One column, or old and new side by side when the screen is wide enough |
| h l , ← → | Scroll the code sideways |
| Tab | Focus the file list or the diff |
| Enter | In the file list, open that file |
| c / C | A note on the line under the cursor, or on the whole hunk |
| e | Edit the note under the cursor |
| x | Resolve (remove) the note under the cursor |
| S | Send every unsent note to the agent as one message, typed when it is at rest |
| u | Switch between the changes since the base and only the uncommitted ones |
| b | Diff from another base |
| w | The compare view, for a pane in a fan |
| r | Read the diff again |
| Esc , q | Close |
The compare view lists the attempts of a fan. j /k move, Enter reviews an attempt, m marks one and d diffs the two marked attempts, V runs a command in every attempt, K keeps the attempt under the cursor and removes the others after a y , and Esc , q or w go back to the review.
## Lists and the settings page
Every list moves the same way: the palette, the launcher, the settings page, the Inbox, the mailbox, the keybind manager, the pickers, the switchers, the menus and the tape manager.
| Keys | Does |
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
| ↑ ↓ , Ctrl +P Ctrl +N | Move one row. Past either end, wrap round |
| Home End | First or last row |
| PgUp PgDn | A page, stopping at the ends |
| k j g G | Up, down, first and last, in a list with no filter to type into |
| Ctrl +U | Clear a typed filter |
Set `appearance.wrap_lists = false` to stop at the ends instead of wrapping.
The settings page (, in window mode, or Ctrl +B , ) reopens on the tab, row and search you left it on:
| Keys | Does |
| -------------------------------------------------------------------------- | ------------------------------------------------- |
| ← → , h l | Change the row's value |
| Enter , Space | Toggle, cycle, or open the row's picker or editor |
| Tab Shift +Tab , ] \[ | Next or previous tab |
| 1 to 9 | Go to that tab |
| / , or a letter the page does not use | Search every tab |
| Backspace , Delete | Reset the row to its default |
| Ctrl +Z | Undo the last change made on the page |
| Esc , q | Close |
A row whose value differs from its default has a dot after its name, including the hand-drawn rows such as the theme and shared borders, which have no reset. See [The in-app settings page](/docs/configuration#the-in-app-settings-page) for the search.
The key hints at the foot of a panel stay on one row. When they do not fit, they get shorter in steps. First, `ctrl+` becomes `^`, `alt+` becomes `M-` and `shift+` becomes `S-`. Then the least important hints go, one whole hint at a time. The way out, such as `esc`, stays. Only when the most important hints still do not fit do their labels go, from the last hint back. A hint is never cut in the middle of a word.
### Mode keys in the dock
Copy mode, multi copy mode and hints mode show their keys at the right end of the dock while the mode is open. The keys follow the rule for panel footers, so a narrow screen shows fewer keys and never part of one. The main action and the way out always show. In hints mode, ? shows all of its keys in the help. A message that arrives while a mode is open shows in place of the keys until the message goes. The dock component is `copy-help`.
## Messages
A message shows at the right end of the dock. A message that is too long for the dock stops after its last whole word, with `…` and `more`. A word with no space in it, such as a long path, is cut where the room ends.
* Put the pointer on a message to hold it. A key press, the terminal losing focus, or 60 seconds ends the hold.
* Put the pointer on a long message to see its first lines above the dock.
* Click a long message to read all of it in the message view. A click on a pane's request to set the clipboard allows it.
* Click a short message from a pane to go to that pane.
| Keys | Where | Does |
| ------------------------------------------------------------------------------------------------------------ | ------------ | --------------------------------------------------------------------------------------------------------- |
| Ctrl +B N | Anywhere | Show the last message from a pane, or the last message too long for the dock, also after it left the dock |
| Ctrl +B j | Anywhere | Go to the pane of the newest message |
| j k , ↑ ↓ , wheel | Message view | Scroll one line |
| Ctrl +D Ctrl +U , Space , PgDn PgUp | Message view | Scroll half a page |
| g G , Home End | Message view | Go to the first or last line |
| y | Message view | Copy the message |
| Enter | Message view | Go to the pane the message came from |
| Esc , q | Message view | Close the message view |
The log viewer (Ctrl +B D l ) lists every message the dock showed, and every warning and error. The entry under the cursor shows wrapped under the list. Press Enter , or click an entry, to read it in the message view. E copies the errors and A copies the whole log.
## Spotlight
B in window mode turns the spotlight on and off. The spotlight dims the screen outside a circle around the pointer. Ctrl +B B turns it on and off in any mode. The key was b in earlier versions.
While the spotlight is on, the dock shows a Spotlight chip with the key that turns it off:
| Where | Turn off the spotlight |
| ------------- | ----------------------------------------- |
| Window mode | Esc |
| Terminal mode | Ctrl +B B |
| Any mode | Click the Spotlight chip in the dock |
In terminal mode Esc goes to the program in the pane, so vim and other programs get it. An open dialog, menu or popup closes before Esc turns the spotlight off. The actions are `toggle_spotlight` and `prefix_toggle_spotlight`. `[spotlight]` sets the size and the dimming. See [Themes](/docs/themes#spotlight).
## Close every pane on a workspace
The `close_workspace` action closes every pane on the current workspace, like tmux `kill-window`. Use it to close the panes that `tuios xpanes` opened. It has no default key. The command palette has the entry `Close workspace`. This example binds it to Ctrl +B Alt +X , because Ctrl +B X closes the session:
```toml
[keybindings.prefix_mode]
close_workspace = ["alt+x"]
```
The action asks first. The dialog shows how many panes close and how many agents are in them. `Cancel` is the default row. Scratch panes stay open. `tuios close-workspace` does the same from a shell.
## Split into the same ssh
These actions open a pane on the machine that the focused pane is connected to with ssh:
| Action | What it does |
| ---------------------- | ------------------------------- |
| `split_ssh_horizontal` | Splits the pane top and bottom. |
| `split_ssh_vertical` | Splits the pane left and right. |
| `new_window_ssh` | Opens a new window. |
The new pane runs ssh again, to the same host as the same user. It keeps the connection options, such as the port, the identity file and the jump host. tuios does not run the remote command again. It also removes `-N`, `-f`, `-T`, port forwards and the other options that stop a shell. `mosh` works the same way. Eternal Terminal (`et`) is not followed.
When the focused pane does not run ssh, the action opens an ordinary pane. You can use the keys in every pane. Some ssh lines also get an ordinary pane. See [Configuration](/docs/configuration#lines-that-are-not-followed).
The actions have no default key. The command palette has an entry for each one. To bind them, add them to a section. This example uses keys for window-management mode:
```toml
[keybindings.layout]
split_ssh_vertical = ["alt+v"]
split_ssh_horizontal = ["alt+s"]
new_window_ssh = ["alt+w"]
```
To make the ordinary split and new-window keys do the same, set `appearance.new_window_follow_ssh`. To start the new pane in the remote folder, make the remote shell report its folder. See [Configuration](/docs/configuration#splits-that-follow-ssh).
## Copy mode
Press Ctrl +B \[ to enter copy mode. It moves a cursor through the scrollback with vim keys. These keys are fixed and are not in the config, except Home and End . You can add keys that pipe a yank through a command. See [Pipe a yank through a command](#pipe-a-yank-through-a-command). Put a count before a motion to repeat it: `10j`, `5w`.
The copy cursor starts on the terminal cursor, which is usually the prompt line. tmux does the same. To start on the middle row, set `appearance.selection.copy_entry` to `center`.
When the focused pane is in the [multifocus](/docs/layout-modes#typing-into-several-panes) set, Ctrl +B \[ starts [multi copy mode](/docs/layout-modes#multi-copy-mode) on every pane of the set.
| Keys | Does |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| h j k l , arrows | Move the cursor |
| w b e | Next word, previous word, end of word |
| W B E | The same, for space-separated words |
| 0 ^ $ | Line start, first non-blank, line end |
| Home End | Line start, line end. You can rebind these two. See [Line start and line end](#line-start-and-line-end) |
| H M L | Top, middle and bottom of the screen |
| g g , G | Top of the scrollback, bottom (live output) |
| `{n}G` | Go to line `n` |
| \{ } | Previous and next paragraph |
| % | Matching bracket |
| Ctrl +U / Ctrl +D | Half page up, down |
| Ctrl +B / Ctrl +F , PgUp / PgDn | Full page up, down |
| f F t T then a character | Find a character on the line |
| ; , | Repeat the character search, forward or back |
| / | Search forward, down to the newest output |
| ? | Search backward, up into the scrollback |
| n | Next match in the direction of the last search |
| N | Next match in the opposite direction |
| Esc in the search prompt | Cancel the search and move the cursor back |
| Ctrl +L | Clear search highlights |
| v V | Start a character or line selection |
| y , c | Copy the selection to the clipboard, through `copy_command` when it is set |
| i | Leave copy mode and go to terminal mode |
| q , Esc | Leave copy mode |
The scrollback browser (Ctrl +B s ) is a different tool. It lists past commands by their prompt marks, for shells that emit OSC 133.
### Line start and line end
Home moves the copy cursor to the start of the line, as 0 does. End moves it to the end of the line, as $ does. Both keys also move the end of a v or V selection. The help lists them next to 0 ^ $ .
| Action | Default key |
| ---------------------- | --------------- |
| `copy_mode_line_start` | Home |
| `copy_mode_line_end` | End |
The keys are in the `copy_mode` section. They work only in copy mode. In the search prompt, they do not move the cursor. 0 and $ are fixed keys, and you cannot rebind them.
Do not bind a key here that copy mode already uses, such as y or v , or a key of a `[[keybindings.copy_pipe]]` entry. `tuios keybinds doctor` and the config check warn about such a key. A copy pipe key runs the pipe, and the binding does not run. A key that copy mode uses loses its copy mode action.
Ctrl +A and Ctrl +E are not defaults. Ctrl +A is a common leader key, and Ctrl +E scrolls one line in tmux copy mode. To use them, add them to the section:
```toml
[keybindings.copy_mode]
copy_mode_line_start = ["home", "ctrl+a"]
copy_mode_line_end = ["end", "ctrl+e"]
```
### Search
The prompt shows `/` or `?` for the direction. The search starts at the copy cursor. When no match is found in that direction, the search continues from the other end of the buffer. n and N also start at the copy cursor. After you move the cursor, they find the nearest match. When a search finds more than 1000 matches, the prompt shows `1000+`.
Two actions start copy mode and open the search prompt with one key. They have no default key.
| Action | Palette entry | Does |
| --------------------------- | ---------------------------- | --------------------------------------- |
| `copy_mode_search_forward` | `Copy mode: search forward` | Start copy mode and open the `/` prompt |
| `copy_mode_search_backward` | `Copy mode: search backward` | Start copy mode and open the `?` prompt |
They do the same as tmux `bind-key b copy-mode \; send-keys ?`. Bind one in any section. This example puts the backward search on Ctrl +B / :
```toml
[keybindings.prefix_mode]
copy_mode_search_backward = ["/"]
```
If the pane is already in copy mode, the action opens the prompt and does not move the cursor. In multi copy mode, the prompt opens in each pane of the mode.
In copy mode, only a key in the `global`, `terminal_mode` or `window_management` section starts the action. A key after the leader does not, because copy mode uses Ctrl +B for page up. In copy mode, / and ? open the same prompts.
TUIOS cannot put two actions on one key. `tuios send-keys` cannot do it either, because a key from `send-keys` does not go to copy mode.
### Pipe a yank through a command
A yank can go through a command before it goes to the clipboard. tmux calls this `copy-command` and `copy-pipe`. TUIOS has two parts:
* `appearance.selection.copy_command` runs one command on every yank in copy mode.
* A `[[keybindings.copy_pipe]]` entry binds a key in copy mode to its own command.
```toml
[appearance.selection]
copy_command = "tmux-copy-it"
[[keybindings.copy_pipe]]
key = "p"
command = "tr '\n' ' '"
cancel = true
description = "flatten"
```
The command runs with `sh -c`. It gets the selection on stdin. The clipboard gets what the command writes to stdout. When the selection does not end with a new line, TUIOS removes one new line from the end of the output.
The clipboard gets the selection itself in these cases:
* The command writes nothing. Use this for a command that sends the text to a socket or a file.
* The command stops with an exit code that is not 0. The dock shows the exit code and the first line of stderr.
* The command runs for more than 10 seconds. TUIOS stops the command and the processes that it started.
TUIOS waits until the command closes its stdout. `xclip` starts a process that stays open after `xclip` stops, and keeps stdout open. TUIOS then waits 1 second more. To prevent the wait, send the stdout of `xclip` to `/dev/null`. This example also puts each yank in the X primary selection:
```toml
[appearance.selection]
copy_command = "xclip -selection primary >/dev/null"
```
| Field | Does |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | The key in copy mode. Write it as in the other sections |
| `command` | The command that gets the selection on stdin |
| `cancel` | `false`, the default, keeps copy mode on and the cursor where it is. TUIOS clears the selection. This is tmux `copy-pipe`. `true` leaves copy mode, as q does. This is tmux `copy-pipe-and-cancel` |
| `description` | The name of the entry in the dock messages. Optional |
The entry takes its key from copy mode. An entry on y replaces the plain yank. Copy mode does not use p or P . When an entry takes q , Esc , v , V , / , ? or a digit, TUIOS shows a warning when it loads the config. When no text is selected, the key shows a message and runs nothing. While you type a search, the key goes into the search.
Without `copy_command`, y copies the selection as it is. With `copy_command`, y and c pipe the selection through that command. A `[[keybindings.copy_pipe]]` key always uses its own command. A mouse selection that copies on release does not use `copy_command`.
In [multi copy mode](/docs/layout-modes#multi-copy-mode), the command gets the text that y copies: the selection of each pane, joined in the current format. It runs one time for all panes. Use the `json` format to process each pane separately, for example with `jq`. With `cancel = true`, multi copy mode stops in every pane.
The command runs on the machine that runs the TUIOS client, the same machine as for a `shell` [command key](#command-keys). For the SSH and web clients, it is the server. The command starts in the folder of the focused pane and gets the variables of a command key: `TUIOS_SESSION`, `TUIOS_SOCKET`, `TUIOS_ACTIVE_PANE_ID` and `TUIOS_ACTIVE_PANE_CWD`. You can use TUIOS while the command runs.
## Mouse
| Gesture | Does |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Click a pane | Focus it |
| Double-click a pane | In window mode, also start typing in it. `appearance.click_to_type` makes it a single click, or turns it off |
| Drag a title bar | Move the window. In tiling, swap it with the pane you drop it on |
| Alt + drag | Move the window from anywhere inside it (`appearance.alt_drag`) |
| Right-drag | Resize the window |
| Right-click | In window mode, open the menu for the pane, the dock or the desktop |
| Shift or Ctrl + right-click | Open the pane menu even when the program in the pane uses the mouse |
| Title bar buttons | Minimize, zoom or close |
| Click a dock item | Restore a minimized window |
| Wheel up | Enter copy mode and scroll, when the program is not using the mouse |
| Drag the right border | Scroll with the scrollbar |
| Hover a link | Show where it goes. The label shows the real target, also when the text on the screen is different |
| Ctrl + click a link | Open it. Shift +click also opens it, but most terminals keep it for their own selection. See [Links](/docs/links) |
In floating mode, drop a window on a screen edge to snap it: the left or right edge for a half, a corner for a quarter, the top center for the full screen.
In copy mode, a click moves the cursor and a drag selects. Dragging past the top or bottom of the pane scrolls.
Ctrl +drag still moves the pane. A link opens only when the pointer does not move. `appearance.link_click` sets which click opens a link.
## Command keys
A `[[keybindings.command]]` entry binds a key to a command that you write.
```toml
[[keybindings.command]]
key = "prefix+alt+g"
type = "scratch"
command = "lazygit"
description = "Lazygit"
width = "80%"
height = "80%"
```
* `key` is the key that runs the command. A key that starts with `prefix+`
works after the leader (Ctrl +B ). Any other key works in window mode and
in terminal mode.
* `type` is one of these:
* `scratch`: a scratch terminal of its own. The key shows it and hides it,
as Ctrl +B g does for the built-in one. Each entry has its own
layout: you can split it. One scratch terminal is on the screen at a time.
When the command exits, its pane closes. When no pane is left, the next
press starts the command again. With no `command`, the first pane runs your
shell.
* `popup`: a popup that closes when the command exits. This is the default.
* `pane`: a new pane in the layout, next to the focused pane. It closes when
the command exits.
* `shell`: the command runs with no window. You do not see its output. When
the command fails, the dock shows a message.
* `command` runs with `sh -c`, so you can write pipes and quotes. It starts in
the folder of the focused pane.
* `description` is the name in the command palette and in
`tuios keybinds list`. It is optional.
* `name` keeps a scratch terminal under a fixed name. It is optional. Without it,
tuios makes the name from the description or the command.
* `width` and `height` set the size of a scratch or popup entry, in cells
(`100`) or percent (`80%`). The default is `80%`.
The command gets these variables:
| Variable | Value |
| ----------------------- | ---------------------------------------- |
| `TUIOS_SESSION` | The session in which you pressed the key |
| `TUIOS_SOCKET` | The socket of the daemon |
| `TUIOS_ACTIVE_PANE_ID` | The pane that had the focus |
| `TUIOS_ACTIVE_PANE_CWD` | The folder in which the command starts |
A command key never takes a key from a tuios action or from the leader key.
When the key is taken, `tuios keybinds doctor` shows the entry as dead. When
two entries have the same key, the first entry in `config.toml` runs, and the
doctor shows the other as dead. To move an entry to a different key, edit its
`key` in `config.toml`. The command palette lists each entry, and
`tuios keybinds list` shows them under Commands.
A key with no modifier and no `prefix+`, for example `g`, works in terminal
mode too. It takes that letter from every pane, so tuios shows a warning. Use
`prefix+g` or a key with a modifier, for example `alt+g`.
When a scratch command stops in its first moments, for example because the
program is not installed, the dock shows its exit code. The next press starts
it again. tuios does not start it again by itself.
The command and the palette row of an entry change when you save
`config.toml`. A changed key works at the next key press, as for the other
keybindings. When you remove or rename an entry, or change the description of
an entry that has no `name`, the panes of its scratch terminal close at the
next reload. The
dock shows a message.
In a session on a different machine, a `scratch` or `popup` entry does not
run. The dock shows a message. A `pane` entry runs on the machine of the
session. A `shell` entry runs on the machine that runs the tuios client.
Only a key press or the command palette runs a command key. A change to
`config.toml` does not run a command. A pane that has the respond grant can
press keys as you through `run-command`. It can press a command key, also from
a session on a different machine, and a `shell` entry then runs on this
machine. Give the respond grant only to a pane that you trust.
### Recipes
These are command keys for common tasks. Copy an entry into `config.toml` and
change the key to taste. Run `tuios keybinds doctor` after you add one, to
check the key is free.
**Per-session notes.** This scratch entry opens `$EDITOR` on a notes file
named after the session, and makes the notes folder first. It falls back to
`vi` when `$EDITOR` is not set.
```toml
[[keybindings.command]]
key = "prefix+alt+n"
type = "scratch"
command = "mkdir -p ~/notes && ${EDITOR:-vi} ~/notes/$TUIOS_SESSION.md"
description = "Session notes"
```
**Browser pane.** This scratch entry opens a page in terminal-browser. It
needs terminal-browser installed, and a terminal with kitty graphics.
```toml
[[keybindings.command]]
key = "prefix+alt+b"
type = "scratch"
command = "terminal-browser open https://example.com"
description = "Browser"
```
**lazygit.** This is the example at the top of this section. It opens lazygit
in the focused pane's folder.
```toml
[[keybindings.command]]
key = "prefix+alt+g"
type = "scratch"
command = "lazygit"
description = "Lazygit"
width = "80%"
height = "80%"
```
**Pick a file into the focused pane.** This popup runs `fzf` and types the
pick into the pane you came from, with
[`tuios send-text`](/docs/cli-reference#send-text) and the pane id from
`TUIOS_ACTIVE_PANE_ID`.
```toml
[[keybindings.command]]
key = "prefix+alt+p"
type = "popup"
command = "tuios send-text -w \"$TUIOS_ACTIVE_PANE_ID\" \"$(fzf)\""
description = "Pick a file"
```
**Open a project.** This popup runs `fzf` over the folders in `~/dev` and
switches to the session of the folder you pick. When the session does not
exist, [`tuios switch-session`](/docs/cli-reference#switch-session) makes it in
that folder. See [Sessionizer](/docs/sessions#sessionizer).
```toml
[[keybindings.command]]
key = "prefix+alt+s"
type = "popup"
command = 'dir=$(find ~/dev -mindepth 1 -maxdepth 1 -type d | fzf) && tuios switch-session --create --cwd "$dir" "$(basename "$dir")"'
description = "Open a project"
```
**Copy the pane's folder.** This shell entry copies the focused pane's folder
to the clipboard. It tries `pbcopy`, then `wl-copy`, then `xclip`, and uses the
first one it finds.
```toml
[[keybindings.command]]
key = "prefix+alt+y"
type = "shell"
command = "printf %s \"$TUIOS_ACTIVE_PANE_CWD\" | (pbcopy || wl-copy || xclip -selection clipboard)"
description = "Copy pane folder"
```
## Customizing
Bindings live in 23 sections under `[keybindings]` in `config.toml`. Each action takes a list of keys.
```toml
[keybindings]
leader_key = "ctrl+a"
[keybindings.window_management]
new_window = ["n", "ctrl+t"]
close_window = [] # unbind: the key goes back to the pane
```
An empty list and a missing line are different. A missing action gets its default back at the next load. An empty list stays empty. Key syntax and every section are on the [Configuration](/docs/configuration#keybindings) page.
The `[keybindings]` sections can also be in a file that `config.toml` includes, or in a file in `config.d`. The tables merge key by key. A `[[keybindings.command]]` entry in a later file changes the earlier entry with the same `name`, or with the same `key` when one of them has no name. Add `disabled = true` to an entry to remove the earlier entry that it matches. The keybind manager and `tuios keybinds unbind` write a change to the file that sets the action. See [Split the Config](/docs/config-files).
### Modifier spellings
A key in `config.toml` can spell a modifier in more than one way. TUIOS reads each spelling as the same key.
| Write | TUIOS reads it as | Where |
| ------------------ | ----------------- | ------------- |
| `opt+`, `option+` | `alt+` | macOS only |
| `cmd+`, `command+` | `super+` | All platforms |
| `control+` | `ctrl+` | All platforms |
The order of the modifiers does not matter, so `shift+ctrl+x` is the same key as `ctrl+shift+x`. This applies to `leader_key`, to every binding table, and to `tuios keybinds explain`, `free` and `unbind`. `tuios keybinds explain opt+f12` prints `opt+f12 (tuios reads it as alt+f12)`. `tuios keybinds doctor` lists each key that TUIOS cannot read.
A `super+` chord needs a terminal that sends the Super key. Most macOS terminals keep Command chords for their own menus. Ghostty and kitty send an unbound Command chord through the Kitty keyboard protocol. A `super+` chord on F1 to F12, such as `super+f12`, works the same way. On a Mac keyboard, F12 can also be a media key, and then TUIOS does not get it.
Inspect and change bindings from the shell:
```bash
tuios keybinds list # the main tables
tuios keybinds browse # search every binding in an explorer
tuios keybinds doctor # conflicts, and keys taken from the pane
tuios keybinds explain alt+left # everything one key does
tuios keybinds unbind close_window w # take one key off one action
tuios keybinds free alt+left # take a key off every action
```
`doctor` tags each finding with its evidence: `certain` comes from TUIOS routing, `observed` from the live pane, `reference` from a list of common program defaults. `doctor` and `explain` take `--json` and `--guest `.
`tuios keybinds browse` opens the rows of `tuios keybinds list --json` in an explorer. Press / to search, and Tab or a click on a tab to show one scope. The detail pane shows the keys, the action, the scope and the description. q or Esc leaves it. Add a search to start with it: `tuios keybinds browse spotlight`. `tuios keybinds list` prints plain text, on a terminal too.
The **keybind manager** (Ctrl +B k ) shows the same analysis in four tabs: Bindings, Conflicts, Guests and Record. Record captures the next key you press. On a binding, Ctrl +D removes it and Ctrl +X takes its key off every action.
A keybinding saved in `config.toml` works at the next key press in the running client. You do not need to restart TUIOS or attach again. Changes made with the keybind manager also apply at once.
`tuios keybinds list-custom` compares every keybinding section. It does not report `leader_key`. Use `tuios keybinds doctor` or read the file for that.
## Other keyboard layouts
Bindings work with layouts for non-Latin scripts, such as Cyrillic, Greek, Hebrew or Arabic. When no binding matches the character that a key types, TUIOS uses the key at the same position on a US layout. On a Ukrainian layout, the key that types `ш` is the US i key. So Ctrl +B then that key opens the Inbox.
* A binding on the typed character wins. You can still bind `ш` yourself.
* Text that you type into a pane, a rename or a search keeps the character that you typed.
* Ctrl on a non-Latin key sends the control code of the US key to the pane. Ctrl +с is Ctrl +C in the shell.
Latin layouts, such as AZERTY, QWERTZ or Dvorak, use the letters on their keys. A letter or a Ctrl chord means the key that it types, not the US key at its position. On Dvorak, Ctrl with the key that types `b` is Ctrl +B , the leader. The showkeys strip, the binding recorder and a tape show the chord the same way. `tuios keybinds explain` checks the key as you write it, so give it the Latin key.
This needs a terminal that sends the US-layout key through the Kitty keyboard protocol: Ghostty, kitty, WezTerm or foot. Other terminals send only the typed character. With those terminals, use a Latin layout for TUIOS commands.
In window mode, TUIOS tells the terminal to send every key as a code. TUIOS resets this when it stops. After `kill -9` or a dropped ssh connection, the terminal can stay in this mode. The shell then shows codes such as `[97u` when you type. To reset the terminal, run `printf '\033[=0;1u'`, or close the tab.
## Keys sent to a pane
In terminal mode, TUIOS sends these keys to the program in the pane:
* The keypad keys, with Num Lock on or off. Keypad Enter sends Enter.
* Begin , the center key of the keypad with Num Lock off.
* F13 and higher. A program that uses the legacy encoding gets them in the form that xterm sends.
* Insert , Delete , Page Up and Page Down with their modifiers. Ctrl +Delete stays Ctrl +Delete .
A program that uses the Kitty keyboard protocol gets each key with the number that the protocol gives it. Caps Lock, Num Lock and a modifier key pressed alone reach that program only when it asks for every key.
## macOS
macOS treats Option as a compose key unless the terminal says otherwise. An Option chord then arrives as a character. TUIOS maps most of those characters back to the chord, which is why Option +1 to Option +9 work with no setup. Two kinds cannot be recovered:
* **Dead keys.** Option +e , i , n , u and \` send nothing until a second key. Alt +n (next window in terminal mode) takes two presses.
* **Rewritten chords.** Many terminals send Option +← and Option +→ as readline word motions, with no sign that an arrow was pressed.
To fix both, turn on your terminal's Option-as-Alt setting:
| Terminal | Setting |
| ------------ | ------------------------------------------------------- |
| Ghostty | `macos-option-as-alt = true` |
| Terminal.app | Settings > Profiles > Keyboard > Use Option as Meta Key |
| iTerm2 | Settings > Profiles > Keys > Left Option key: Esc+ |
| kitty | `macos_option_as_alt yes` |
| WezTerm | `send_composed_key_when_left_alt_is_pressed = false` |
| Alacritty | `option_as_alt = "Both"` under `[window]` |
| VS Code | `terminal.integrated.macOptionIsMeta` |
Ghostty also needs `keybind = alt+left=unbind` and `keybind = alt+right=unbind`, because its own keybinds rewrite those arrows first.
The prefix works on every terminal with no setup. Ctrl +B then an arrow, n , p , ( , ) , a or a digit covers every navigation command.
Command chords never reach a terminal program. macOS sends them to the menu bar.
## Related
# Hints
URL: https://tuios.dev/docs/hints
> Put a short label on each URL, path, hash, address and number in a pane, then type the label to copy, paste or open the text.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
Hints mode puts a short label on the useful text in the focused pane. Type a label to copy the text. It works like tmux-fingers and the kitty hints kitten. Hints mode can also put labels on all panes at once. See [All panes](#all-panes).
## Use it
1. Press Ctrl +B F . The labels show and the rest of the pane dims. The mode pill in the dock shows `HINTS`, and the other end of the dock shows the keys.
2. Type a label. TUIOS copies the text and closes hints mode.
| Keys | Does |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A label, such as a or l s | Copy the text |
| The label with Shift , such as A | Copy the text and type it into the pane |
| The label with Ctrl , such as Ctrl +A | [Open](#open) a URL or a path |
| Backspace | Remove the last letter you typed |
| ? | Close hints mode and show all of its keys in the help |
| Esc , or the leader key | Close hints mode |
| q | Close hints mode, when q is not a label letter |
| Ctrl +C , Ctrl +G | Close hints mode, when c or g is not a label letter |
The text nearest to the cursor gets the shortest label. The same text gets the same label each time it shows. A key that starts no label does nothing.
When c or g is a label letter, Ctrl +C and Ctrl +G open that label. g is in the default letters, so Ctrl +G opens the label `g`. Use Esc to close. The leader key always closes hints mode.
You can also open hints mode from the command palette. Search for `hints`.
A terminal that reports Caps Lock (the Kitty keyboard protocol) lets TUIOS read an upper case letter from Caps Lock as a plain label letter. In other terminals, turn Caps Lock off before you type a label.
Hints mode keeps all input from the pane while the labels show. TUIOS drops a paste, a key release and a mouse move over the pane. A click or the mouse wheel closes hints mode first and then works as usual. Hints mode also closes when its pane closes, when focus moves to a different pane, and when you change the workspace.
The copy uses the same path as a mouse copy. TUIOS writes the clipboard with OSC 52. On a local client, it also uses the system clipboard tool.
## All panes
The `hints_all_panes` action puts labels on all panes that the workspace shows. This includes tiled panes, floating panes and the focused pane.
* TUIOS gives no label to text that you cannot see. This includes a minimized pane, a pane behind a zoomed pane, a pane off the screen, and text under a different pane.
* Each label is different on all panes.
* The focused pane gets the shortest labels. The panes nearest to it get the next shortest labels.
* The same text gets the same label on all panes. A path or a `file://` URL gets one label for each pane, because each pane has its own folder.
* A label copies the text from any pane.
* Shift and a label types the text into the focused pane. The text can come from a different pane.
* Ctrl and a label [opens](#open) the text from the pane that shows it. TUIOS uses that pane's folder and machine.
* Hints mode closes when one of these panes closes, moves or changes size.
The action has no default key. Run it from the command palette, or bind it:
```toml
[keybindings.prefix_mode]
hints_all_panes = ["A"]
```
To make Ctrl +B F put labels on all panes, set `hints.all_panes`:
```toml
[hints]
all_panes = true
```
## What hints mode finds
| Name | Examples |
| -------- | ---------------------------------------------------------------- |
| `url` | `https://example.com/a`, `git@github.com:user/repo.git` |
| `path` | `/etc/hosts`, `./run.sh`, `~/notes.md`, `main.go:12:5` |
| `diff` | The file in `diff --git a/x b/x`, `--- a/x` and `modified: x` |
| `sha` | `149c8a8f`, a full 40-character hash |
| `ip` | `10.0.0.1`, `10.0.0.0/8`, `192.168.1.2:8080`, `fe80::1` |
| `uuid` | `550e8400-e29b-41d4-a716-446655440000` |
| `color` | `#1e1e2e`, `#fa0` |
| `hex` | `0xdeadbeef` |
| `number` | Numbers of 4 digits or more |
| `email` | `ops@example.com` |
| `id` | `pod/web-1`, `deployment.apps/web`, pod names, `sha256:` digests |
`path` and `email` accept letters in all scripts, with accents and combining marks. The other built-in patterns use only ASCII.
A URL ends at a space, a quote, a backtick or an angle bracket. A `)` or a `]` ends the URL when the URL did not open it. A pair that the URL opens stays in it, as in `https://en.wikipedia.org/wiki/Go_(language)`. TUIOS removes `.,;:!?` from the end. So the markdown badge `[](https://a.example/c)` gives two URLs. The pointer uses the same rules to find a link. See [Links](/docs/links).
A path that starts with `/` must not come directly after `<`, a letter, a digit or `_`. So `
` in HTML is not a path.
Hints mode reads only the text on the screen. If you scroll the pane back, it reads the lines you scrolled to. A URL that wraps onto the next row is one match. TUIOS joins two rows only when the terminal wrapped the text. A line that fills the row and then ends is not joined to the next line.
## Open
Ctrl and a label opens the text:
* A URL opens in your browser. A remote client (`tuios ssh`, the web client) copies the URL, because it cannot open a browser on your machine.
* A path or a `file://` URL opens in a new pane with `$EDITOR`. TUIOS removes a `:line:col` at the end. A relative path starts in the directory of the pane.
* Other text is copied.
TUIOS opens a file only when the file is on this machine. In these cases, it copies the path and tells you why:
* The pane runs on another machine.
* The session runs on another machine (`tuios attach` to a host).
* The client is remote (`tuios ssh`, the web client).
* The pane runs `ssh`, `autossh`, `mosh`, `et`, `telnet`, `tsh`, `kitten` (for example `kitten ssh`), `docker`, `kubectl` or `podman`.
* The shell reported a folder on another machine.
* The path is relative and a program runs in front of the shell. TUIOS knows only the folder that the shell reported.
* The path is relative and TUIOS does not know the folder of the pane.
TUIOS never gives the text to a shell. It gives the text to the opener as one argument. Only `http`, `https`, `mailto`, `ftp` and `ftps` URLs open. The opener is the same as for Ctrl +click on a link. See [Links](/docs/links).
## Settings
Put the settings in the `[hints]` table of `config.toml`:
```toml
[hints]
# Built-in patterns: "all", "none", or names such as "url,path,sha".
builtins = "all"
# More patterns, as Go regular expressions. A group named "match"
# sets the part that is copied.
patterns = ['JIRA-\d+', 'branch: (?P\S+)']
# The letters of the labels, easiest first. Lowercase letters only.
alphabet = "asdfghjkl"
# Let Ctrl and a label open the text.
open = true
# How much the text around the labels dims, in percent (10 to 90).
dim = 60
# Put labels on all panes on the workspace, not only on the focused pane.
all_panes = false
```
Your own patterns come before the built-in patterns. When a pattern does not compile, TUIOS shows a warning when it reads the config, and hints mode skips that pattern.
`hints.builtins`, `hints.alphabet`, `hints.open`, `hints.dim` and `hints.all_panes` are also on the settings page (Selection tab) and work with `tuios set-config`. `hints.patterns` is a list, so you set it in the file.
## Change the key
The action is `hints` in `[keybindings.prefix_mode]`. To use a different key, bind it:
```toml
[keybindings.prefix_mode]
hints = ["f"]
```
If your config already binds F in `[keybindings.prefix_mode]` to a different action, TUIOS keeps your binding and gives `hints` no key. `tuios keybinds doctor` tells you when this happens.
## Related
# Finding a pane
URL: https://tuios.dev/docs/find-a-pane
> Jump to a pane by its label, or find any pane in any session by its name, folder, command or screen text.
import { Callout } from 'fumadocs-ui/components/callout';
Two keys take you to a pane without walking through the panes one by one. Pane labels number the panes on the screen, as tmux's `display-panes` does. The pane navigator lists every pane in every session, as tmux's `choose-tree` does, and searches them.
## Pane labels
1. Press Ctrl +B Q . Each pane of the workspace shows a large label with its name under it.
2. Type a label. TUIOS focuses that pane and the labels close.
| Keys | Does |
| ----------------------------- | ------------------------------------------------------ |
| A label, such as 3 | Focus that pane |
| Backspace | Take back the last key of a two-key label |
| Esc , the leader | Close the labels |
| q | Close the labels, when q is not a label key |
The labels are digits by default, in the order that `select_window_1` to `select_window_9` count the panes. So label 3 is the pane that 3 selects in window mode. A minimised pane gets no label. With more panes than keys, a label has two keys.
Use other keys, such as the home row, in `config.toml`:
```toml
[panes]
label_keys = "asdfghjkl"
```
Letters `a` to `z` and digits are allowed. The first keys go to the first panes. A key typed with Shift is the same key.
When a pane is zoomed, the zoomed pane shows its label, and a list under it shows the labels of the panes the zoom hides. A label of a hidden pane moves the zoom to that pane. A pane that is mostly off the screen, or too small for its label, is in the same list.
The labels own the keyboard while they are up. In multifocus, a label key or a paste does not reach any pane. The labels close when the layout changes (a pane opens, closes, moves or zooms) and on a click or the mouse wheel.
The action is `display_panes`. Bind it to another key in `[keybindings.prefix_mode]`.
## The pane navigator
Press Ctrl +B / . The navigator has three boxes. **Search** is at the top and shows how many panes match, such as `3/7`. **Panes** lists the sessions, workspaces and panes. **Preview** shows the screen of the pane under the cursor, in the colors of that pane. The sessions on your [remote hosts](/docs/remote-hosts) are in the tree too.
| Keys | Does |
| ---------------------------------- | ------------------------------------------------------ |
| j , k , arrows | Move |
| l , → | Open a session or a workspace |
| h , ← | Close it, or go to its parent row |
| Space | Open or close |
| Enter | Go to the row: the session, the workspace and the pane |
| / | Search |
| v | Change the layout: tree, flat, cards |
| Esc , q | Close the navigator |
A click on a pane goes to it. A click on a session or a workspace opens or closes it.
### What a row shows
A session name has the color of the session in the sidebar. Its workspaces and panes hang under it on tree lines. A pane row shows:
* a dot with the state of the agent in the pane, if there is one;
* the pane name. A pane that has no name and no title shows its folder;
* the command that runs in the pane, after a `│`. At a prompt this is the shell. For `ssh`, the host follows when the shell reports its commands (OSC 133);
* the folder.
The current session and the current pane say `current`. The counts at the right are the panes in each session and workspace. A search shows the matched characters in the accent color.
### Layouts
Press v to change how the list shows the panes:
| Layout | Shows |
| ------- | ------------------------------------------------------------------------------------------------ |
| `tree` | Sessions, their workspaces and their panes |
| `flat` | One row for each pane, with its session and workspace |
| `cards` | Two rows for each pane: the name and the command, then the session, the workspace and the folder |
Your client keeps the last layout until it closes. Set the first layout in `config.toml`:
```toml
[panes]
navigator_layout = "flat"
```
### Search
Press / and type. The search reads each pane's name, title, folder, running command, session and workspace, and the last 40 lines of its screen. So you can find the pane where a build failed, or the server you logged in to, even when you did not name the pane. A pane that matches only by its screen text shows the matching line on its row.
Esc stops the search and keeps the results. A second Esc clears the search. A paste in the search line is search text.
The current session's panes are read from your client, and their preview is live. The other sessions are read when the navigator opens, so their preview shows the screen as it was then. The preview shows colors and text only. Links, clipboard writes and other control sequences from a pane do not reach your terminal. One load reads at most 150 screens and waits at most 8 seconds. A session that does not answer in time says so on its row.
The action is `choose_tree`.
## From a script
`list-windows --all` prints the same rows as the navigator:
```bash
tuios list-windows --all # every pane of every session
tuios list-windows --all --all-hosts --json # and the panes on each host
tuios list-windows --all --text 20 --json # with the last 20 lines of each screen
```
Each row has `session`, `workspace`, `workspace_name`, `window_id`, `name`, `title`, `command`, `cwd`, `focused` and `agent_state`. A pane on another machine also has `host` and `"untrusted": true`. `--text` reads with `capture-pane`, so the same [pane grants](/docs/agents#what-a-pane-may-do) apply: a pane that holds only `read` gets its own session, and the JSON has an `errors` entry for the rest.
# Links
URL: https://tuios.dev/docs/links
> Open a link in a pane with Ctrl+click, choose the click and the opener, and see which terminals pass the click to TUIOS.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
Ctrl +click on a link in a pane opens it. This works on every platform, macOS included.
TUIOS finds two kinds of link:
* An **OSC 8 link** is a link that a program marked, such as `ls --hyperlink`, `gcc`, `delta`, `gh` or an agent CLI. Its text and its target can be different.
* A **bare URL** is plain text that starts with `http://`, `https://`, `ftp://`, `ftps://`, `file://`, `ssh://` or `git://`.
## Open a link
| Gesture | Does |
| ---------------------- | ------------------------------------------------------------------------------------------------------------ |
| Hover a link | Underline it and show its target in a label under the pointer |
| Ctrl +click | Open the link |
| Shift +click | Also open the link. Most terminals keep this click for their own selection, so it often does not reach TUIOS |
| Ctrl +drag | Move the pane, as before. A link opens only when the pointer does not move |
The label shows the real target, also when the text on the screen is different. After a click, the dock names the host, for example `Opening github.com.`
A URL that wraps onto the next row is one link. A URL whose start or end is off the screen also opens whole: TUIOS reads the rest of it from the scrollback or from the rows under the view.
[Hints mode](/docs/hints) opens links from the keyboard. Ctrl +B F , then Ctrl and a label, opens that URL. Hints mode and the pointer use the same URL detector, so they find the same links.
## Where a URL ends
* A URL ends at a space, a quote, a backtick or an angle bracket.
* A `)` or a `]` ends the URL when the URL did not open it. A pair that the URL opens stays in it, as in `https://en.wikipedia.org/wiki/Go_(language)`.
* TUIOS removes `.,;:!?` from the end.
So the markdown badge `[](https://a.example/c)` gives two URLs.
## Settings
```toml
[appearance]
links = "all" # off, marked (OSC 8 only), all
link_click = "both" # both, ctrl, shift, off
link_opener = "" # for example "firefox --new-tab" or "open -a Safari %s"
```
| Key | Default | What it does |
| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
| `links` | `"all"` | The links TUIOS finds. `marked` finds only OSC 8 links. `off` finds none |
| `link_click` | `"both"` | The click that opens a link. `both` is Ctrl +click and Shift +click. `off` turns the click off |
| `link_opener` | `""` | The command that opens a web link. TUIOS puts the URL where `%s` is, or at the end |
All three work with `tuios set-config` and are on the settings page.
If `link_opener` is empty, TUIOS uses `$BROWSER`, then the system opener:
| System | Opener |
| ------------- | ---------- |
| macOS | `open` |
| Windows | `rundll32` |
| WSL | `wslview` |
| Other systems | `xdg-open` |
TUIOS runs no shell. It gives the URL to the opener as one argument.
## When the link does not open
TUIOS watches the opener for 3 seconds. If the opener fails in that time, the dock shows "The link did not open. The address is on your clipboard." The log (Ctrl +B D l ) keeps what the opener wrote to stderr.
If TUIOS cannot start the opener, the dock names the command and TUIOS copies the URL. If `link_opener` does not parse, the dock tells you to change it.
## Which machine opens the link
* A local client opens the link on your machine.
* Under `ssh`, TUIOS does not use the system opener, because it would open the browser on the remote machine. TUIOS puts the URL on your clipboard. Set `$BROWSER` or `link_opener` to a command that forwards the URL, if you have one.
* A remote client (`tuios ssh`, the web client) puts the URL on your clipboard.
* A Linux machine with no `DISPLAY` and no `WAYLAND_DISPLAY` has no desktop. TUIOS puts the URL on your clipboard.
In all these cases, TUIOS also sends each link to your terminal as OSC 8. Use the link click of your terminal to open it on your machine.
## OSC 8 to your terminal
TUIOS draws each pane into its own frame, so your terminal sees the output of TUIOS and not the output of the program. TUIOS writes every link in the focused pane as OSC 8:
* An OSC 8 link keeps its URI and its `id=`.
* A bare URL gets an `id=` from TUIOS, so a URL that wraps to the next row is one link.
Your terminal can then show and open the real target. In a pane that is not focused, TUIOS writes the OSC 8 links of the program, and your terminal finds the bare URLs itself.
## Safety
* Only `http`, `https`, `mailto`, `ftp` and `ftps` links go to the opener. TUIOS copies other links, such as `javascript:`, `data:` or the scheme of an application.
* A `file://` link opens in an editor pane only when the file is on this machine. A folder opens in the rail's files section. A `file://` link with the name of another host does not open.
* TUIOS refuses an address with control characters or spaces.
* TUIOS does not ask before it opens a link. The hover label and the scheme list protect you instead.
## Terminals
A terminal sends a click to TUIOS only when the terminal does not use the click itself. Most terminals keep Shift +click for their own selection when a program reads the mouse. That is why Ctrl +click is the main click.
| Terminal | Shift +click gets to TUIOS | Ctrl +click gets to TUIOS | The terminal opens OSC 8 links |
| ---------------- | --------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------- |
| kitty | No. kitty opens the URL under the pointer | Yes | Yes, with Shift +click or Ctrl +Shift +click |
| Ghostty | No. Ghostty extends its selection | Yes. On a link, Ghostty opens it | Yes |
| WezTerm | No. Shift bypasses mouse reporting | Yes | Yes |
| Alacritty | No. Alacritty opens a hinted URL | Yes | Yes, with Shift +click |
| foot | No. Shift bypasses mouse reporting | Yes | Yes, in URL mode (Ctrl +Shift +O ) |
| iTerm2 | Yes | Yes | Yes |
| Windows Terminal | No. Shift bypasses mouse mode | Yes | Yes, with Ctrl +click when it gets the click |
| xterm | No. Shift bypasses mouse reporting | Yes | No |
On macOS, Ctrl +click opens a link in Ghostty, kitty, WezTerm and Terminal.app. Cmd +click does not open a link in any of them while TUIOS reads the mouse.
A program can ask Ghostty for Shift +click with `XTSHIFTESCAPE`, or you can set `mouse-shift-capture = true`. TUIOS does not ask, because that takes Shift selection away from you.
## Related
# TUIOS vs Others
URL: https://tuios.dev/docs/comparison
> How TUIOS compares with tmux, zellij, herdr, GNU screen and the splits in WezTerm, kitty and Ghostty, and when to pick one of them instead.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
TUIOS is a terminal multiplexer that works like a window manager. Shells run in windows that float or tile. A daemon keeps them running when you detach. TUIOS also tracks the coding agents running in its panes. It runs inside any terminal, over SSH, or in a browser.
TUIOS is not a terminal emulator: it does not draw fonts or open GUI windows. It is also young. The first commit is from September 2025, and the latest release is v0.8.5 (October 2026). This page describes that release.
Each fact about another tool comes from that project's own docs, changelog, manual or source, as of September 2026. The [Sources](#sources) list links them. The versions checked are tmux 3.7c (3.8 is in release candidate), zellij 0.45.1, herdr 0.9.1, GNU screen 5.0.2, WezTerm 20240203, kitty 0.49.0 and Ghostty 1.3.1. The TUIOS facts are checked against the TUIOS source and a current build. There are no speed numbers here, because no side-by-side benchmark has been run.
## At a glance
This table covers the tools that run inside a terminal. Terminals with their own splits, such as WezTerm, kitty and Ghostty, are a different kind of tool and have [their own section](#terminals-with-built-in-splits).
| | TUIOS | tmux | zellij | herdr | GNU screen |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------- |
| Language, licence | Go, MIT | C, ISC | Rust, MIT | Rust, Apache 2.0 | C, GPL 3 |
| Binary, macOS arm64 | 23 MB, plus `tuios-web` | 1 MB, with shared libraries | 41 MB | 22 MB | Not measured |
| Layout | Floating windows, or tiled: BSP, master-stack, scrolling columns | Tiled panes with preset layouts. Floating panes since 3.7 | Tiled, floating and stacked panes | Tiled splits, with zoom | Split regions |
| Grouping | 9 workspaces per session | Windows in a session | Tabs in a session | Workspaces, then tabs | Windows in a session |
| Mouse | Always on. Drag, resize, focus | Off by default in 3.7, on in 3.8 | On by default | On by default. Click, drag, menus | Focus a region (`mousetrack`) |
| Detach and reattach | Yes, a daemon | Yes, a server | Yes, a server | Yes, a server | Yes |
| After a restart | Layout, working directories, pane history and agent conversations | Nothing built in. The tmux-resurrect plugin | Layout and commands. Optional scrollback | Layout, working directories and supported agent conversations. Optional screen history | Nothing |
| Several clients | Yes, all can type. Sized to the smallest client | Yes. Read-only clients and per-user access | Yes. A cursor per user, read-only attach | Yes. Each client can view its own tab | Yes. Per-user access lists, read-only possible |
| Remote machines | Built-in SSH server. Sessions and panes on other hosts in your own client | ssh in and attach | ssh in, or attach to a zellij web server over HTTPS | ssh in, `--remote`, or several saved machines in one window | ssh in. Also serial and telnet windows |
| Browser client | `tuios-web`, a separate binary. No login, read-only flag | None | Built in. Login tokens, read-only tokens | None | None |
| Scripting | CLI verbs, JSON protocol, blocking waits, event stream, tapes, hooks | tmux commands, control mode, hooks, formats | `zellij action`, `subscribe`, JSON pane lists, plugin pipes | CLI and JSON socket API, output and agent waits, event subscriptions | `screen -X` and `-Q` |
| Coding agents | States, alerts, waits, an Inbox that answers approvals, review with notes, queued prompts, messages between agents, worktrees, fan-out | None | None | States, alerts, prompt and wait, worktrees, conversation restore | None |
| Images | Kitty graphics and sixel, passed through. Experimental | Sixel when built with it. Others by passthrough | Sixel and Kitty graphics | Kitty graphics | None |
| Config | TOML, live `set-config`, settings page | `tmux.conf`, written as tmux commands | KDL | TOML | `.screenrc` |
| Extending | Hooks, dock components, control protocol. No plugins | No plugin system. TPM, a third-party manager | WebAssembly plugins | Plugins: any executable with a manifest | None |
| Platforms | Linux, macOS, Windows, FreeBSD, OpenBSD | Linux, macOS, the BSDs, Solaris | Linux, macOS, Windows | Linux, macOS, Windows | Unix-like systems |
Binary sizes are for macOS on Apple silicon: tmux 3.7c from Homebrew, the zellij 0.45.1 and herdr 0.9.1 release binaries, and TUIOS built from main with `-ldflags "-s -w"`.
## tmux
tmux is the default choice, and for good reason. It is packaged almost everywhere and is often already installed. The binary is about 1 MB. Its control mode (`tmux -C`) lets terminals such as iTerm2 show tmux windows as native tabs and windows.
**Where tmux is stronger**
* It runs on servers you do not control, with nothing to install.
* Read-only clients (`attach -r`) and per-user access (`server-access`) are built in. TUIOS has neither in its terminal client.
* It is mature and widely documented, with a large body of shared configs and scripts.
* 3.8 is moving fast: floating panes that move and resize with commands, modal panes, JSON layout strings, and `wait-for -E` to wait on hook events.
**Where TUIOS is stronger**
* Floating windows are the core model, not an add-on. In 3.7 tmux floating panes move and resize only with the mouse. TUIOS also has three tiling modes and zoom.
* Layout and working directories come back after a reboot with no plugin.
* Programs get a JSON protocol with `list-verbs` for discovery, blocking `wait-for` on output, exit, idle or agent state, and an event stream.
* Agent state, alerts and messages between agents. tmux has nothing for agents.
* A browser client and a built-in SSH server.
**Pick tmux** if you work on many machines you do not own, need read-only sharing, or use a terminal built around control mode.
## zellij
zellij is the modern, friendly multiplexer. It shows the available keys on screen, extends through WebAssembly plugins, and ships a web client.
**Where zellij is stronger**
* It teaches itself: the status bar shows the keys for the current mode.
* Plugins are WebAssembly. Rust is the officially supported language, and community projects cover others.
* The web client (`zellij web`) has login tokens, read-only tokens, and requires HTTPS on any address other than `127.0.0.1`. `tuios-web` has no login and needs an authenticating proxy in front.
* Resurrection brings back the commands in each pane, behind a "Press ENTER to run" prompt, and can save the viewport and scrollback. TUIOS brings back a fresh shell under the pane's saved history, and runs no command again.
* Read-only attach and a separate cursor per user.
* It supports sixel, and Kitty graphics as of 0.45. TUIOS passes images through, and its support is experimental.
**Where TUIOS is stronger**
* Overlapping, draggable windows and three tiling modes. zellij floating panes sit above a tiled layout.
* Agent tracking, and waits on agent state.
* Blocking waits from the CLI on a pattern in the output, on idle and on exit. zellij can block until a pane's command exits, and `zellij subscribe` streams a pane's output for a script to watch.
* Sessions on other machines in your own client, without a web server.
**Pick zellij** if you want discoverable keys, plugins, or browser access with its own login.
## herdr
herdr is the closest tool to TUIOS. It is a multiplexer built for coding agents, released in March 2026. It is more widely used, with about 40k [GitHub stars](https://github.com/herdrdev/herdr) against 3.7k for TUIOS, and it releases more often: 0.9.0 and 0.9.1 both shipped in September 2026. Several of the agent screen rules in TUIOS are adapted from herdr's manifests, which are Apache 2.0.
**Where herdr is stronger**
* After a server restart it can replay recent screen history if you turn that on. TUIOS restores the layout, a fresh shell, and supported agents' conversations, but not the screen.
* It adds new agents often: 0.9.1 added Letta Code and 0.9.0 added Muse.
* Plugins: a directory with a `herdr-plugin.toml` manifest and commands in any language, plus a marketplace.
* One window holds local work and several saved SSH machines, with one combined agent list.
* Each attached client can look at a different tab at its own size. TUIOS draws a session at the size of the smallest client.
* `herdr update --handoff` can update the server while panes keep running. This is experimental.
**Where TUIOS is stronger**
* Floating windows, and BSP, master-stack and scrolling-column layouts. herdr has splits and zoom.
* A browser client and a built-in SSH server. herdr uses an SSH client on phones instead.
* Tape scripts to drive the UI, and tape recording.
* Sixel images as well as Kitty graphics.
* Mailboxes and questions between agents, and a stash for files agents share.
* Agents that report to herdr, such as Crush, report to TUIOS too, with no install step.
* Tools built for herdr's socket API, such as Collie's herdr adapter, work with TUIOS too. See [herdr Compatibility](/docs/herdr-compatibility).
* An Inbox that answers held approvals, with risk rules that make a dangerous allow take two presses, and a review of what an agent changed, with notes sent back to it.
**Pick herdr** if agents are most of what you run, splits are enough, and you want plugins or screen history after a restart.
### tmux-based agent managers
Tools such as [claude-squad](https://github.com/smtg-ai/claude-squad) (1.0.20) are not multiplexers. claude-squad runs each agent in its own tmux session and git worktree, and gives you a list of tasks to review, check out and push. It needs tmux and `gh`. Pick it if you want a task list over tmux. Pick TUIOS or herdr if you want to see the agents' panes side by side.
## GNU screen
screen is the oldest of these, and is still maintained. 5.0 added truecolor and rewrote authentication, and 5.0.2 shipped in July 2026.
**Where screen is stronger**
* It is on many old systems where nothing else is.
* A window can open a serial line (`screen /dev/ttyUSB0 115200`) or a telnet connection directly.
* Multiuser mode has per-user access lists, including read-only access.
**Where TUIOS is stronger**: almost everything else on this page. screen splits into regions, but has no floating windows, no restore after a restart, no JSON interface and no image support.
**Pick screen** for serial consoles and old machines.
## Terminals with built-in splits
WezTerm, kitty and Ghostty are terminal emulators. They draw their own windows, and their splits and tabs are part of that window. TUIOS, tmux, zellij and herdr run inside a terminal instead, so they work over SSH and in any terminal. You can use both: TUIOS inside kitty, WezTerm or Ghostty gets Kitty graphics from the host.
| | WezTerm | kitty | Ghostty |
| --------------- | ------------------------------------------------------- | ------------------------------------------------------------- | ----------------------- |
| Version checked | 20240203 (latest stable, nightly builds since) | 0.49.0 | 1.3.1 |
| Language | Rust | C, Python and Go | Zig |
| Splits | Panes, tabs, windows, workspaces | Layouts: splits, tall, fat, grid, stack, horizontal, vertical | Native tabs and splits |
| Detach | Yes, through a mux server | No | No |
| Remote | SSH and TLS domains to a WezTerm mux on the remote host | `kitten ssh`. No remote persistence | No |
| Scripting | `wezterm cli`, Lua events | `kitten @` remote control, JSON protocol | AppleScript, macOS only |
| Images | iTerm2, Kitty graphics, sixel | Kitty graphics (kitty created it) | Kitty graphics |
| Config | Lua | `kitty.conf` | Plain text config file |
| Platforms | Linux, macOS, Windows, FreeBSD, NetBSD | Linux, macOS, BSDs | Linux, macOS |
### WezTerm
WezTerm is the one terminal here that is also a real multiplexer. A unix domain runs a mux server that keeps panes alive after the GUI closes. An SSH or TLS domain connects to a WezTerm mux running on another machine, and draws its panes as native tabs. `wezterm cli` splits panes, sends text, reads pane text and lists panes as JSON. The config is a Lua program.
* **Stronger than TUIOS**: native GUI rendering, fonts and ligatures, and remote panes with native scrollback and mouse.
* **TUIOS is stronger**: it runs in any terminal and over plain SSH, has floating windows and agent tracking, and needs nothing but `tuios` on the remote side. WezTerm needs a matching WezTerm on the remote host. The last stable WezTerm release is from February 2024.
* **Pick WezTerm** if you want one program for terminal and multiplexer and you control both ends.
### kitty
kitty tiles its own windows in seven layouts and has a remote control protocol (`kitten @`) that can open windows, send text and read their contents. Session files set up tabs, windows and layouts, and since 0.43 kitty can save the current layout as a session file. kitty's FAQ says the one thing it lacks from tmux is remote persistence: close kitty and its shells end.
* **Stronger than TUIOS**: GPU rendering, native windows, and it created the Kitty graphics and keyboard protocols that other terminals now use.
* **TUIOS is stronger**: detach and reattach, sessions that survive closing the terminal, remote hosts, floating windows and agent tracking.
* **Pick kitty** if you work on one machine and do not need sessions to outlive the terminal.
### Ghostty
Ghostty has native tabs and splits, and on macOS an AppleScript dictionary for windows, tabs and input. It has no detach and no multiplexer of its own. Pick it as a fast native terminal, and run TUIOS or tmux inside it when you need sessions that persist.
## Coming from tmux
TUIOS uses the same leader key as tmux, and several prefix keys match:
| Action | tmux | TUIOS |
| -------------------- | ------------------------------------------ | ------------------------------------------ |
| New window | Ctrl +B c | Ctrl +B c |
| Detach | Ctrl +B d | Ctrl +B d |
| Copy mode | Ctrl +B \[ | Ctrl +B \[ |
| Zoom | Ctrl +B z | Ctrl +B z |
| Split left and right | Ctrl +B % | Ctrl +B \| |
| Split top and bottom | Ctrl +B " | Ctrl +B - |
Many CLI verbs share tmux's names too: `new-window`, `split-window`, `send-keys`, `capture-pane`, `list-windows`, `kill-session`.
The difference to learn is the two modes. Keys typed in window management mode move windows. Keys typed in terminal mode go to the shell. i enters terminal mode and Alt +Esc leaves it. The prefix key works in both. [Keybindings](/docs/keybindings) lists everything, and every key can be rebound.
**Try it live:** [Coming from tmux](/learn?t=from-tmux) and [Coming from zellij](/learn?t=from-zellij) walk you through the switch in your browser, with the keys you already know side by side.
## Sources
**tmux**
* [tmux 3.7c release](https://github.com/tmux/tmux/releases/tag/3.7c) and [3.8-rc2](https://github.com/tmux/tmux/releases/tag/3.8-rc2)
* [CHANGES](https://github.com/tmux/tmux/blob/master/CHANGES): floating panes in 3.7 and 3.8, mouse on by default in 3.8, `wait-for -E`, sixel fixes
* [Manual page source](https://github.com/tmux/tmux/blob/master/tmux.1): `attach -r`, `server-access`, `mouse`, `allow-passthrough`, control mode
* [README](https://github.com/tmux/tmux/blob/master/README): supported platforms
* [configure.ac](https://github.com/tmux/tmux/blob/master/configure.ac): sixel is off unless built with `--enable-sixel`
* [tmux-resurrect](https://github.com/tmux-plugins/tmux-resurrect) and [TPM](https://github.com/tmux-plugins/tpm), both third party
* [iTerm2 tmux integration](https://iterm2.com/documentation-tmux-integration.html): tmux windows as native tabs and windows
**zellij**
* [CHANGELOG](https://github.com/zellij-org/zellij/blob/main/CHANGELOG.md): web client (0.43.0), native Windows, read-only attach, `subscribe` and `list-panes` (0.44.0), Kitty graphics (0.45.0)
* [Session resurrection](https://zellij.dev/documentation/session-resurrection.html)
* [Web client](https://zellij.dev/documentation/web-client.html)
* [CLI actions](https://zellij.dev/documentation/cli-actions.html)
* [Plugins](https://zellij.dev/documentation/plugins.html)
* [0.45.1 release](https://github.com/zellij-org/zellij/releases/tag/v0.45.1)
**herdr**
* [Concepts](https://herdr.dev/docs/concepts/): workspaces, tabs, panes, mouse, clients
* [Session state and restore](https://herdr.dev/docs/session-state/): restart, screen history, agent restore, live handoff
* [Supported agents](https://herdr.dev/docs/agents/) and [agent automation](https://herdr.dev/docs/agent-automation/)
* [Plugins](https://herdr.dev/docs/plugins/), [configuration](https://herdr.dev/docs/configuration/) (TOML, Kitty graphics), [connecting machines](https://herdr.dev/docs/connecting-machines/), [install](https://herdr.dev/docs/install/)
* [CHANGELOG](https://github.com/herdrdev/herdr/blob/master/CHANGELOG.md) and [0.9.1 release](https://github.com/herdrdev/herdr/releases/tag/v0.9.1)
* [claude-squad](https://github.com/smtg-ai/claude-squad)
**GNU screen**
* [Manual](https://www.gnu.org/software/screen/manual/screen.html): `-X`, `-Q`, `split`, `mousetrack`, `aclchg`, serial and telnet windows
* [Release tarballs](https://ftp.gnu.org/gnu/screen/): 5.0.2, July 2026
* [5.0.0 announcement](https://lists.gnu.org/archive/html/info-gnu/2024-08/msg00004.html)
**Terminals**
* WezTerm: [multiplexing](https://wezterm.org/multiplexing.html), [features](https://wezterm.org/features.html), [CLI](https://wezterm.org/cli/cli/index.html), [releases](https://github.com/wezterm/wezterm/releases)
* kitty: [layouts](https://sw.kovidgoyal.net/kitty/layouts/), [remote control](https://sw.kovidgoyal.net/kitty/remote-control/), [sessions](https://sw.kovidgoyal.net/kitty/sessions/), [FAQ](https://sw.kovidgoyal.net/kitty/faq/), [changelog](https://sw.kovidgoyal.net/kitty/changelog/)
* Ghostty: [features](https://ghostty.org/docs/features), [1.3.1 release notes](https://ghostty.org/docs/install/release-notes/1-3-1)
## Related
# Layout Modes
URL: https://tuios.dev/docs/layout-modes
> Floating, BSP, master-stack, scrolling columns and zoom, and how to switch between them.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
Tiling is a switch: on or off. When it is on, the panes follow one of three layouts. When it is off, the panes float and you place them yourself. Zoom sits on top of all of them and shows one pane large for a moment.
**Try it live:** the [Layouts track](/learn?t=layouts) lets you switch between them in your browser.
| Mode | What it does |
| ------------ | ------------------------------------------------------------------------------------------------------------------ |
| BSP | A tree of splits. Any number of panes, each resizable on its own. The default. See [BSP Tiling](/docs/bsp-tiling). |
| Master-stack | One or more master panes on one side, and the other panes in a stack beside them. |
| Scrolling | Niri-style columns on a strip wider than the screen. The screen scrolls instead of every pane shrinking. |
| Floating | Tiling off. Panes can overlap, and you move, snap and resize them. |
Open, close and focus panes below to see how each layout places them:
## Switching modes
Turn tiling on or off:
| Key | Where |
| --------------------------------------------- | ---------------------- |
| t | Window management mode |
| Ctrl +B Space | Any mode |
Pick the layout from the command palette (Ctrl +P ):
* `Layout: BSP tiling`
* `Layout: master-stack`
* `Layout: scrolling (niri-style)`
* `Layout: disable tiling`
Picking a layout also turns tiling on. There is no default key for a layout. Bind one yourself if you switch often.
The layout mode belongs to the session. It is kept in the daemon's session state, so a scrolling session is still scrolling after you detach and reattach, and turning tiling off does not forget which layout you had. The layout itself (the BSP tree, the scrolling columns) is kept per workspace, so each of the nine workspaces has its own.
`startup.layout` sets the mode a **new** session starts in: `bsp`, `master-stack` or `scrolling`. `startup.tiled` (default `true`) decides whether a new session starts tiled at all. A session that already exists keeps its own mode.
```toml
[startup]
layout = "scrolling"
tiled = true
```
From a script, `tuios set-layout` turns tiling on or off and tidies the splits:
```bash
tuios set-layout --tiling true
tuios set-layout --equalize
tuios set-layout --rotate
```
It needs an attached client.
## Master-stack
The master-stack layout puts the first panes of a workspace in a master area and the other panes in a stack beside it. The master area is for the pane you work in most, such as an editor. The layout is close to dwm's tile layout and Hyprland's master layout.
| Option | Default | Meaning |
| ---------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `appearance.master_position` | `left` | The side the masters take: `left`, `right`, `top`, `bottom` or `center` |
| `appearance.master_count` | `1` | How many panes are masters (1 to 9). The masters share the master area in equal parts |
| `appearance.master_ratio` | `50` | The share of the screen the master area takes, as a percent (10 to 90) |
| `appearance.master_grid` | `true` | With one master on the left, show four or more panes as an equal grid. `false` keeps the master and the stack at every pane count |
With `center`, the first stack pane goes to the right, the next to the left, and so on. With only one stack pane, the master goes to the left. Two panes on a screen that is taller than it is wide stack one above the other.
Each workspace starts with these options. The keys below change the workspace on screen, and the workspace keeps the change. In a daemon session every attached client shows the same layout.
| Key | Action | What it does |
| --------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------ |
| Ctrl +B L o | `cycle_master_position` | Move the masters to the next side |
| Ctrl +B L Enter | `swap_with_master` | Swap the focused pane with the master. On the master, swap with the first stack pane |
| Ctrl +B L m | `focus_master` | Focus the master |
| Ctrl +B L i | `add_master` | Make one more pane a master |
| Ctrl +B L d | `remove_master` | Make one pane fewer a master |
| \< / > (window mode) | `resize_master_shrink`, `resize_master_grow` | Narrow or widen the master area |
| = (window mode), Ctrl +B = | `equalize_splits`, `prefix_equalize_splits` | Put the master back at `master_ratio` and give every other pane an equal share |
```bash
tuios set-layout --master-position center
tuios set-layout --masters 2
```
### How a resize is kept
A resize goes into the workspace's splits: the master ratio, and the share of every other pane. That covers each master, each stack pane, and each row and pane of the grid. The keyboard resizes, the [percentage resizes](#gaps-and-sizing) and a mouse drag on a divider all do this, so the resize survives a retile. A terminal resize is a retile too. When a pane opens or closes, the part of the layout it changes goes back to equal shares.
With the master in the center, a resize moves one divider, and the next retile puts the master back in the middle at its new width. The splits are session state, so every client attached to the session lays the workspace out the same way.
In every layout, a workspace keeps its splits when the session's size changes while the workspace is off screen. When you switch back, the panes fit the new size.
## Scrolling
The scrolling layout is modeled on the [niri](https://github.com/niri-wm/niri) window manager. Each pane is a **column** on a horizontal strip, and the screen is a window onto that strip. A new pane gets a new column right after the focused one, and the strip scrolls to show it. The other columns keep their width.
A column holds one pane by default. It can hold several stacked vertically, which split its height evenly. Closing the last pane in a column removes the column and moves focus to the column on its left.
### Moving around
| Input | Action |
| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- |
| Alt +Left / Alt +Right (terminal mode) | Focus the column to the left or right |
| h / l (window mode) | Focus the column to the left or right |
| j / k (window mode), Alt +Down / Alt +Up (terminal mode) | Focus the pane below or above in a column of stacked panes |
| Alt +P / Alt +N | Focus the column to the left or right |
| H / L , or Ctrl +Left / Ctrl +Right (window mode) | Move the focused column left or right along the strip |
| \< / > (window mode) | Shrink or grow the focused column by four cells |
| Alt or Shift + mouse wheel | Scroll the strip sideways |
Moving focus with the keyboard scrolls the strip by the least amount that shows the whole focused column, with four cells of its neighbour peeking in beside it. It does not center the column: when all of it and that margin are already on screen, the strip stays put. Clicking a column that is partly off screen brings all of it on screen (`appearance.niri_click_reveals`). With `focus_follows_mouse` on, hovering does the same (`appearance.niri_hover_reveals`).
The plain mouse wheel belongs to the pane under the pointer. The strip only scrolls while Alt or Shift is held. This stops a trackpad's sideways drift from sliding the strip while you scroll a pane's history.
### Column commands
These have palette entries and no default key. The action names can be bound in `[keybindings]`.
| Palette entry | Action | What it does |
| ------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `Scroll: cycle column width` | `scroll_cycle_width` | Cycle the focused column through 33%, 50%, 55%, 67% and 90% of the screen, and 100% when `scroll_column_max` is `100` |
| `Scroll: maximize column width` | `scroll_maximize` | Widen the focused column to `scroll_column_max`, the widest the strip gives it |
| `Scroll: move window into the column below` | `scroll_consume` | Pull the pane from the next column into the focused one, stacked below |
| `Scroll: move window out to its own column` | `scroll_expel` | Move the focused pane out of its column into a new column to the right. Focus follows it, so expel undoes a consume |
| none | `scroll_focus_left`, `scroll_focus_right` | Focus the column to the left or right |
| none | `scroll_move_left`, `scroll_move_right` | Move the focused column left or right |
A column's width is a share of the screen until you resize it with \< or > , which pins it to a cell count. Each press changes the width by four cells, from 20 cells up to `scroll_column_max`. Cycling the width unpins it again.
### Settings
| Key | Default | Meaning |
| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- |
| `appearance.scroll_column_width` | `55` | Width of a new column, as a percent of the screen (20 to 100) |
| `appearance.scroll_column_max` | `90` | The widest a column may be set, as a percent. `100` lets a column fill the screen, and the next column no longer peeks in |
| `appearance.niri_scroll_cells` | `8` | Cells the strip moves per wheel event (1 to 200) |
| `appearance.niri_reverse_scroll` | `false` | Reverse the wheel direction on the strip |
| `appearance.niri_click_reveals` | `true` | Clicking a partly hidden column scrolls it fully into view |
| `appearance.niri_hover_reveals` | `true` | With focus-follows-mouse, hovering does the same |
The default column width of 55% is over half on purpose: two columns never quite fit, so the strip reads as something you scroll.
The scroll offset and the columns are part of the session. Every client attached to the session sees the same columns at the same offset, and the columns come back after a session switch or a reattach. [A session switch forgot my column widths](/blog/a-session-switch-forgot-my-column-widths) tells how the columns became session state.
**Shared borders:** with `shared_borders` on, columns draw no border of their own. One divider stands between two columns. Drag it to set the width of the column on its left. Windows stacked in one column have a divider between them too, but they always share the column's height evenly.
**Limits:** the strip always animates when it slides, even with animations off, because a jump is hard to follow.
## Floating
With tiling off, panes float. Drag a pane by its title bar to move it, or Alt +drag anywhere on it. Right-drag resizes it from the nearest corner. In window management mode:
| Key | Action |
| ------------------------------------------------------------------- | ------------------------------ |
| h / l | Snap to the left or right half |
| f | Snap to full screen |
| u | Unsnap |
| Ctrl +B L 1 -4 | Snap to a corner |
Dragging a pane to a screen edge or corner snaps it there too. See [Keybindings](/docs/keybindings).
The palette entry `Toggle floating` lifts only the focused pane out of the tiling and lets it float over the others. Run it again to put the pane back.
For a short-lived floating pane that closes when its program exits, use a popup:
```bash
tuios popup --width 90% --height 80% -- htop
```
## Zoom
Zoom shows the focused pane over the layout. By default it takes 95% of the screen, so the edges of the layout stay visible around it. The layout underneath is not changed.
| Key | Where |
| ----------------------------------------- | ---------------------- |
| z | Window management mode |
| Ctrl +B z | Any mode |
On a tiled pane, the third title bar control toggles zoom too (`appearance.window_button_zoom`).
| Key | Default | Meaning |
| ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `appearance.zoom_size` | `95` | Share of the screen a zoomed pane takes, as a percent (50 to 100). `100` is fullscreen |
| `appearance.zoom_max_width` | `0` | Width cap in cells. `0` means no cap. Useful on an ultrawide display |
| `appearance.zoom_borderless` | `false` | Show the zoomed pane on the whole pane region, with no border and no title bar. The program in the pane gets the full size. The dock and the rail stay on the screen. With it on, `zoom_size` and `zoom_max_width` have no effect |
| `appearance.zoom_follows_focus` | `true` | Moving focus while zoomed zooms the pane you moved to |
| `appearance.zoom_animation` | `true` | Slide between the tile and the zoomed box instead of switching in one frame |
## Gaps and sizing
`appearance.gap` keeps empty cells between neighbouring panes, in every tiling mode (0 to 8, default `0`). It is an inner gap only: there is no margin at the screen edge.
The layout prefix sizes the focused pane to a percentage:
| Key | Action |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| Ctrl +B L 5 -9 | Width to 50%, 60%, 70%, 80% or 90% of the content area |
| Ctrl +B L Shift +5 -9 | Height to 50% through 90% |
Each percentage is its own action (`resize_width_50` to `resize_width_90`, `resize_height_50` to `resize_height_90`), so any of them can be rebound in `[keybindings.layout_prefix]`. The layout still applies its minimum pane size and gaps. In the scrolling layout only the width actions do anything.
`gap`, `master_ratio`, `scroll_column_width` and `shared_borders` decide how many cells each pane gets, so they are shared across the session: change one on any client and every attached client follows. Purely visual settings such as the theme, border style and glyphs stay per client.
## Finding a pane
The palette entry `All windows` opens a searchable list of every pane on every workspace, with a short preview of each. It has no default key.
| Key | Action |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| Type | Filter by title, workspace number, preview text or directory |
| Up / Down , Ctrl +P / Ctrl +N | Move the selection |
| Enter | Go to the pane: switch workspace, restore it if minimized, focus it |
| Ctrl +U | Clear the query |
| Esc | Close |
The preview is the first three non-empty lines of the pane, taken when the list opens. The directory is only read on Linux.
## Typing into several panes
Multifocus sends your typing to several panes at once, like tmux's `synchronize-panes`. Use it to run the same command on several hosts.
| Input | Action |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Ctrl +Shift +click on a pane | Add or remove it from the set |
| Palette: `Toggle multifocus`, or action `toggle_multifocus_active` | Add or remove the focused pane |
| Palette: `Toggle multifocus on all panes`, or action `toggle_multifocus_all` | Add every visible pane on the workspace. When all of them are in the set, remove them. Panes on other workspaces stay in the set |
| Palette: `Clear multifocus` | Empty the set |
The two actions have no default key. This example binds them to Ctrl +B y and Ctrl +B Y :
```toml
[keybindings.prefix_mode]
toggle_multifocus_active = ["y"]
toggle_multifocus_all = ["Y"]
```
To open many panes with multifocus on in one step, use [`tuios xpanes`](/docs/cli-reference#xpanes). It opens one pane per item on a new workspace, tiled, and puts all of them in the set:
```bash
tuios xpanes --ssh web1 web2 web3
```
From a script, `tuios run-command SetMultifocus ...` sets the set to exactly these windows. With no window, it empties the set.
Panes in the set get a distinct border colour. A notification shows the size of the set when you change it. With `appearance.dim_unfocused` on, the panes in the set are not dimmed. Set `appearance.dim_multifocus = true` to dim them like the other panes.
While the set is not empty and you are in terminal mode, every key that goes to the focused pane also goes to each pane in the set. A paste goes to each pane in the set too. A pane gets a bracketed paste only when its program turned bracketed paste on. A paste into a scrolled pane first returns it to live output, as a typed key does. In copy mode, TUIOS drops the paste. Keys that TUIOS handles itself, such as the leader key, are not copied.
Limits:
* Nothing is copied in window mode.
* A key goes only to the panes of the set that are on the current workspace and are not minimized. The other panes stay in the set and get keys again when they are on screen.
* The set belongs to your client. Other clients do not share it. It does not survive a detach, and a session switch clears it.
* The set follows panes, not positions. A swap keeps the same panes in the set. A pane that closes leaves the set.
* A popup cannot join the set.
### Multi copy mode
Multi copy mode is copy mode on every pane of the multifocus set at the same time. Use it to search the output of many panes at once and copy the same part from each pane. Each pane has its own cursor and its own selection. Each key moves all of them together.
To start it, add two or more panes to the multifocus set and focus one of them. Then press Ctrl +B \[ , the copy mode key. When the focused pane is not in the set, the key starts copy mode on one pane. When multifocus is on, the palette entry `Multi copy mode` also starts it, and the prefix menu shows `Multi copy (N)` on the \[ line.
The dock shows `MULTI 3`, where 3 is the number of panes. After a search, it shows `MULTI 2/3`: the search found text in 2 of the 3 panes.
| Key | Action |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| h j k l , w b e , g G and the other [copy mode](/docs/keybindings#copy-mode) motions | Move the cursor in every pane |
| / or ? , then text, then Enter | Search every pane. Each pane goes to its own match |
| n / N | Go to the next or previous match in each pane |
| v / V | Start a character or line selection in every pane that has a match |
| Esc | End the selection. A second Esc , or q , leaves copy mode in every pane |
| y | Copy the selection of each pane to the clipboard, as one block |
| Y | Save the selection of each pane to a file |
| Tab | Change the format: plain, markdown, json |
| Shift +Up / Shift +Down | Scroll every pane one line |
A pane where the search finds no text loses the multifocus border colour. With a theme, the pane is also dimmed. It keeps its cursor. It takes no motions or selections until a later search finds text in it. Ctrl +L clears the search.
y copies only the selected text of each pane. A pane without a selection adds nothing. The panes are in window order.
| Format | What it writes |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `plain` | The selected lines of each pane, one pane after the other. Each pane ends with a newline. Nothing is added. Use it when each line already names its host |
| `markdown` | For each pane, a `## Pane N: title` heading and the lines in a fenced code block |
| `json` | An array with one object for each pane: `{"pane": N, "window_id": "...", "title": "...", "lines": ["..."]}`. The pane number is the index that `tuios list-windows --json` shows |
The format starts as `appearance.selection.multi_format` (default `plain`). Tab changes it until you leave multi copy mode.
`appearance.selection.copy_command` and a `[[keybindings.copy_pipe]]` key also work in multi copy mode. The command gets the text that y copies, in the current format, one time for all panes. See [Pipe a yank through a command](/docs/keybindings#pipe-a-yank-through-a-command).
Y opens a prompt at the bottom of the focused pane. The default path is `~/tuios-copy--.`, with `txt`, `md` or `json` for the format. Type or paste a path. Press Enter to save, or Esc to cancel.
* A relative path starts in the directory of the focused pane, when its shell reports one. Otherwise it starts in your home directory. The line above the prompt shows the full path.
* TUIOS does not write over a file that exists. It tells you when the folder does not exist or when the path is a folder.
* The dock shows the path of the file it wrote. In the web client and over SSH, the file is on the machine that runs TUIOS, and the dock names that machine.
A pane title can come from a remote shell. In the markdown and json formats, TUIOS removes control, bidirectional and zero-width characters from the title and cuts it to 200 characters.
Limits:
* Soft-wrapped lines are joined by a width estimate, as in copy mode on one pane.
* Multi copy mode ends when focus moves to a pane that is not in it.
* A pane that you minimize or move to another workspace leaves multi copy mode.
* The clipboard gets the copy through OSC 52. Some terminals cut a long OSC 52 copy. Above 100 KB, the dock tells you. Use Y to save a large copy to a file.
## Related
# BSP Tiling
URL: https://tuios.dev/docs/bsp-tiling
> Tile windows with binary space partitioning, and switch to the master-stack or scrolling layouts.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
When tiling is on, TUIOS arranges windows for you so they never overlap. The default layout is **BSP** (binary space partitioning): every new window splits an existing one in two, so the screen becomes a tree of splits you can reshape with the keyboard or the mouse.
All keys on this page are for window management mode unless they start with Ctrl +B , which works in both modes. On macOS, read Alt as Opt .
## Turning tiling on and off
New sessions start tiled. To switch it:
| Key | Where |
| ------------------------------------------------------ | ---------------------- |
| t | Window management mode |
| Ctrl +B Space | Any mode |
| Ctrl +B t t | The window prefix menu |
Tiling is on or off for the whole session. When it is off, windows float: you move and resize them freely, and h , l and f snap the focused window to the left half, the right half or the full screen.
While tiling is on, the dock shows the tiling icon followed by `V` or `H`: the direction the **next** split will take.
`startup.tiled = false` in the [configuration](/docs/configuration) makes new sessions start floating.
## Layout modes
Tiling uses one of three layouts:
| Layout | How windows are arranged |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **BSP** (default) | A tree of splits. Any number of windows, each resizable on its own. |
| **Master-stack** | One window: full screen. Two: side by side, or stacked on a tall screen. Three: a master window on the left and two stacked on the right. Four or more: a grid, two columns wide up to six windows and three beyond. |
| **Scrolling** | Niri-style columns on a strip wider than the screen. New windows add a column and the view scrolls, instead of every window shrinking. |
There is no default key for switching layouts. Open the command palette (Ctrl +P ) and run one of:
* `Layout: BSP tiling`
* `Layout: master-stack`
* `Layout: scrolling (niri-style)`
* `Layout: disable tiling`
Choosing a layout turns tiling on if it was off. The layout belongs to the session and survives detaching; turning tiling off and on again brings the same layout back. `startup.layout` sets the layout new sessions start with: `bsp`, `master-stack` or `scrolling`.
`Next layout` and `Previous layout` in the palette do something else: they cycle through your saved [layout templates](#layout-templates).
The rest of this page is about BSP. The scrolling layout's keys and commands are described in the repository's [LAYOUT\_MODES.md](https://github.com/Gaurav-Gosain/tuios/blob/main/docs/LAYOUT_MODES.md).
## How new windows are placed
A new window splits the focused one. Which way it splits depends on the tree's **insertion scheme**:
| Scheme | Rule |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `spiral` (default) | Alternate vertical and horizontal by the depth of the window being split, like bspwm. Repeatedly splitting the newest window spirals inward. |
| `longest_side` | Split across the longer side of the window. |
| `alternate` | Alternate vertical and horizontal by the total number of splits in the tree. |
| `smart_split` | Split vertically when the window is more than twice as wide as it is tall, horizontally when it is taller than wide, and otherwise by depth. |
A new workspace starts with the scheme in `appearance.tiling_scheme` (default: `spiral`). A workspace that already has windows keeps its own scheme. Changing the option does not move it.
Set the option in `config.toml`, on the settings page (Ctrl +B , ), or with `tuios set-config appearance.tiling_scheme `.
To change a workspace's scheme directly, run `Cycle tiling scheme` in the command palette, or one of the `Set tiling scheme: ...` rows. Their action names are `cycle_tiling_scheme` and `set_tiling_scheme_spiral`, `set_tiling_scheme_longest_side`, `set_tiling_scheme_alternate`, `set_tiling_scheme_smart_split`, in case you want to bind one. None of them ships with a default key.
A [layout template](#layout-templates) still saves its own scheme (`tiling_scheme`). Loading a template that carries one sets it too.
`Smart split` in the command palette splits the focused window once with the `smart_split` rule, whatever the tree's scheme. Its action name is `smart_split` if you want to bind it.
## Splitting
A split opens a new window beside the focused one.
| Key | Prefix form | Action |
| ------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------- |
| - | Ctrl +B - | Split horizontally: the new window goes below |
| \| or \\ | Ctrl +B \| | Split vertically: the new window goes to the right |
| R | Ctrl +B R | Rotate the split holding the focused window, so side by side becomes stacked and back |
| = | Ctrl +B = | Equalize: set every split back to half and half |
A split creates the window at once. There is no empty slot waiting to be filled, so pressing n after a split adds a third window.
## Preselection
Preselection chooses where the **next** window goes, relative to the focused one, without creating it yet.
| Key | Next window appears |
| --------------------------- | ------------------- |
| Alt +h | Left |
| Alt +j | Below |
| Alt +k | Above |
| Alt +l | Right |
It only applies while tiling is on, and the next new window uses it up. There is no key to cancel a preselection. It stays until a window is created, or until a split key replaces it with its own direction.
## Moving focus and swapping
| Key | Action |
| ------------------------------------------------------------------- | -------------------------------------------------- |
| h / l | Focus the window to the left / right |
| Alt +arrow, or Ctrl +B then an arrow | Focus the window in that direction, in either mode |
| H or Ctrl +← | Swap with the window to the left |
| J or Ctrl +↓ | Swap with the window below |
| K or Ctrl +↑ | Swap with the window above |
| L or Ctrl +→ | Swap with the window to the right |
A swap exchanges two windows without changing the shape of the tree. If there is no window in that direction, nothing happens. Dragging a window onto another with the mouse also swaps them.
## Resizing
Each key moves one edge of the focused window. Every window that shares that edge moves with it.
| Key | Edge | Step |
| ---------------- | ------------------- | --------- |
| > | Right edge, outward | 4 columns |
| \< | Right edge, inward | 4 columns |
| . | Left edge, outward | 4 columns |
| {'}'} | Bottom edge, down | 2 rows |
| {'{'} | Bottom edge, up | 2 rows |
| ] | Top edge, up | 2 rows |
| \[ | Top edge, down | 2 rows |
A window cannot shrink below 20 columns or 5 rows, and an edge already at the screen border cannot move outward. If a key does nothing, try the opposite edge: . instead of > for a window on the right side of the screen.
There is no default key to move the left edge inward. , would be the natural one, but it opens the settings page. Bind the `resize_master_shrink_left` action to a free key if you want it.
To resize with the mouse, drag a window's border. With [shared borders](#shared-borders) on, drag the divider line between two windows.
In master-stack, the same keys move the edge by 4 columns, as in any tiled layout. The master window's default share of the screen is set with `appearance.master_ratio` (30 to 70, default 50) or on the settings page.
### Resizing to a percentage
The layout prefix sizes the focused window to a share of the screen:
| Keys | Action |
| -------------------------------------------------------------------------------------------- | -------------------- |
| Ctrl +B L then 5 to 9 | Width to 50% to 90% |
| Ctrl +B L then Shift +5 to 9 | Height to 50% to 90% |
The layout still keeps every window at its minimum size, so you get the requested share wherever the neighbours allow it.
### How a resize is stored
A resize moves the window rectangles first, then recomputes every split ratio in the tree from the result. Ratios are what the tree keeps, so a resize survives later retiling, detaching and reattaching. Split ratios in BSP have no fixed range; only master-stack's `appearance.master_ratio` setting is limited to 30% to 70%.
## Shared borders
By default each window draws its own border, so two tiled windows show two lines between them. Shared borders draw a single line instead, and leave more room for content.
| | |
| ---------- | -------------------------------------------------------------------- |
| Config | `appearance.shared_borders = true` |
| Default | off |
| Flag | `--shared-borders` |
| In the app | the settings page, or `Toggle shared borders` in the command palette |
```toml
[appearance]
shared_borders = true
```
Shared borders apply in every layout: BSP, master-stack and scrolling.
With shared borders on, the pointer changes shape over a divider, and dragging it moves the divider.
The divider takes one cell, and it comes out of the right or bottom window. With a 160-column screen split in half, the left window is 80 columns wide and the right one 79. This is expected, and = does not change it.
`appearance.gap` adds empty cells between neighbouring windows, in every layout.
## Layout templates
A template saves the current arrangement so you can restore it later.
| Key | Action |
| ------------------------------------------------------ | ---------------------------------------- |
| Ctrl +B L s | Save the layout, after asking for a name |
| Ctrl +B L l | Load a layout from a searchable list |
The same two are in the command palette as `Save layout` and `Load layout`, and `Next layout` and `Previous layout` cycle through saved templates. A template records window positions, workspaces, working directories, startup commands and the insertion scheme. Layout load starts each new window in its folder. It does not type a `cd` into a new window.
From the shell:
```bash
tuios layout list # list saved templates
tuios layout dir # print the templates directory
tuios layout export mysetup # print a template as a tape script
tuios layout delete mysetup # delete a template
```
With tiling off, Ctrl +B L then 1 to 4 snaps the focused window to a corner: top left, top right, bottom left, bottom right.
## Persistence
Each workspace has its own BSP tree. The trees are part of the session, so they survive detaching, and the daemon saves them to disk so they come back after a restart. See [Sessions](/docs/sessions#session-resurrection) for what else a restart keeps.
If a tree goes stale, for example because it still names a window that has closed, TUIOS rebuilds it from the current windows and keeps their proportions.
## Troubleshooting
### Windows are not tiling
Check the dock for the tiling icon with `V` or `H`. If it is not there, press t .
If windows are arranged but not as BSP would arrange them, you are in master-stack or scrolling. Run `Layout: BSP tiling` from the command palette.
### A resize key does nothing
The edge is at the screen border, or moving it would shrink a window below 20 columns or 5 rows. Use the key for the opposite edge. , opens settings and never resizes.
### The layout looks lopsided
Press = to set every split back to half and half.
### A new window appeared in the wrong place
A preselection was still set. It lasts until a window uses it, and a split key replaces it: pressing - after Alt +l puts the window below, not to the right.
## Related
# Session Rail
URL: https://tuios.dev/docs/session-rail
> The sidebar that lists sessions, machines, panes, files, agents and git state.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
The session rail is a sidebar down one edge of the screen. It lists your sessions, the panes in the current session, the files in the focused pane's directory, the agents at work, and the git state of the focused pane. Everything on it can be reached with the mouse or the keyboard.
**Try it live:** the [Workspaces track](/learn?t=workspaces) opens the rail and walks it in your browser.
## Showing and hiding it
The rail is on by default, on the right edge, 24 columns wide.
| How | What it does |
| ----------------------------------------- | ---------------------------------------------- |
| Ctrl +B b | Show or hide the rail |
| Palette: `Toggle sidebar` | The same |
| `appearance.sidebar.enabled` | Whether a new session shows it. Default `true` |
```bash
tuios set-config appearance.sidebar.enabled false
tuios set-config appearance.sidebar.position left
```
In a daemon session, the rail belongs to the session. When you show or hide it on one client, every client of the session shows or hides it, and the panes take the new space on every client. A new session takes the setting of the first client that attaches. When you toggle the rail, TUIOS also keeps the new value in your config for the next new session. A reload of the config file does not change the rail of a running session. The width of the rail stays per client. See [The rail with more than one client](/docs/sessions#the-rail-with-more-than-one-client).
`position` is `right` (default), `left` or `hidden`. `width` is the preferred width in columns on a wide screen (default `24`). A config file that sets `enabled`, `position` or `width` keeps its value, so an install from before v0.8.0 whose file names them keeps the old rail. On a narrow screen, or after \< , the rail collapses to a one-column strip of marks. Hover the strip for a label (`tooltips`).
## The sections
| Section | What it lists |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sessions` | Every session, grouped by machine when you have [remote hosts](/docs/remote-hosts). Worktree sessions sit under their repository, labelled by branch. A `global` group appears once a second machine is reachable |
| `terminals` | The panes of the current session |
| `files` | The focused pane's directory. The list updates when a file is added, removed or renamed. See [the files section](/docs/configuration#the-files-section) |
| `agents` | Every pane running a coding agent, with its state. See [Agents](/docs/agents) |
| `git` | The focused pane's repository, branch, and how far it is ahead of or behind its upstream |
| `custom` | The output of a command you write, one row per line. See [The custom section](/docs/configuration#the-custom-section) |
`appearance.sidebar.sections` is one string that says which sections the rail draws, in what order, and the most each may take:
```toml
[appearance.sidebar]
sections = "sessions:25,terminals,files:25,agents:34"
```
A name left out is a section the rail does not draw. The percent is a ceiling, not a reservation. `spacer` is an empty block you can repeat to add a gap or push the next section to the bottom. The `git` and `custom` sections are not in the default layout, so add them by name:
```toml
[appearance.sidebar]
sections = "sessions:25,terminals,git,files:25,agents:34"
```
The settings page (, in window mode) has an editor for this under Sidebar, Sections. The full syntax is on the [configuration page](/docs/configuration#sections).
The last section in the layout is pinned to the bottom of the rail. A divider row sits above it. Drag the divider, or put the cursor on it and press \< or > , to move the split. A double click on it, or Enter , restores the layout's own share.
`appearance.git_dirty` (default `true`) adds counts of staged, changed and untracked paths to the git section. It is the only part of the section that walks the working tree, so turn it off on a very large repository.
## Using it with the keyboard
Move the keyboard into the rail with s in window management mode, or Ctrl +B e from anywhere. The same keys take you back to the panes. Esc also leaves.
While the rail has the keyboard, these keys from `[keybindings.sidebar]` apply:
| Key | Action |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| j / k , Down / Up | Move the cursor |
| g / G , Home / End | First or last row |
| l / h , Right / Left | Next or previous section |
| Tab / Shift +Tab | Cycle sections |
| Enter | Activate the row: attach the session, or focus the pane |
| 1 -9 | Jump to a session by its position |
| J / K | Move a session or machine down or up the rail |
| n | New session. Asks which machine when there is more than one |
| t | New terminal in the session |
| r | Rename the pane under the cursor |
| c | Recolour the pane under the cursor |
| m | Open the menu for the row under the cursor |
| x | Open that menu on its Close or Kill entry |
| f | Agents: show all sessions, or only this one |
| o | Agents: sort by what needs you (`you`, the default), by priority (`pri`), or by most recent (`rec`) |
| i | Open the mailbox for the pane under the cursor |
| / | Search every pane in every session. `@state` filters by agent state |
| \< / > | Collapse or expand the rail. On the divider, move the split |
| ? | Show the rail's keys |
On a row of the files section, `[keybindings.sidebar_files]` answers first: a create, r rename, d delete to the trash, D delete permanently, y copy, x cut, p paste, Y copy the absolute path.
On an agent row, `[keybindings.sidebar_agents]` answers first:
| Key | Action |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| r | Reply to the agent. The line opens under the Inbox, and the message is typed when the agent is at rest |
| v | [Review](/docs/agent-inbox#reviewing-an-agents-changes) the pane's changes |
| u | Mark the pane's finished turn unread again, for every client |
| z | Snooze the pane's Inbox item |
| x | Drop the newest queued message. u within 10 seconds queues it again. With nothing queued, x opens the menu as on any row |
Every key can be rebound. See [Configuration](/docs/configuration#keybindings).
## Edit and search files
Shift +Enter , or **Edit** in a file's context menu, opens the row in a new pane. The pane runs the editor from Settings, Sidebar, File editor, or `$EDITOR` when that setting is empty.
Ctrl +B f searches file names below the focused pane's directory. Ctrl +F does the same below the folder the rail shows, while the rail has focus. Type a name or a relative path and press Enter . tuios opens the rail, opens the file's folder, and selects the file.
The search stops after about 2.5 seconds and skips `.git`, `node_modules`, `.venv` and `target`. Its title says `partial` when it did not search the whole tree.
## Using it with the mouse
* Click a session to switch to it, or a pane to focus it.
* Click the `+` on a section header to make a session or a terminal. The `+` beside a machine makes a session on that machine.
* Right-click a row for its menu. A folder's menu has **Copy path**, which copies its absolute path.
* Hover a row whose title is too long and it scrolls (`marquee`).
* Click a folder in the files section to walk into it (`folder_click`).
## Agent rows
The agents section lists every pane running an agent, in every session. By default it is sorted by what each one needs from you, in four groups: needs you (an approval, a question, or an error), finished and not yet seen, working, and at rest. Inside a group, rows keep the order the panes were opened, so a row moves only when what it wants from you changes.
Each row shows the pane's state glyph and its name. A row says how long it has been in its state once that is five minutes or more, or when the cursor or pointer is on it. The second line says the one thing the row's group is about:
* **Working:** what the agent is doing now, such as `Bash: go test ./...`, when its hooks report it.
* **Needs you:** a word for what it needs (`approval`, `question`, `risky`, `plan`) and what the pane asks.
* **Finished, not yet seen:** the first line of what the agent last said.
* **At rest:** nothing.
`ctx 84%` appears once the agent's context is 80% full or more, and `1 queued` at the right edge while messages wait in its [queue](/docs/agent-inbox#replying-and-the-queue).
```
│ agents 2 need you
│▎▲ review 4m
│ risky · rm -rf bu…
│ ■ docs
│ Updated CHANGELOG…
│ ● api 1 queued
│ ctx 84% · Bash: go…
```
While a Claude Code agent has subagents at work, the second line says how many, such as `3 subagents`, on any row. An agent that hands work to subagents and ends its turn shows `done`, and the count shows that the work goes on. On a narrow line, the harness name gives way to the count. The count needs the Claude Code [integration](/docs/agents#the-agent-reports-for-itself), version 3 or later.
```
│ ○ lead
│ claude · 3 subagents
```
On a rail 30 columns wide or narrower, a pane running an agent is listed once, in the agents section, and the terminals section keeps the panes that run no agent. Rows that have rested longer than `agent_rest_fold` (an hour by default) fold into one `+N at rest` line. Enter or a click on it shows them again. A row that needs you, a working agent, the focused pane, a pane with messages queued and a pane whose agent has subagents at work never fold.
`[appearance.sidebar.agent_row]` picks which tokens a row shows, in what order, and how each is coloured. It is a table, so set it in `config.toml` rather than with `set-config`:
```toml
[appearance.sidebar.agent_row]
# the shipped list; state, host, prompt and $key are the others
tokens = ["session", "need", "harness", "name", "elapsed", "context", "subagents", "pr", "meta", "now", "message"]
[[appearance.sidebar.agent_row.elapsed.rule]]
gt = 30
fg = "warning"
```
`pr` shows the pull request of the session's worktree branch and its checks, such as `PR #12 open pass`. A `tokens` list you wrote gets no new tokens, so add `subagents` or `pr` to it to see them.
A rule has exactly one test (`equals`, `contains`, `starts_with`, `gt` or `lt`) and sets `fg`, `bold` or both. For `elapsed`, `gt` and `lt` compare minutes. The first matching rule wins, with at most eight per token.
## On a narrow rail
When a row does not fit, the rail shares its columns between the name and the other parts of the row, such as the state, the time or the queue count.
* A part is shown whole or not at all. The rail never cuts a part short.
* The parts at the right end of the row have priority. When one part does not fit, the next part can still use the space.
* The name keeps at least eight characters and an ellipsis. It then takes every column that the other parts leave.
* The harness before a name, and the branch of a session, stay only while the whole name fits beside them. When the harness goes, the session before it goes too. So `api/deploy` never reads as a harness called `api`.
* The queue count on an agent row takes the longest form that fits: `2 queued`, then `2q`.
A machine that does not answer keeps its header row. The right end of the row says why:
* `offline`, or `seen 3m ago` for a machine that answered before and then dropped off.
* `no daemon`, `no tuios` or `version` for a machine that answered and refused.
* `connecting` or `reconnecting` while TUIOS tries the link.
When messages wait for the machine, the count comes first, as in `3 queued, offline`. On a narrow rail, the row shows `3 queued`, and then the count in place of part of the name. With no messages waiting, a narrow row shows `✕` (`x` with ASCII glyphs), so a down machine does not look like one that is up. A machine that is connecting gets no mark.
## Other options
| Key | Default | Meaning |
| ------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `show_glyphs` | `true` | Agent state glyph on each row |
| `show_counts` | `true` | Pane count on each session row |
| `show_numbers` | `false` | Show the switch number before each local session name. The number is the one `switch_session_N` opens, for sessions 1 to 9 |
| `marquee` | `true` | Scroll a hovered row's long title |
| `tooltips` | `true` | Label the collapsed strip on hover |
| `file_icons` | `true` | A Nerd Font icon per file type |
| `file_icon_colors` | `true` | Draw each file icon in its type's colour |
| `folder_click` | `"navigate"` | `navigate`, `cd` (send the focused pane there) or `both`. TUIOS types a `cd` only into a shell at its prompt |
| `file_actions` | `true` | Allow create, rename, delete, copy, cut and paste |
| `file_delete` | `"trash"` | `trash` or `permanent` |
| `agent_rest_fold` | `"1h"` | How long an agent row rests before it folds. A duration, or `off` |
| `background` | `""` | The rail's background: `off`, `theme` or `#RRGGBB`. Empty follows `appearance.background` |
"cd here" types a `cd` only into a pane whose shell is at its prompt. In daemon mode, the daemon does this check. TUIOS types no `cd` for a folder whose name holds a quote, a backslash or a control character. The dock says when it did not type.
All of these live under `[appearance.sidebar]`. `appearance.session_colors` gives each session its own colour on the rail, and `appearance.session_border` carries that colour onto every pane border.
The flat `sidebar_*` keys and the `show_windows` and `show_agents` booleans still load. They are folded into `[appearance.sidebar]` and `sections` when the file is read. Use the new keys in new configs.
## Related
# Themes and Appearance
URL: https://tuios.dev/docs/themes
> Colour themes, custom and imported themes, chrome colours, glyph sets, spacing, dimming and the spotlight.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
Four things decide what TUIOS looks like:
| Part | What it controls | How to set it |
| ------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Colour | The terminal colours, the accents, the borders | `appearance.theme` |
| Shape | The characters the chrome is drawn with: borders, controls, rules, rail marks | `appearance.glyphs` |
| Spacing | Empty ground between panes, padding inside dialogs | `appearance.gap`, `appearance.panel_padding` |
| Text | What a window title, a workspace tab and the clock show | `window_title_format`, `dock_workspace_tab_format`, `clock_format` |
Every option on this page can be changed while TUIOS runs, from the settings page (, in window mode), with `tuios set-config`, or by editing `config.toml`, which is reloaded when saved.
**Try it live:** the [Make it yours track](/learn?t=make-it-yours) lets you try themes and glyphs in your browser.
## Choosing a theme
TUIOS ships over 300 themes. A theme sets the 16 ANSI colours plus foreground, background and cursor, and TUIOS derives its own interface colours from them.
```toml
[appearance]
theme = "catppuccin_mocha"
```
```bash
# Change it in the running session
tuios set-config appearance.theme catppuccin_mocha
# Start with a theme, overriding the config for this run
tuios --theme dracula
# Find one
tuios list-themes --filter gruvbox
tuios --list-themes | fzf --preview 'tuios --preview-theme {}'
```
Theme ids use underscores: `tokyo_night`, not `tokyo-night`.
In the app, open the picker from the palette (`Theme picker`) or from the Theme row on the settings page. It is searchable, shows a swatch per theme, and applies the selection live as you move. Esc puts back the theme you started with.
An empty `theme` turns theming off. TUIOS then emits plain colour indices and your terminal's own palette decides what they look like.
### Checking a theme
`tuios list-themes ` prints a theme's colours with the contrast each one measures against the theme's background. It tells you whether a palette is readable before you look at it.
```bash
tuios list-themes catppuccin_latte
tuios list-themes catppuccin_latte --json | jq -r '.palette.illegible[]'
tuios list-themes --json | jq -r .active
```
## Importing a theme
`tuios import-theme` converts a kitty, ghostty, alacritty or wezterm colour scheme into a TUIOS theme. The format is read from the file's content, so the extension does not matter.
```bash
tuios import-theme ~/.config/kitty/current-theme.conf --name mine
tuios set-config appearance.theme mine
```
A scheme that sets only some colours imports those, and the rest fall back to the xterm defaults. Wezterm's TOML schemes are read. Its Lua schemes are not.
## Writing a theme
A theme is a JSON file in `~/.config/tuios/themes/` (following `$XDG_CONFIG_HOME`). The file name without `.json`, lowercased, is its id unless the file sets `id`.
```json
{
"id": "my-theme",
"display_name": "My Theme",
"fg": "#e0def4",
"bg": "#191724",
"cursor": "#e0def4",
"black": "#26233a",
"red": "#eb6f92",
"green": "#31748f",
"yellow": "#f6c177",
"blue": "#9ccfd8",
"purple": "#c4a7e7",
"cyan": "#ebbcba",
"white": "#e0def4",
"bright_black": "#6e6a86",
"bright_red": "#eb6f92",
"bright_green": "#31748f",
"bright_yellow": "#f6c177",
"bright_blue": "#9ccfd8",
"bright_purple": "#c4a7e7",
"bright_cyan": "#ebbcba",
"bright_white": "#e0def4"
}
```
* The colour names are `purple` and `bright_purple`, not `magenta`.
* A colour is a hex string or an object: `{"r": 255, "g": 0, "b": 0, "a": 255}`.
* Every field is optional. `fg` defaults to `#e5e5e5`, `bg` to `#000000`, `cursor` to `fg`, the eight normal colours to the xterm defaults, and each `bright_*` to its normal colour. Set the bright colours yourself, or bright text looks the same as normal text.
* The directory is read again when you select a theme or run `list-themes`, so a file you just wrote can be selected at once. Subdirectories are not read.
* A file that does not parse is skipped. `tuios list-themes` reports it under `problems` with the reason.
### Chrome colours
The 16 ANSI colours are what programs in your panes paint with. By default TUIOS also takes its own accents from them: the logo and the selected row from `bright_blue`, the terminal-mode border from `bright_green`, and so on. A `chrome` object sets those accents directly, without changing what programs see:
```json
{
"id": "amber",
"chrome": {
"accent": "#ffb454",
"accent_bright": "#ffd580",
"success": "#aad94c",
"warning": "#ffb454",
"error": "#ff3333",
"info": "#59c2ff",
"surface": "#2b2118"
}
}
```
| Field | What it colours | Taken from when absent |
| ------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `accent` | Logo, selected row, window-mode pill | `bright_blue` |
| `accent_bright` | Secondary accent, focused border in window mode | `bright_cyan` |
| `success` | Terminal-mode pill, focused border in terminal mode, success messages | `bright_green` |
| `warning` | Copy-mode pill, warnings | `yellow` |
| `error` | Error messages | `red` |
| `info` | Info messages | `blue` |
| `surface` | The fill of every dialog: palette, pickers, menus, which-key | A fixed dark grey, or on a light theme a step below its background |
| `canvas`, `panel`, `card` | The darker and lighter steps around `surface` | Derived from `surface` |
Dialogs sit on a fixed grey ramp so they stay readable over any pane content. On a light theme the ramp is built from the theme's background instead, so a dialog is a light panel rather than a dark slab, and the screen behind it fades toward the background rather than toward black. The accent and key colours are darkened at their own hue until they read as text on it (4.5:1), and the status colours until they read as marks (3:1).
`surface` moves the ramp, and TUIOS picks text colours for it by contrast, so a light surface gets dark text. Text colours cannot be set directly. A field that is not a valid hex colour is ignored on its own.
### Border colours
`appearance.border_focused_color` and `appearance.border_unfocused_color` override the theme's border colours with a hex value. They live in `config.toml`, not in the theme. Clear one to hand the border back to the theme.
## Colour depth
The chrome is designed for each colour depth a terminal can have, rather than drawn in truecolor and stepped down. TUIOS reads the depth the way other programs do, from `TERM`, `COLORTERM` and `NO_COLOR`. There is no setting for it.
| Depth | Detected when | What the chrome does |
| --------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| truecolor | `COLORTERM=truecolor` or `24bit`, or a terminal known to have it (kitty, Ghostty, WezTerm, Alacritty, foot) | The ramp described above |
| 256 | `TERM` ends in `256color` and `COLORTERM` is unset: Apple Terminal, mosh, tmux without `Tc` | The greys sit on the xterm grey ramp (232 to 255), so no panel or selected row turns navy. Colours step to the nearest palette entry in OKLab, and a saturated colour never lands on a grey |
| 16 | `TERM=xterm`, `TERM=linux`, or `NO_COLOR` set | No grounds are painted. Panels use your terminal's background and frame themselves with a line, the selected row and title chips are reverse video, and colours are your palette's slots |
At 16 colours a theme's own colours give way to your terminal's palette slots, so set the terminal to the same theme to see them. The review diff is drawn per depth too: tinted lines in truecolor, fixed palette entries at 256 colours, and coloured line numbers with bold, underlined changed words at 16.
mosh passes 24-bit colour but not `COLORTERM`, so over mosh TUIOS sees 256 colours. If your terminal has truecolor, `export COLORTERM=truecolor` on the far machine.
## Your terminal's colours
With no theme and no pane background, TUIOS asks each client's terminal for its background, foreground and 16 colours, and answers a program in a pane that asks for them (OSC 10, 11 and 4) with those. The chrome is measured against your terminal's background, so a light terminal gets the light ramp. A local client follows your system's light and dark switch as it happens. A client over SSH or in a browser asks once, when it attaches. A terminal that answers nothing, such as mosh, gets plain black and white; set a theme or `appearance.pane_background` there.
## Glyph sets
A glyph set picks the shapes the chrome is drawn with. Four ship: `default`, `unicode` (no Nerd Font glyphs), `heavy` and `ascii`.
```bash
tuios set-config appearance.glyphs heavy
tuios set-config appearance.border_style glyphs
tuios list-glyphs heavy
```
With no set configured and no `--ascii-only`, TUIOS picks one from the environment: a locale that is not UTF-8 (`C`, `POSIX`, ISO-8859-1, read from the first of `LC_ALL`, `LC_CTYPE` and `LANG` that is set) gets ASCII, and the Linux console (`TERM=linux`) gets `unicode`. `tuios list-glyphs` with no argument says which set that is and why.
`border_style = "glyphs"` is what makes the border follow the set. The settings page has a searchable Glyph set picker with a live preview. Writing your own set, the roles it can name and the width rules are covered in [Configuration](/docs/configuration#glyph-sets).
## Spacing and text
```bash
tuios set-config appearance.gap 2 # cells between tiled panes, 0 to 8
tuios set-config appearance.panel_padding 4 # columns inside dialogs, 1 to 6
tuios set-config appearance.clock_format "Mon 3:04PM"
tuios set-config appearance.window_title_format "{index}: {title}"
```
* `window_title_format` accepts `{title}`, `{index}` and `{cwd}`.
* `dock_workspace_tab_format` accepts `{index}` and `{name}`.
* `clock_format` is a Go time layout, such as `15:04` or `Mon 3:04PM`.
## Quieting what you are not using
| Key | Default | Meaning |
| -------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `appearance.dim_unfocused` | `0` | Fade panes you are not in, as a percent from 0 to 90. `0` is off |
| `appearance.zen_mode` | `"disabled"` | Hide pane borders: `disabled`, `always`, or `mouse` (hidden while the mouse is still) |
| `appearance.border_style` | `"rounded"` | `rounded`, `normal`, `thick`, `double`, `block`, `outer-half-block`, `inner-half-block`, `ascii`, `hidden` or `glyphs` |
`dim_unfocused` dims pane content only. Borders, title bars, the rail and the dock stay as they are. Without a theme it can only dim cells a program coloured itself, because a cell in the terminal's default colour has no colour TUIOS knows. Set a theme first.
## Selection and search colours
`[appearance.selection]` holds the colours a pane marks text with. They are settings rather than part of the theme, because TUIOS chooses them and the program in the pane does not.
| Key | Default | Meaning |
| ------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `bg` / `fg` | `#45475A` / empty | Selected text. An empty `fg` keeps the text's own colour |
| `bold` | `false` | Also draw selected text bold |
| `search_bg` / `search_fg` | `#8A6D2F` / `#F5E7C8` | Every search match |
| `match_bg` / `match_fg` | `#E5A93D` / `#1C1B19` | The match under the cursor |
| `cursor_bg` / `cursor_fg` | `#39C5CF` / `#08222B` | The copy mode cursor |
| `flash` | `true` | Sweep a band of light over text you just copied. The sweep follows `appearance.motion`: `basic` and `full` draw it, `none` turns it off |
| `flash_ms` | `420` | How long the sweep takes, 80 to 3000 |
| `flash_style` | `"diagonal"` | `diagonal`, `diagonal-reverse`, `horizontal` or `vertical` |
| `flash_color` | empty | The colour of the sweep |
The scrollback browser uses the same search and selection colours.
## Spotlight
The spotlight lights one area of the screen and dims the rest. It is meant for recordings and demos. Press B in window management mode, Ctrl +B B in any mode, or run `Toggle spotlight` from the palette. The key was b in earlier versions.
While the spotlight is on, the dock shows a Spotlight chip. Click it to turn the spotlight off. Esc in window mode also turns it off. In terminal mode, Esc goes to the program in the pane, so use Ctrl +B B . See [Spotlight](/docs/keybindings#spotlight).
| Key | Default | Meaning |
| ------------------- | --------- | ------------------------------------------------- |
| `spotlight.enabled` | `false` | Start with the spotlight on |
| `spotlight.follow` | `"mouse"` | Follow the `mouse` or the focused pane's `cursor` |
| `spotlight.radius` | `10` | Half the beam's height, in rows |
| `spotlight.dim` | `75` | Percent of its light an unlit cell loses |
| `spotlight.edge` | `"hard"` | `hard` cuts the beam off, `soft` fades it |
| `spotlight.shake` | `false` | Shake the mouse left and right to toggle the beam |
The spotlight belongs to one client. Another client attached to the same session sees its own screen unchanged. Over SSH or in the browser, `follow = "cursor"` and `edge = "hard"` send far fewer bytes.
## Related
# Images Without Graphics
URL: https://tuios.dev/docs/images-without-graphics
> How TUIOS shows sixel images as block characters on kmscon, the Linux console and other terminals without graphics.
import { Callout } from 'fumadocs-ui/components/callout';
A program in a pane can draw a picture with sixel. Your terminal shows the real picture when it supports sixel or kitty graphics. Some terminals support neither: kmscon, the Linux console, Alacritty, Terminal.app and many SSH clients. On these terminals, TUIOS draws the picture with Unicode block characters. Before this, TUIOS showed a box with "image" in it.
## What you see
Each cell of the picture becomes one block character with two colours: a foreground and a background. The character shows the shape of the light and dark parts of the cell.
The picture is part of the pane, like text:
* It moves with the pane and scrolls into the scrollback.
* Popups and other panes cover it.
* The modal shade, the spotlight and `dim_unfocused` shade it like text.
* It goes away when the program clears or writes over its cells.
A terminal with sixel or kitty graphics always gets the real picture. It never gets block characters. If you attach later from a terminal with graphics, you see the same image as a real picture.
## The setting
`appearance.image_symbols` sets the characters.
```toml
[appearance]
image_symbols = "auto" # the default
# image_symbols = "octant"
# image_symbols = "off" # show a box instead of the picture
```
| Value | Characters | Detail in one cell |
| ------------ | --------------------------------------- | ------------------ |
| `"auto"` | Chosen from `TERM`. See the next table. | |
| `"octant"` | Octants, Unicode 16 (U+1CD00 and on) | 2 by 4 |
| `"sextant"` | Sextants, Unicode 13 (U+1FB00 and on) | 2 by 3 |
| `"quadrant"` | Quadrants (▖ ▗ ▘ ▝ and others) | 2 by 2 |
| `"half"` | Half blocks (▀ ▄) | 1 by 2 |
| `"off"` | None. TUIOS shows the box. | |
A finer set shows more detail, but your font must have the characters. A terminal cannot tell TUIOS which characters its font has. So `auto` reads `TERM`. Over SSH, TUIOS reads the `TERM` that the SSH client sends.
| `TERM` | `auto` uses | Why |
| ------------- | ----------- | --------------------------------------------------------------------------------------------------------------------- |
| `kmscon` | Octants | kmscon draws with its built-in Unifont, which has the octants. |
| `linux` | Half blocks | The Linux console uses fonts of 256 or 512 characters. These fonts have the CP437 block characters and nothing finer. |
| Anything else | Quadrants | Quadrants are in the Basic Multilingual Plane. Every font with block characters has them. |
If your font has octants, set `"octant"` for the best picture. You can also change the setting on the settings page. The change applies on the next frame. An image that arrived while the setting was `off` keeps the box.
## Colours
TUIOS uses the colours your terminal shows.
* **24-bit colour.** Each cell gets the two colours that fit the picture best.
* **256 colours.** TUIOS mixes palette colours in a dither pattern.
* **16 colours**, for example the Linux console. TUIOS always uses half blocks, whatever the setting says. It mixes the 16 colours in a dither pattern. It uses only the 8 dark colours as a background, because the Linux console uses bright backgrounds for blink. If a picture keeps too little of its shape in 16 colours, TUIOS shows the box instead.
* **No colour.** With `NO_COLOR` set, or when the output is not a terminal, TUIOS shows the box. It tells programs that sixel does not work.
## Turn it off
Set `image_symbols = "off"`:
```toml
[appearance]
image_symbols = "off"
```
TUIOS then shows the box for a sixel image. It also tells programs that sixel does not work, so programs use their own text output again.
## What programs do now
chafa, timg and yazi now send sixel on terminals without graphics. TUIOS draws the sixel as block characters.
With block characters on, TUIOS tells the program in the pane that sixel works (attribute 4 in the DA1 answer). Programs that choose between sixel and their own text output then send sixel. Examples are chafa, timg, lsix and yazi. Before this change, these programs used their own text output.
TUIOS does not draw kitty graphics images as block characters yet. On a terminal without graphics, a program that only uses kitty graphics uses its own text output.
In daemon mode, the client tells the daemon that it draws block characters. An older daemon ignores this and tells programs that sixel does not work.
## Drawing budget
TUIOS draws only the part of a picture that is on screen. It draws the rest of the picture when that part comes into view. The characters count toward the pane's 16 MiB image budget.
Each pane has a drawing budget: a quarter of the time, with bursts of up to 100 ms. All panes also share one budget for the parts that come into view. A pane that sends pictures faster than this, for example a video played as sixel, shows the box for the pictures past the budget. When the budget comes back, TUIOS draws the part on screen.
## How TUIOS makes the characters
TUIOS splits each cell into sub-cells: 2 by 4 for octants, 2 by 3 for sextants, 2 by 2 for quadrants and 1 by 2 for half blocks. Each sub-cell gets the average colour of the pixels under it, taken in linear light. TUIOS then tries every split of the sub-cells into two groups and keeps the split that loses the least colour in OKLab. That is 128 splits for an octant. The character is the shape of the foreground group. A transparent sub-cell keeps the pane's own background.
At 256 colours, TUIOS dithers the sub-cells with a 4x4 Bayer matrix and picks from the xterm palette (entries 16 to 255). A gradient between two palette entries shows as a pattern of both.
At 16 colours, TUIOS dithers each half block to the 16 ANSI colours and sends the colour index, so the terminal paints its own palette. TUIOS chooses the colours against the default VGA palette of the Linux console. A cell whose two colours are both bright gets the one that loses least as its nearest dark colour.
TUIOS measures how much shape a 16-colour picture keeps. The measure is the correlation between the lightness of the picture and the lightness of the cells. Both are averaged over blocks of 2 by 2 cells, so a dither pattern counts as the shade it makes. Below 0.5, TUIOS shows the box.
TUIOS measured ten pictures, each at full contrast and at 30, 15, 8 and 4 per cent contrast, at 60 by 20 cells:
| | Lowest | Typical |
| --------------------------------- | ---------------------------------------- | ------------------------------ |
| Half blocks, 16 colours, dithered | 0.62 (fine contour lines, full contrast) | 0.85 to 0.99 |
| The same without the dither | 0.23 (contour lines, 4 per cent) | 0.3 to 0.5 at 8 and 4 per cent |
| Half blocks, 24-bit colour | 0.96 | 0.99 to 1.00 |
| Random noise, any mode | 0.51 to 0.53 | |
Without the dither, a picture with low contrast becomes flat bands. With the dither, no measured picture fell under 0.5. So the box is a safety net.
### Speed
* **Quality.** The mean error per colour channel is 4.6 to 5.0 out of 255 in 24-bit colour, and 25.9 at 256 colours. On cells with an edge, octants leave 16.4 against 32 for a flat colour.
* **Drawing.** An image of 80 by 24 cells takes 2.2 ms with half blocks, 2.5 ms with quadrants, 3.3 ms with sextants and 6.5 ms with octants. With the dither, it takes 3.5 to 9.6 ms.
* **Text frames.** A frame with no image takes no new path. Its time and allocations do not change.
* **A frame with an image.** A frame over an image of 60 by 16 cells takes about 3.5 ms with the box, 3.7 ms with quadrants and 3.9 ms with octants.
## Why block characters: kmscon
kmscon is the KMS/DRM console. TUIOS runs well on it, but kmscon draws neither sixel nor kitty graphics. Block characters were the one option that TUIOS could ship.
### What kmscon supports
* **No image protocol.** kmscon has no sixel, no kitty graphics and no iTerm2 images. The open request is [kmscon/kmscon#138](https://github.com/kmscon/kmscon/issues/138). A maintainer said there that libtsm "only supports a table of cells". Sixel or kitty images need a rewrite of most of libtsm and a large part of the renderer. [#301](https://github.com/kmscon/kmscon/issues/301) says the same.
* **Where the project is.** The maintained repository is [kmscon/kmscon](https://github.com/kmscon/kmscon), with libtsm at kmscon/libtsm. Aetf/kmscon points there. The latest release is kmscon 10.0.4 with libtsm 4.8.0 (2026-09-25).
* **No sixel work upstream.** No branch, pull request or issue in kmscon or libtsm adds image storage to the cell grid.
* **Renderers.** Two are left: bbulk (software, drm2d) and gltex (OpenGL ES, drm3d, `--hwaccel`). The mouse pointer recently got a `draw_pointer` hook in both, and a hardware cursor plane. An image layer needs the same kind of hook, and image storage for each cell in libtsm.
* **Characters.** kmscon draws every character from its font engine (freetype, pango, psf, unifont, 8x16). It does not draw block elements itself. Its built-in Unifont covers U+1FB00 to U+1FBFF and U+1CD00 to U+1CDFF. Code points past U+FFFF work since early 2026. So `--font-engine=unifont` draws sextants and octants.
* **Colour.** kmscon 10 sets `TERM=kmscon` and `COLORTERM=truecolor`. The terminfo entry says 256 colours. TUIOS reads `COLORTERM` and draws in 24-bit colour.
### How other console programs draw pixels
| Program | How it draws | Works beside kmscon? |
| ---------------- | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| fbi, fim, fbv | Write to `/dev/fbN`. fbi needs the real VT, not a pty. | No. See the next section. |
| fbcon, simpledrm | The kernel's own console and its DRM fbdev emulation. | Not while kmscon is DRM master. |
| mpv `--vo=drm` | Takes DRM master and shows its own buffers. `--drm-draw-plane` picks a plane. | Only after kmscon releases master. `kmscon-launch-gui` does this, and keyboard input then breaks ([#406](https://github.com/kmscon/kmscon/issues/406)). In a terminal, use mpv `--vo=tct` (text) or `--vo=sixel`. |
| w3m-img | `w3mimgdisplay` has an fb backend on `/dev/fb0` (`$FRAMEBUFFER`). | No. The same as fbi. |
| chafa | Characters when the terminal has no graphics: half, quadrant, sextant, octant (since 1.16), braille and more. | Yes. It is the model for what TUIOS does. |
| yaft | A framebuffer terminal with experimental sixel. It draws into `/dev/fb0` itself. | It replaces kmscon. It does not run beside it. |
### Can a second process draw while kmscon holds DRM master?
No. In Linux 6.17, `SETCRTC`, `SETPLANE`, `PAGE_FLIP`, `DIRTYFB`, `ATOMIC` and `CREATE_LEASE` all need DRM master. `CREATE_DUMB` and `ADDFB2` do not. So a second process can make a buffer, but it cannot put the buffer on screen.
The fbdev emulation returns `-EBUSY` while another master exists. So a write to `/dev/fb0` goes into a buffer that is not on screen. kmscon still has an fbdev backend (`no-drm`). There it owns the framebuffer and paints its cells over anything written beside it. A DRM lease can work, but only the master can create one, and kmscon has no lease support.
### The options
| Option | Without root or DRM master? | Cost | Result |
| ----------------------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Draw to `/dev/fb0` or a DRM plane at cell positions, and draw again after each flip | No | Needs master, a lease that kmscon does not give, or kmscon in the background. With the fbdev backend it races the kmscon paints. Device access from a multiplexer is a security boundary of its own. | Not practical. |
| Sixel or kitty graphics in kmscon | Yes, after it merges | Image storage for each cell in libtsm, a draw hook in bbulk and gltex, and scroll and erase rules. Months of work. The maintainers will not do it themselves. | The correct long-term fix. TUIOS cannot ship it. |
| TUIOS draws images as Unicode block characters | Yes | A font with the characters. Two colours for each cell. | **Built.** Works on kmscon, plain SSH clients and any terminal without graphics. |
| TUIOS as its own DRM console | No | Seat management (logind or seatd), VT switching, input, font rendering and a renderer: a second kmscon. | Too large for the gain. |
Real pixels on kmscon need sixel or kitty graphics in kmscon itself. That work starts with a libtsm design for image cells, proposed on [#138](https://github.com/kmscon/kmscon/issues/138).
### Try it on kmscon
kmscon needs a free VT and DRM master. On a machine where VT 6 is free:
```sh
sudo kmscon --vt=6 --switchvt --font-engine=unifont
# log in, then
tuios
chafa -s 60x20 picture.png # in a pane
```
`sudo systemctl start kmsconvt@tty6.service` does the same through systemd. It reads its options from `/etc/kmscon/kmscon.conf`. Set `font-engine=unifont` there.
# Configuration
URL: https://tuios.dev/docs/configuration
> Every table and option in the TUIOS config.toml, with defaults and how changes apply.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
TUIOS reads `~/.config/tuios/config.toml`. If `XDG_CONFIG_HOME` is set, the file is `$XDG_CONFIG_HOME/tuios/config.toml`. On the first run, TUIOS writes a `config.toml` with comments and no settings, except `[startup]`.
```bash
tuios config path # print the path
tuios config edit # open it in $EDITOR
tuios config reset # overwrite it with the defaults, after asking
```
Write only what you want to change. Missing keys and missing tables take their defaults.
`config.toml` can include other files, and TUIOS also reads the `*.toml` files in `~/.config/tuios/config.d/`. `config.toml` wins over all of them. See [Split the Config](/docs/config-files).
```toml
[appearance]
theme = "nord"
[keybindings.window_management]
new_window = ["ctrl+t"]
```
Two things live outside the file: custom themes in `~/.config/tuios/themes/` and custom glyph sets in `~/.config/tuios/glyphs/`.
`tuios list-options` prints every settable option with its type, default and accepted values. It reads the same registry the validator and the settings page use, so it always matches the build you run.
## Tables
| Table | What it holds |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| [`[appearance]`](#appearance) | Theme, backgrounds, borders, windows, mouse, tiling, dock. Sub-tables `scrollbar`, `selection` and `sidebar` |
| [`[notifications]`](#notifications) | How long messages stay up, and agent alerts |
| [`[keybindings]`](#keybindings) | The leader key and 23 sections of bindings |
| [`[daemon]`](#the-daemon-table) | Daemon log level, agent detection, resuming agents, saved history and the size of a shared session |
| [`[startup]`](#the-startup-table) | How a new session starts |
| [`[workspaces]`](#the-workspaces-table) | What happens when a workspace becomes empty |
| [`[launcher]`](#the-launcher-table) | How the app launcher starts a graphical program |
| [`[tape]`](#the-tape-table) | Project tapes |
| [`[debug]`](#the-debug-table) | The keycast overlay |
| [`[screenshot]`](#the-screenshot-table) | Capture format, frame and fonts |
| [`[screensaver]`](#the-screensaver-table) | The idle animation |
| [`[hints]`](#the-hints-table) | What hints mode labels, the label letters and how much the pane dims |
| [`[panes]`](#the-panes-table) | The keys of the pane labels that Ctrl +B Q shows |
| [`[scratch]`](#the-scratch-table) | The size of the scratch terminal that Ctrl +B g shows |
| [`[pip]`](#the-pip-table) | The size and corner of the picture-in-picture view that p pins |
| [`[spotlight]`](#the-spotlight-table) | The beam that dims all but one part of the screen |
| [`[dock]`](#the-dock-table) | Which components the bar draws, and your own |
| [`[hosts]`](#the-hosts-table) | Other machines this daemon links to, and what each may do here |
| [`[tailscale]`](#the-tailscale-table) | Which tailnet machines `tuios hosts add` offers |
| [`[agents]`](#the-agents-table) | The switch for agent features, approvals from the Inbox, risk rules, what a pane may do, the away recap, the queue and checkpoints |
| [`[notify]`](#the-notify-table) | Push notifications to your phone for Inbox items |
| [`[hooks]`](#hooks) | Shell commands run on events |
## Applying changes
TUIOS watches the file. A save is applied about 200 ms later, with no restart. The command palette also has a **Reload Config** entry.
A file with an error is not applied. The running settings stay, and the error is shown on screen.
Most of the file applies live. These parts do not:
| Change | When it applies |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `[hooks]` | The daemon reads it at start. Run `tuios kill-server` after editing a hook the daemon runs |
| `[daemon]` | The daemon reads it at start. Run `tuios kill-server` after editing it. `tuios set-config daemon.log_level` also changes the log level of a running daemon |
| `[startup]` | The next new session |
| `scrollback_lines` | Panes opened afterwards |
| Custom theme files | See [Custom themes](#custom-themes) |
The daemon also follows the file for `[hosts]`, `[agents]`, `[notify]` and `preferred_shell`. Removing a host closes its link at once. A change that gives panes or other machines more waits for `tuios config apply`. This covers a new host, a wider `[agents.permissions]` and a wider link policy. See [Changes that wait](/docs/agents#changes-that-wait).
### Changing a setting at runtime
```bash
tuios list-options # every path, with default and accepted values
tuios list-options --section sidebar # one group
tuios list-options --search "pane bg" # fuzzy search, best match first
tuios get-config appearance.border_style
tuios set-config appearance.border_style thick
tuios config browse # search the options and set one, in an explorer
```
`tuios config browse` shows each option with its type, default, current value and source. Press / to search. Press Enter on an option to set it the same way as `set-config`. q or Esc leaves it. It needs a running daemon. See [Explorers](/docs/cli-reference#explorers).
`set-config` changes a running session. With a client attached, the client applies the value and writes it to the config. With no client attached, the daemon keeps the value for this session and does not write the file. A path the registry does not know is refused. An `[appearance]` option also answers to its bare name, so `border_style` and `appearance.border_style` are the same path. `set-config` cannot reach `[keybindings]`, `[hooks]`, `[hosts]`, `[tailscale]`, `[agents]` or the `[dock]` lists, because those are maps and lists rather than single values. For `[agents]` this is also a safeguard: a program in a pane cannot loosen it through TUIOS. `dock.clock.format` is the one `[dock]` key it can set.
### The in-app settings page
The settings page edits every option in the registry, in the same groups `list-options` prints. Open it with:
* , in window mode (`open_settings`)
* Ctrl +B , (`prefix_settings`)
* **Settings** in the Ctrl +P command palette
Most rows apply as you change them. Colour options open a colour picker. Two rows open editors of their own: the rail's [sections](#sections) and the dock's [components](#the-dock-table).
The page reopens on the tab, row and search you left it on. 1 to 9 go to a tab. A row whose value in force differs from its default has a dot after its name. That includes the rows the page draws by hand, such as the theme, glyph set, shared borders, pane gap, the master and column ratios, the rail's sections, max FPS, show keys, spotlight and the screen saver effect. For the pane geometry rows the value compared is the session's, not the config's. Backspace (or Delete ) resets a changed row to its default, and its description says what the default is. The hand-drawn rows have no reset: Backspace on one says so, and you change it back with its own control. Ctrl +Z undoes the last change made on the page.
/ , or any letter the page does not use, searches every tab. It ranks each row by its name, its config key, its value and its description, and each result names its tab. In the results, ← , → and Enter change the row where it is, Tab goes to the row on its own tab, and Esc clears the search. The command palette reaches the same rows by name (`settings: pane background`), and `tuios list-options --search` runs the same search from a shell.
The settings page writes only the key that you changed. Your comments, blank lines and key order stay. When the config has more than one file, the change goes to the file that sets the key. See [How TUIOS saves a change](/docs/config-files#how-tuios-saves-a-change).
### Command-line flags
Interface flags override the file for one run. They are accepted by `tuios`, `tuios new`, `tuios attach`, `tuios ssh` and `tuios tape play`.
| Flag | Option |
| ------------------------------------------ | ----------------------------------------------- |
| `--theme` | `appearance.theme` |
| `--border-style` | `appearance.border_style` |
| `--dockbar-position` | `appearance.dockbar_position` |
| `--window-title-position` | `appearance.window_title_position` |
| `--window-button-style` | `appearance.window_button_style` |
| `--window-button-position` | `appearance.window_button_position` |
| `--hide-window-buttons` | `appearance.hide_window_buttons` |
| `--hide-scrollbar` | `appearance.hide_scrollbar` |
| `--shared-borders` | `appearance.shared_borders` |
| `--scrollback-lines` | `appearance.scrollback_lines` |
| `--zoom-max-width` | `appearance.zoom_max_width` |
| `--no-animations` | `appearance.motion = "none"` |
| `--confirm-quit` | `appearance.confirm_quit` |
| `--show-clock`, `--show-cpu`, `--show-ram` | `appearance.show_clock`, `show_cpu`, `show_ram` |
| `--show-keys` | `debug.show_key_events` |
| `--ascii-only` | none. Draws ASCII instead of Nerd Font glyphs |
## Appearance
All keys below go under `[appearance]`.
### Theme and chrome
| Key | Default | Values | What it does |
| ------------------------ | ------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `theme` | `""` | a theme id | Colour theme. Empty keeps your terminal's own colours |
| `glyphs` | `"default"` | a glyph set id | Shapes of the chrome. See [Glyph sets](#glyph-sets) |
| `border_style` | `"rounded"` | see [below](#border_style) | Border drawn around every pane |
| `border_focused_color` | `""` | `#RRGGBB` | Override the focused border colour |
| `border_unfocused_color` | `""` | `#RRGGBB` | Override the unfocused border colour |
| `shared_borders` | `false` | bool | Draw one border between neighbouring tiled panes, in every layout |
| `gap` | `0` | 0 to 8 | Empty cells between tiled panes |
| `dim_unfocused` | `0` | 0 to 90 | Fade panes you are not in, in percent |
| `dim_multifocus` | `false` | bool | Dim the panes in the [multifocus](/docs/layout-modes#typing-into-several-panes) set like other panes you are not in |
| `session_colors` | `true` | bool | Give each session its own colour on the rail and in the switcher |
| `session_border` | `false` | bool | Also draw that colour on every pane border |
| `zen_mode` | `"disabled"` | `disabled`, `always`, `mouse` | Hide borders never, always, or while the mouse is idle |
| `panel_padding` | `2` | 1 to 6 | Columns of padding inside overlay panels |
| `motion` | `"full"` | `none`, `basic`, `full` | How much moves. `basic` animates window slides, zoom and the copy sweep. `full` adds the fade of a dialog opening (truecolor only), the shimmer on a working agent's row and the confetti. `none` moves nothing |
| `modal_dim` | `30` | 0 to 90 | Dim the screen behind a dialog, in percent. `0` turns it off. At 16 colours the text goes faint instead |
| `max_fps` | `0` | 0, 10 to 240, or `"auto"` | The highest frame rate TUIOS draws at. `0` means 60. See [below](#max_fps) |
| `wrap_lists` | `true` | bool | Moving past the end of a list wraps to the other end |
`animations_enabled`, the key `motion` replaces, is still read from older files: `false` reads as `motion = "none"`, and `true` changes nothing. Where both are set, `motion` wins.
#### border\_style
Values: `rounded` (default), `normal`, `thick`, `double`, `block`, `outer-half-block`, `inner-half-block`, `ascii`, `hidden`, `glyphs`.
`glyphs` uses the border of the active [glyph set](#glyph-sets). `hidden` also hides the window buttons and the scrollbar, because both are drawn on the border.
#### max\_fps
`max_fps` is the highest frame rate TUIOS draws at. Your terminal and your monitor can show fewer frames. A value above what they can show does not make TUIOS smoother.
```toml
[appearance]
max_fps = 144 # a number from 10 to 240
# max_fps = "auto" # the refresh rate of your display
# max_fps = 0 # 60, the default
```
| Value | Frame rate |
| ------------- | ------------------------------------------------------------------------------------------- |
| `0` | 60. This is the default |
| `10` to `240` | That number. A value outside the range moves to the nearest end |
| `"auto"` | The refresh rate of your display, from 10 to 240. If TUIOS cannot find the rate, it uses 60 |
The settings page offers Auto, 30, 60, 90, 120, 144, 165 and 240. With Auto selected, the row shows the rate in use, for example `Auto (144)`. A change applies at once, with no restart.
* **What you see.** TUIOS sends a frame to your terminal, and the terminal draws it on its own schedule. kitty and Ghostty draw at most once for each refresh of the monitor, so a 60 Hz monitor shows at most 60 frames a second. Over SSH, the network and the remote terminal set the limit too.
* **What it costs.** TUIOS draws only when something on the screen changes. The terminal library under it still wakes at `max_fps` while TUIOS is idle. An idle TUIOS at 240 wakes about three times as often as at 60.
* **How auto finds the rate.** TUIOS looks once in the background when it starts, and again when the config file reloads. Until the answer arrives, it draws at 60. On Wayland it asks `hyprctl` on Hyprland, `niri` on niri and `wlr-randr` on other wlroots compositors. On X11, or on another compositor, it asks `xrandr --current`. On macOS it asks `system_profiler SPDisplaysDataType`. On Windows it does not look, and uses 60.
* **Several displays.** A terminal window cannot tell which display it is on, so auto uses the highest rate among them.
* **Remote clients.** Over SSH, in `tuios-web` and in the SSH server, auto uses 60, because the displays on the machine are not the ones you see.
### Backgrounds
A cell with no background of its own is transparent, so your terminal's own background shows through. That is the default everywhere: pane content left on the default background, the space between panes, the borders, the dock and the rail. These options paint those cells instead, one surface at a time or all at once.
| Key | Surface | Default |
| -------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------- |
| `background` | Every surface below that is not set on its own | `"off"` |
| `pane_background` | Pane content, the thin scrollbar over it, and the scrollback browser | `""` (follows `background`) |
| `desktop_background` | Behind and between panes: gaps, the space around floating panes, an empty workspace and its welcome screen | `""` |
| `window_chrome_background` | Pane borders and title bars, and the lines between shared borders | `""` |
| `dock_background` | The dock | `""` |
| `sidebar.background` | The rail, under `[appearance.sidebar]` | `""` |
Each takes `off`, `theme` or a `#RRGGBB` colour:
* `off` paints nothing, so your terminal's background shows through.
* `theme` paints the active theme's background, with the theme's foreground on default-coloured text. With no theme set, it behaves as `off`.
* `#RRGGBB` paints that colour. With a theme set, default-coloured text takes the theme's foreground, lightened or darkened until it reads on the colour.
A surface's own option wins whenever it holds a value, `off` included. Left empty, it follows `background`:
```toml
[appearance]
background = "theme" # every surface on the theme's background
dock_background = "#11111b" # the dock a shade darker
pane_background = "off" # panes keep the terminal's own background
[appearance.sidebar]
background = "#181825"
```
Only cells with no background of their own are painted. A background a program chose, the selection and search marks, and the colours the chrome sets itself always win. A value that is not a keyword or a `#RRGGBB` colour reads as `off` for that surface, with a validation warning.
While the pane background paints a colour, a program that asks the terminal for its colours (OSC 10 and 11) is told the colours it is drawn on, so it can pick a light or dark palette. All six options apply live, reach every attached client, and are on the **Backgrounds** tab of the settings page, where x clears a surface back to following `background`.
### Windows and titles
| Key | Default | Values | What it does |
| ------------------------ | ---------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `window_title_position` | `"top"` | `top`, `bottom`, `hidden` | Where the title is drawn. `hidden` also disables rename |
| `window_title_format` | `""` | template | Title template. See [below](#window_title_format) |
| `window_button_style` | `"dots"` | `dots`, `pill` | macOS-style traffic lights, or a filled pill. On the left, the pill puts close at the outer corner: close, zoom, minimize |
| `window_button_position` | `"left"` | `left`, `right` | Which end of the title bar holds the buttons |
| `hide_window_buttons` | `false` | bool | Hide the minimize, zoom and close buttons |
| `window_button_zoom` | `true` | bool | Show the zoom button on tiled panes |
| `hide_scrollbar` | `false` | bool | Hide the scrollbar on the pane border |
| `confirm_quit` | `false` | bool | Always confirm on quit, not only when processes are running |
| `preferred_shell` | `""` | path | Shell for new panes. Empty picks one for your platform |
| `new_window_inherit_cwd` | `true` | bool | Start a new window in the focused pane's directory. On Windows, the shell must report its folder with OSC 7 or OSC 9;9 |
| `new_window_follow_ssh` | `false` | bool | A split or new window of a pane that runs ssh runs the same ssh. See [below](#splits-that-follow-ssh) |
| `whichkey_enabled` | `true` | bool | Show the which-key popup after the leader key |
| `whichkey_position` | `"bottom-right"` | `bottom-right`, `bottom-left`, `top-right`, `top-left`, `center` | Where the popup opens |
| `prefix_repeat_time` | `500` | 0 to 5000 | Milliseconds the prefix stays armed after a repeatable command. `0` turns it off |
#### window\_title\_format
A template for every title. It expands three placeholders:
* `{title}`: the custom name, or the title the program set
* `{index}`: the window's 1-based position in its workspace
* `{cwd}`: the window's working directory
```toml
[appearance]
window_title_format = "{index}: {title} ({cwd})"
```
Any other `{...}` produces a validation warning and is drawn as written.
### Mouse and input
| Key | Default | Values | What it does |
| ------------------------------ | ---------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `click_to_type` | `"double"` | `double`, `single`, `off` | In window mode, what starts typing in a pane: a double click, a single click, or no click. A single click always focuses |
| `auto_enter_terminal_on_focus` | `"off"` | `off`, `targeted`, `all` | Start typing when a keyboard focus command lands on a pane. `targeted` covers numbered select and arrows, `all` adds Tab |
| `focus_follows_mouse` | `false` | bool | Focus the pane under the pointer |
| `alt_drag` | `true` | bool | Alt + left-drag moves a pane |
| `right_click_opens_menu` | `false` | bool | In terminal mode, a plain right-click opens the pane menu |
| `copy_on_select` | `true` | bool | Copy a mouse selection when you release the button |
| `word_characters` | `"@-./_~?&=%+#"` | string | Punctuation that counts as part of a word on double-click |
| `links` | `"all"` | `all`, `marked`, `off` | The links TUIOS finds. `marked` finds only OSC 8 links. See [Links](/docs/links) |
| `link_click` | `"both"` | `both`, `ctrl`, `shift`, `off` | The click that opens a link. `both` is Ctrl +click and Shift +click |
| `link_opener` | `""` | a command | The command that opens a web link. TUIOS puts the URL where `%s` is, or at the end. Empty uses `$BROWSER`, then the system opener |
| `nvim_navigation` | `false` | bool | Let the terminal-mode focus keys move through Neovim splits first. See [Neovim pane navigation](/docs/keybindings#neovim-pane-navigation) |
| `kitty_placeholders` | `"auto"` | `auto`, `on`, `off` | Draw images placed with kitty Unicode placeholders |
| `image_symbols` | `"auto"` | `auto`, `octant`, `sextant`, `quadrant`, `half`, `off` | The block characters for a sixel image on a terminal without graphics. See [Images Without Graphics](/docs/images-without-graphics) |
#### Links
Ctrl +click on a link opens it. Ctrl +drag still moves the pane: a link opens only when the pointer does not move. Only `http`, `https`, `mailto`, `ftp` and `ftps` links go to the opener. TUIOS copies other links. Under `ssh`, on a remote client, or on a machine with no desktop, TUIOS copies the URL, and your terminal can still open the link from the OSC 8 that TUIOS writes. If the opener fails, the dock says so and TUIOS copies the URL. See [Links](/docs/links) for the opener, the safety rules and a table of terminals.
### Scrolling
| Key | Default | Values | What it does |
| ------------------ | ------- | -------------- | ---------------------------------------------------- |
| `scrollback_lines` | `10000` | 100 to 1000000 | Lines each pane keeps. Read when the pane is created |
| `scroll_lines` | `3` | 1 to 50 | Lines per mouse wheel notch |
### Tiling and zoom
| Key | Default | Values | What it does |
| --------------------- | ---------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tiling_scheme` | `"spiral"` | `spiral`, `longest_side`, `alternate`, `smart_split` | BSP scheme a workspace starts with the first time it is tiled. A workspace already tiled keeps its own scheme. See [BSP tiling](/docs/bsp-tiling#how-new-windows-are-placed) |
| `master_ratio` | `50` | 10 to 90 | The share of the screen the master panes take in the master-stack layout, in percent |
| `master_position` | `"left"` | `left`, `right`, `top`, `bottom`, `center` | The side the master panes take. With `center`, the stack panes go to the right and the left in turn |
| `master_count` | `1` | 1 to 9 | How many panes are master panes |
| `master_grid` | `true` | bool | With one master on the left, show four or more panes as a grid |
| `scroll_column_width` | `55` | 20 to 100 | Column width in the scrolling layout, in percent |
| `scroll_column_max` | `90` | 20 to 100 | Widest a scrolling column may be set to. `100` lets a column fill the screen |
| `niri_scroll_cells` | `8` | 1 to 200 | Cells the scrolling layout moves per wheel event |
| `niri_reverse_scroll` | `false` | bool | Reverse the wheel in the scrolling layout |
| `niri_click_reveals` | `true` | bool | Clicking a partly hidden column brings it fully on screen |
| `niri_hover_reveals` | `true` | bool | With `focus_follows_mouse`, hovering does the same |
| `zoom_size` | `95` | 50 to 100 | Share of the screen a zoomed pane takes, in percent. Below 100 the layout stays visible at the edges. `100` is fullscreen |
| `zoom_max_width` | `0` | cells | Width cap for a zoomed pane. `0` means no cap |
| `zoom_borderless` | `false` | bool | Show a zoomed pane on the whole pane region, with no border and no title bar. The pane's program gets the full size. With it on, `zoom_size` and `zoom_max_width` have no effect |
| `zoom_animation` | `true` | bool | Slide a pane into and out of zoom |
| `zoom_follows_focus` | `true` | bool | Moving focus while zoomed zooms the pane you move to |
The layout a new session starts in is `startup.layout`. See [The startup table](#the-startup-table). Each workspace starts with the master-stack options, and the layout keys change the workspace on screen. See [Master-stack](/docs/layout-modes#master-stack).
### Dock
| Key | Default | Values | What it does |
| --------------------------- | ------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `dockbar_position` | `"top"` | `top`, `bottom`, `hidden` | Edge the dock sits on |
| `dock_workspace_tabs` | `true` | bool | Show the clickable workspace strip |
| `dock_workspace_tab_format` | `""` | template | Tab label with `{index}` and `{name}`. Empty means `{name}` |
| `dock_workspace_label_max` | `12` | cells, `0` for no cap | Longest workspace tab label. The cap counts the characters of the tab format |
| `dock_workspace_tooltip` | `true` | bool | Show a truncated workspace name in full on hover |
| `dock_pill_caps` | `true` | bool | Rounded caps on every dock pill: the mode chip, the workspace pills and the minimized windows. `false` draws flat pills |
| `dock_compact` | `false` | bool | Draw the dock as one row with no rule. The panes get one more row |
| `dock_mode_icon_window` | unset | text, `""` or `default` | Icon in the mode pill in window mode. Unset or `default` uses the icon of the glyph set. An empty value shows no icon |
| `dock_mode_icon_terminal` | unset | text, `""` or `default` | Icon in the mode pill in terminal mode |
| `dock_mode_icon_tiling` | unset | text, `""` or `default` | Icon in the mode pill while tiling is on |
| `show_clock` | `false` | bool | Show the clock |
| `clock_format` | `"15:04:05"` | Go time layout | Clock format, for example `15:04` or `Mon 3:04PM` |
| `show_cpu` | `false` | bool | Show a CPU graph |
| `show_ram` | `false` | bool | Show RAM use |
| `hide_clock` | `false` | bool | Deprecated. Use `show_clock` |
`dock_workspace_label_max` stops one long workspace name from pushing the other tabs off the dock. Set it to `0` to show the whole name. The workspace strip then scrolls. A dock with no room for one full tab still shows the current tab, cut to fit.
`dock_compact` works with the dock at the top and at the bottom. It has no effect when `dockbar_position` is `hidden`. The change applies at once. A compact dock does not show the rule that tells how long a dock message stays. When clients with different docks share a session, the panes keep the largest dock height. A client with a compact dock shows a blank row in that case.
The mode pill icons can be any text up to 8 cells wide. A wide character, such as `終`, counts as two cells. The dock does not accept control characters. For an icon that it does not accept, the dock shows the default icon and the config check shows a warning. The pill adds one space on each side of the icon. If an icon is empty and the mode has no other text, the dock shows no pill in that mode. While tiling is on, the pill also shows the next split direction, `V` or `H`.
```toml
[appearance]
dock_mode_icon_window = "WM"
dock_mode_icon_terminal = ">_"
dock_mode_icon_tiling = ""
```
To remove the whole mode pill, remove `"mode"` from the `left` list in the `[dock]` table. You can also set the icons on the settings page, in the Dock section, or with `tuios set-config appearance.dock_mode_icon_window WM`. The change applies at once.
To reorder the dock or add your own cells, see [The dock table](#the-dock-table).
### Rail options
Two rail options sit directly in `[appearance]`:
| Key | Default | What it does |
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `global_session` | `true` | Offer a session on the rail that holds panes from several machines, once a second machine is reachable |
| `git_dirty` | `true` | Count staged, changed and untracked paths in the rail's git section. This is the only part of that section that walks the working tree |
The rest are in [`[appearance.sidebar]`](#the-session-rail).
### The scrollbar
`[appearance.scrollbar]`:
| Key | Default | Values | What it does |
| ------- | --------- | ---------------------------------------- | --------------------------------------------------------- |
| `style` | `"track"` | `track`, `thin` | A full-height track, or a hairline thumb over the content |
| `thumb` | `""` | one-cell glyph | Thumb glyph. Empty uses the style's own |
| `track` | `""` | one-cell glyph, or `none` | Track glyph. Empty uses the style's own |
| `tint` | `"quiet"` | `quiet`, `border`, `muted`, or `#RRGGBB` | Bar colour |
### Selection colours
`[appearance.selection]` sets the colours TUIOS draws over pane content to mark text.
| Key | Default | What it sets |
| ------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bg` | `"#45475A"` | Background of selected text |
| `fg` | `""` | Selected text. Empty keeps the text's own colour |
| `bold` | `false` | Also draw selected text bold |
| `search_bg`, `search_fg` | `"#8A6D2F"`, `"#F5E7C8"` | Every search match |
| `match_bg`, `match_fg` | `"#E5A93D"`, `"#1C1B19"` | The match the cursor is on |
| `cursor_bg`, `cursor_fg` | `"#39C5CF"`, `"#08222B"` | The copy mode cursor |
| `flash` | `true` | Sweep a band of light over text you just copied. `appearance.motion = "none"` also turns it off |
| `flash_color` | `""` | Colour of the sweep |
| `flash_ms` | `420` | Length of the sweep, 80 to 3000 ms |
| `flash_style` | `"diagonal"` | `diagonal`, `diagonal-reverse`, `horizontal`, `vertical` |
| `copy_entry` | `"cursor"` | Where the copy cursor starts. `cursor` is the terminal cursor, usually the prompt line, as in tmux. `center` is the first column of the middle row |
| `multi_format` | `"plain"` | The format that [multi copy mode](/docs/layout-modes#multi-copy-mode) starts with: `plain`, `markdown` or `json` |
| `osc52_write` | `"focused"` | What OSC 52 from a pane does to the host clipboard: `off`, `ask`, `focused` or `on`. See [Clipboard writes from a pane](#clipboard-writes-from-a-pane) |
| `copy_command` | `""` | A command that gets each yank in copy mode on stdin. The clipboard gets its stdout. Empty copies the selection as it is. See [Pipe a yank through a command](/docs/keybindings#pipe-a-yank-through-a-command) |
An empty foreground keeps the colour the program wrote. An empty background falls back to the default.
### Clipboard writes from a pane
A program in a pane sets the clipboard with OSC 52. An editor uses OSC 52 to yank over ssh. Any output can also carry OSC 52, for example a file that a program prints. `appearance.selection.osc52_write` controls what happens.
| Value | What happens |
| --------- | ---------------------------------------------------------------------------------------------------- |
| `off` | The host clipboard does not change. |
| `ask` | The dock shows a message. Click the message to copy the text. |
| `focused` | The focused pane copies. A write from another pane shows a message in the dock. This is the default. |
| `on` | Every pane copies. This is the behaviour of v0.8.0. |
```toml
[appearance.selection]
osc52_write = "focused"
```
* When the focused pane copies text with a line break or a control character, the dock shows one line for that pane. A one-line copy shows nothing.
* Each pane keeps one clipboard ask. A click copies only the text that the dock shows. If the text changes first, click the new message.
* A pane that runs without the daemon keeps its own copy of the text. The program reads it back with an OSC 52 query. A pane under the daemon keeps no copy, so an OSC 52 query there always gets an empty answer.
* TUIOS drops a clipboard reply that answers no read request of TUIOS.
Pastes, notifications, OSC 66 text and link addresses lose their control characters. A paste into a pane keeps its tabs and line breaks. Text that holds `ESC[201~` cannot end a bracketed paste early.
### The session rail
`[appearance.sidebar]` configures the rail at the side of the screen.
| Key | Default | Values | What it does |
| ------------------ | -------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true` | bool | Show the rail |
| `position` | `"right"` | `right`, `left`, `hidden` | Edge the rail sits on |
| `width` | `24` | columns | Preferred width on a wide screen |
| `background` | `""` | `off`, `theme`, `#RRGGBB` | The rail's background. Empty follows `appearance.background`. See [Backgrounds](#backgrounds) |
| `agent_rest_fold` | `"1h"` | a duration, or `off` | How long an agent row rests (idle, unknown, or done and seen) before it folds into one `+N at rest` line |
| `sections` | `"sessions:25,terminals,files:25,agents:34"` | layout string | Which sections to draw, in what order. See [below](#sections) |
| `show_glyphs` | `true` | bool | Agent-state glyph on each row |
| `show_counts` | `true` | bool | Window count on each session row |
| `show_numbers` | `false` | bool | Switch number (the one `switch_session_N` opens) before each local session name, for sessions 1 to 9 |
| `marquee` | `true` | bool | Scroll a hovered row's long title |
| `tooltips` | `true` | bool | Label the collapsed strip on hover |
| `file_icons` | `true` | bool | A Nerd Font icon per file type in the files section |
| `file_icon_colors` | `true` | bool | Draw each icon in its file type's colour |
| `folder_click` | `"navigate"` | `navigate`, `cd`, `both` | What a click on a folder row does |
| `file_actions` | `true` | bool | Allow create, rename, delete, copy, cut and paste |
| `file_delete` | `"trash"` | `trash`, `permanent` | Where d sends a file |
| `editor` | `""` | a command | The terminal editor that opens a text file. Empty uses `$EDITOR`, then `$VISUAL`, then `vi`. See [The file editor](#the-file-editor) |
Deprecated keys still load: `show_windows`, `show_agents` and `workspaces` in this table, and the flat `sidebar_*` keys in `[appearance]`. They are folded into the current keys when the file is read.
#### sections
One string sets which sections the rail draws, their order from the top, and the share of the rail each may take.
```toml
[appearance.sidebar]
sections = "sessions:25,terminals,files:25,agents:34"
```
* Separate sections with commas. Add `:N` for a share in percent.
* The section names are `sessions`, `terminals`, `files`, `agents`, `git` and `custom`. Leave a name out to hide that section. See [The custom section](#the-custom-section).
* A share is a ceiling, not a reservation. A section only takes the lines its rows fill. A section with no share takes what the others leave.
* `spacer` draws nothing and takes lines. It is the only name you can repeat. A spacer with a share keeps that share. A spacer with no share takes the lines nothing else wants, which pushes what follows to the bottom.
```toml
[appearance.sidebar]
# Sessions at the top, files at the bottom
sections = "sessions:30,terminals,spacer,files:40"
```
An unknown name, a bad percent or a repeated section is dropped and reported as a config warning.
The settings page has an editor for this: the **Sections** row under Sidebar. Arrows select, Shift +arrows move, Enter adds or removes a section, ← /→ change the share, r restores the default and u undoes your edits.
#### The files section
The files section lists the focused pane's directory. It is read again when the shell changes directory, when the focus moves and when a client attaches. It is also read again when a file in the listed folder is added, removed or renamed, by the pane or by another program. tuios asks the system to report these changes (inotify on Linux, kqueue on macOS and the BSDs, ReadDirectoryChangesW on Windows). Nothing polls, and a change to the contents of a file does not cause a read. For a pane on another machine, the list changes only when the shell changes directory.
`folder_click` decides what a click on a folder does: `navigate` opens it in the listing, `cd` sends the focused pane there, `both` does both. A `cd` is only typed into a pane that is at a shell prompt.
The file keys are in `[keybindings.sidebar_files]` and work while the rail has the keyboard and the cursor is on a file row. See [Keybindings](/docs/keybindings#session-rail). Each action opens a centred dialog. The delete dialog opens on Cancel, so Enter on an untouched dialog deletes nothing.
Delete uses the freedesktop.org trash in your home directory, so your desktop's trash shows the file and can restore it. D deletes permanently. Set `file_delete = "permanent"` to make d permanent too. The per-volume trash is not implemented.
Y copies the absolute path of the selected file or folder. A folder's context menu has a **Copy path** row that does the same.
On Windows, a shell under MSYS2 or Cygwin reports its folder as `/c/work`. TUIOS reads that as `C:\work`, by the rules of `cygpath`, so the files section lists the right folder.
#### The file editor
Shift +Enter on a text file opens it in a terminal editor. `editor` sets the command. The settings page shows it as **File editor** under Sidebar.
```toml
[appearance.sidebar]
editor = "micro"
```
* Other terminal editors and arguments work too, such as `nano` or `vim -f`.
* Quote a path or an argument that contains spaces. TUIOS splits the command into arguments and runs no shell.
* TUIOS adds the full path of the file at the end.
* When `editor` is empty, TUIOS uses `$EDITOR`, then `$VISUAL`, then `vi`.
* File links and hints open files with the same command.
* TUIOS reads the first 512 bytes of a file to check that it is text. It refuses binary files and special files.
#### The custom section
The `custom` section shows the output of a command you write. Place `custom` in `sections` and describe it in `[appearance.sidebar.custom]`:
```toml
[appearance.sidebar]
sections = "sessions:25,terminals,custom:35,agents:30"
[appearance.sidebar.custom]
title = "Brief"
command = "agent-brief render"
refresh = "event:window-focused,agent-state"
```
| Key | What it does |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `title` | The section's heading. Defaults to `Custom` |
| `command` | Run with `sh -c`. Every line of stdout is a row |
| `refresh` | `once` (the default), a duration such as `"30s"` (one second at least), or `event:TYPE[,TYPE...]` with the dock's event types. `push` is refused |
The command follows the rules of a [dock component](#custom-components). It runs in the client, under a three second timeout, with bounded output. SGR colour stays, and every other control sequence is removed. A command that fails, times out or prints nothing leaves the title over an empty section. Five failures in a row stop it until the config reloads or you run `tuios refresh-dock rail/custom`. `tuios list-dock-components` lists it as `rail/custom`. Rows are cut to the rail's width and to the section's lines, with `… +N` for the rows below.
Each run gets the client's environment and these variables: `TUIOS_SESSION`, `TUIOS_SOCKET`, `TUIOS_RAIL_SECTION` (`custom`), `TUIOS_RAIL_WIDTH`, `TUIOS_RAIL_HEIGHT`, `TUIOS_ACTIVE_PANE_ID` and `TUIOS_ACTIVE_PANE_CWD`. An event that arrives during a run causes one more run after it, so a quick focus change never leaves the old pane's rows on screen. The command does not run while the rail is hidden or folded.
`sections` works with `tuios set-config`. `[appearance.sidebar.custom]` is read from the file only, so a program in a pane cannot change the command.
#### Agent rows
`[appearance.sidebar.agent_row]` chooses which tokens an agent row shows and styles each one, with optional rules that match on the token's value:
```toml
[appearance.sidebar.agent_row]
tokens = ["session", "need", "harness", "name", "elapsed", "context", "subagents", "pr", "meta", "now", "message"]
[[appearance.sidebar.agent_row.elapsed.rule]]
gt = 30 # minutes in the current state
fg = "warning"
```
The `tokens` above are the shipped list. The other tokens are `state`, `host` and `prompt`. A `tokens` list you wrote keeps its own order and gets no new tokens, so add `subagents` or `pr` to it to see them. `need` says what a row wants from you, such as `approval` or `question`. `context` shows `ctx 84%` once the agent's context is 80% full. `now` is what a working agent is doing, such as `Bash: go test ./...`. `subagents` shows how many subagents a Claude Code agent has at work, such as `3 subagents`, on any row while any run. `pr` shows the pull request of the session's worktree branch and its checks, such as `PR #12 open pass`. `meta` draws the other keys the pane reported with `tuios set-agent-meta`, and `$` followed by a key, such as `$model` or `$cost`, places one key. See [Session Rail](/docs/session-rail#agent-rows).
Each rule has one test: `equals`, `contains`, `starts_with`, `gt` or `lt`. The first matching rule wins. A mistake in this table is a warning, not an error.
## Splits that follow ssh
The actions `split_ssh_horizontal`, `split_ssh_vertical` and `new_window_ssh` open a pane on the machine that the focused pane is connected to with ssh. See [Keybindings](/docs/keybindings#split-into-the-same-ssh).
`appearance.new_window_follow_ssh` makes the ordinary actions do the same: `split_horizontal`, `split_vertical` and `new_window`. When the focused pane does not run ssh, these actions open an ordinary pane. The default is `false`.
```toml
[appearance]
new_window_follow_ssh = true
```
tuios finds ssh in the foreground of the pane. ssh can run from a shell, or from `sshpass`, `autossh` or `mosh`. tuios reads only the processes that you own in the pane. It runs the `ssh` or `mosh` that it finds on its own `PATH`, with no shell on this machine. It never runs the program that the pane runs.
The new ssh gets the same environment as an ordinary new pane. If the ssh that tuios follows has an `SSH_AUTH_SOCK` that names your own agent socket, the new ssh gets that value too. An agent that your shell started in the pane then works in the split. The socket must not be a link, and its folder must be yours and closed to other users.
The new ssh line keeps only what it needs to reach the same host as the same user. It always gets `-o ControlMaster=no`. Your `~/.ssh/config` applies to it as usual, so a setting that you put there still works in the split.
### Lines that are not followed
tuios follows an ssh line only when each `-o` option is on one of two lists. The new pane keeps these options:
`AddressFamily`, `BatchMode`, `CertificateFile`, `CheckHostIP`, `Ciphers`, `Compression`, `ConnectionAttempts`, `ConnectTimeout`, `EscapeChar`, `HostKeyAlgorithms`, `HostName`, `IdentitiesOnly`, `IdentityFile`, `KbdInteractiveAuthentication`, `KexAlgorithms`, `LogLevel`, `MACs`, `PasswordAuthentication`, `Port`, `PreferredAuthentications`, `ProxyJump`, `PubkeyAcceptedAlgorithms`, `PubkeyAuthentication`, `ServerAliveCountMax`, `ServerAliveInterval`, `StrictHostKeyChecking`, `TCPKeepAlive` and `User`.
The new pane does not get these options, and tuios still follows the line:
* Forwards and sessions: `LocalForward`, `RemoteForward`, `DynamicForward`, `GatewayPorts`, `ExitOnForwardFailure`, `ClearAllForwardings`, `Tunnel`, `TunnelDevice`, `RemoteCommand`, `SessionType`, `StdinNull`, `ForkAfterAuthentication` and `RequestTTY`.
* Files, sockets, environment and forwarding: `ControlMaster`, `ControlPersist`, `ControlPath`, `UserKnownHostsFile`, `GlobalKnownHostsFile`, `HostKeyAlias`, `IdentityAgent`, `SendEnv`, `SetEnv`, `ForwardAgent`, `ForwardX11` and `ForwardX11Trusted`.
* The flags `-A`, `-X`, `-Y`, `-S`, `-L`, `-R`, `-D`, `-W`, `-w`, `-N`, `-f`, `-n`, `-T`, `-t`, `-M` and `-s`.
These ssh lines get an ordinary pane:
* An `-o` option that is not on the two lists above. `ProxyCommand`, `LocalCommand`, `XAuthLocation` and `Match` are examples.
* An option or a value with a control character, such as a line break.
* `-E`, `-F`, `-I`, `-G`, `-V`, `-Q` and `-O`, and an option that tuios does not know.
* A `-J` or `ProxyJump` hop that is not `[user@]host[:port]` or the `ssh://` form, made of letters, digits and `._:@[]-`, or a hop that starts with `-`.
* A mosh destination that starts with `-`.
* mosh with `--ssh`, `--client` or `--server`.
* ssh that another program starts, such as `scp`, `rsync`, `git` or `sftp`.
Put a `ProxyCommand` or a `ProxyJump` in `~/.ssh/config` to use it with a split. A split with `-J` on the command line is followed.
### Start in the remote folder
The new pane starts in the remote folder when the remote shell reports its folder with OSC 7. The host name in the report must be the same as the host in the ssh line, or as the `HostName` that `ssh -G` gives for that line. `reachy-mini` matches `pollen@reachy-mini`. When `~/.ssh/config` makes `prod` an alias for `box.example.com`, a report from `box.example.com` or from `box` matches `ssh prod`. A report from `box.other.com` does not. When there is no match, the new pane starts in the remote home folder.
fish sends the report by default. For bash, add this line to `~/.bashrc` on the remote machine:
```bash
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$HOSTNAME" "$PWD"'${PROMPT_COMMAND:+;$PROMPT_COMMAND}
```
For zsh, add these lines to `~/.zshrc` on the remote machine:
```zsh
autoload -Uz add-zsh-hook
_tuios_osc7() { printf '\e]7;file://%s%s\e\\' "$HOST" "$PWD" }
add-zsh-hook precmd _tuios_osc7
```
To go to the folder, tuios adds `-t`, `-o RemoteCommand=none` and the command `exec sh -c 'cd "FOLDER" ...'` to ssh. This works with every login shell that can start `sh`. If the folder is gone, the new pane starts in the home folder. A folder name with a quote, `$`, a backslash or `!` is not used. A Windows folder, such as `C:/Users/x` or a network share `//server/share`, is not used either, because a Windows ssh server has no `sh`. The new pane then starts in the home folder.
## Glyph sets
A theme sets the chrome's colours. A glyph set sets its shapes: border corners, window buttons, rules, separators and rail marks.
```toml
[appearance]
glyphs = "heavy"
```
```bash
tuios list-glyphs # every set, and every role name
tuios list-glyphs heavy # what this set draws, role by role
tuios set-config appearance.glyphs heavy
```
| Id | What it is |
| --------- | ------------------------------------------------------------ |
| `default` | Rounded frame, Nerd Font powerline caps, `✕` and `□` buttons |
| `unicode` | Box drawing and geometric shapes, with no Nerd Font glyphs |
| `heavy` | One stroke weight heavier throughout |
| `ascii` | 7-bit ASCII only |
A set's border is used only when `border_style = "glyphs"`. This keeps a set from silently overriding a border style you chose. A set that defines only some border runes gets the rounded border for the rest.
### Writing a set
Save `.json` in the glyphs directory. `tuios list-glyphs` prints its path.
```json
{
"display_name": "Mine",
"inherits": "heavy",
"bullet": "◦",
"focus": "▐",
"border": {
"top_left": "╔",
"top_right": "╗",
"bottom_left": "╚",
"bottom_right": "╝"
}
}
```
Every field is optional. The id defaults to the file name. A role you do not set comes from the inherited set, and finally from `default`. Inheritance goes up to eight levels deep.
The roles:
* **Window buttons:** `close`, `maximize`, `minimize`, `dot`, `pill_left`, `pill_right`
* **Rules and separators:** `rule`, `separator`, `arrow_left`, `arrow_right`, `dash_rule`
* **Rail marks:** `focus`, `attention`, `bullet`, `add`, `collapse`, `expand`, `fold_open`, `fold_shut`, `tree_branch`, `tree_last`
* **Files section:** `folder`, `parent`, `file`
* **Scrollbar:** `scrollbar_thumb`, `scrollbar_track`
* **Text:** `ellipsis`, `sigil`
* **Border:** `border.top`, `border.bottom`, `border.left`, `border.right`, the four corners, and the junctions `middle`, `middle_top`, `middle_bottom`, `middle_left`, `middle_right`
Most roles must be exactly one cell wide, because the window buttons' click areas are fixed offsets. A glyph of the wrong width falls back to the default. `separator`, `ellipsis`, `collapse` and `expand` may be any width. To see what was dropped:
```bash
tuios list-glyphs mine --json | jq -r '.problems[]?'
```
The directory is read again on every lookup, so a new file can be selected at once. A file that does not parse is skipped and listed under `problems`.
`--ascii-only` replaces non-ASCII glyphs role by role, so a set keeps every role it already spelled in ASCII. A glyph set never changes colours, and it does not change the dock's icons.
The settings page has a **Glyph set** row under **Theme**. Its picker applies each set as you move through the list.
## Themes
342 themes ship with TUIOS.
```bash
tuios --list-themes # every theme id
tuios --preview-theme dracula # the 16 ANSI colours
tuios list-themes --filter gruvbox # from a running daemon, with --json
tuios list-themes catppuccin_mocha # colours and their contrast
```
```toml
[appearance]
theme = "dracula"
```
With no theme, TUIOS uses your terminal's own colours. A theme only changes ANSI colours 0 to 15. 256-colour and truecolor output is drawn as the program wrote it.
To pick one in the app, press Enter on the **Theme** row of the settings page, or choose **Theme Picker** in the command palette. The picker previews as you move and Esc restores the theme you had. It has no key binding.
### Custom themes
Save one theme per JSON file in `~/.config/tuios/themes/`. Subdirectories are ignored.
```json
{
"id": "my-theme",
"display_name": "My Theme",
"fg": "#e0def4",
"bg": "#191724",
"black": "#26233a",
"red": "#eb6f92",
"green": "#31748f",
"yellow": "#f6c177",
"blue": "#9ccfd8",
"purple": "#c4a7e7",
"cyan": "#ebbcba",
"white": "#e0def4"
}
```
* `id` defaults to the file name, lowercased. `display_name` defaults to `id`.
* The colour names are `black`, `red`, `green`, `yellow`, `blue`, `purple`, `cyan`, `white`, each with a `bright_` variant, plus `fg`, `bg` and `cursor`. Note `purple`, not `magenta`.
* A missing colour gets the xterm default. A missing `bright_*` colour copies its normal colour, so set them if you want bright text to look different.
* An optional `chrome` object sets the UI colours directly (`accent`, `accent_bright`, `success`, `warning`, `error`, `info`, `surface`) without changing the pane palette.
* A file that does not parse is skipped and logged.
`tuios import-theme ` converts a kitty, Ghostty, Alacritty or WezTerm scheme into a file here.
Selecting an id that is not registered yet, with `set-config` or the settings page, reads the directory again first. So does `tuios list-themes`. The picker builds its list when it opens.
## Notifications
`[notifications]` sets how long messages stay on screen. `0` uses the built-in default.
| Key | Default | What it does |
| ------------------ | ------- | ---------------------------------------------------- |
| `duration` | `0` | Seconds an info or success message stays up |
| `warning_duration` | `0` | Seconds a warning stays up |
| `error_duration` | `0` | Seconds an error stays up when `error_sticky` is off |
| `error_sticky` | `true` | Errors stay until you press Esc |
### Agent alerts
`[notifications.agent]` controls what happens when an agent in a pane changes state.
| Key | Default | What it does |
| ------------------------ | --------- | ------------------------------------------------------------------------------ |
| `enabled` | `true` | Turn every agent alert on or off |
| `notify` | `true` | Send a desktop notification through the terminal (OSC 9, so it works over SSH) |
| `dock` | `true` | Show the alert in the dock. Click it to go to the pane |
| `sound` | `false` | Make the alert audible |
| `sound_mode` | `"audio"` | `audio` plays a cue, `bell` sends BEL, `both` does both |
| `sound_cooldown_seconds` | `3` | Shortest gap between two sounds, across all panes |
| `command` | `""` | Shell command to run on an alert |
| `settle_seconds` | `2` | Wait this long, and drop the alert if the pane leaves the state |
| `suppress_focused` | `true` | No alert for the pane you are looking at |
| `quiet_hours` | `""` | `HH:MM-HH:MM` in local time when nothing alerts |
`[notifications.agent.states]` turns alerts on per state: `needs_input`, `errored` and `done` default to `true`. `idle` and `working` default to `false`.
To hear an alert, set `sound = true`. `sound_mode` then decides how:
* `audio` plays a short cue through the audio player of the machine that runs TUIOS: `paplay`, `pw-play` or `aplay` on Linux, `afplay` on macOS, PowerShell on Windows. If the machine has no player, the alert is silent and TUIOS shows no message.
* `bell` sends a BEL character to your terminal. Your terminal decides what a bell does, and some terminals make no sound. In kitty, check that `enable_audio_bell` is `yes`. Other terminals have a similar bell setting.
* `both` does the two.
Over SSH or in the browser, `audio` plays on the server, not where you sit. Use `bell` there. See [Agents](/docs/agents#hear-an-alert).
`[notifications.agent.sounds]` replaces the two built-in cues with your own files: `needs_input` (also used for `errored`) and `done` (also used for `idle`). A path that does not exist falls back to the built-in cue.
The `after-agent-state` [hook](/docs/hooks) follows the same rules, including the settle wait.
### Mail alerts
`[notifications.mail]` controls the alerts for mail to you and for session notices. A key that you leave out uses the same key from `[notifications.agent]`.
| Key | Default | What it does |
| ---------------- | ------------------------------------- | ------------------------------------------------------- |
| `enabled` | follows `notifications.agent.enabled` | Turn mail alerts on or off |
| `notify` | follows `notifications.agent.notify` | Send a desktop notification |
| `dock` | follows `notifications.agent.dock` | Show the alert in the dock. Click it to open the thread |
| `sound` | follows `notifications.agent.sound` | Make the alert audible |
| `between_agents` | `false` | Also alert on a message from one agent to another |
TUIOS warns when `between_agents` is on and mail alerts are off.
The sound mode, the cooldown, the cue files and the quiet hours always come from `[notifications.agent]`. `tuios set-config notifications.mail.sound ""` clears a key, and `get-config` then prints `(follows notifications.agent.sound)`. See [Agent Messaging](/docs/agent-messaging#mail-alerts).
## Keybindings
### leader\_key
The prefix key. The default is `ctrl+b`.
```toml
[keybindings]
leader_key = "ctrl+a"
```
`alt+space` is already the app launcher.
`opt+` and `option+` mean `alt+` (macOS only), `cmd+` and `command+` mean `super+`, and `control+` means `ctrl+`. See [Modifier spellings](/docs/keybindings#modifier-spellings). Press the leader two times in terminal mode to send it to the pane.
### Sections
Each section is a table under `[keybindings]`. Each action takes a list of keys. The [Keybindings](/docs/keybindings) page lists every default.
| Section | When it is active |
| ------------------- | -------------------------------------------------------------------- |
| `window_management` | Window mode: windows, sessions, zoom, copy |
| `workspaces` | Window mode: switch and move between workspaces |
| `layout` | Window mode: snapping, tiling, swaps, resizes, splits, preselect |
| `mode_control` | Window mode: enter terminal mode, help, settings, quit |
| `restore_minimized` | Window mode: restore a minimized window by number |
| `system` | Window mode: the spotlight |
| `navigation` | Empty. Kept so older files still parse |
| `terminal_mode` | Terminal mode, no prefix |
| `global` | Both modes: the command palette and the launcher |
| `prefix_mode` | After the leader key |
| `window_prefix` | After leader, t |
| `minimize_prefix` | After leader, m |
| `workspace_prefix` | After leader, w |
| `layout_prefix` | After leader, L |
| `tape_prefix` | After leader, T |
| `debug_prefix` | After leader, D |
| `script` | While a tape plays |
| `sidebar` | While the rail has the keyboard |
| `sidebar_files` | While the rail's cursor is on a file row. Checked before `sidebar` |
| `sidebar_agents` | While the rail's cursor is on an agent row. Checked before `sidebar` |
| `inbox` | While the Inbox is open |
| `inbox_peek` | While a prompt is open over the Inbox |
| `mail` | While the mailbox is open |
### Key syntax
* **Modifiers:** `ctrl+`, `alt+`, `shift+`, `super+`. On macOS, also `opt+` and `option+`, which mean the same as `alt+`.
* **Special keys:** `enter`, `return`, `esc`, `escape`, `tab`, `space`, `backspace`, `delete`, `up`, `down`, `left`, `right`, `home`, `end`, `pgup`, `pageup`, `pgdown`, `pagedown`, `f1` to `f12`.
* **Any single character:** letters, digits, symbols, and accented letters.
* **Held modifiers:** `leftalt`, `rightalt`, `leftctrl`, `leftsuper` and the like, for `hold_window_mode` only. They need the Kitty keyboard protocol.
`super+` only reaches TUIOS in terminals that speak the Kitty keyboard protocol. `cmd+` is not valid. On Linux, `opt+` is an error.
```toml
new_window = ["n", "ctrl+t"] # several keys for one action
close_window = [] # no key: the key goes to the pane
```
An empty list is different from a missing line. A missing action gets its default back at the next load. An empty list stays empty.
`shift+1` and `!` are the same key, and so are `shift+a` and `A`. Binding one spelling binds both. Do not bind the two spellings to different actions.
### Platform defaults
On macOS, the workspace and preselect defaults use `opt+`, and `terminal_mode` adds `opt+tab`, `opt+shift+tab` and `opt+esc`. Writing `alt+` on macOS works but gives an advisory warning. Elsewhere the defaults use `alt+`. See [Keybindings](/docs/keybindings#terminal-mode).
## The daemon table
| Key | Default | What it does |
| -------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `log_level` | `"off"` | `off`, `errors`, `basic`, `messages`, `verbose` or `trace` |
| `agent_autodetect` | `true` | Detect an agent CLI running in a pane and show its state |
| `agent_detect_seconds` | `0` | Seconds between checks. `0` means 2. A negative number turns detection off |
| `agent_binaries` | `[]` | Extra program names to treat as agents, added to the built-in list |
| `resume_agents` | `"ask"` | After a daemon restart: `ask` puts a Resume row in the Inbox for each pane whose agent was running, `auto` types each harness's resume command into the new shell, `off` does neither |
| `persist_scrollback` | `true` | Save each pane's history with its session, and show it above a divider after a daemon restart or a reboot. The files can hold secrets. `false` stops this, and the daemon deletes the saved history when it next starts. See [Pane history](/docs/sessions#pane-history) |
| `persist_scrollback_lines` | `0` | Most history lines one pane saves. `0` means 1000 |
| `persist_scrollback_kb` | `0` | Most KiB one pane's saved history takes on disk, compressed. `0` means 2048 |
| `window_size` | `"smallest"` | The size of a session that has more than one client: `smallest`, `largest` or `latest` (the client that last had input). The names are those of the tmux `window-size` option. See [Session size](/docs/sessions#session-size-with-more-than-one-client) |
| `single_client` | `false` | Keep one client for each session. Each attach detaches the other clients of the session, as `tuios attach -d` does. See [One client for each session](/docs/sessions#one-client-for-each-session) |
| `ssh_agent` | `"off"` | `"follow"` gives each session a stable link to the ssh agent of the client used last. New panes get `SSH_AUTH_SOCK` set to the link. See [SSH Agent](/docs/ssh-agent) |
| `respond_from_shell` | `false` | Let `tuios respond`, run from a shell outside every pane, answer an agent's prompt. A caller inside a pane is refused either way. Not settable with `set-config` |
A change to `single_client` or `ssh_agent` in the file applies to the next attach.
To change `window_size` for one session while it runs, use `tuios set-config daemon.window_size latest -s NAME`. That value is not saved to the file. A change in the file applies when the daemon starts again.
`tuios set-config daemon.log_level messages` changes the level of a running daemon at once. `verbose` and `trace` also log pane content, window titles and paths. `tuios logs` reads the log.
## The startup table
`[startup]` decides how a new session starts. It never changes a running session.
| Key | Default | What it does |
| ------------------------ | ------- | ------------------------------------------------------------------------- |
| `daemon` | `true` | A bare `tuios` attaches to a daemon session instead of running standalone |
| `tiled` | `true` | Start with tiling on |
| `layout` | `"bsp"` | Tiling layout: `bsp`, `master-stack` or `scrolling` |
| `open_default_window` | `false` | Open one window when a session starts empty |
| `start_in_terminal_mode` | `false` | Start in terminal mode when a window is present |
`--standalone` and `TUIOS_NO_DAEMON=1` override `daemon` for one run or one shell. If the daemon will not start, a bare `tuios` runs standalone and says so.
`daemon` and `tiled` default to `true` in a new install. A config file that already exists and does not mention them reads them as `false`, so an upgrade keeps the floating, standalone start it had. Add the keys to opt in.
In the same way, v0.8.0 changed eight appearance defaults: the rail is on, on the right, 24 columns wide; the dock and the window titles are on top; a zoomed pane takes 95% of the screen; a double click starts typing; and the scrollbar is a track. A config file that sets one of these keys keeps its value. The first-run file older releases wrote names most of them, so such an install keeps the old look until you remove those lines.
A session keeps its own layout once it is running. Attaching never rearranges a session to match your config.
## The workspaces table
When the last pane on the workspace on screen closes, TUIOS shows the workspace you came from. This happens when the pane's program stops, when you close the pane, when `tuios xpanes -ss` closes it, and with `tuios close-workspace`.
| Key | Default | What it does |
| ----------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `return_when_empty` | `true` | Show the workspace you came from when the workspace on screen becomes empty. `false` keeps the empty workspace on screen |
| `new_window_when_empty` | `false` | Open a pane when you switch to a workspace that has no panes, as the new-window key does |
TUIOS goes back in this order:
1. For a workspace that `tuios xpanes` opened, the workspace where you ran `tuios xpanes`.
2. The workspace you showed last that still has panes. After a chain of empty workspaces, TUIOS goes back past each one.
3. The workspace with the lowest number that has panes.
When no workspace has panes, TUIOS shows the splash screen. The workspace on screen is the same for every client of the session, so every client goes back. A pane that moves to another workspace does not close, so TUIOS does not go back.
```toml
[workspaces]
return_when_empty = false
```
The daemon reads the setting when it starts and when the file changes. `tuios set-config workspaces.return_when_empty false` changes it while TUIOS runs.
### A pane on an empty workspace
By default, a workspace with no panes shows the splash screen. To open a pane there when you switch to it, as tabs do in other multiplexers, turn on `new_window_when_empty`:
```toml
[workspaces]
new_window_when_empty = true
```
The new pane starts in the folder of the pane that had focus on the workspace you came from. It starts in the session's start folder (the folder that `tuios new --cwd` gave) when you came from a workspace with no pane, from a scratch terminal, or from a pane on another machine. With `appearance.new_window_follow_ssh = true`, a pane that runs ssh gives a new pane that runs the same ssh, as the new-window key does.
A pane opens only when you switch: with a workspace key, a click on the dock, the workspace switcher, the pane navigator or the command palette. These switches do not open a pane:
* `move_and_follow` and other moves that take a pane with them.
* `tuios xpanes`, `tuios select-workspace`, `tuios run-command` and tape scripts.
* The switch back that `return_when_empty` makes.
* The start of TUIOS and an attach to a session.
A workspace gets one pane, also when two clients switch to it at the same time. When you switch away before the pane opens, you stay where you went.
Each client reads the setting from its config file, and a change applies at once. The settings page lists it under Daemon, and `tuios set-config workspaces.new_window_when_empty true` changes it while TUIOS runs.
The daemon must be from the same release or newer. An older daemon opens no pane. Run `tuios kill-server` and start TUIOS again to load the new daemon.
## The launcher table
The app launcher starts a desktop entry in a new pane. A graphical program opens its own window, so its pane stays empty. Set `launcher.gui_command` to start such an entry with another command. The launcher adds the entry's argv to that command. Entries with `Terminal=true` and programs from `$PATH` still open in a pane.
```toml
[launcher]
gui_command = "tuios-wayland launch --" # or "niri msg action spawn --"
```
Do not use a command that joins its arguments into a shell line, such as `swaymsg exec --` or `hyprctl dispatch exec`. With such a command, the text of a desktop entry becomes shell code. Any installed package can write a desktop entry.
## The tape table
`[tape]` controls project tapes: a `.tuios.tape` file in a directory. See [Tape Scripting](/docs/tape-scripting).
| Key | Default | What it does |
| ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `autorun` | `"ask"` | `off` ignores project tapes. `ask` shows a badge and runs nothing until you choose. `auto` runs a trusted, unedited tape on entry |
| `auto_review` | `false` | Open the review dialog when a tape is found, instead of only showing the badge |
A tape edited after you trusted it is untrusted again.
## The debug table
| Key | Default | What it does |
| ----------------- | ------- | ------------------------------------- |
| `show_key_events` | `false` | Show the keycast of recent keypresses |
`--show-keys` and Ctrl +B D k toggle the same overlay. See [Showkeys](/docs/showkeys).
## The screenshot table
`[screenshot]` sets the defaults for [`tuios screenshot`](/docs/cli-reference#screenshot) and the in-app capture (Ctrl +B C ). Each key has a command flag that overrides it for one capture.
| Key | Default | Values | What it does |
| -------------- | ----------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `format` | `"png"` | `png`, `svg`, `ansi`, `html`, `txt` | Output format |
| `directory` | `"~/Pictures/tuios"` | path | Where captures are saved |
| `copy` | `true` | bool | Try to copy the capture to the clipboard |
| `preview` | `true` | bool | Open the preview panel after a capture |
| `frame` | `"window"` | `window`, `plain`, `none` | A window card, a plain card, or nothing |
| `background` | `"auto"` | `auto`, `none`, `#RRGGBB`, `#RRGGBB..#RRGGBB` | Backdrop. `auto` derives it from the theme. Two colours make a gradient |
| `padding` | `48` | 0 to 128 | Space around the card, in pixels |
| `radius` | `10` | 0 to 32 | Card corner radius, in pixels |
| `shadow` | `true` | bool | Soft shadow under the card |
| `controls` | `"auto"` | `auto`, `macos`, `glyphs`, `none` | Window buttons on the card |
| `title_format` | `"{title}"` | template | Title bar text, with `{title}`, `{index}` and `{cwd}` |
| `font_family` | `"JetBrains Mono, monospace"` | string | Font to use when your terminal does not report its own |
| `font_file` | `""` | path | Font file to draw with. Wins over every other font choice, and is embedded in SVG and HTML output |
| `scale` | `2` | 1 to 4 | PNG size multiplier |
| `cursor` | `false` | bool | Draw the cursor cell |
```toml
[screenshot]
format = "svg"
frame = "plain"
background = "none"
padding = 24
```
`png` and `svg` carry the frame. `ansi` and `txt` are plain output, so the frame keys do not apply.
## The screensaver table
`[screensaver]` animates the screen after a period with no input. It is off by default.
| Key | Default | What it does |
| -------------- | ---------- | ----------------------------------------------------- |
| `enabled` | `false` | Start after `idle_minutes` with no input |
| `idle_minutes` | `10` | Minutes of quiet before it starts, 1 to 240 |
| `effect` | `"random"` | The effect, or `random` for a different one each time |
| `while_busy` | `false` | Start even while a pane runs a command or an agent |
```toml
[screensaver]
enabled = true
idle_minutes = 5
effect = "matrix"
```
There are 36 effects. `tuios list-options screensaver.effect` prints them. The settings page previews each effect on your current screen.
S in window mode starts the screen saver now, whatever `enabled` says.
## The hints table
`[hints]` sets what [hints mode](/docs/hints) (Ctrl +B F ) labels.
| Key | Default | Values | What it does |
| ----------- | ------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `builtins` | `"all"` | `all`, `none`, or names such as `url,path,sha` | The built-in patterns to look for |
| `patterns` | `[]` | list of Go regular expressions | Your own patterns. A group named `match` sets the part that is copied |
| `alphabet` | `"asdfghjkl"` | lowercase letters | The letters of the labels, easiest first |
| `open` | `true` | bool | Let Ctrl and a label open a URL or a path |
| `dim` | `60` | 10 to 90 | How much the text around the labels dims, in percent |
| `all_panes` | `false` | bool | Put labels on all panes on the workspace, not only on the focused pane. See [All panes](/docs/hints#all-panes) |
See [What hints mode finds](/docs/hints#what-hints-mode-finds) for the pattern names.
## The panes table
`[panes]` sets the keys of the [pane labels](/docs/find-a-pane#pane-labels) (Ctrl +B Q ) and the first layout of the pane navigator (Ctrl +B / ).
| Key | Default | Values | What it does |
| ------------------ | -------------- | ----------------------------- | -------------------------------------------------------------------------------------------------- |
| `label_keys` | `"1234567890"` | letters `a` to `z` and digits | The keys the labels are made of, first pane first. With more panes than keys, a label has two keys |
| `navigator_layout` | `"tree"` | `tree`, `flat`, `cards` | The layout the pane navigator opens in. v changes it while the navigator is open |
## The scratch table
`[scratch]` sets the size of the scratch terminal that Ctrl +B g shows in a popup. See [Scratch terminal](/docs/sessions#scratch-terminal).
| Key | Default | Values | What it does |
| -------- | ------- | -------------------------------- | ------------------------------ |
| `width` | `"80%"` | cells (`100`) or percent (`80%`) | Width of the scratch terminal |
| `height` | `"80%"` | cells (`30`) or percent (`80%`) | Height of the scratch terminal |
The old `session` key is no longer used. TUIOS ignores it, and the config warnings tell you to remove it.
## The pip table
`[pip]` sets the size and the first corner of the picture-in-picture view. See [Picture in picture](/docs/sessions#picture-in-picture).
| Key | Default | Values | What it does |
| -------- | ---------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `width` | `40` | 12 to 200 | Width of the view in cells, border included. The view gets smaller on a small screen |
| `height` | `12` | 3 to 100 | Height of the view in cells, border included |
| `corner` | `"bottom-right"` | `bottom-right`, `bottom-left`, `top-right`, `top-left` | The corner the view goes to first. The view moves to a different corner when the line you type reaches it |
## Command keys
`[[keybindings.command]]` binds a key to a command that you write: a scratch terminal, a popup, a pane or a command with no window. A mistake in an entry is a warning, and TUIOS ignores that entry. See [Command keys](/docs/keybindings#command-keys).
## The spotlight table
`[spotlight]` lights one part of the screen and dims the rest, for screen sharing. B in window mode, or Ctrl +B B in any mode, turns it on and off. While it is on, Esc in window mode or a click on the Spotlight chip in the dock turns it off. See [Spotlight](/docs/keybindings#spotlight).
| Key | Default | Values | What it does |
| --------- | --------- | ----------------- | ---------------------------------------------------------------------- |
| `enabled` | `false` | bool | Turn the spotlight on |
| `follow` | `"mouse"` | `mouse`, `cursor` | Follow the pointer, or the focused pane's cursor |
| `radius` | `10` | 2 to 200 | Half the beam's height, in rows |
| `dim` | `75` | 10 to 95 | How much an unlit cell is dimmed, in percent |
| `edge` | `"hard"` | `hard`, `soft` | Cut the beam off, or fade it. A fade sends about three times the bytes |
| `shake` | `false` | bool | Shake the mouse left and right to toggle the beam |
`follow = "cursor"` sends fewer bytes to a remote client.
## The dock table
`[dock]` sets what the bar draws: three ordered lists of component names, plus a table for each component that needs one. Edit it in the file, or with the **Dock > Components** editor on the settings page. `set-config` cannot change the lists.
```toml
[dock]
left = ["mode", "workspaces", "trail", "tape"]
center = ["windows"]
right = ["notifications", "copy-help", "cpu", "ram", "clock", "session-controls"]
[dock.clock]
format = "15:04"
[dock.custom.branch]
command = "git branch --show-current"
refresh = "event:after-focus-change"
on-click = "tuios new-window log -- git log --oneline -20"
```
These lists are the default. A missing list uses the default. An empty list (`left = []`) draws nothing on that side.
The built-in components are `mode`, `workspaces`, `trail`, `tape`, `windows`, `notifications`, `copy-help`, `cpu`, `ram`, `clock` and `session-controls`. Listing one lets it draw. It still needs its own condition, so `cpu` still needs `show_cpu`. `copy-help` shows the keys of copy mode, multi copy mode and hints mode while the mode is open. See [Mode keys in the dock](/docs/keybindings#mode-keys-in-the-dock).
`[dock.clock]` takes `format`, a Go time layout. A layout without seconds refreshes once a minute.
### Custom components
`[dock.custom.NAME]` defines a cell. Put `custom/NAME` in a list to place it.
| Key | Default | What it does |
| ----------- | -------- | ------------------------------------------------------------------ |
| `command` | required | Run with `sh -c`. The first line of output is the cell text |
| `refresh` | `"once"` | `once`, a duration such as `"30s"`, `push`, or `event:TYPE[,TYPE]` |
| `on-click` | `""` | Run with `sh -c` when the cell is clicked |
| `max-width` | `24` | Widest the cell may be, in cells |
With `push`, the command keeps running and each line it prints replaces the cell. With `event:`, the command runs again when the daemon reports one of those [hook events](/docs/hooks).
A component whose command fails is hidden. `tuios list-dock-components` shows what each component last did and why a cell is missing. `tuios refresh-dock [name]` runs a component again now.
A command runs in the client, with a three second timeout. TUIOS reads at most 64 KiB of its output and stops the command when it prints more. SGR colour stays. TUIOS removes other control sequences, C1 controls, bidirectional and invisible characters, and bytes that are not UTF-8. Wide characters and emoji stay.
## The hosts table
`[hosts]` names the other machines this daemon links to. Each host is a table.
```toml
[hosts.build]
addr = "gaurav@buildbox"
[hosts.work]
addr = "workstation"
connect_timeout = 5
```
| Key | Default | What it does |
| ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `addr` | required | Anything ssh accepts, including an `ssh_config` alias. A host with no `addr`, or with one that starts with `-`, is ignored and logged |
| `connect_timeout` | `10` | Seconds one dial may take before the host counts as unreachable |
| `command` | `""` | The `tuios` binary on the host. Empty means the link finds it on the `PATH`, at the usual install paths, or through a login shell |
| `ssh_options` | `[]` | Extra ssh arguments, placed before the address. Only safe options are accepted. See [Rules for ssh\_options](#rules-for-ssh_options). `-A` forwards your ssh agent to the host. See [SSH Agent](/docs/ssh-agent#remote-hosts) |
| `repos_root` | `""` | Where the host keeps its checkouts, as the host reads it, such as `~/src`. `fan --host`, `worktree new --host` and `start-agent` look for a repository there by its origin URL |
You can also manage the table from the shell. These commands edit the file:
```bash
tuios hosts add build gaurav@buildbox
tuios hosts test build
tuios hosts remove build
```
A change that gives a machine less applies at once, also on links that are open. A new host, a host that dials another way, or a change that gives a machine more waits for `tuios config apply` from a terminal outside TUIOS. A daemon restart also applies it. So `tuios hosts add` from a pane waits. A host that you add with "Add a host" on the settings page applies at once. See [Changes that wait](/docs/agents#changes-that-wait).
### Rules for ssh\_options
`ssh_options` accepts only options that cannot run code or write files on this machine. Write each option as `-o Keyword=value`, with one plain value and no quotes.
* Accepted examples: `-p`, `-i`, `-l`, `-J`, `Port`, `User`, `HostName`, `HostKeyAlias`, `IdentityFile`, `StrictHostKeyChecking`, the `ServerAlive` options and the algorithm lists.
* Refused: `ProxyCommand`, `LocalCommand`, the known hosts files, `ControlPath`, `-F` and socket forwards.
* Refused: port forwards (`-L`, `-R`, `-D`).
* `-J` and `ProxyJump` take host names only. `ForwardAgent` takes only `yes` or `no`.
* A host `addr` or `command` cannot start with a dash.
TUIOS drops a host with a refused option. `tuios hosts` and the Inbox name the reason. Put a refused option in `~/.ssh/config`, and name that host in `addr`.
```toml
[hosts.build]
addr = "gaurav@buildbox"
ssh_options = ["-J", "bastion", "-o", "ServerAliveInterval=30"]
```
After the upgrade, look in the Inbox or run `tuios hosts`. A host with a refused option is dropped. Move the option, such as `ProxyCommand` or `LocalForward`, into `~/.ssh/config`. Keep port forwards in `~/.ssh/config` too.
The daemon runs ssh with `BatchMode` on, so a link never asks for a password or about a host key. Run ssh to the host once by hand to accept its key.
### What another machine may do here
The same `[hosts.NAME]` table, read on the machine a link arrives at, also says what the machine of that name may do there. `[hosts."*"]` covers every machine with no table of its own.
```toml
[hosts.laptop]
addr = "laptop" # optional: without it nothing is dialled
allow = ["list", "mail", "open", "write", "respond"]
hold_mail = true
hosted_grace = "10m"
[hosts."*"]
allow = ["list", "mail"]
```
| Capability | What it lets the other machine do here |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list` | Read sessions, windows, captures, agent state, the Inbox, prompts, waits and events |
| `mail` | Send and read agent mail, and use the stash |
| `open` | Start sessions, windows, worktrees, fans and agents |
| `write` | Type into panes, close and move windows, set options and names, report agent state, attach, and pull a worktree's work |
| `respond` | Answer for you: prompts, held approvals, `ask-human` questions, dismissing Inbox items and passing on held mail. Also type, from a pane on the other machine, into a pane here that waits on a prompt. The person at the other machine, outside every pane, needs only `write` for that |
With no table, a machine may `list`, `mail`, `open` and `write`. `respond` is opt-in. An `allow` that is set replaces that list, and `allow = []` allows nothing. Anything not allowed is refused with `forbidden`.
`hold_mail = true` holds mail from that machine to an agent here in your Inbox, marked `held for NAME`, until you pass it on with p . `hosted_grace` is how long a pane this machine runs for the other one (a window opened there with `--host`) keeps running after the link drops: a duration, `"0"` to end it with the link, 10 minutes when unset, at most 24 hours.
The name is the one the other machine gives for itself, its host name up to the first dot, unless the ssh key it logs in with pins one with `command="tuios stdio-proxy --as laptop",restrict`. Only a pinned name is a boundary. A change that allows less applies to the next call on every link. A change that allows more waits for `tuios config apply`.
### Using a linked host
Once a host is linked, you can list its sessions and agents (`tuios ls --all-hosts`, `tuios list-agents --host build`), attach to or create sessions on it (`tuios attach --host build api`, `tuios new --host build`), and open a pane whose process runs there (`tuios new-window --host build`). See [`tuios hosts`](/docs/cli-reference#hosts).
### Reaching a host you cannot ssh to directly
A host behind NAT, with no port forward and no jump host, is a network problem to solve under TUIOS. Any tool that pipes stdio to a remote port works as an ssh `ProxyCommand`. `ssh_options` refuses `ProxyCommand`, so name it in `~/.ssh/config`.
Tailscale's [tailcat](https://github.com/tailscale/tailcat) is one such tool. It carries a connection over WireGuard with no Tailscale account and no open port. Run it on the far side against sshd:
```sh
tailcat --serve=22
```
Then add a host to `~/.ssh/config` that uses it:
```text
Host laptop-tailcat
HostName laptop
User gaurav
ProxyCommand tailcat 22
```
Name that host in the host entry:
```toml
[hosts.laptop]
addr = "laptop-tailcat"
```
ssh still decides who may connect: the tunnel only carries bytes to sshd, which asks for your key as usual. A leaked tailcat token gives reachability, not access. TUIOS adds no identity system of its own and opens no listening port.
## The tailscale table
`[tailscale]` changes which tailnet machines `tuios hosts add` and `tuios hosts tailnet` offer as addresses. It never adds a host by itself.
| Key | Default | What it does |
| -------------------- | ------------------------ | --------------------------------------------------------------------- |
| `enabled` | `true` | Offer tailnet machines. Empty on a machine that is not on a tailnet |
| `addr` | `"dns"` | Address form: `dns` (MagicDNS name), `name` (short name) or `ip` |
| `user` | `""` | ssh login to put before every address |
| `users` | none | Per-machine logins, keyed by short name. Wins over `user` |
| `os` | platforms that run TUIOS | Operating systems to offer. An empty list offers every machine |
| `offline` | `false` | Also offer machines that are offline |
| `self` | `false` | Also offer this machine |
| `shared` | `false` | Also offer machines shared into your tailnet |
| `include`, `exclude` | `[]` | Glob patterns on the short name and the MagicDNS name. `exclude` wins |
| `max` | built in | Most machines to offer |
| `socket` | `""` | Path to the tailscaled socket, if it is not in the usual place |
```toml
[tailscale]
user = "ubuntu"
exclude = ["*-pad-*"]
[tailscale.users]
build = "admin"
```
## The agents table
`[agents]` sets how TUIOS treats the coding agents in its panes. Except for `enabled`, it is outside `list-options`, and `set-config` cannot change it, so a program in a pane cannot loosen it. The daemon reads it at start and again whenever the file changes. See [Inbox and Review](/docs/agent-inbox) for what each part does.
### Turn off agent features
```toml
[agents]
enabled = false
```
`enabled` (default `true`) is the one switch for every agent feature. With it off, TUIOS is only a multiplexer: no agent detection, no agents section on the rail, no Inbox, mail, approvals or agent alerts, and agent commands stop with a message. The change applies at once. It is the first row of the settings page, "Agent features", and `tuios set-config agents.enabled false` from a shell outside TUIOS also sets it. A process in a pane cannot change it. See [Turn Off Agent Features](/docs/agents-off).
### The other agent tables
```toml
[agents.approvals]
enabled = ["claude-code", "opencode"] # harnesses whose permission prompts the Inbox may answer
hold_seconds = 120 # 10 to 300
hold_plans = true # a plan follows enabled
[agents.approvals.risk]
builtin = true # keep the shipped risk rules
panes_may_allow = false # a pane with the respond grant may not allow a risky call
[[agents.approvals.risk.rule]]
name = "kubectl apply"
tools = ["Bash"] # empty: every tool
pattern = '\bkubectl\s+(apply|delete)\b' # RE2, matched per command
[agents.permissions]
mode = "strict" # open (default) or strict
grants = ["read", "write", "fan"] # what a pane holds under strict
[agents.recap]
mode = "toast" # toast, inbox or off
away = "10m"
test_patterns = ["go test", "pytest"]
[agents.queue]
max = 8 # at most 64
[agents.checkpoints]
enabled = true
keep = 50 # at most 1000
max_untracked_mb = 50 # a negative value means no limit
```
| Key | Default | What it does |
| -------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `approvals.enabled` | `[]` | Harnesses whose permission prompts wait for an answer from the Inbox: `claude-code` (or `claude`), `opencode` and `kilo`. Empty turns it off |
| `approvals.hold_seconds` | `120` | How long a prompt waits for your answer before the harness asks in its own pane |
| `approvals.hold_plans` | `true` | Also hold a plan an agent asks you to approve when it leaves plan mode |
| `approvals.risk.builtin` | `true` | Keep the shipped [risk rules](/docs/agent-inbox#risk-rules) |
| `approvals.risk.panes_may_allow` | `false` | Let a pane holding the `respond` grant allow a risky call |
| `approvals.risk.rule` | none | Your own rules: a `name`, the `tools` it applies to, and an RE2 `pattern` |
| `permissions.mode` | `"open"` | `open` gives a pane started with no grants of its own `admin`. `strict` gives it `grants`. Any other value reads as `strict` |
| `permissions.grants` | `["read", "write", "fan"]` | What such a pane holds under `strict`. An empty list gives nothing |
| `recap.mode` | `"toast"` | Where the away recap shows: `toast` in the dock and the Inbox, `inbox` only in the Inbox, `off` only in `tuios agent-log --recap` |
| `recap.away` | `"10m"` | How long you must have been away from a pane for the dock to show its recap |
| `recap.test_patterns` | built in | Commands that count as a test run in the recap, such as `go test` and `npm test` |
| `queue.max` | `8` | Messages one pane's [queue](/docs/agent-inbox#replying-and-the-queue) holds |
| `checkpoints.enabled` | `true` | Save the git work tree of an agent's pane each time the agent finishes a turn that changed a file. See [Checkpoints](/docs/checkpoints) |
| `checkpoints.keep` | `50` | How many checkpoints one pane keeps, at most 1000 |
| `checkpoints.max_untracked_mb` | `50` | Leave an untracked file larger than this many megabytes out of a checkpoint. `tuios checkpoint list` names the files left out, and a restore does not change them |
After turning on `approvals.enabled`, run `tuios integration install` for that harness again, so its hook is the version that waits. The grants are `read`, `write`, `fan`, `respond` and `admin`. See [What a pane may do](/docs/agents#what-a-pane-may-do).
A change to `[agents.permissions]` that gives panes less applies at once. A change that gives more, such as `strict` to `open`, waits. Run `tuios config apply` from a terminal outside TUIOS, or restart the daemon. See [Changes that wait](/docs/agents#changes-that-wait).
## The notify table
`[notify]` sends a push notification when the Inbox gets an item that waits for you. The daemon sends it, so it works when no client is attached. The providers are ntfy, Pushover and a webhook.
```toml
[notify]
web_url = "https://term.example.com/" # tuios-web, for a link to the item
[notify.ntfy]
url = "https://ntfy.sh/a-long-random-topic"
token_file = "~/.config/tuios/ntfy-token" # optional
```
| Key | Default | What it does |
| ---------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true` | `false` turns every notification off. Nothing is sent without a provider |
| `web_url` | `""` | The address of `tuios-web`. Each notification links to `web_url/inbox?item=ID` |
| `content` | `"summary"` | `summary` sends the kind, the pane name and the line the Inbox shows. `title` sends only the kind and the session |
| `quiet_active_seconds` | `120` | Hold a notification while you typed at an attached client in this many seconds. `0` sends at once |
| `cooldown_seconds` | `60` | The shortest time between two notifications for the same pane and kind |
| `max_per_hour` | `30` | The most notifications in one hour. `0` sets no limit |
| `allow_http_redirects` | `false` | Let a provider redirect to a plain `http` address |
`[notify.triggers]` selects the Inbox kinds: `approval`, `plan`, `question` and `ask` are on by default, `mail`, `errored` and `finished` are off. Like `[hosts]`, `[notify]` is not in `list-options`, and `tuios set-config` cannot change it. A new address or a new source for a secret waits for `tuios config apply` from a terminal outside TUIOS. `tuios notify test` sends a test notification through each provider. See [Push Notifications](/docs/push-notifications) for the providers and the secrets.
## Hooks
`[hooks]` runs shell commands on window, workspace, session and agent events. Event names use hyphens, such as `after-new-window`. See [Hooks](/docs/hooks) and `tuios list-hooks`.
## Validation
The file is checked when it loads. Problems are errors or warnings.
**Errors** are bindings TUIOS cannot parse, such as `"cmd+t"` or `"ctrl+"`. At startup they print to stderr:
```
Config error in [window_management]: cmd+t - invalid modifier: cmd
```
If the file has an error when TUIOS starts, TUIOS uses the built-in defaults for everything, not just the broken key. The message prints before the screen is taken over, so it is easy to miss. Run `tuios keybinds list` to see it: that command prints `Using default keybindings...` when the file was rejected.
A file saved with an error while TUIOS runs is not applied, and the error is shown on screen.
**Warnings** keep the file. They cover unknown values (which fall back to the default), unknown title placeholders, actions with no key, two actions on one key in the same section, and the macOS `alt+` advice. TUIOS shows them on screen.
## Troubleshooting
**The file is not loading.** Run `tuios config path` to confirm which file TUIOS reads, then `tuios keybinds list` to see parse errors. Run `tuios config files` to see every file that TUIOS reads.
**A setting changed nothing.**
1. Check the [table of what applies live](#applying-changes). A saved keybinding works at the next key press.
2. Check that the file parsed. Look for an error notification, or run `tuios keybinds list`.
3. Check whether a command-line flag is overriding it.
4. Check whether a later file sets the key. Run `tuios config origin `. An old `config.toml` that sets every key hides the other files. Run `tuios config prune` to remove its default values. See [Split the Config](/docs/config-files#clean-up-an-old-configtoml).
5. For a hook, check the event name spelling with `tuios list-hooks`.
**A key does nothing.** Run `tuios keybinds explain ` to see every place it is bound, and `tuios keybinds doctor` for conflicts.
## Examples
### Browser-style keys
```toml
[keybindings.window_management]
new_window = ["ctrl+t"]
close_window = ["ctrl+w"]
next_window = ["ctrl+tab"]
prev_window = ["ctrl+shift+tab"]
```
### Screen-style leader
```toml
[keybindings]
leader_key = "ctrl+a"
[keybindings.prefix_mode]
prefix_new_window = ["c"]
prefix_split_horizontal = ["S"]
prefix_split_vertical = ["V"]
prefix_session_switcher = ["s"]
prefix_scrollback = ["/"]
```
In this example, `S` and `s` are taken by the splits and the switcher, so the scrollback browser moves to `/`. Keys inside one section must not collide.
### A quiet, dense layout
```toml
[appearance]
theme = "nord"
border_style = "hidden"
dockbar_position = "hidden"
shared_borders = true
whichkey_enabled = false
window_title_format = "{index}: {title}"
scrollback_lines = 50000
max_fps = 120
```
## Related
# Split the Config
URL: https://tuios.dev/docs/config-files
> Keep the TUIOS config in more than one file with include lists and config.d, and see which file sets each key.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
You can keep the TUIOS config in more than one file. Use this to share one config between machines and keep the settings of one machine in a separate file. A Nix or home-manager setup can also put its settings in a file that it manages.
There are two ways to add files. Use one or both:
* An `include` list in `config.toml`.
* A `config.d` directory next to `config.toml`.
## The include list
Put an `include` list at the top of `config.toml`, before the first table:
```toml
include = ["hosts.toml", "~/.config/tuios/local.toml", "?work.toml"]
[appearance]
border_style = "thick"
```
* A relative path is relative to the file that has the `include` line.
* `~` is your home directory.
* A name that starts with `?` is optional. When the file is not there, TUIOS says nothing.
* An included file can have its own `include` list.
TOML puts a key below a table header into that table. An `include` key below `[appearance]` is `appearance.include`, and it includes nothing. TUIOS shows a warning when this happens.
### Windows paths
On Windows, write an include path with forward slashes, or put it in a single-quoted literal string. In a double-quoted TOML string, a backslash starts an escape.
```toml
include = ["C:/Users/me/tuios/local.toml", 'C:\Users\me\tuios\work.toml']
```
## The config.d directory
Put `*.toml` files in a `config.d` directory next to `config.toml`. TUIOS reads them in name order, so `10-theme.toml` comes before `50-hosts.toml`. You do not need to name them in `config.toml`.
## Which file wins
TUIOS reads the files in this order. A later file wins over an earlier file.
1. The files in the `include` list, in list order. An included file's own includes come before it.
2. The files in `config.d`, in name order.
3. `config.toml`.
`config.toml` comes last and wins. It holds only the keys that you changed. On the first start, TUIOS writes a `config.toml` with comments and no settings. The one exception is `[startup]`: TUIOS writes `tiled = true` and `daemon = true` there, unless another file sets them. Each change from TUIOS adds or changes only the key that you changed. So an included file applies everywhere except where you set a value of your own.
### How the files merge
* Tables merge key by key, at every depth. `[hosts.NAME]` tables merge by name, so each file can add its own hosts.
* For a single value, the later file wins.
* An array of values replaces the earlier array. It does not add to it. For example, a later `new_window = ["ctrl+t"]` replaces the earlier keys of `new_window`.
* An array of tables, such as `[[keybindings.command]]`, merges entry by entry. TUIOS never replaces it whole. A later entry matches an earlier entry by `name` when both have a name, and by `key` when they do not. A matched entry merges key by key. An entry that matches nothing goes at the end.
A relative file path in an included file, such as a `[plugins] dirs` entry or a `token_file`, is relative to that file.
### Remove an entry from an earlier file
To remove an array entry that an earlier file sets, add an entry with the same `name` or `key` and `disabled = true`. TUIOS removes both entries.
```toml
# config.d/50-work.toml: drop the deploy command from shared.toml on this machine
[[keybindings.command]]
name = "deploy"
disabled = true
```
## What is not an error
TUIOS shows a warning and continues in these cases:
* An include names a file that does not exist. TUIOS skips it, so one machine can include a file that only it has. With `?`, there is no warning.
* An include makes a cycle, or a file includes itself. TUIOS reads each file one time.
* TUIOS cannot read an optional include or a `config.d` file, such as a link that points to itself. TUIOS skips it.
* An `include` key is below a table header.
These stop the load, the same as an error in `config.toml`:
* A file that exists and has a TOML error.
* A required include that TUIOS cannot read.
* An include that names a directory. Put a directory of files in `config.d` instead.
## Find where a key comes from
```bash
tuios config files # every file, in merge order
tuios config origin # every key and the file that sets it
tuios config origin appearance.theme # one key, and the keys under it
```
`tuios config files` marks a file that TUIOS cannot write as read-only. `tuios config origin` also names the earlier files that set the same key. Their values lose.
```text
$ tuios config origin appearance
appearance.border_style config.toml (also set in shared.toml)
appearance.theme shared.toml
```
Both commands take `--json`.
## Hot reload
TUIOS watches every file it reads: `config.toml`, each included file, each file in `config.d`, and the `config.d` directory itself. When a file is a link, TUIOS also watches the file that the link points to. A save to any of these files applies at once. An included file that was missing applies when you make it, if its directory exists.
## How TUIOS saves a change
The settings page, `tuios set-config`, the keybind manager, `tuios keybinds unbind`, `tuios hosts add` and `tuios plugins` write the config. They write only the keys that you changed. They never copy the other files into `config.toml`. A save keeps the new values of a file that changed on disk while you worked.
Each change goes to one file:
* A key that a file sets goes to the last file that sets it.
* A new entry in a table of entries, such as a new `[hosts.NAME]`, goes to the last writable file that has entries in that table.
* Any other new key goes to `config.toml`.
* A removed key is removed from each file that sets it.
TUIOS changes only the lines of the key. The comments, the blank lines and the line endings of the file stay. A comment above a table stays with that table. TUIOS never writes a file again from its values. When TUIOS cannot write a change into the lines of an included file, it writes the change to `config.toml` and tells you. When it cannot remove a key that way, the save fails and the message names the file.
## Read-only files
TUIOS never writes a read-only file. This includes a file that Nix or home-manager links in from the store.
* When a read-only file sets the key, TUIOS writes the change to `config.toml` and tells you.
* When `config.toml` is read-only too, TUIOS writes the change to the last writable file that `config.toml` includes. That file must come after every file that sets the key. If there is no such file, the save fails and the message names the file to change.
* TUIOS cannot remove a key from a read-only file. To remove an array entry, TUIOS writes a `disabled = true` entry to a writable file.
TUIOS does not make a missing include. When `config.toml` is read-only, make the writable file yourself, and put it last in the `include` list:
```bash
touch ~/.config/tuios/local.toml
```
If TUIOS can write no file, a save fails with this message:
```text
tuios cannot write config.toml or any file it includes. Make config.toml writable, or include a writable file
```
## Clean up an old config.toml
An older TUIOS wrote every key into `config.toml`. That file wins over every other file, so it hides them. Run `tuios config prune` to remove the keys that have their default value.
```bash
tuios config prune --dry-run # show the keys, change nothing
tuios config prune # list the keys, ask, then change the file
tuios config prune --yes # do not ask
```
The command lists the keys that another file sets, because their value changes after the prune. A run with no terminal needs `--yes`. The `[startup]` keys stay. The comments and the `include` list stay.
`tuios config reset` writes the first-start `config.toml` again. It keeps your `include` list and lists the other files that still apply.
## Example: a dotfiles layout
One shared file in a dotfiles repository, one file per machine, and a `config.toml` that TUIOS owns:
```text
~/.config/tuios/
├── config.toml # written by TUIOS: only your local changes
├── shared.toml # link to ~/dotfiles/tuios/shared.toml
└── config.d/
└── 50-hosts.toml # the hosts of this machine, not in the repository
```
```toml
# config.toml
include = ["shared.toml", "?work.toml"]
```
```toml
# shared.toml
[appearance]
theme = "nord"
border_style = "rounded"
[keybindings]
leader_key = "ctrl+a"
```
```toml
# config.d/50-hosts.toml
[hosts.build]
addr = "build.lan"
```
`work.toml` is optional, so a machine without it gets no warning. A new host from `tuios hosts add` goes to `50-hosts.toml`, because that is the last writable file with `[hosts]` entries.
### Nix and home-manager
There are two setups:
* **TUIOS owns `config.toml`.** Let Nix write an included file, such as `nix.toml`, and put `include = ["nix.toml"]` in `config.toml`. A change in the app goes to `config.toml` and wins over the Nix value.
* **Nix owns `config.toml`.** Let Nix write `config.toml` with `include = ["local.toml"]` as the last entry. Make `local.toml` yourself, as a normal file. A change in the app goes to `local.toml`.
In the second setup, `config.toml` comes after `local.toml` and wins. A key that the Nix `config.toml` sets cannot change from the app, and the save fails. Put the settings that you change in the app in an included file, not in `config.toml`.
## Related
# Sessions & Daemon Mode
URL: https://tuios.dev/docs/sessions
> Persistent sessions that survive detaching, shared sessions, and what comes back after a restart.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
A session is a set of windows and workspaces. By default a background daemon holds your sessions, so they keep running when you close the terminal, and you can attach to them again later, from the same terminal or several at once.
## Quick start
```bash
tuios # attach to the session you used last, or create one
tuios new work # create a session called "work" and attach to it
# ... work, then press Ctrl+B d to detach ...
tuios ls # list sessions
tuios attach work # attach to "work" again
tuios kill-session work # end "work" and every window in it
```
## Daemon sessions and standalone sessions
TUIOS runs a session in one of two ways.
**Daemon session (the default).** A separate `tuios` daemon process owns the shells and runs a terminal emulator for each one. The TUIOS you see is a client: it draws what the daemon sends and forwards your keys. Closing the client does not stop anything.
**Standalone session.** Everything lives in the one `tuios` process. No daemon starts, no socket is created and nothing is saved. When the process exits, the session is gone.
Ask for a standalone session in any of these ways:
```bash
tuios --standalone # this run only
TUIOS_NO_DAEMON=1 tuios # every tuios started from this shell
```
```toml
[startup]
daemon = false # every tuios, from the config file
```
The flag and the environment variable win over the config file. If the daemon fails to start, `tuios` says so and runs standalone for that run, so you always get a terminal.
The rest of this page is about daemon sessions.
Daemon sessions work on Linux, macOS and Windows. Windows needs build 17063 or later, which added Unix socket support.
## Creating sessions
```bash
tuios # attach to the session you used last, creating one if none exist
tuios new # create a session with a generated name (session-0, session-1, ...)
tuios new work # create a session called "work" and attach to it
tuios attach work -c # attach to "work", creating it if it does not exist
```
A session's windows start in the directory where you run `tuios new`. `--cwd` names a different directory. The directory applies to the first window, and to each later window that has no pane to take a directory from. The session keeps this directory after a daemon restart, also when the directory is missing while the daemon starts.
```bash
tuios new api --cwd ~/dev/api
```
Inside TUIOS, press N in window management mode to create a session.
The daemon starts on its own the first time you need it.
A session name cannot contain `/` or `\`, start or end with whitespace, contain control characters, or be `.` or `..`. The name is also the name of the file the session is saved to.
### Headless sessions
`--detach` (short `-d`) creates a session without attaching to it. The session gets one window and is ready for `send-text`, `capture-pane`, `wait-for` and the [control protocol](/docs/control-protocol) straight away. This is the starting point for scripts. A session made by a command with no terminal on stdout (a script, a service, CI) gives its panes `TERM=xterm-256color` and `COLORTERM=truecolor`, so programs in them draw as they would under an attached client.
```bash
tuios new build --detach
tuios send-text -s build 'make all
'
tuios wait-for window-output -s build --pattern 'make: \*\*\*|Build complete'
```
A headless session is 80 columns by 24 rows until a client attaches. Anything you capture before then is 80 columns wide.
Appearance flags such as `--theme` and `--show-keys` do nothing with `--detach`. They apply to a client, and a headless session has none. Passing them is not an error.
## Listing sessions
```bash
tuios ls
```
`tuios ls --json` prints the same list for scripts. When no daemon is running, `tuios ls` lists the sessions saved on disk instead, marked `saved`, and exits with status 3.
A session with a label (`tuios set-session-name`) shows in the table as `name (label)`. The label is not the session's name: scripts and `-s` keep using the real name.
## Attaching and detaching
| Action | How | What happens |
| ------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Attach | `tuios attach [name]` | Draws the session in this terminal. With no name, attaches to the session you last typed or clicked in. |
| Detach | Ctrl +B d | Saves this client's view to the daemon and exits. The session and its shells keep running. |
| Quit | Ctrl +B q , or q in window mode | Opens the quit menu: detach (the default, so q q detaches), switch session, or kill this session. |
When you attach, you get the session exactly as you left it: the same windows, positions and screen contents, because the shells never stopped.
A client that dies without detaching, because its terminal closed or its SSH connection dropped, counts as a detach. The daemon notices the connection close and keeps the session running.
### Nested attach
Inside a TUIOS pane, `tuios attach` refuses to attach the session that holds the pane. A bare `tuios attach` or `tuios` in a pane also refuses, because it names no session.
* To show a different session in the pane, name that session: `tuios attach other`.
* To attach the same session, open a terminal outside TUIOS.
* To attach anyway, use `tuios attach --force`, or set `TUIOS_ALLOW_NESTED=1`.
The same refusal applies to a chain. For example, session A shows session B in a pane, and a pane of B then attaches A.
TUIOS finds the pane in these ways:
* The client runs on the pane's terminal.
* The client's output reaches the pane. This finds `script`, and ssh from the pane to the same machine or to the TUIOS SSH server. The attach does not wait for this check. If TUIOS finds the pane in the first 2 seconds, it detaches the client with the same message.
* A client with no terminal runs under the pane's shell, or has the pane's `TUIOS_` variables.
A terminal window that you start from a pane can attach, because its output does not reach the pane. New panes do not get the `TUIOS_` variables of an outer TUIOS pane.
TUIOS cannot find a client behind tmux or mosh in a pane. These redraw the screen and do not pass the output through. Such an attach goes through, and the session shrinks to 20x6 until the inner client exits. A forced attach also stays at 20x6 or more.
## Switching sessions
Ctrl +B S opens the session switcher.
| Key | Action |
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| Type | Filter sessions by name |
| ↑ ↓ , Ctrl +P Ctrl +N | Move the selection |
| Enter | Switch to the selected session. If nothing matches, create a session with the name you typed. |
| Ctrl +R | Rename the selected session. This changes its real name, not only a display label. |
| Ctrl +D | Kill the selected session, after a y /n confirmation. You cannot kill the session you are on this way. |
| Ctrl +U | Clear the filter |
| Esc | Close without switching |
Ctrl +R , the rail's rename, and [`tuios rename-session`](/docs/cli-reference#rename-session) all change the session's real name in the daemon. `tuios ls`, `attach` and every client show the new name at once, and the rename clears any label set with `set-session-name`. A pane that is already running keeps the old name in `TUIOS_SESSION`, and TUIOS commands from it still reach the session, until the session closes or a new session takes that name. `attach` and `kill-session` by the old name fail and name the new one. A name that another running or saved session has is refused, and so is an empty name or one with a slash or a control character.
With [remote hosts](/docs/remote-hosts), the switcher also lists the sessions on your other machines. A remote session shows as `name @ host`, and the filter matches that text. Enter switches to it, and back again, in either direction. The rail, the palette and session cycling switch across machines the same way. Rename and kill work only on sessions on the machine you are on. A rename on another machine reaches this one as that session closing and a new one appearing under the new name.
The session you leave keeps running. Ctrl +B ( and Ctrl +B ) step to the previous and next session without opening the switcher.
[`tuios switch-session`](/docs/cli-reference#switch-session) switches a client from the command line, also from a pane. When the target session is not arranged yet, `[startup]` applies to it, as on a first attach. For example, `startup.tiled = true` tiles a session that `tuios new --detach` made.
### Sessionizer
A sessionizer opens a project as a session. You pick a folder, and TUIOS shows its session. When the session does not exist, TUIOS makes it in that folder first.
Add this command key to `config.toml`. It opens a popup with `fzf` over the folders in `~/dev`:
```toml
[[keybindings.command]]
key = "prefix+alt+s"
type = "popup"
command = 'dir=$(find ~/dev -mindepth 1 -maxdepth 1 -type d | fzf) && tuios switch-session --create --cwd "$dir" "$(basename "$dir")"'
description = "Open a project"
```
Press Ctrl +B Alt +S , pick a folder, and press Enter . The client switches to the session of that folder, and its windows start in the folder.
* `tuios switch-session` switches the client that shows the popup. Nothing is nested.
* `--create` makes the session when it is missing. `--cwd` sets the folder of its windows. The session keeps this folder after a daemon restart.
* With `startup.tiled = true`, the new session is tiled. TUIOS reads this setting from the config of the client that switches. This is also true for a session on a host.
* To open a project on a [remote host](/docs/remote-hosts), put the host before the name: `"build:$(basename "$dir")"`. The folder is a path on that host.
From a shell outside TUIOS, name the client with `-s`:
```bash
tuios switch-session -s work --create --cwd ~/dev/api api
```
## Scratch terminal
Ctrl +B g shows the scratch terminal in a box over the current layout. Press Ctrl +B g again to hide it. tmux-floax does the same for tmux.
The scratch terminal is a small layout of its own. It starts as one shell. You can split it into more panes, and move, resize and zoom them, as on a workspace. The key shows and hides all of its panes together. For example, keep log tails of three services and a shell for tests in the scratch terminal, and go back to your code with one key.
Hiding does not close a pane. Each shell keeps running with its scrollback. The next Ctrl +B g shows the panes as you left them. The keyboard goes to the pane that had the focus, in terminal mode.
* Each session has one scratch terminal. TUIOS creates it the first time you press the key.
* The first shell starts in the folder of the focused pane, or in your home folder.
* The box shows over the workspace you are on. Its frame shows the name.
* When you hide it, the focus goes back to the pane you used before, in the mode you used before.
* The window keys act inside the box while a pane of the scratch terminal has the focus: split, focus movement, resize, swap, zoom, close and multifocus. `tuios xpanes` that you run in a pane of the scratch terminal opens its panes in the scratch terminal.
* A pane of the scratch terminal is not minimized, not floated and not moved to a workspace. The dock shows a message for a move.
* Closing a pane closes it. When you close the last pane, the scratch terminal ends and the box goes away. The next Ctrl +B g starts a new one.
* A focus on a pane of the hidden scratch terminal, for example with `tuios focus-window`, shows the box first.
* A click outside the box hides it and focuses the pane under the pointer. The click does nothing else. A focus on a pane outside the box, by a key or from the sidebar, also hides it.
* The mouse acts on the panes inside the box, as on a workspace. The box itself does not move or change size. Its size comes from `[scratch]`.
* The key works from inside the box. TUIOS reads Ctrl +B before the shell gets it.
* The sidebar, the window list, the window count and the workspace list do not show the panes of the scratch terminal. The `tuios list-windows` table does not show them. `tuios list-windows --json` shows each with `"scratch": true`, its `"scratch_name"`, and a workspace number of 1000 or more.
* The focus is part of the session. When a client shows the scratch terminal, every client of the session shows it.
* When you detach, the scratch terminal stays as it is. It is there when you attach again.
* After a daemon restart, the scratch terminal comes back hidden. Each pane has a new shell in its own folder, under its saved history, in the same layout.
* In a session on a different machine, the key does not open a scratch terminal. The dock shows a message.
* Until the daemon restarts after an upgrade, and while a client of an older TUIOS is attached to the session, the scratch terminal is one pane in a popup, as in older versions. The key shows and hides it the same way.
```toml
[scratch]
width = "80%" # cells (100) or percent (80%)
height = "80%"
```
Set these with `tuios set-config`, or on the in-app settings page (Ctrl +B , ). The `session` key is no longer used. TUIOS ignores it, and the config warnings tell you to remove it.
The scratch terminal is part of the current session. It does not make a separate session, so it does not change the session that a bare `tuios attach` picks.
The first version of this key made a session called `scratch`. TUIOS does not use that session any more. To remove it, run `tuios kill-session scratch`.
To add more scratch terminals, each on its own key and with its own command, add `[[keybindings.command]]` entries of type `scratch`. See [Command keys](/docs/keybindings#command-keys). Each one has its own layout, and the rules above apply to each of them. One box is on the screen at a time: a show replaces the other. After a daemon restart, the first pane of an entry has a shell, not the command of the entry. To start the command again, close the pane.
The action is `toggle_scratch`. To use a different key, bind the action in `[keybindings.prefix_mode]` or in a different section. If your config puts `g` on a different prefix action, `toggle_scratch` has no key. `tuios keybinds doctor` shows this. See [Keybindings](/docs/keybindings#prefix-commands).
## Picture in picture
The picture-in-picture view is a small live copy of one pane in a corner of the screen. Use it to watch a pane, such as a coding agent, while you work in a different pane.
To pin the focused pane, press p in window mode. Press p again, on any pane, to unpin it. The command palette has the same command. From a shell, run `tuios pip ` to pin a pane by ID or name, and `tuios pip --off` to unpin it.
* The view is not the pane. The pane stays where it is in the layout. It keeps running when it is on a different workspace, minimized or in a hidden scratch terminal.
* The view shows the last rows of the pane that hold text, from the left edge, at one cell for each cell. Its border shows the pane name and the agent state mark. It shows text only. Images do not show in it.
* The view is on top of the tiled panes and a zoomed pane. Popups, the scratch terminal, menus and panels are on top of the view.
* The view never takes the focus or a key.
* A click on the view focuses the pane. TUIOS goes to the workspace of the pane, restores a minimized pane and shows a hidden scratch terminal.
* The view does not show while its pane has the focus.
* The view does not cover the cursor of the focused pane or the text to the left of the cursor. When that text reaches the view, the view moves to a different corner.
* When the pane closes, the view closes and the dock shows a message.
* Each client has its own view. The view is not in the session state, so a different client does not see it. A detach or a switch to a different session removes it.
* `tuios pip` reaches one client. With more than one client attached, it acts on the client that `tuios run-command` reaches. When you attach to a session on a different machine, that machine cannot pin a pane on your screen.
* A kitty image in a pane under the view is cut around the view. A sixel image is not, so it can draw over the view. Floating panes have the same limit.
* One pane at a time is pinned. A pin replaces the previous one.
* The view reads the cells that the pane already holds. It adds no timer and does no work while the pane is quiet.
```toml
[pip]
width = 40 # cells, border included
height = 12
corner = "bottom-right" # bottom-right, bottom-left, top-right or top-left
```
The action is `toggle_pip`. If your config puts p on a different window mode action, `toggle_pip` has no key. `tuios keybinds doctor` shows this. See [Keybindings](/docs/keybindings#windows).
## Sharing a session
Several clients can attach to one session at the same time, for example two terminals on one machine, or a terminal and an SSH login.
* Every client sees the same windows, workspaces and output, and changes made in one show up in the others.
* The session has one size for all of its clients. `daemon.window_size` selects the client that sets it. See [Session size with more than one client](#session-size-with-more-than-one-client).
* Each client keeps its own input mode and copy mode position, so one person entering terminal mode does not change what another is doing.
* Focus is shared. The newest focus change wins, from any client or from a command such as `tuios focus-window`.
* Each change to the tiling tree goes to the daemon, which applies it and gives it a number. Two clients that change one layout at the same time end on the same layout.
* Each client shows a short notice when another client joins or leaves.
```bash
# Terminal 1
tuios new pairing
# Terminal 2
tuios attach pairing
```
Every attached client has full control: it sees all output, types into every shell and can close windows. The one exception is `tuios-web --read-only`, which ignores all input from browsers. Local clients are limited to your own user by the socket's permissions. SSH clients are limited by `tuios ssh` authentication, and browser clients by the `tuios-web` password. See [Who can connect](/docs/web#who-can-connect).
### Session size with more than one client
A pane has one size, so a session has one size for all of its clients. `window_size` in `[daemon]` selects the client that sets it. The values are those of the tmux `window-size` option.
| Value | The session uses |
| ---------- | -------------------------------------------------------------------------------------------------------------------------- |
| `smallest` | The smallest client. This is the default. Every client shows the full session. A larger client shows empty space around it |
| `largest` | The largest client |
| `latest` | The client that last had input |
```toml
[daemon]
window_size = "latest"
```
For `latest`, input is a key, a paste, a click, a drag or a turn of the mouse wheel. A resize, a focus change and a reply that your terminal sends by itself are not input. So a phone that only attaches does not take the session from the person who types on a laptop. A client keeps the session for 1 second after its last input, so two people who type at the same time do not resize the session on each key. When the latest client detaches, the session goes to the client with the most recent input.
A client that is smaller than the session shows the part around the cursor of the focused pane, as tmux does. In copy mode it follows the copy-mode cursor. An agent CLI hides its cursor in its input box, so you see the input box. A mark at the right end of the line above the dock shows the size of the session, with arrows that point to the parts you cannot see. The rail and the dock stay at the edges of your screen.
To change the value for one session while it runs:
```bash
tuios set-config daemon.window_size latest -s mysession
tuios session-info -s mysession --json # window_size, session_width, session_height
```
Under the tmux shim, `tmux set -g window-size latest` does the same.
A client that cannot send input, such as `tuios-web --read-only`, does not set the size under `largest` or `latest`. Under `smallest` it counts like every other client. A tool that uses only the verb socket, such as Collie, does not change the size. A client from a version of TUIOS before `window_size` makes the session use `smallest` while it is attached.
### The rail with more than one client
The rail is part of the session. When you show or hide it on one client, every client of the session shows or hides it. The panes then take the new space on every client.
A new session takes the rail setting of the first client that attaches, from `appearance.sidebar.enabled`. A later client with a different setting uses the session's value. When you toggle the rail, TUIOS also keeps the new value in your config for the next new session. A reload of the config file does not change the rail of a session that is running.
The width of the rail stays per client. A client too narrow to show the rail does not change it for the session. When the clients use different widths, the panes use the space next to the widest rail. A client with a narrower rail shows the difference as empty space.
A client from an older version of TUIOS keeps its rail to itself. A new client attached to an older daemon also keeps its rail to itself.
### Detaching other clients
To take a session for yourself, attach with `-d`. Each other client of the session detaches as you attach, as with `tmux attach -d`.
```bash
tuios attach -d work
```
The session continues to run. Each detached client exits with status 0 and shows this message:
```
Another client attached to this session.
Detached from session 'work'.
```
To detach clients without an attach, use `tuios detach-client`:
```bash
tuios detach-client -s work # detach every client of work
tuios detach-client -s work --all-other # keep the client used last in work
tuios detach-client --client client-1790941960197517900
```
`tuios list-clients` shows the client ids. With no flags, the command detaches the client used last in the session of the pane you run it in. Outside a pane, it detaches the client used last in the only session with a client. From a pane, the command needs the `admin` grant. See [detach-client](/docs/cli-reference#detach-client).
### One client for each session
Set `single_client` to keep one client for each session. Then each attach is `tuios attach -d`, and the newest attach wins.
```toml
[daemon]
single_client = true
```
Two kinds of attach never detach other clients:
* A view-only client, such as `tuios-web --read-only`.
* The attach that TUIOS makes by itself to get a session back after a host link drops.
A change in the config file applies to the next attach.
## Session resurrection
Detaching loses nothing, because the daemon keeps running. Resurrection is what happens when the daemon itself stops: `tuios kill-server`, a crash, or a reboot. It brings back the **shape** of each session and the history each pane showed, not what was running in it.
### When state is saved
Each session is written to disk within a couple of seconds of any change to its windows or layout, every 30 seconds regardless (which keeps each window's working directory current), and once more on a clean shutdown. The write is atomic, so a crash while saving cannot leave a half-written file.
When the daemon starts, it restores every saved session before it accepts connections.
### What comes back
* Every window, with its position, size, title and custom name
* Workspace assignment, minimize state and which window had focus
* The BSP tree for each workspace and the layout mode
* The working directory of each window
* Session options
* Each pane's history, above a dimmed divider. See [Pane history](#pane-history).
### What does not come back
Resurrection is crash recovery, not a snapshot. Your layout comes back and each window starts in the right directory, but whatever was running in it is gone.
* **Processes.** Every window gets a fresh shell. A `vim`, a build or an SSH connection that was running is not restarted. A coding agent's conversation can come back, though: for a pane whose agent was running and whose hook reported the conversation id, the Inbox offers to resume it (`daemon.resume_agents`). See [Resuming after a restart](/docs/agent-inbox#resuming-after-a-restart).
* **The last few seconds before a crash.** A window created just before a `SIGKILL` may be missing, and a `cd` made less than 30 seconds before it may not be recorded.
* **Your terminal's environment.** Restored shells inherit the daemon's environment and `$SHELL`, not those of the terminal you later attach from.
* **Working directories on Windows and the BSDs.** The daemon asks the kernel where each shell is, which works on Linux and macOS. Elsewhere, restored windows open in the shell's default directory.
A restored window prints a dimmed line so you do not mistake it for the old shell:
```
-- tuios: session restored, fresh shell in /home/you/project --
```
When the pane has saved history, the line is a divider under that history, with the time of the save:
```
$ make
...the last lines of the build...
$
-- tuios: restored from Sep 30 14:02, fresh shell in /home/you/project --
$ _
```
The `in ` part is left out when the directory is unknown. Restored shells also get `TUIOS_RESTORED=1` in their environment, so your shell profile can react.
The session is marked too. It shows a `restored` tag in `tuios ls`, the sidebar and the session switcher, and `tuios attach` prints a notice before drawing it. The mark clears on the first attach.
### What survives each kind of interruption
| | Detach, client crash, SSH drop | `kill-server`, `SIGTERM` | Daemon crash (`SIGKILL`, OOM) | Reboot |
| ------------------------------------ | ------------------------------ | --------------------------------- | ------------------------------------------------ | --------------------------------------- |
| Session exists afterwards | Yes | Yes, restored | Yes, restored | Yes, restored |
| Window layout | Yes | Yes | As of the last save | As of the last save |
| Working directories | Yes | Yes, on Linux and macOS | As of the last save, on Linux and macOS | As of the last save, on Linux and macOS |
| Shell processes and running programs | Yes | No | No | No |
| Screen contents and scrollback | Yes | Yes, as history above the divider | As of the pane's last save, up to 30 seconds old | Yes, as history above the divider |
| Rail shown or hidden | Yes | Yes | As of the last save | As of the last save |
| Copy mode position, input mode | No, these are per client | No | No | No |
### Pane history
The daemon saves each pane's history next to the session's state, and a restore shows it again. The history is text to read. It is not replayed into the new shell, and no command in it runs again. Copy mode, search and hints reach it like any other history.
* **What is saved.** The last 1000 lines of scrollback by default, and the screen. Colors, styles, wide characters and wrapped lines come back as they were.
* **Full-screen programs.** When `vim` or `htop` was open, the shell's screen under it is saved. The program's own screen is not.
* **Popups.** The scratch terminal's history comes back. Other popups do not come back, so their history is not saved.
* **Other machines.** A pane that ran on another machine comes back as a shell on this machine, without history.
* **Agents.** The resume offer still works. The resumed agent starts under the divider.
* **When.** At a clean stop, for every pane with new output. During the session, a pane with new output is saved at most once in 30 seconds. An idle pane is not written again.
* **Size.** Each pane's file is compressed and at most 2 MiB by default. One session's files are at most 16 MiB together. A pane that does not fit saves fewer lines.
* **Cleanup.** A pane's file goes when the pane closes. A session's files go when the session is killed. When the daemon starts, it deletes history that has no saved session.
The files are in `$XDG_STATE_HOME/tuios/sessions/scrollback//`, with mode 0600 in a directory with mode 0700.
The history files hold what your panes printed, which can include passwords, tokens and other secrets. Set `persist_scrollback = false` in `[daemon]` to stop saving it. The next time the daemon starts, it deletes the history it saved before.
```toml
[daemon]
persist_scrollback = true # save each pane's history (default true)
persist_scrollback_lines = 1000 # most history lines one pane saves (0 = 1000)
persist_scrollback_kb = 2048 # most KiB one pane's file takes (0 = 2048)
```
The daemon reads these settings when it starts.
### Controlling it
```bash
tuios resurrect # list saved sessions, whether each is live, and when it was saved
tuios resurrect work # restore "work" from saved state and attach to it
tuios daemon --no-restore # start a daemon that restores nothing on start
```
`tuios resurrect ` starts the daemon if needed. If the daemon already restored that session, it simply attaches. `restore` is an alias.
`tuios kill-session` deletes the session's saved state, and so does killing it from the quit menu. A session you killed on purpose never comes back.
A state file that is corrupt, or written by a newer TUIOS, is moved to an archive directory and skipped, so one bad file never stops the daemon from starting.
## Sessions on other machines
The daemon can reach TUIOS on other machines over ssh. Name a machine once:
```bash
tuios hosts add build you@buildbox # any address ssh understands, including an ssh_config alias
tuios hosts test build # check the link
```
Then:
```bash
tuios attach --host build api # draw the session "api" on build, in this terminal
tuios new --host build ci -d # create a session on build without attaching
tuios new-window deploy --host build # a window in this session whose shell runs on build
```
A window whose process runs elsewhere shows the machine in its title, as `build:deploy`. `tuios new NAME --global` creates a session meant to hold windows from several machines. The daemon runs ssh with `BatchMode` on, so ssh to the host once by hand first to accept its key.
See the [CLI Reference](/docs/cli-reference) for `tuios hosts`, including `tuios hosts tailnet` for machines on a Tailscale tailnet.
## Daemon location
| What | Where |
| ----------------------- | ------------------------------------------------------------------------------------------------------------ |
| Socket, Linux and macOS | `$XDG_RUNTIME_DIR/tuios/tuios.sock`, or `/tmp/tuios-/tuios.sock` when `XDG_RUNTIME_DIR` is unset |
| Socket, Windows | `%LOCALAPPDATA%\tuios\tuios.sock` |
| PID file | The socket path with `.pid` appended |
| Saved sessions | `$XDG_STATE_HOME/tuios/sessions/.json`, usually `~/.local/state/tuios/sessions/`, readable only by you |
| Archived bad state | `$XDG_STATE_HOME/tuios/sessions/archive/`, pruned after 14 days |
Whoever controls the socket's directory could put their own socket in its place and read what every client sends. Without `XDG_RUNTIME_DIR`, the directory is `/tmp/tuios-`, a name any local user could create first. So TUIOS creates the directory with mode `0700`, and before using one that exists it checks that it is a real directory (not a symlink) owned by you. It refuses one that is not, and tells you to set `XDG_RUNTIME_DIR` to a directory you own. A directory you own that others can open is closed to them.
To run the daemon in the foreground with logging, for debugging:
```bash
tuios daemon --log-level=messages
```
`tuios kill-server` stops the daemon and every session. It waits until every session is saved and the socket is removed, so you can start a new daemon as soon as it returns.
## How it works
Pick a scenario to see which process does the work:
The daemon owns the PTYs and keeps an emulator fed for each one, so output is parsed whether or not a client is watching. When a client attaches, it asks the daemon for each window's screen and scrollback and paints them.
## Remote control
Scripts can drive a running session through CLI commands that talk to the daemon. They all take `-s ` (default: the most recently active session) and most take `-w `, a window name or ID (default: the focused window).
### Run commands in a window
```bash
# Type a line and press Enter (the trailing newline submits it)
tuios send-text -w build 'go test ./...
'
# Send keys by name, as if typed at TUIOS
tuios send-keys 'ctrl+b,c'
```
`send-text` writes its argument to the shell exactly as given. `send-keys` parses key names and splits on spaces and commas, which suits chords and control keys. Use `send-text` for anything that contains spaces.
### Open windows
```bash
tuios new-window build # prints the new window's ID and name
tuios new-window htop /usr/bin/htop # run a program instead of a shell
tuios new-window --json logs | jq -r .window_id
```
### Wait and read
```bash
tuios wait-for window-output -w build --pattern 'PASS|FAIL' --timeout 600000
tuios wait-for window-idle -w build --idle 2000
tuios capture-pane -w build --lines 40
tuios capture-pane -w build --scrollback > build.log
```
`wait-for` blocks until the condition matches and exits non-zero on timeout, so you do not need a poll loop.
### Inspect
```bash
tuios list-windows --json # every window, with window_id, display_name, workspace and more
tuios get-window build # one window
tuios session-info # the session
```
### Run any command
`tuios run-command` runs any tape command, and any keybinding action by its name. `tuios run-command --list` prints the commands, and `tuios keybinds list` the actions.
```bash
tuios run-command ToggleTiling
tuios run-command SwitchWorkspace 2
tuios run-command --json NewWindow dev
```
With `--json`, the result fields sit at the top level, so the new window's ID is at `.window_id`.
### Example: a development layout
```bash
#!/bin/sh
tuios new dev --detach
tuios new-window -s dev editor
tuios new-window -s dev server
tuios new-window -s dev logs
tuios send-text -s dev -w editor 'nvim .
'
tuios send-text -s dev -w server 'npm run dev
'
tuios send-text -s dev -w logs 'tail -f app.log
'
tuios attach dev
```
### Example: pick a window with fzf
```bash
#!/bin/sh
WINDOW=$(tuios list-windows --json |
jq -r '.windows[] | "\(.display_name)\t\(.window_id)"' |
fzf --with-nth=1 | cut -f2)
[ -n "$WINDOW" ] && tuios focus-window "$WINDOW"
```
### Beyond the CLI
Each CLI call opens its own connection. For a long-running program, speak the [JSON control protocol](/docs/control-protocol) on the daemon socket directly: structured errors, blocking waits and a live event stream. Run `tuios list-verbs` to see every verb. The [CLI Reference](/docs/cli-reference#remote-control-commands) documents every command and flag.
## Troubleshooting
### "Daemon not running" or a stale socket
```bash
tuios kill-server # stops the daemon, or removes a stale socket if none is running
tuios # starts a fresh daemon
```
This does not give you a clean slate. `kill-server` saves every session on the way out, and the next daemon restores them. To start a daemon with no sessions, run `tuios daemon --no-restore`.
### Which `TERM` a pane gets
A pane gets the `TERM` of the client that made the session, if the machine that runs the pane has a terminfo entry for it. If it has none, the pane gets `xterm-256color`, so `clear`, `tput` and curses programs still work. To keep your terminal's own `TERM`, install its terminfo entry on that machine, for example with `infocmp -x xterm-kitty | ssh HOST tic -x -`.
### "Session not found"
The name is wrong or the session was killed. `tuios ls` lists the live ones, and `tuios resurrect` lists the saved ones.
### A session stopped responding
```bash
tuios kill-session stuck
tuios new fresh
```
`tuios logs` shows the daemon log.
## Related
# Remote Hosts
URL: https://tuios.dev/docs/remote-hosts
> Open sessions on other machines in your own client, run panes on another machine, and script across hosts.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
TUIOS can reach other machines over ssh. Once a machine is added as a host, you can list its sessions and agents, open one of its sessions in your own client, run a single pane on it inside a local session, and send commands and messages to it.
Each host needs TUIOS installed. Your daemon holds one ssh link to each host and talks to the TUIOS daemon on the other side. There is no new listening port and no second login system: ssh decides who can connect.
## Adding a host
```bash
tuios hosts add build gaurav@buildbox
```
The name (`build`) is what you type later. It takes letters, digits, dot, dash and underscore. The address is anything ssh understands, including an alias from `~/.ssh/config`.
The command writes the `[hosts]` table in your config file. The link is tested at once and the result is printed. A host that does not answer is still added.
From a terminal outside TUIOS, the running daemon opens the new host with no restart. From a pane, the new host waits for `tuios config apply` from a terminal outside TUIOS. A host that you add with "Add a host" on the settings page applies at once. See [Changes that wait](/docs/agents#changes-that-wait).
```bash
tuios hosts # every host and the state of its link
tuios hosts test build # dial it now and print what ssh said
tuios hosts test build --start # start its daemon if it does not run
tuios hosts remove build # drop it
```
`hosts add` also takes:
* `--ssh-option ARG`: one extra ssh argument. Repeat it, for example `--ssh-option -J --ssh-option bastion` for a jump host.
* `--connect-timeout SECONDS`: how long one dial may take (default 10).
* `--command PATH`: the TUIOS binary to run on the host. Without it, the link looks on the `PATH`, then in the usual install locations, then in a login shell.
* `--repos-root DIR`: where the host keeps its checkouts, such as `~/src`. `fan --host`, `worktree new --host` and `start-agent -s HOST:SESSION` look there for a repository by its origin URL.
The daemon runs ssh with `BatchMode` on, so a link never asks for a password or about a host key. Run `ssh` to the host once by hand to accept its key, and use key-based login.
### Link states
| Status | Meaning |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `up` | The link is open and the remote daemon answers |
| `no_daemon` | The machine is up and no TUIOS daemon runs on it. See [Start the daemon on a host](#start-the-daemon-on-a-host) |
| `no_tuios` | The machine is up and the link cannot find TUIOS on it. `tuios hosts test` shows where it looked |
| `unreachable` | The last attempt failed. The line under the table says why |
| `reconnecting` | The link dropped and TUIOS is dialling again |
| `tailscale_check` | Tailscale SSH waits for you to approve the login. The line under the table gives the link to open. See [Tailscale SSH check mode](#tailscale-ssh-check-mode) |
| `incompatible` | The two sides speak different control protocol versions. Update one of them, or run [`tuios hosts sync`](#keeping-hosts-on-one-version) |
| `connecting` | The first attempt has not finished |
### The hosts table
`tuios hosts add` writes this for you. You can also edit it by hand:
```toml
[hosts.build]
addr = "gaurav@buildbox"
[hosts.lab]
addr = "lab-01"
connect_timeout = 5
command = "/opt/tools/tuios"
ssh_options = ["-J", "bastion"]
```
`ssh_options` accepts only safe options, such as `-J`, `-p` and `-o Keyword=value`. TUIOS drops a host with a refused option, such as `ProxyCommand` or a port forward, and `tuios hosts` names the reason. Put such options in `~/.ssh/config`. See [Rules for ssh\_options](/docs/configuration#rules-for-ssh_options).
A machine behind NAT is a reachability problem for ssh. Any tool that works as an ssh `ProxyCommand` in `~/.ssh/config` works here too. See [Configuration](/docs/configuration#reaching-a-host-you-cannot-ssh-to-directly) for an example.
## Machines on a tailnet
If this machine is on a [Tailscale](https://tailscale.com) tailnet, TUIOS can list the machines on it:
```bash
tuios hosts tailnet
```
```
arch-btw arch-btw.example.ts.net offline
+ ente ente.example.ts.net
= forgejo forgejo.example.ts.net already the host forgejo
my-phone my-phone.example.ts.net cannot run tuios (iOS)
```
Add one by its name:
```bash
tuios hosts add ente --tailnet
```
Nothing is added on its own. The list only saves you typing the MagicDNS name. A host added this way is still reached over ssh. TUIOS gets the list by running `tailscale status --json`, looked up on `PATH` and then inside the macOS app bundle, so it needs no root and no auth key. Without the `tailscale` command, the list is empty.
By default the list leaves out machines that are offline, this machine, machines shared from another tailnet, and phones and tablets. The `[tailscale]` table changes that:
```toml
[tailscale]
addr = "dns" # dns (MagicDNS name), name (short name) or ip (100.x address)
user = "ubuntu" # ssh login put in front of every address
os = ["linux", "macOS"] # an empty list offers every OS
offline = false
include = ["*"]
exclude = ["*-pad-*"]
[tailscale.users]
build = "root" # per-machine login, wins over user
```
`tuios hosts tailnet --json` gives every machine with `offered`, and a `skipped` reason when it is false.
### Tailscale SSH check mode
Tailscale SSH can hold a login until you approve it in a browser. This is "check" mode. ssh then waits, and Tailscale prints a link to open:
```text
# Tailscale SSH requires an additional check.
# To authenticate, visit: https://login.tailscale.com/a/l1d9c7e392d3020
```
TUIOS reads this link and shows it.
* **The daemon's link** shows `tailscale_check` in `tuios hosts`, with the link to open. The link waits up to 10 minutes. It goes on when you approve the login.
* **`tuios hosts test`** and **`tuios hosts add`** report `tailscale_check` with the link. Open it, approve the login, then run the command again.
* **`tuios hosts sync`** on a terminal lists the link of each host that waits. Then it asks to wait up to 5 minutes. Each host continues when you approve its login. Without a terminal, or with `--json`, the host fails at once. Its row has `"error_kind": "tailscale_check"` and `approval_url`.
All ssh calls of one `hosts sync` run to a host use one shared connection. Thus one approval covers the whole run. The connection uses a private socket in `$XDG_RUNTIME_DIR` and stops when the run ends.
If the tailnet policy refuses the login, TUIOS names the user it refused. The row has `"error_kind": "tailscale_policy"`. Change the user in the `addr` of the host in `[hosts]`, for example `addr = "ubuntu@ente"`.
## Opening a session on a host
```bash
tuios attach --host build api # attach the session api on build
tuios new --host build # create a session on build and attach it
tuios new --host build ci --detach # create the session ci on build and return
tuios ls --all-hosts # sessions on every machine
```
The session is drawn in your client, with your theme, config and prefix key. Nothing is nested. The connection goes through your local daemon and its link to the host.
In the rail, sessions are grouped by machine. Press Enter on a session under a host to attach it, or on the `+` beside a host to create a session there. While you are on a host, the rail lists this machine's sessions under `local`. Press Enter on one to come back.
If the link drops, the session keeps running on the host. Your client keeps the pane on screen, the dock says it is reconnecting, and TUIOS dials again on its own. After three minutes it gives up, says why, and returns you to the session you left on this machine.
`--ssh` runs `ssh` to the host and starts the TUIOS there instead. Use it when the host's TUIOS is too old to serve your client. You then see the remote client nested in yours, and you press the prefix key twice to send it to the inner one.
## Panes on another machine
A single pane can run on another machine while it stays in a local session:
```bash
tuios new-window deploy --host build
```
The palette has the same thing as `New window on another machine`. The pane is drawn, laid out and closed here. Only its process runs on `build`. Its title bar reads `build:deploy`, so two panes side by side never look alike when they are on different machines.
The session is still an ordinary session: `tuios ls`, scripting, the mailbox, hooks and resurrection work on it as usual. The host runs only the process and its pty. It keeps no screen or scrollback for the pane, and the pane does not appear in `tuios ls` on the host.
Agent detection works in such a pane: the local daemon asks the host what the pane is running. An agent inside it can also report its own state and read its own mail with the usual commands and hooks: the report goes to the daemon on the host, which passes it back over the link as that window and nothing else. These panes have `TUIOS_PANE_ID`, `TUIOS_PANE_HOSTED=1`, `TUIOS_HOST` set to the host, and `TUIOS_SESSION_REMOTE` set to the session name here. They do not have `TUIOS_SOCKET` or `TUIOS_SESSION`, since those would name a daemon and a session on the host.
To paste an image from your clipboard into such a pane, press Ctrl +B V . TUIOS sends the image over the link. The host saves it to a file, and TUIOS pastes the path of that file into the pane. See [Paste an image](/docs/keybindings#paste-an-image).
**Limits:**
* The pane outlives a dropped link for a while. The host keeps its process running for its `hosted_grace` (10 minutes unless the host's config says otherwise) and keeps its last 64 KB of output. The title bar reads `[reconnecting] build:deploy`, and keys are refused rather than queued. When the link comes back, the pane is reattached and what it printed meanwhile is written to it. If the grace runs out first, the pane closes.
* Closing the pane ends its process at once.
* A restart of this daemon does not reattach. The processes on the host wait out their grace and end.
* A resurrected session brings the pane back as a local shell.
* Both machines need a TUIOS new enough to open remote panes. An older one refuses with a message that says to update.
### Global sessions
A session with panes from several machines belongs to none of them. Once a second machine is reachable, the rail draws a `global` group above the machines for such sessions.
```bash
tuios new deploy --global
```
A global session starts with no panes. Every way of making a pane in it (the key, the rail's `+`, the palette) asks which machine to run on. Turn the group off with `appearance.global_session = false`.
## Scripting across machines
Name the host in the target and a command runs on that host's daemon, through the link:
| Target | Means |
| ------------------------ | ----------------------------------------------------- |
| `-s HOST:SESSION` | A session on a host |
| `-w HOST:SESSION:WINDOW` | A pane in a session on a host |
| `-s local:NAME` | A session on this machine whose name contains a colon |
```bash
tuios list-windows -s build:api
tuios capture-pane -w build:api:0
tuios send-text -s build:api -w 0 'make test'
tuios wait-for window-idle -w build:api:0
tuios list-agents --host build
tuios kill-session build:api
```
The CLI says which host answered, and `--json` adds a `host` field. A host name is matched exactly. An unknown name fails with `unknown_host` and lists the configured names, and a host that is down fails with `host_unreachable`. A command is not queued for later.
Messages and files cross links too. Mail to a host whose link is down waits on this machine, under Waiting to send in the Inbox, and goes when the link is back. See [Agent Messaging](/docs/agent-messaging#across-machines).
Agents on every linked host appear on the rail and in your [Inbox](/docs/agent-inbox), and `fan`, `worktree` and `start-agent` can run there. See [Worktrees](/docs/worktrees#work-from-another-machine).
## Keeping hosts on one version
`tuios hosts sync` installs the TUIOS version of this machine on each host that runs another version. A release build sends its own release. `--dev` sends a build of a checkout, and `--binary` sends a file.
```bash
tuios hosts sync --dry-run # see what would change
tuios hosts sync --dev # install a build of this checkout on every host
tuios hosts sync build lab # only these hosts
```
The host needs no internet: sync sends the binary over ssh. It never uses sudo. It installs over the `tuios` on the host, or to `~/.local/bin/tuios` when it cannot write that folder.
The daemon on a host keeps running the old version, and its sessions keep running. The row for the host gives the command that restarts it. Add `--restart` to restart the daemons in the same run. sync lists the sessions and the programs that a restart ends, and asks first. Without a terminal, add `--yes`. The sessions come back with their layouts and new shells.
See [hosts sync](/docs/cli-reference#hosts-sync) for every flag.
## Start the daemon on a host
A link reaches a host only when a TUIOS daemon runs there. When none runs, `tuios hosts test build` reports `no_daemon` and prints the command to run on the host:
```
build gaurav@buildbox no_daemon
The host is up and no tuios daemon is running on it.
To start the daemon, run this command on the host:
/home/gaurav/.local/bin/tuios start-server
Or start it from this machine:
tuios hosts test build --start
```
To start it from this machine, add `--start`. It runs that command over ssh and tests the host again. `--dry-run` shows the command and changes nothing.
```bash
tuios hosts test build --start # start the daemon on build if it does not run
tuios hosts sync build --start # install TUIOS on build if needed, then start it
```
`hosts test --start` does not install TUIOS. On a host with no TUIOS, use `hosts sync --start`.
## Your ssh agent on a host
With `ssh_agent = "follow"` on both machines and agent forwarding on the link, a session that you attach on a host uses the ssh agent of your client here. TUIOS does not forward the agent by default. Forward it only to a host you trust. See [SSH Agent](/docs/ssh-agent#remote-hosts).
## What another machine may do here
The `[hosts]` table on the machine a link arrives at also says what the machine of that name may do there. With no entry, a linked machine may list, send mail, open sessions and panes, and type into panes. Answering prompts for you (`respond`) is off unless you allow it:
```toml
[hosts.laptop]
allow = ["list", "mail", "open", "write", "respond"]
hold_mail = true # hold its mail to agents here in your Inbox until you pass it on
[hosts."*"] # every machine with no entry of its own
allow = ["list", "mail"]
```
Anything not allowed is refused with `forbidden`. See [What another machine may do here](/docs/configuration#what-another-machine-may-do-here) for every capability and option.
## Related
# SSH Agent
URL: https://tuios.dev/docs/ssh-agent
> Let the panes of a session use the ssh agent of the client you attached from, on this machine and on remote hosts.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
A shell in a pane keeps the `SSH_AUTH_SOCK` it started with. When you attach later from a new ssh connection, that value names an old agent, and `git push` or `ssh` in the pane fails. With `ssh_agent = "follow"`, the panes of a session use the agent of the client that you used last.
## Set it up
Turn on the option in `[daemon]`:
```toml
[daemon]
ssh_agent = "follow"
```
The default is `"off"`. A change in the config file applies to the next attach.
Then ssh to the machine with agent forwarding, and attach:
```bash
ssh -A workstation
tuios attach work
```
Each session gets a stable link to an agent socket. Each new pane of the session gets `SSH_AUTH_SOCK` set to that link. The link is `agent-.sock` in the folder of the daemon socket, for example `$XDG_RUNTIME_DIR/tuios/agent-3f2a9c1d-0b7e-4d0a-9c4e-5a1f2b3c4d5e.sock`. To print it, run `tuios ssh-agent-path`.
### The shell rc line
A shell that started before you turned on the option keeps its old value. Add this line to your shell rc. Then every shell in a pane uses the link:
```bash
p=$(tuios ssh-agent-path 2>/dev/null) && export SSH_AUTH_SOCK="$p"
```
Outside a pane, or with the option off, the command fails and the line changes nothing.
To see the socket that the link points at:
```bash
tuios ssh-agent-path -s work --json
```
```json
{"session": "work", "path": "/run/user/1000/tuios/agent-3f2a9c1d-0b7e-4d0a-9c4e-5a1f2b3c4d5e.sock", "follow": true, "target": "/tmp/ssh-XXXXabc/agent.4242"}
```
`target` is absent when there is no link.
## How follow chooses the agent
The link points at the agent socket of the client that attached to the session, or used it, last.
* Only `tuios attach` and `tuios new` send their `SSH_AUTH_SOCK`. Other commands do not move the link.
* A client that runs inside a pane does not count. TUIOS finds such a client by its process.
* When the client that the link points at detaches, the link moves to the socket of the client before it.
* When no attached client has a socket, the link points at the `SSH_AUTH_SOCK` of the daemon. The daemon socket must pass the checks below. If it does not, TUIOS removes the link.
* A client of the TUIOS SSH server or of `tuios-web` has no agent socket, so it does not move the link. To follow your agent, ssh to the machine with `ssh -A` and run `tuios attach`.
* The daemon removes its links when it stops and when you set the option to `"off"`. At start, it removes the links that a stopped daemon left.
### Which sockets TUIOS accepts
TUIOS resolves a symbolic link once, so `~/.ssh/agent.sock` and the 1Password agent work. The link then points at the real socket. TUIOS refuses the socket unless all of these are true:
* The real socket is a Unix socket that you own.
* Every folder above it is owned by you or by root.
* No other user can write to those folders. A sticky folder such as `/tmp` is accepted.
## Remote hosts
The agent can also follow you to a [remote host](/docs/remote-hosts). Then a session that you attach on the host uses the agent of your client on this machine.
1. Set `ssh_agent = "follow"` on both machines.
2. Turn on agent forwarding for the link to that host. TUIOS does not forward the agent by default.
Use one of these to turn on forwarding:
```
# ~/.ssh/config
Host buildbox
ForwardAgent yes
```
```toml
# config.toml
[hosts.build]
addr = "gaurav@buildbox"
ssh_options = ["-A"]
```
For each host that forwards, the daemon keeps one link, `agent-link--.sock`. The host name is in lower case and cut to 32 characters. The hash is of the exact name, so two hosts never share a link.
* The link points at the agent of the person on this machine who attached a session on that host, or typed in one, last.
* A session of this machine does not move the link of a host.
* The daemon starts the link ssh to the host with `SSH_AUTH_SOCK` set to that link.
* Before any client attaches a session on the host, the link points at the daemon's own `SSH_AUTH_SOCK`. If the daemon has none, or its socket fails the checks, the panes on the host have no agent until a client attaches.
* If the link to a host shares an ssh master connection, ssh forwards the agent of that master.
TUIOS reads what `ssh -G` prints for the address of the host with its `ssh_options`, as ssh itself does. TUIOS asks again when the config changes, and when `ssh -G` failed the last time. A host that you remove from the config takes its link with it.
A host that does not forward gets the environment of the daemon, unchanged. Its panes use the `SSH_AUTH_SOCK` that their own login there sets: the agent of the daemon on the host, one from keychain or gnome-keyring through PAM, or none.
## Security
Root on the host can use a forwarded agent to sign in to other machines as you while the link is up.
* If the host pins this machine with `restrict` in `authorized_keys`, add `agent-forwarding` to the options of that key. Without it, the host refuses the forwarded agent.
* The check for a client in a pane stops mistakes and agents. It does not stop a local process that wants to get past it. A process that leaves its pane on purpose, for example with `setsid` and a clean environment, can still move the link.
* TUIOS refuses an agent socket that another user owns or can replace. See [Which sockets TUIOS accepts](#which-sockets-tuios-accepts).
## Related
# Web Terminal
URL: https://tuios.dev/docs/web
> Use TUIOS from a browser with tuios-web, a separate server binary.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
`tuios-web` serves TUIOS to a web browser. The browser draws the terminal with WebGL and connects over WebTransport when it can, or WebSocket when it cannot.
It is a separate binary from `tuios`, so the main binary contains no web server. It is built on [sip](https://github.com/Gaurav-Gosain/sip), a library for serving any Bubble Tea program in a browser.
A browser that opens `tuios-web` gets a shell as the user who runs it. Set a password to control who connects. See [Who can connect](#who-can-connect).
In v0.8.1, a bind to an address other than `localhost` needs a password. See [Upgrade from v0.8.0](#upgrade-from-v080).
## Install
```bash
brew tap Gaurav-Gosain/tap
brew install tuios-web
```
```bash
yay -S tuios-web-bin
```
```bash
curl -fsSL https://raw.githubusercontent.com/Gaurav-Gosain/tuios/main/install-web.sh | bash
```
```bash
go install github.com/Gaurav-Gosain/tuios/cmd/tuios-web@latest
```
## Quick start
### Start the server
```bash
tuios-web
```
It listens on `http://localhost:7681`, and for WebTransport on UDP port 7682 of the same host.
### Open it
Go to [http://localhost:7681](http://localhost:7681). The page picks the best transport on its own.
By default every browser attaches to one daemon session called `web`, created the first time it is needed. Close the tab and the session keeps running, like any [daemon session](/docs/sessions). Open a second tab and both show the same session.
## Reaching it from another device
A bind to an address other than loopback needs TLS and a password. TLS encrypts the keystrokes. The password controls who connects. `tuios-web` does not start without both, and it prints the commands to use.
```bash
# HTTPS from a self-signed certificate that tuios-web makes and keeps,
# and a new password at each start. tuios-web prints the password.
tuios-web --host 0.0.0.0 --auto-tls --random-password
# HTTPS from a certificate you already have, and a password in a file
tuios-web --host 0.0.0.0 --cert cert.pem --key key.pem \
--password-file ~/.config/tuios/web-password
# Plain HTTP and no password, only on a network you trust
tuios-web --host 0.0.0.0 --insecure --no-auth
```
An empty `--host` listens on every interface. `tuios-web` applies the same rules to it as to `0.0.0.0`.
With `--auto-tls`, each browser shows a certificate warning on its first visit, because the certificate is self-signed. `tuios-web cert info` explains what the warning looks like and how to make it go away. The other `cert` subcommands are `new` (replace the certificate), `path` (print its path) and `rm` (delete it and the key).
An SSH tunnel is another option, and needs no certificate:
```bash
ssh -L 7681:localhost:7681 you@server
```
Then open `http://localhost:7681` on your own machine.
On phones and tablets, `tuios-web` adds a key bar with the prefix key and buttons for common commands (new, close, next, zoom, the command palette and more). It detects touch devices from the browser's request. Use `--touch on` or `--touch off` when it guesses wrong.
## Flags
| Flag | Default | Description |
| ------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `--host` | `localhost` | Address to bind |
| `--port` | `7681` | HTTP port. WebTransport uses the next port up. |
| `--auto-tls` | off | Serve HTTPS from a self-signed certificate that `tuios-web` generates and keeps |
| `--cert`, `--key` | | Serve HTTPS from your own certificate and key, in PEM form |
| `--cert-days` | `365` | Days an `--auto-tls` certificate is valid. Under 14 also keeps WebTransport working in Chrome. |
| `--cert-dir` | sip's directory in your user config dir | Where `--auto-tls` keeps its certificate |
| `--cert-host` | | Extra DNS name or IP for the `--auto-tls` certificate. Repeatable. |
| `--insecure` | off | Allow plain HTTP on a non-loopback address |
| `--random-password` | off | Make a new password at start and print it |
| `--password-file` | | Read the password from the first line of this file. The file must be yours, with mode 600 or 400. |
| `--user` | `tuios` | User name that the browser must give with the password |
| `--no-auth` | off | Serve a non-loopback address with no password |
| `--allow-host` | | On a loopback `--host`, also accept this host name in the Host header. Give the name with no port. Repeatable. |
| `--read-only` | off | Ignore all input from browsers |
| `--max-connections` | `0` | Maximum concurrent connections. `0` means no limit. |
| `--default-session` | `web` | Session every browser attaches to |
| `--ephemeral` | off | Give each connection its own standalone session, with no daemon. Nothing persists. |
| `--touch` | `auto` | Touch mode: `auto`, `on` or `off` |
| `--debug` | off | Log connections, sessions and errors |
All the appearance flags of `tuios` also work here and apply to every browser session: `--theme`, `--border-style`, `--dockbar-position`, `--show-keys`, `--shared-borders`, `--ascii-only`, `--no-animations` and the rest. `tuios-web --help` lists them. They are layered over your config file the same way `tuios` layers them.
```bash
tuios-web --theme nord --show-keys
```
`tuios-web` starts the daemon if it is not running. If the daemon cannot start, it logs a warning and falls back to ephemeral sessions, which do not persist.
Windows opened from the browser get `TERM=xterm-256color` and `COLORTERM=truecolor`.
## Browser settings
The gear button in the corner opens the settings panel. Settings are saved in the browser's local storage.
| Setting | Options |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Transport | Auto (prefers WebTransport), WebTransport, WebSocket |
| Renderer | Auto (prefers WebGL), WebGL, Canvas, DOM, vtgl (experimental) |
| Font size | 10 to 28 px, default 14 |
| Toggles | Copy on select, blinking cursor, the browser's right-click menu, and capturing Ctrl +W and similar keys (fullscreen only) |
The page ships with JetBrains Mono Nerd Font, so icons render without anything installed on the client.
## Graphics in the browser
Inline images work, with a smaller part of the Kitty graphics protocol than a native terminal supports.
**Works:** direct base64 transmission (`t=d`), placing and deleting images, RGB, RGBA and PNG data, zlib compression, chunked transmission, source-region clipping, and images moving with scrolling. Sixel images work too.
**Does not work:** file transmission (`t=f`), temporary files (`t=t`), shared memory (`t=s`), animation frame edits (`a=f`), and Unicode placeholders (`U=1`).
TUIOS tells programs what the browser can do. A program that asks whether it can send a file path is told no, and falls back to sending the bytes. A program that insists on shared memory gets no image: `mpv --vo=kitty` is the common case. See [Graphics](/docs/architecture#graphics) for how TUIOS handles graphics in a native terminal.
## Security
### Who can connect
Every browser that opens `tuios-web` gets a shell on this machine. The session switcher reaches every session. Set a password to control who connects. The browser asks for a user name and the password. The user name is `tuios`. Use `--user` to change it.
`tuios-web` reads the password from one of these:
* `--random-password`: `tuios-web` makes a new password at each start. It prints the password and a URL that holds it.
* `--password-file `: `tuios-web` reads the first line of the file. The file must belong to you, with mode 600 or 400. `tuios-web` does not check the file mode on Windows.
* `TUIOS_WEB_PASSWORD`: `tuios-web` reads this environment variable. It does not pass the variable to panes. Your own processes can still read it from `/proc`, so use `--password-file` if you can.
No flag takes the password itself, because other users can see command line arguments in `ps`. Give only one password source. A password and `--no-auth` do not go together.
```bash
# Make a password file that only you can read
mkdir -p ~/.config/tuios
(umask 077; head -c 18 /dev/urandom | base64 > ~/.config/tuios/web-password)
tuios-web --host 0.0.0.0 --auto-tls --password-file ~/.config/tuios/web-password
```
What `tuios-web` needs depends on the address:
| `--host` | Password | What happens |
| --------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `localhost` or another loopback address | Optional | With no password, other users on this machine can connect. `tuios-web` prints one line at start to say so. |
| Any other address, or an empty `--host` | Required | `tuios-web` does not start without a password or `--no-auth`. |
With a password, sessions use WebSocket. A WebTransport connection carries no password, so `tuios-web` refuses it and the browser falls back to WebSocket.
### The Host check
On a loopback address, `tuios-web` accepts a session only when the Host header names this machine, such as `localhost` or `127.0.0.1`. This stops a web page that points its own name at 127.0.0.1 (DNS rebinding). A refused session gets the message "tuios-web does not answer to this host name".
Browser connections from other web sites are also refused. A WebSocket or WebTransport connection must come from a page that `tuios-web` served.
To accept the host name of a reverse proxy, use `--allow-host`. See [Behind a reverse proxy](#behind-a-reverse-proxy).
### If other people can reach the port
1. **Set a password** with `--random-password` or `--password-file`.
2. **Use TLS** with `--auto-tls` or `--cert`, or put a proxy with TLS in front.
3. **Bind to localhost** with `--host 127.0.0.1` when a proxy or an SSH tunnel carries the traffic.
4. **Limit connections** with `--max-connections`.
5. **Use `--read-only`** for demos, so viewers cannot type.
### WebTransport and certificates
WebTransport always runs over TLS.
* **On localhost without TLS,** `tuios-web` generates a short-lived self-signed certificate for WebTransport alone. It is valid for 10 days, Chrome's limit for certificates pinned by hash, and the page fetches its hash from `/cert-hash`, so no warning appears.
* **With `--cert` or `--auto-tls`,** WebTransport uses the same certificate as HTTPS. For a self-signed `--auto-tls` certificate, Chrome only accepts WebTransport if the certificate is valid for under 14 days, so set `--cert-days 13` if you want it.
* **With `--insecure`** there is no certificate, so there is no WebTransport. Browsers use WebSocket.
## Behind a reverse proxy
Run `tuios-web` on `127.0.0.1` and let the proxy handle TLS. Most proxies carry only the WebSocket connection, so browsers use WebSocket.
The proxy must pass the original `Host` header through. `tuios-web` compares it with the page's origin and refuses the WebSocket connection if they differ.
The proxy sends its own host name, such as `tuios.example.com`. Add that name with `--allow-host`. Give the name with no port. `--allow-host` works only with a loopback `--host`.
`--allow-host` needs a password or `--no-auth`, because the proxy lets the network in. Pick one of these:
```bash
# tuios-web asks for the password. The proxy adds no login of its own.
tuios-web --host 127.0.0.1 --allow-host tuios.example.com \
--password-file ~/.config/tuios/web-password
# The proxy asks for the login, as in the examples below.
tuios-web --host 127.0.0.1 --allow-host tuios.example.com --no-auth
```
`tuios-web` uses HTTP basic auth. Do not put a second basic auth login in the proxy in front of a `tuios-web` password. The browser sends only one login.
### Nginx with basic auth
```nginx
server {
listen 443 ssl http2;
server_name tuios.example.com;
ssl_certificate /etc/letsencrypt/live/tuios.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tuios.example.com/privkey.pem;
location / {
auth_basic "TUIOS";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:7681;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
server {
listen 80;
server_name tuios.example.com;
return 301 https://$server_name$request_uri;
}
```
Create the password file with `htpasswd -c /etc/nginx/.htpasswd yourname`.
### Caddy with basic auth
Caddy gets a certificate for you:
```text
tuios.example.com {
basic_auth {
yourname
}
reverse_proxy localhost:7681
}
```
### Cloudflare Tunnel
A tunnel reaches the server without opening a port. Put [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/) in front of the hostname, or anyone who finds it gets a shell.
### Create the tunnel
```bash
cloudflared tunnel login
cloudflared tunnel create tuios
cloudflared tunnel route dns tuios tuios.example.com
```
### Configure it
`~/.cloudflared/config.yml`:
```yaml
tunnel:
credentials-file: /path/to/credentials.json
ingress:
- hostname: tuios.example.com
service: http://localhost:7681
- service: http_status:404
```
### Run both
```bash
tuios-web --host 127.0.0.1 --allow-host tuios.example.com \
--password-file ~/.config/tuios/web-password
cloudflared tunnel run tuios
```
With Cloudflare Access in front, you can use `--no-auth` in place of the password.
### Traefik
```yaml
# docker-compose.yml
services:
tuios:
# Build this image yourself (see Docker below). No published image contains tuios-web.
image: tuios-web:local
labels:
- "traefik.enable=true"
- "traefik.http.routers.tuios.rule=Host(`tuios.example.com`)"
- "traefik.http.routers.tuios.entrypoints=websecure"
- "traefik.http.routers.tuios.tls.certresolver=letsencrypt"
- "traefik.http.routers.tuios.middlewares=tuios-auth"
- "traefik.http.middlewares.tuios-auth.basicauth.usersfile=/etc/traefik/htpasswd"
- "traefik.http.services.tuios.loadbalancer.server.port=7681"
```
The `basicauth` middleware is the only access control here, because the container runs `tuios-web` with `--no-auth`. Without the middleware, this publishes a shell to the internet.
## Running as a service
### Docker
The published `ghcr.io/gaurav-gosain/tuios` image contains only `tuios`. Build your own image for `tuios-web` from a checkout of the repository:
```dockerfile
FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN go build -o /out/tuios-web ./cmd/tuios-web
FROM debian:bookworm-slim
COPY --from=build /out/tuios-web /usr/local/bin/tuios-web
EXPOSE 7681
# Plain HTTP and no password inside the container: a proxy in front of it
# must add TLS and a login. Or replace --no-auth with --password-file.
ENTRYPOINT ["tuios-web", "--host", "0.0.0.0", "--insecure", "--no-auth"]
```
### systemd
```ini
# /etc/systemd/system/tuios-web.service
[Unit]
Description=TUIOS web terminal
After=network.target
[Service]
Type=simple
User=tuios
# The file must belong to the user tuios, with mode 600 or 400.
ExecStart=/usr/local/bin/tuios-web --host 127.0.0.1 --port 7681 --password-file /home/tuios/.config/tuios/web-password
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now tuios-web
```
## How it works
Each browser connection runs a TUIOS client inside `tuios-web`, which attaches to the daemon like any other client. The first byte of every message says what it is. It is an ASCII digit, not a raw byte value:
| Type | Byte | Direction | Content |
| -------------- | ----- | ----------------- | ---------------------------- |
| Input | `'0'` | browser to server | Keyboard and mouse input |
| Output | `'1'` | server to browser | Terminal output |
| Resize | `'2'` | browser to server | New size in cells and pixels |
| Ping | `'3'` | browser to server | Keep-alive |
| Pong | `'4'` | server to browser | Keep-alive reply |
| Title | `'5'` | server to browser | Window title |
| Options | `'6'` | server to browser | Session settings |
| Close | `'7'` | server to browser | The session ended |
| Kitty keyboard | `'8'` | browser to server | Keyboard protocol flags |
## Open the Inbox from a notification
A [push notification](/docs/push-notifications) links to its Inbox item when `notify.web_url` is the address of `tuios-web`. The link is `web_url/inbox?item=ID`. `tuios-web` answers it with a cookie that names the item and sends the browser to the page. When the page connects, TUIOS opens the Inbox with the cursor on that item. Answer the item there as at any other client.
* The link needs the same password as the page.
* The cookie holds only the item number. It expires after 60 seconds.
* If the item closed before you open the link, the cursor goes to the first item.
* Behind a reverse proxy, set `web_url` to the address with the proxy's path. The link sends the browser back to that path.
* With `--no-auth` and TLS, a browser can connect over WebTransport. Some browsers do not send the cookie there. Then the Inbox does not open by itself.
## Troubleshooting
### It refuses to start on `0.0.0.0`
A non-loopback address needs TLS and a password. Add `--auto-tls`, or `--cert` and `--key`, or `--insecure`. Add `--random-password`, `--password-file` or `--no-auth`. The error message lists the commands.
### The session is refused with "does not answer to this host name"
The Host header does not name this machine. This happens behind a reverse proxy. Start `tuios-web` with `--allow-host` and the proxy's host name.
### It refuses the password file
The file must belong to you, with mode 600 or 400. Run `chmod 600` on the file.
### WebTransport does not connect
The page falls back to WebSocket, so this only costs latency. Check that UDP port 7682 (or your `--port` plus one) is reachable, that you are not using `--insecure`, and that your browser supports WebTransport. The browser console shows certificate errors. You can force WebSocket in the settings panel.
### The WebSocket connection is refused behind a proxy
The proxy is probably rewriting the `Host` header. Pass it through unchanged, for example `proxy_set_header Host $host;` in nginx.
### Blank terminal
Check the browser console for errors, try another renderer in the settings panel, and run `tuios-web --debug` to see the server's log.
### The session ended
When the session stops, the browser shows a last message before the connection closes. The message says why:
* **The session stopped.** Another client or a command removed it. Connect again to use a different session.
* **TUIOS lost the connection to the daemon.** The daemon stopped or failed. Connect again after the daemon starts.
* **The link to a remote host closed.** TUIOS tried to connect again and stopped. Connect again when the link is back.
This needs a `tuios-web` newer than v0.8.0. Older builds, which use sip before v0.8.2, can close the tab before the message arrives.
### The browser asks for a login
`tuios-web` has a password. Give the user name `tuios`, or the name from `--user`, and the password. With `--random-password`, `tuios-web` prints both at start.
### The tab stays open after quitting
In a daemon session, Ctrl +B q opens the quit menu, and its default is to detach. Detaching or killing the session from that menu ends the browser connection. If the page does not close, reload it.
## Upgrade from v0.8.0
v0.8.1 adds passwords to `tuios-web`. Some commands that worked in v0.8.0 are refused now.
* A bind to an address other than `localhost` needs a password. `--auto-tls` or `--cert` alone is not enough. Add `--random-password` or `--password-file`. On a network you trust, add `--no-auth`.
* An empty `--host` listens on every interface, so the same rule applies to it.
* A reverse proxy on this machine sends its own host name. Add that name with `--allow-host`, and add a password or `--no-auth`.
* On `localhost` with no password, `tuios-web` still starts. It prints one line to say that other users on this machine can connect.
* The password file must belong to you, with mode 600 or 400.
## Related
# Agents
URL: https://tuios.dev/docs/agents
> Track what coding agents in your panes are doing, get alerted when one needs you, and let agents drive TUIOS.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
TUIOS keeps an agent state for every pane. When a pane runs a coding agent such as Claude Code, Codex or Gemini CLI, the state says whether it is working, waiting for you, or done. The state is drawn on the pane's title bar and in the rail, it can raise an alert, and scripts can read it and wait on it. Everything an agent waits on you for collects in the [Inbox](/docs/agent-inbox).
To keep only the multiplexer, turn every agent feature off with `[agents] enabled = false`. See [Turn Off Agent Features](/docs/agents-off).
**Try it live:** the [Agents track](/learn?t=agents) runs a pretend agent in your browser so you can watch its state change.
## States
| State | Glyph | ASCII | Meaning |
| ------------- | ----- | ----- | --------------------------------------------------------------------------------- |
| `none` | | | Not an agent, or not reporting. The default |
| `working` | `●` | `*` | Working on a task |
| `needs_input` | `▲` | `!` | Blocked, waiting for a person |
| `idle` | `○` | `o` | Not working and not blocked |
| `done` | `■` | `#` | Finished its task, and you have not looked at it yet. Once you have, it draws `○` |
| `errored` | `×` | `x` | Stopped on an error |
| `unknown` | `□` | `?` | An agent is there and nothing says what it is doing |
The glyphs are different shapes, not the same shape in different colours, so they still read in a monochrome screenshot. The ASCII column is what `--ascii-only` draws.
`needs_input` and `errored` are the two states where a person has to act. `get-agent-state` and `list-agents` report that as `needs_you: true`, so a script does not need to know which states mean it.
The state is held by the daemon, per pane. Every attached client sees it, and it survives a detach. It does not survive a daemon restart.
## Where it shows
* **Title bar:** the glyph sits in each pane's title.
* **Rail:** the agents section lists every agent pane, sorted by what each needs from you, with what it is doing or asking. Each session row carries the loudest state among its panes. See [Session Rail](/docs/session-rail#agent-rows).
* **Inbox:** Ctrl +B i lists every approval, question, error and finished turn waiting for you, in every session. See [Inbox and Review](/docs/agent-inbox).
* **Alerts:** a state change can raise a notification, a sound and a dock message. See [Alerts](#alerts).
## How TUIOS learns the state
Several sources can report on a pane. From most to least trusted:
| Source | What it is |
| ------------ | ---------------------------------------------------------------------------------- |
| `report` | The agent reports for itself with `tuios set-agent-state` |
| `program` | The program printed an OSC 7501 report. See [Program Status](/docs/program-status) |
| `transcript` | The daemon reads the record file the agent's harness writes |
| `osc` | An escape sequence the agent printed: OSC 9;4 progress, or its window title |
| `screen` | A rule matched text on the pane's screen |
| `detect` | The daemon saw an agent CLI in the pane's foreground |
| `stall` | The pane was `working` and then went quiet |
A source can overwrite a claim from its own rank or lower, never from a higher one. So a screen rule cannot overwrite what an agent said about itself. There is one exception: a screen rule that sees a permission prompt can mark a pane `needs_input` over a stale `working` claim, and the old claim comes back as soon as the prompt is gone.
### The agent reports for itself
This is the most accurate source, and the only one that can reliably say `needs_input`. The usual way to report is to let TUIOS wire the agent's hooks for you:
```bash
tuios integration install claude-code # or codex, gemini-cli, opencode, kilo, amp, ...
tuios integration install --all # every supported harness whose config directory exists
tuios integration status # installed and current, per harness
tuios doctor agents # PATH, install state, and agent panes missing an integration or out of date
```
You can do the same from inside TUIOS. See [The Agents tab](#the-agents-tab).
The installer adds its hook entries to the harness's own settings file, keeps everything else in it, and saves the file as it was the first time as `.tuios.bak`. It refuses a file it cannot parse rather than rewrite it. Each entry runs `tuios agent-hook`, which never blocks the harness for long and prints nothing a harness could read as an answer. `tuios integration uninstall ` removes exactly what it wrote.
Twelve integrations report the pane's state: Claude Code, Codex, Copilot, Cursor Agent, Gemini CLI, opencode, Kilo, Amp, Kimi Code CLI, Pi, oh-my-pi and Qwen Code. Seven more (Antigravity, Crush, Devin, Droid, Grok, Hermes and Qoder) report only the conversation id, so the pane can be [resumed after a restart](/docs/agent-inbox#resuming-after-a-restart), and leave the state to the screen rules. The Claude Code and Codex hooks also feed the rail what the agent is doing now and the Inbox its [activity log](/docs/agent-inbox#what-the-agent-has-been-doing). The Claude Code hooks also count the subagents the agent runs, and the rail shows the count, such as `3 subagents`. This needs version 3 of the Claude Code integration. `tuios integration status` shows an older install as out of date, and `tuios integration install claude-code` updates it. `tuios integration install claude-code --statusline` also feeds the model, context use and cost from Claude Code's status line. It never replaces a status line of your own: `--then` chains to it.
The opencode integration works with opencode V1 (1.18.29 or later) and V2. On V2, the plugin runs in the opencode terminal client, so it reports the state of the session that pane shows. Events from other sessions and subagents do not change the pane. A V2 server that several panes share does not report pane state. After you update TUIOS, run `tuios integration install opencode` again and restart opencode. Headless V2 clients, such as `opencode run`, do not load the plugin.
You can also report by hand, or from a script of your own:
```bash
tuios set-agent-state working -m "running the test suite"
tuios set-agent-state needs_input -m "waiting for approval to push"
tuios set-agent-state done
tuios set-agent-state none # clear it
```
From inside a pane, name the pane and the harness, since the focused pane may be a different one:
```bash
tuios set-agent-state working -s "$TUIOS_SESSION" -w "$TUIOS_PANE_ID" --harness claude-code
```
A report that loses to a higher-ranked source still exits 0 and prints `Not applied: ...` on stderr.
The old shell shim in `integrations/claude-code/` of the repository now just runs `tuios agent-hook claude-code`. Settings that still point at it keep working, but alongside an installed integration every event is reported twice. `tuios doctor agents` says so.
#### The Agents tab
The settings page has an Agents tab with the same report as `tuios doctor agents`. Press Ctrl+B A to open it. You can also type `Agents: settings, install and update integrations` in the command palette.
Each row is one harness. It shows whether the harness is on `PATH`, and one of these states:
| State | What it means |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| installed | The integration is installed and current. An install made with `--command` is current too, and the line under the row names the program its hooks run. |
| out of date | An older TUIOS installed it. Update it. |
| not installed | The integration is not installed. If the harness has not run on this machine, run it once first. |
| no integration | TUIOS has no integration for this harness. The line under the row gives the reason. |
| cannot read | TUIOS cannot read or parse the integration's file. The line under the row names the file. |
Press Enter or click a row to see its actions:
* **Install** writes the hook entries.
* **Update** replaces an older install. It keeps the program the hooks already run, so an install made with `--command /path/to/tuios` keeps that path.
* **Uninstall** removes what TUIOS wrote: the hooks, the MCP server and the status line, each only if it is there. Your own entries stay.
Each action names the files it changes. Press Enter on the action to do it, or Esc to go back. The actions run the same code as `tuios integration install` and `tuios integration uninstall`. The result or the error shows in the dock. If the harness saves its settings file while TUIOS edits it, TUIOS tries once more. If the file changes again, TUIOS does not write it and asks you to try again.
The tab is only there for a terminal on this machine. It is not there when [agent features are off](/docs/agents-off), or on a client that `tuios ssh` or `tuios-web` serves, because the integrations on the server are not the remote person's to change.
#### The integration notice
When a pane runs a harness whose integration is out of date, or not installed where it could be, the dock shows one notice for that harness. An example:
> Claude Code integration is out of date. Open Settings, Agents to update it.
The notice comes once per harness each time the client starts. To dismiss it for good, click the right end of the notice. It then stays away until the state or the version changes. Esc and a click on the text clear it for this session of the client only, so the notice comes back the next time you attach.
Panes on other machines get no notice, because their harness uses that machine's integration.
### Agents that report to herdr
Some agents report their state to [herdr](https://herdr.dev), another multiplexer for coding agents, by themselves: Crush, Kiro CLI, Command Code, Muse Code and Prime Agent. TUIOS accepts the same reports, so these agents need no install step.
Every pane starts with herdr's variables: `HERDR_ENV=1`, `HERDR_SOCKET_PATH` naming a socket of TUIOS's own, `HERDR_PANE_ID` and `HERDR_WORKSPACE_ID` naming the pane and its session in herdr's form, and `HERDR_BIN_PATH` naming `tuios`. So a Crush that you start from a shell prompt reports `working`, `needs_input` on a permission prompt or a question, and `done` when its turn ends. Its session title and model show as [agent metadata](#where-it-shows). Several Crush panes report side by side, and each pane follows only its own reports.
* A pane may report only about itself.
* A report older than the last one from the same agent is dropped.
* An agent that exits without saying so, for example after a crash, does not keep its state. When the pane is back at its shell prompt, the state clears after two seconds.
* An agent that reports through herdr's commands, `"$HERDR_BIN_PATH" pane report-agent`, reaches TUIOS the same way.
Crush sends no detail with a permission prompt, so TUIOS also reads Crush's permission dialog from the screen. The pane goes to `needs_input` with a message such as `approve bash: touch hello.txt`, and you can answer it from the Inbox. See [Crush permission prompts](/docs/agent-inbox#crush-permission-prompts).
The same socket answers herdr's socket API, so tools built for herdr work with TUIOS. See [herdr Compatibility](/docs/herdr-compatibility).
TUIOS never uses herdr's own socket. A herdr that you start inside a TUIOS pane stops with a message about nesting. To run herdr there, give herdr's variables only to panes that start a known reporter:
```toml
[agents]
herdr_protocol = "agents" # always (default), agents, off
```
### Program status
Any program can report its own state with one escape sequence, OSC 7501, or with `tuios status`. The report can say what the program waits for: an approval, an answer or a login. See [Program Status](/docs/program-status).
```sh
tuios status blocked --kind permission --app deploy --msg 'Approve deploy to production?'
```
### Progress sequences
An agent that prints OSC 9;4 progress sequences needs no wiring. Setting a progress bar maps to `working`, clearing it to `idle`, the error state to `errored`, and the warning state to `needs_input`. After a pane sends an OSC 7501 report, TUIOS stops reading OSC 9;4 as the pane's state until the next full reset.
### Process detection
With no report at all, the daemon checks each pane's foreground process every two seconds. If it is a known agent CLI, the pane is marked `working`. It reads the process name, its executable, and for an interpreter such as `node` or `python3`, the script it runs. It also looks behind wrappers such as `sh -c`, `timeout`, `npx`, `uvx` and `mise exec`. A directory named after an agent is never counted.
TUIOS ships manifests for 24 agent CLIs, including Claude Code, Codex, Gemini CLI, Cursor Agent, Aider, OpenCode, Crush, Amp and Copilot. Add your own manifest as a TOML file in `~/.config/tuios/harnesses/` (or `$TUIOS_HARNESS_DIR`). It is picked up on the next daemon start.
| Key | Default | Meaning |
| ----------------------------- | ------- | ---------------------------------------------------------------------- |
| `daemon.agent_autodetect` | `true` | Detect agent CLIs in the foreground |
| `daemon.agent_detect_seconds` | `0` | Seconds between checks. `0` uses 2. A negative number turns checks off |
Detection alone can say that an agent is present. It cannot say the agent is waiting for you.
### Screen and title rules
An agent sitting on a permission prompt often prints nothing more: no output, no title, no progress. The only evidence is the text on the screen. A manifest can carry screen rules that match the bottom of the pane and report `needs_input`, and title rules that match the window title. The Codex manifest, for example, reads `Action Required` from the title.
A rule with `show = "dialog"` reads a prompt drawn as a bordered box over the transcript. It matches only when one box holds all its strings, so the same words quoted in the transcript do not count. `tool_field` and `what_fields` build the message `approve : ` from the box's fields, the form the [risk rules](/docs/agent-inbox#risk-rules) read. The Crush manifest uses this for its permission dialog.
Rules only run on a pane already attributed to a harness, and only when the pane writes, so a quiet pane costs nothing.
### The stall timer
If a pane reported `working` and then prints nothing for 30 seconds, the daemon looks at its screen one last time. If no rule matches, the pane becomes `unknown`. The timer only ever moves a pane out of `working`, so it never overrides `needs_input`, `done` or `errored`.
Set `TUIOS_AGENT_STALL_SECONDS` in the daemon's environment to change the window. `0` or a negative value turns the timer off.
## Reading the state
```bash
# One pane
tuios get-agent-state -w build
tuios get-agent-state -w build --json
# Every agent pane in the session
tuios list-agents
tuios list-agents --all # every pane, including ones not seen as agents
tuios list-agents --all-hosts # every machine in [hosts]
# Block until an agent needs a person
tuios wait-for agent-state -s work --until needs_input
tuios wait-for agent-state -w build --until idle,done --timeout 600000
```
`get-agent-state --json` includes `state`, `message`, `needs_you`, the winning `source`, the `harness_id`, and `identity` and `confidence` saying how the harness was recognised. `list-agents` rows and `explain-agent-detect` carry `identity` and `confidence` too. `list-windows --json` carries each pane's `agent_state`.
The same three commands report `evidence_age_ms`: the milliseconds since the last evidence about the state arrived. Confidence says how TUIOS learned the state, and the age says how long ago.
* A report from the agent, a detector change and the stall timer reset the age.
* A title or screen rule that reads the same claim again does not reset it.
* For a state that the detector or the stall timer inferred (`source` `detect` or `stall`), new output from the pane also resets it. A pane that keeps printing reads fresh.
* The value is `null` for a pane that never had a state.
## When a pane is marked wrong
Two commands show exactly what the daemon saw.
```bash
tuios explain-agent-detect -w build
```
This starts with a verdict in plain words, then the process name, arguments and executable the daemon read, any wrapper it looked behind, and what every manifest made of it:
```
This pane runs claude-code behind timeout.
The foreground process timeout is a wrapper, so tuios read the processes behind it.
The process claude matched the manifest claude-code on comm=claude.
A process name is strong evidence.
```
```bash
tuios explain-agent-screen -w build
tuios explain-agent-screen -w build --harness codex --lines 20
```
This prints the pane's screen tail exactly as the rules read it, then each rule, which one fired, and for each rule that did not, which string was the reason. Use it when writing a rule.
## Alerts
A state change can raise a desktop notification, a sound or terminal bell, a dock message you can click to go to the pane, and a shell command. The `[notifications.agent]` table controls all of it:
```toml
[notifications.agent]
enabled = true
notify = true # notification in the terminal you are using
dock = true # clickable message in the dock
sound = false
sound_mode = "audio" # audio, bell or both
suppress_focused = true # nothing for the pane you are looking at
settle_seconds = 2 # drop the alert if the state changes within this time
quiet_hours = "22:00-07:00"
command = "" # shell command to run on an alert
[notifications.agent.states]
needs_input = true
errored = true
done = true
idle = false
working = false
```
The notification is written into the stream your terminal draws, so it reaches the terminal in front of you even when the session runs on another machine. Alerts are raised by an attached client, so a detached session tracks state without announcing it. To reach your phone when no client is attached, see [Push Notifications](/docs/push-notifications). See [Configuration](/docs/configuration#notifications) and [Hooks](/docs/hooks) for the command contract.
### Hear an alert
Alerts make no sound by default. To hear them, turn on `sound`:
```toml
[notifications.agent]
sound = true
sound_mode = "audio" # audio, bell or both
```
You can also run `tuios set-config notifications.agent.sound true`, or use the Alerts tab of the settings page (, in window mode).
| `sound_mode` | What you hear |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `audio` | A short cue from the audio player of the machine that runs TUIOS. This is the default |
| `bell` | Your terminal's bell. TUIOS sends a BEL character, and the terminal decides what to do with it |
| `both` | The cue and the bell |
* **`audio`** uses `paplay`, `pw-play` or `aplay` on Linux, `afplay` on macOS and PowerShell on Windows. If the machine has none of them, the alert is silent and TUIOS shows no message.
* **`bell`** depends on your terminal's own bell setting. Some terminals make no sound for a bell, or only flash the window. In kitty, check that `enable_audio_bell` is `yes`. Other terminals have a similar setting.
* **Over SSH or in the browser**, `audio` plays on the server, not on the machine in front of you. Use `bell` there.
TUIOS plays at most one cue every `sound_cooldown_seconds` (3 by default), across all panes. `[notifications.agent.sounds]` sets your own files for the cues. See [Configuration](/docs/configuration#agent-alerts).
## Environment in a pane
Every daemon pane gets these variables, which is how a shim or an agent finds TUIOS:
| Variable | Value |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `TUIOS_ENV` | `1` inside TUIOS |
| `TUIOS_SOCKET` | The daemon socket |
| `TUIOS_PANE_ID` | The pane's window id |
| `TUIOS_WINDOW_ID` | The same id |
| `TUIOS_SESSION` | The session name |
| `TUIOS_HOST` | The hostname of the machine the pane's process runs on |
| `TUIOS_RESTORED` | `1` in a shell started again after a restore |
| `TUIOS_PANE_GRANTS` | What the pane may do through TUIOS as it starts, such as `admin` or `read,write,fan` |
| `TUIOS_PANE_TOKEN` | Proves `TUIOS_PANE_ID` to the daemon where the kernel cannot name the caller's pane |
| `HERDR_ENV`, `HERDR_SOCKET_PATH`, `HERDR_PANE_ID`, `HERDR_WORKSPACE_ID`, `HERDR_BIN_PATH` | herdr's variables, for [agents that report to herdr](#agents-that-report-to-herdr) |
A pane whose process runs on another machine has a different set. See [Remote Hosts](/docs/remote-hosts#panes-on-another-machine).
## What a pane may do
Every pane holds grants that say what a program in it may do through TUIOS:
| Grant | Allows |
| --------- | -------------------------------------------------------------------------------------------------------------- |
| `read` | Read its own session and its fan group: listings, captures, agent state, waits and events |
| `write` | Type into the panes of its own session, and leave mail there |
| `fan` | Write in its fan group, and start agents with `fan` and `start-agent` |
| `respond` | Answer another pane's prompt without you, and type into a pane that waits on a prompt |
| `admin` | Everything else, as every pane could before grants existed. Implies `read`, `write` and `fan`, never `respond` |
A pane holds the grants it was started with (`--grants` on `start-agent`, `fan` and `new-window`), or else the default of `[agents.permissions]`: `admin` under `mode = "open"`, the default, and the `grants` list under `mode = "strict"`. A pane without `admin` types only into panes that hold nothing it does not, and never gives more than it holds.
```bash
tuios pane-grants # from inside a pane: what this pane may do
tuios start-agent claude --name reviewer --grants read
tuios set-pane-grants -w reviewer --grants read,write
tuios set-pane-grants -w reviewer --reset # back to the default
```
```toml
[agents.permissions]
mode = "strict"
grants = ["read", "write", "fan"]
```
The daemon checks the grants on every way into a pane: the control socket, the link sockets, the tmux shim's pane holder, prompts, `send-keys`, `run-command` and typing through the client protocol.
* **Prompts.** A pane without `respond` cannot type into another pane that waits on a prompt. This applies to a pane with `admin` too. To give `respond` to one pane, use `tuios set-pane-grants` or `--grants`.
* **`run-command`.** From a pane without `respond`, `run-command` cannot type or press keys. Use `send-keys` or `send-text` with `-w`.
* **`send-keys` with `PREFIX`.** `send-keys` with `PREFIX` from a pane is refused. Use the TUIOS command for the action, such as `focus-window`, `new-window` or `split-window`.
* **`send-keys` with no window.** From a pane, `send-keys` with no `-w` types into the focused pane. TUIOS does not read these keys as key bindings.
* **Link sockets.** A process inside a pane, or a process that the daemon started, cannot connect to a link socket.
* **Other machines.** Grants do not travel over a link. A pane on another machine that types into a pane here is held to the same prompt rule. It types into a pane that waits on a prompt only when this machine's link policy allows `respond`. See [Remote Hosts](/docs/remote-hosts).
Some acts are yours alone whatever a pane holds: answering a held approval, answering an `ask-human` question, dismissing or snoozing an Inbox item, and writing a review note or queued reply that reads as yours. They need the nonce your attached client got when it attached, and the daemon refuses a caller inside a pane even with a copy of it. The Inbox also refuses an answer that `send-keys` typed. This covers the digit keys, the second press of a risky allow, question answers and the release of held mail.
### Changes that wait
A change to `config.toml` that gives panes or other machines more waits for you:
* A change that narrows `[agents.permissions]`, `[hosts]` or a link policy applies at once.
* A change that widens them waits. An example is `mode = "strict"` to `mode = "open"`. A new host, or a host that dials another way, waits too.
* To apply a change that waits, run `tuios config apply` from a terminal outside TUIOS. A daemon restart also applies it.
```bash
tuios config apply
```
While a change waits, the Inbox shows an item, and `tuios pane-grants` and the daemon log say so. `tuios hosts add` from a pane also waits. A host that you add with "Add a host" on the settings page applies at once.
When the daemon starts and finds wider grants than at the last run, it says so in the log, in `tuios pane-grants` and in the Inbox.
* A pane that types into another pane's prompt needs the `respond` grant. `admin` does not include it.
* A script in a pane that sends `PREFIX` with `send-keys` fails now. Use the TUIOS command for the action.
* A config change that widens grants or hosts waits for `tuios config apply`.
## Starting agents
```bash
tuios start-agent claude --name reviewer # one agent in a new pane, returns once it is ready
tuios start-agent codex --name tests --prompt 'Run the tests and fix what fails.'
tuios start-agent --protocol acp 'opencode acp' --name helper
```
`start-agent` opens a pane in the session `-s` names (creating it if needed), starts the agent, and returns once the agent shows it is at its prompt. With `--prompt`, it types the first prompt then. `--protocol acp` or `--protocol codex` runs the agent headless over the Agent Client Protocol or the Codex app-server: the pane shows the conversation as a transcript you type into, and reports the agent's state itself. In that pane, Ctrl +C cancels the running turn and every permission request it still waits on. Ctrl +D on an empty line quits. To start the same prompt in several agents at once, each in its own git worktree, see [Worktrees](/docs/worktrees#fan-out-one-prompt-several-agents).
## Letting an agent drive TUIOS
TUIOS ships a skill: a guide written for an agent running inside a pane. It covers addressing panes, reading and writing them, opening panes to run work in, waiting on conditions instead of polling, reporting state, and working with other agents. It is built into the binary, so it always matches the version you run.
```bash
tuios --skill # the core
tuios --skill fleet # one topic
tuios --skill all # everything
```
The topics are `panes`, `state`, `mail`, `inbox`, `fleet`, `hosts`, `grants`, `events`, `mcp`, `tmux`, `config`, `errors` and `recipes`.
To install it for Claude Code:
```bash
mkdir -p ~/.claude/skills/tuios
tuios --skill > ~/.claude/skills/tuios/SKILL.md
```
Run it again after you update TUIOS.
An agent can also drive TUIOS as Model Context Protocol tools. `tuios integration install claude-code --mcp` registers `tuios mcp` as a server for Claude Code, Codex, Gemini CLI or opencode.
* By default the server types into no pane, and reaches only the session of the pane it runs in. Its tools list and read panes, wait for states, report the agent's own state and send and read mail.
* `--mcp-write` adds the tools that type into panes: `send_text`, `send_keys`, `ask_agent`, `respond` and `fan`.
* Each tool says whether it changes something. A tool that sends mail, reports state or types is not marked read-only, so a client that approves read-only tools by itself still asks before it runs one. The tools that type into panes are marked destructive.
* The server speaks MCP protocol versions 2024-11-05 to 2025-11-25, and the stateless 2026-07-28 version.
Everything an agent does goes through the same `tuios` commands and [control protocol](/docs/control-protocol) you can use from a shell. There is no separate agent API.
## Related
# Inbox and Review
URL: https://tuios.dev/docs/agent-inbox
> One list of everything your agents are waiting on, answers from wherever you are, safer approvals, messages queued for a busy agent, and a review of what an agent changed.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
The Inbox is one list of everything waiting for you, in every session and on every linked machine: approvals and questions an agent is blocked on, questions an agent put to you, mail, agents that errored, conversations to resume after a restart, and finished turns you have not looked at. From it you answer prompts, reply to agents and review what they changed.
The daemon keeps the Inbox, so every attached client sees the same list, and `tuios list-attention` prints it from a shell. The daemon can also send an item to your phone when nobody is at a client. See [Push Notifications](/docs/push-notifications).
## Setting up
Wire each harness you use to report its state. Its hooks then say when it works, waits and finishes:
```bash
tuios integration install claude-code # or codex, gemini-cli, opencode, ..., or --all
tuios doctor agents # what is installed, and agent panes missing one
```
Without an integration, TUIOS still reads the process, the screen and the title, but it can say less. See [Agents](/docs/agents#how-tuios-learns-the-state).
A person who never runs an agent is not shown any of this. Until an agent has been seen, the Inbox lines of the prefix menu, the palette's agent entries and the help's Agents section stay hidden. The keys work before that too.
## Opening it
| Key | Does |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Ctrl +B i | Open the Inbox |
| Ctrl +B o | Go to the oldest item that needs you, switching session and workspace. o again goes to the next |
| Ctrl +B O | Go to the newest finished turn you have not seen. O again goes to the next older one |
| Ctrl +B M | Open the Inbox on its mail. m there opens the whole mailbox |
The palette has them too: type `agent`.
Rows are grouped under Approvals, Plans, Questions, Mail, Errored, Resume and Done, oldest first in each group. Mail to another machine whose link is down waits under Waiting to send. A row shows its kind's glyph, the pane's name, what it said, its session and how long it has waited. The footer offers only the keys that act on the row under the cursor, the one that answers it first. An item closes by itself when what opened it stops being true: the agent leaves `needs_input`, the mail is read, or someone looks at the pane that finished.
f shows one kind at a time. / narrows the list with a selector, such as `harness:codex needs:you` or `session:api-fan-*`. See [Selectors](#selectors).
Every key in the Inbox is rebindable. The full list is on the [Keybindings](/docs/keybindings#inbox-and-mailbox) page.
From a shell:
```bash
tuios list-attention
tuios list-attention --kind approval --kind question
tuios list-attention --select 'harness:codex group:fan/add-retry'
tuios list-attention --snoozed
```
```
Approvals
35s #2 api/claude approve Bash: make
Questions
46s #1 docs/claude which retry policy?
2 waiting. Open the Inbox with the prefix key then i, or jump to the oldest with the prefix key then o.
```
Only a person at an attached client can dismiss, snooze or answer an item. An agent cannot empty the list you read to find out what the agents want.
## Answering a prompt from the Inbox
Space on an approval or a question opens the prompt as the pane shows it, with its numbered options and how long the agent has waited:
| Key | Does |
| ---------------------------------------------- | ----------------------------------------------------- |
| 1 to 9 | Choose that option |
| a | Approve |
| A | Approve and do not ask again, when the menu offers it |
| d | Deny |
| Tab | Type an answer |
| r | Read the prompt again |
| Enter | Go to the pane instead |
| Esc , q , Space | Back to the list |
The keys are the ones the harness's manifest declares for that prompt. An answer is bound to an option's label, not its number, so `A` is offered only where the menu really has an "always" option. Before it presses anything, the daemon reads the prompt again and refuses if it changed. When two people answer the same prompt, the first answer wins.
The same two steps work from a shell outside every pane:
```bash
tuios peek-prompt -w review
tuios respond -w review --prompt-id 75f8b9fadb5b5dfc approve
tuios respond -w review choose 2
```
### Crush permission prompts
Crush draws its permission prompt as a "Permission Required" dialog. TUIOS reads the dialog from the screen:
* The pane goes to `needs_input`, and the Inbox lists it under Approvals.
* The message names the tool and what it acts on, such as `approve bash: touch hello.txt`: the command for bash, the file for edit and write, and the URL or folder for the others.
* Space on the row shows the dialog. a allows, A allows for the rest of the Crush session, and d denies. TUIOS presses Crush's own keys for each: `a`, `s` and `d`.
* The dialog does not always show the whole call. A long command scrolls in the dialog, and a narrow dialog shows only Crush's description of the command. Then the message ends with `(not all shown)`, and allowing it takes a second press, as for a [risky](#risk-rules) call. Read the dialog before you allow it.
* TUIOS answers only the dialog itself. The dialog's words in Crush's chat, with no box around them, are not a prompt. When two boxes on the screen hold the dialog's words, TUIOS offers no answers.
* TUIOS answers only when it sees a Crush process in the pane. Any program can report to the herdr socket as Crush.
Crush v0.97.1 and earlier can report `working` while the dialog is still up. TUIOS then reads the screen, and the pane stays on `needs_input` until the dialog closes. If Crush stops at its dialog, for example after a crash, the pane clears when it is back at its shell.
### Who may answer
`respond` acts as you, so the daemon takes it only from your attached client, or from a shell outside every pane when `[daemon] respond_from_shell = true` is set in the config file. A program inside a pane is refused with `not_human`, unless you gave that pane the `respond` grant.
## Approvals the Inbox holds
The peek presses keys into a prompt on the pane's screen. For Claude Code, opencode, Kilo and Qwen Code, the Inbox can also hold a permission prompt before the harness shows it, and answer it for you. Turn it on per harness:
```toml
[agents.approvals]
enabled = ["claude-code", "opencode"]
hold_seconds = 120
```
Then run `tuios integration install claude-code` (or `opencode`, `kilo`, `qwen`) again, so its hook is the version that waits.
While a prompt is held, the harness shows nothing in its pane. The Approvals row shows the request, such as `approve Bash: go test ./...`, and the whole line is under the list:
| Key | Does |
| ------------ | ---------------------------------------------------------------------------------------- |
| 1 | Allow once |
| 2 | Always allow. Offered only when every rule it adds can be shown, and the rules are shown |
| 3 | Deny |
| n | Deny with a reason you type, which the model reads |
A key answers only what you have read: the digits do nothing until the item has been on screen, unchanged, for 0.4 seconds. Only a request that fits on one line whole is held, such as a shell command or a file read. An edit, an MCP tool call or a long command is answered in the pane as before.
The hold ends with no answer, and the harness asks in its own pane, when `hold_seconds` passes, when you go to the pane, when you dismiss the item, or when the pane moves on. Any error on the way (no daemon, a restart, an unreadable reply) also falls back to the harness's own prompt. A failure can never approve.
### Risk rules
An approval whose command matches a risk rule is marked risky: its row reads `risky:`, the rail's word for it is `risky`, and the detail names each rule. 1 and 2 then allow it only on a second press of the same key within 3 seconds. The first press sends nothing and says when it lapses. 3 denies with one press. The daemon enforces this too: an allow of a risky call that does not name the rules it matched is refused.
The shipped rules are matched against every command of a shell line, after `sudo`, `env` and similar wrappers, and inside `sh -c`, `$( )`, backticks and `eval`:
| Rule | Matches |
| -------------------- | ------------------------------------------------------------------------------------------------------- |
| recursive delete | `rm` with `-r` and `-f`, in any order |
| force push | `git push` with `--force`, `-f`, `--force-with-lease`, or a `+` refspec |
| hard reset | `git reset --hard` |
| clean | `git clean -f`, `-fd`, `-fx` |
| discard changes | `git checkout -- .`, `git checkout .`, `git restore .` |
| pipe to shell | `curl` or `wget` piped to a shell or interpreter, or run through one with a substitution |
| sudo | any command under `sudo` or `doas` |
| disk | `dd of=`, `mkfs`, a redirect to a disk device |
| wide permissions | `chmod -R 777`, `chown -R` on `/` or `~` |
| database | `DROP TABLE`, `DROP DATABASE`, `TRUNCATE TABLE` |
| infrastructure | `terraform apply` or `destroy`, `kubectl delete`, `docker system prune`, `npm publish`, `cargo publish` |
| outside the worktree | a write, copy, move or delete of a path outside the pane's worktree, or a `Write` or `Edit` of one |
Add your own, or turn the shipped ones off:
```toml
[agents.approvals.risk]
builtin = true
[[agents.approvals.risk.rule]]
name = "kubectl apply"
tools = ["Bash"]
pattern = '\bkubectl\s+(apply|delete)\b'
```
A command written to hide what it does (a variable holding `rm`, an alias, a script) passes the rules. The harness's own permission system stays the boundary. The rules make allowing a dangerous call take two deliberate presses.
### Plans
With approvals on for Claude Code, a plan it asks you to approve when it leaves plan mode is held too, under Plans. The row is the plan's title and length, and the whole plan is under the list. J and K scroll it.
| Key | Does |
| ------------ | ----------------------------------------------------------------------- |
| 1 | Approve. Claude Code leaves plan mode and still asks before each edit |
| 2 | Approve and accept edits for this session, when Claude Code offers that |
| 3 | Keep planning |
| n | Keep planning, with a reason you type |
1 and 2 work only once the plan's last line has been on screen. 1 is the safest answer, which differs from Claude Code's own menu on purpose. Set `hold_plans = false` under `[agents.approvals]` to answer plans in the pane.
## Questions an agent asks you
An agent, or a script, that needs a decision asks with fixed answers:
```bash
tuios ask-human 'Deploy the branch to staging?' -o yes -o no -o later
```
The question waits in the Inbox under Questions. When your client shows the pane that asked, the Inbox opens on it at once, and a digit picks the answer. From any other pane it raises the usual alert and waits. The command prints your answer and exits `0`. If its `--timeout` runs out first it exits `2`, the question stays, and your answer is mailed to the asking pane when you give it. Only you can answer: an agent calling it gets `not_human`.
## Snoozing, undo and unread
Some of what lands in the Inbox is for later.
* z snoozes the item, then 1 for 15 minutes, 2 for an hour, 3 until 9:00 tomorrow, or 4 until it changes. A snoozed item leaves the list and the counts, and comes back early when the pane reports something new.
* S shows the snoozed items under a Snoozed heading. z on one wakes it now.
* d dismisses. u undoes the last dismiss or snooze for 10 seconds.
* On the rail, u on an agent row marks its finished turn unread again, for every client, and z opens the Inbox on it to snooze.
An approval the Inbox holds, a plan, and a question from `ask-human` cannot be snoozed, because an agent is waiting on them.
Try the keys on a pretend Inbox. Focus the list, then allow the risky command, snooze the finished turn and undo it:
## Replying and the queue
A message for an agent in the middle of a turn has nowhere good to go: typed now, it lands in the middle of the turn. The queue holds it in the daemon and types it the moment the agent comes to rest.
r on a finished or errored item in the Inbox, or on a rail agent row, opens a reply line. Enter queues it. If the agent is at rest it is typed within a second or so. Otherwise the row says `1 queued` until it is. On the rail, x drops the newest queued message and u within 10 seconds queues it again.
From a shell:
```bash
tuios queue -w build 'make the backoff jitter configurable'
tuios queue ls
tuios queue rm q3
```
```
ID STATE BY AGE PANE MESSAGE
q1 waiting shell just now build make the backoff jitter configurable
```
* A message is typed once the agent has been at rest for a second, one message per rest.
* It is never typed over a prompt the agent waits on, so a queued message cannot answer an approval.
* The daemon watches for the agent to take it. A message it does not take within 5 seconds is marked `stalled`, is never typed again, and opens a question in the Inbox asking you to look at the pane.
* A pane holds at most 8 messages (`[agents.queue] max`), each at most 16 KiB. The queue lives in memory: a daemon restart, the pane closing, or the agent leaving the pane drops it.
## Reviewing an agent's changes
When an agent finishes, read what it changed, leave notes on the lines you want changed, and send the notes back as one message.
Ctrl +B v reviews the focused pane. v in the Inbox or on a rail agent row reviews that item's pane. The review opens over the whole screen: the changed files on the left, the file under the cursor on the right, and each note under its line. The code is coloured by file type in your theme's colours, and s puts the old and new sides next to each other on a wide screen.
```
│ Review api-fan-demo-2 Terminal f40d4b17 2 files +2 -0 vs main 1 note
├──────────────────┬──────────────────────────────────────
│ M a.txt +1 -0 │ › @@ -1 +1,2 @@
│ U b.txt +1 -0 │ 1 1 hi
│ │ 2 + change
│ │ ▌ note from a script: why this line
```
c adds a note on the line under the cursor and C one on the whole hunk. S sends every unsent note to the agent as one message, through the queue, so it is typed when the agent is at rest. Every key is on the [Keybindings](/docs/keybindings#the-review-overlay) page.
To read or undo what one turn changed, see [Checkpoints](/docs/checkpoints). When the work is good, [ship it](/docs/ship): commit, merge, push and open a pull request. A push that an agent asks for waits for your yes under Questions.
From a shell:
```bash
tuios review api-fan-retry-2 --stat
tuios review note -s api-fan-retry-2 api/retry.go:42 'log the attempt number here too'
tuios review notes -s api-fan-retry-2
tuios review send -s api-fan-retry-2
```
```
Review of api-fan-demo-2, pane f40d4b17, against main (20e3b85): 2 files, +2 -0
M a.txt +1 -0
U b.txt +1 -0
```
**What is diffed.** The pane's repository, from its base: the base the worktree was made from (by `fan` or `worktree new`), else the merge base with the branch's upstream, else `HEAD`. Committed and uncommitted work show together, untracked files included. `--base` names another base, `--uncommitted` shows only what is not committed, and `--against` diffs two attempts of a fan. The diff is made through a temporary git index, so the repository, its index and its files are never changed. It stops at 400 files, 2 MiB of text or 5000 lines in one file.
**Notes.** A note keeps the text of its line, so it follows the line when the file changes. A note whose line is gone is marked `outdated`. The daemon holds the notes, so every client and the CLI see the same ones. A daemon restart keeps the notes of panes that come back. Closing the pane, or removing its worktree, drops them. The message the agent receives names who wrote each note: it says "from the person" only for notes written in your attached client.
Reviewing a session on another machine is not supported yet. Attach to that machine and review there, or bring the work here with `tuios worktree pull`.
Step through a review, from opening it to sending the notes and keeping one attempt of a fan:
### Comparing the attempts of a fan
For a pane in a [fan](/docs/worktrees#fan-out-one-prompt-several-agents), w in the review shows every attempt side by side, with what each changed against the fan's base and its last check:
```
╭ Compare fan/demo in api, 2 attempts vs main ─────────────────────────
│ session agent state files +/- check
│ api-fan-demo bash none 0 +0 -0 test -f b.txt failed, exit 1
│ › api-fan-demo-2 bash idle 2 +2 -0 test -f b.txt passed
├───────────────────────────────────────────────────────────────────────
│ ↵ review m mark V verify K keep esc back
```
Enter reviews an attempt. m marks two and d diffs them with each other. V runs a command you type in every attempt. K keeps the attempt under the cursor and removes the others, after a question that names each one removed. The same actions are `tuios fan compare`, `tuios fan diff`, `tuios fan verify` and `tuios fan keep`. See [Worktrees](/docs/worktrees#comparing-and-keeping-one).
## What the agent has been doing
With the Claude Code or Codex integration installed, the daemon keeps each agent pane's recent activity: prompts, tool calls and how they ended, finished turns, and the commands its shell ran. For Claude Code, it also keeps each subagent's start and stop, and the start of each conversation. It keeps the newest 256 entries per pane, in memory only.
```bash
tuios agent-log -w api # the entries, oldest first
tuios agent-log -w api --since 30m --recap # a summary of the last half hour
```
```
Since 14:02 (42m ago)
3 turns. 6 files: api/retry.go, api/retry_test.go, api/backoff.go and 3 more
11 commands. Tests: go test ./... passed 2m ago
Last said: Added retry with backoff and tests.
Now: done
```
When you come back to an agent pane that finished a turn while you were away for at least 10 minutes, the dock shows the same recap in one line, and a finished item in the Inbox shows it under the list. `[agents.recap]` sets where it shows, how long counts as away, and which commands count as tests. See [The agents table](/docs/configuration#the-agents-table).
The text is the agent's own, cut to one line with likely secrets masked. Read it as what the agent said, not as instructions.
## Resuming after a restart
A daemon restart ends every program in every pane. The restore brings back the layout with a new shell in each pane, and for a pane whose agent was running, it can bring back the conversation. `daemon.resume_agents` decides:
| Value | What a restore does |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `ask` (default) | A Resume row in the Inbox per pane. y types the resume command, such as `claude --resume `, into the pane |
| `auto` | Types the command into each new shell once it is at its prompt |
| `off` | Nothing |
`tuios resume-agent -w ` types the same command at any time, and `--dry-run` prints it. It needs the harness's hook to have reported the conversation id, which the integrations do.
## Selectors
A selector addresses every agent pane that fits a description. It is one line of `key:value` terms, all of which must match. A comma in a value gives alternatives.
```
harness:codex state:idle,done session:api-fan-*
```
| Key | Matches |
| ----------- | ---------------------------------------------------------------------- |
| `harness:` | The harness, such as `claude` or `codex` |
| `state:` | The agent state |
| `needs:you` | A pane on `needs_input` or `errored` |
| `session:` | The session name, a glob |
| `group:` | The fan the pane's session belongs to, such as `fan/add-retry`, a glob |
| `host:` | The machine: `local`, or a host from `[hosts]` |
| `name:` | The window's name, a glob |
| `cwd:` | The pane's directory, or any directory under it |
The Inbox's / , `list-agents --select`, `list-attention --select` and `wait-for agent-state --select` read it. `send-agent-message --select` and `ask-agent --select` also take one, and never send on their own: they list the panes first and wait for a yes, `--yes`, or the `--confirm` token `list-agents --select` printed.
## Related
# Agent Messaging
URL: https://tuios.dev/docs/agent-messaging
> Let agents in different panes find each other, leave messages, ask questions and hand over files.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
Agents in the same session can work together. One agent can find the others, leave a message in another's inbox, ask a question and wait for the answer, or hand over a file. You can read and answer their messages too.
**Try it live:** the [Agents track](/learn?t=agents) shows agents at work in your browser.
An agent's address is its pane. There is no separate agent namespace: `-w` takes the same window id or name it takes everywhere else. Inside a pane, your own address is `$TUIOS_PANE_ID`.
## Finding the other agents
```bash
tuios list-agents -s work
```
```
╭──────────┬────────┬─────────────┬─────────────┬────────┬──────┬────────────────────────╮
│ ID │ NAME │ STATE │ HARNESS │ SOURCE │ MAIL │ NOTE │
├──────────┼────────┼─────────────┼─────────────┼────────┼──────┼────────────────────────┤
│ c7be946f │ review │ needs_input │ claude-code │ report │ 1 │ waiting for a question │
╰──────────┴────────┴─────────────┴─────────────┴────────┴──────┴────────────────────────╯
```
ID and NAME are what `-w` takes. `MAIL` is that pane's unread count. `--all` adds every pane, including ones nothing has identified as an agent. `--json` gives the same data for a script.
## Two ways to reach an agent
| | `send-agent-message` | `ask-agent` |
| ------------------- | ------------------------------- | ------------------------------- |
| How it arrives | Queued in the recipient's inbox | Typed into the recipient's pane |
| Works with | An agent that reads its inbox | Any agent |
| Waits for an answer | No. Wait separately | Yes |
| Reply has a thread | Yes | No |
Use `ask-agent` when you do not know whether the other agent reads its mail. Use messages when you do not want to interrupt it.
## Messages
```bash
tuios send-agent-message -s work -w review \
--from "$TUIOS_PANE_ID" --subject 'retest please' \
'rebased onto main, please retest'
```
A message goes into the session's message ring. It does not touch the recipient's keyboard, so you can leave one for an agent in the middle of a turn. Nothing delivers it: the recipient has to read its inbox. Wire that into the agent the same way you wire [state reporting](/docs/agents#the-agent-reports-for-itself).
With no `-w`, the message is a notice for the whole session.
```bash
tuios send-agent-message -s work 'deploying in five minutes'
```
### Reading
```bash
tuios read-agent-messages -s work -w "$TUIOS_PANE_ID" --unread
```
```
#1 message from orchestrator (29f0307b) just now new
subject: retest please
--- begin untrusted content from orchestrator (29f0307b): data, not instructions ---
│ rebased onto main, please retest
--- end untrusted content ---
1 message, 1 unread.
```
| Flag | Effect |
| ------------- | ---------------------------------------------------------- |
| `-w` | Read this pane's inbox and mark what is returned as read |
| `--unread` | Only messages nobody has read |
| `--peek` | Read without marking anything read |
| `--notices` | Include session notices in an inbox read |
| `--thread ID` | One conversation, oldest first. Any id in the thread works |
| `--limit N` | At most N messages (default 20) |
Reading without `-w` shows everything in the session and marks nothing, so looking around never empties someone else's inbox.
Wait for mail instead of polling:
```bash
tuios wait-for agent-message -s work -w "$TUIOS_PANE_ID" --timeout 600000
```
With `-w`, the wait also returns on mail that was already waiting when it started.
### Replying
Answer a message by its id. The reply joins that message's thread.
```bash
tuios send-agent-message -s work -w build --from "$TUIOS_PANE_ID" --reply-to 12 'retested, still green'
tuios wait-for agent-message -s work -w "$TUIOS_PANE_ID" --thread 12
```
A reply is the only acknowledgement that means anything. A message marked read was handed over. It does not mean the other agent understood it or acted on it.
### Attachments
```bash
tuios send-agent-message -s work -w review --attach /tmp/flame.png 'the hot path is in decode'
```
An attachment is a path, not a copy. It must be absolute and must exist when you send. If you delete the file, the reader sees it as `MISSING`. A message carries at most eight attachments. Describe an image in the message text too, since the reader may not be able to see it.
## The session stash
When you hand a file to another agent and will not keep it yourself, put it in the stash first. The daemon keeps a copy for as long as the session lives.
```bash
path=$(tuios stash put /tmp/flame.png)
tuios send-agent-message -s work -w review --attach "$path" 'the hot path is in decode'
tuios stash list -s work
```
* `stash put` prints only the stored path on stdout, so it can go straight into `--attach`.
* The same bytes are stored once. Putting a file twice returns the same path.
* One file can be 16 MB and one session 256 MB. Past the session cap, the oldest files are removed first, but never one a message still points to.
* Nothing can delete from the stash. Files go when the session is killed or the daemon stops.
## Asking a question
```bash
tuios ask-agent -s work -w review --from "$TUIOS_PANE_ID" 'does the payment retry path look right to you?'
```
`ask-agent` works with any agent, because it types the question rather than relying on an inbox. It:
1. Waits until the target is not in the middle of a turn. If it is still `working` after `--ready-timeout` (default 30 s), the call fails with `not_ready` and types nothing.
2. Types the question and presses Enter.
3. Waits until the target has dealt with it, then prints what the pane printed in between.
The result says what ended the wait:
| `settled_by` | Meaning |
| ------------- | ------------------------------------------------------------------------------------------------------------- |
| `agent-state` | The target reported coming back to rest. The reliable signal, and only a pane that reports its state gives it |
| `idle` | The pane printed nothing for `--settle` milliseconds (default 2000). A guess, for panes that report no state |
| `timeout` | Neither happened within `--timeout` (default 300000 ms). The reply may be partial |
`--force` skips the wait in step 1 and types anyway. `--lines` caps the reply (default 200).
`ask-agent` never types at an agent on `needs_input`: it is waiting on a prompt, and the question would be read as the answer. That fails with `agent_blocked` and types nothing. After Enter, the target has 5 seconds (`--stall-timeout`) to show it took the question, or the call fails with `prompt_stalled`.
To reach a group of agents at once, `send-agent-message --select` and `ask-agent --select` take a [selector](/docs/agent-inbox#selectors), such as `group:fan/add-retry`. They list the panes and send nothing until you say yes, or pass `--yes`.
To leave a prompt for a busy agent without interrupting it, queue it: `tuios queue -w build 'text'` types it the moment the agent is at rest. See [Replying and the queue](/docs/agent-inbox#replying-and-the-queue).
## Messages to and from you
The person watching the session has an inbox called `human`. It resolves before any pane name.
```bash
tuios send-agent-message -s work -w human --from "$TUIOS_PANE_ID" \
--subject 'which retry policy?' 'exponential or fixed? both pass the suite'
```
The message reaches the attached client at once. The dock says who wrote, the rail's agents header shows the unread count, and the mail waits in the [Inbox](/docs/agent-inbox). Ctrl +B M opens the Inbox on its mail, and m there opens the whole mailbox, including the threads between agents. i on a pane's row in the rail opens the mailbox for that pane.
In the Inbox, r on a mail row replies. In the mailbox, Enter opens a thread, r replies, o goes to the pane, j /k scroll, and Esc goes back. Your reply arrives in the agent's thread from `human`, marked `verified_human`. A pane cannot send as `human`.
Each row in the mailbox shows the thread id, such as `#12`. The title of an open thread shows it too, as in `Mail #12: ...`. This is the id that `read-agent-messages --thread` takes.
The mailbox shows each message body inside the same fence that the CLI prints. Every line of the body starts with `│ ` (`| ` with ASCII glyphs).
### Writing to an agent first
You can start a thread yourself. In the mailbox list, press n . A list of the agents in this session opens. Choose one with Enter , type the message, and press Enter to send it. The message goes from `human`, marked `verified_human`, and starts a new thread. The agent reads it with `read-agent-messages` and replies with `--reply-to`. If the session has no agent, the list says so.
### Mail alerts
Mail to you and session notices raise alerts. The `[notifications.mail]` table controls them:
```toml
[notifications.mail]
enabled = true # unset: follows notifications.agent.enabled
notify = true # desktop notification. Unset: follows notifications.agent.notify
dock = true # dock message, a click opens the thread. Unset: follows notifications.agent.dock
sound = false # unset: follows notifications.agent.sound
between_agents = false # also alert on a message from one agent to another
```
* A key that you leave out uses the same key from `[notifications.agent]`. A config without the table alerts as before.
* `between_agents` is `false` by default. A message between two agents then counts only on the rail row of the agent that gets it.
* The sound mode, the cooldown, the cue files and the quiet hours always come from `[notifications.agent]`.
* `tuios set-config notifications.mail.dock ""` clears a key. `tuios get-config notifications.mail.dock` then prints `(follows notifications.agent.dock)`.
The settings page shows these keys under the agent alert rows. A reset there clears the key.
For a decision with fixed answers, an agent can ask you directly and wait for the answer with `tuios ask-human`. See [Questions an agent asks you](/docs/agent-inbox#questions-an-agent-asks-you).
`ask-agent -w human` is refused, because there is no pane to type into. Send a message and wait for the reply, or use `ask-human`.
## Across machines
With [remote hosts](/docs/remote-hosts) configured, name the machine in the session: `-s HOST:SESSION`.
```bash
tuios send-agent-message -s build:api -w reviewer --from "$TUIOS_PANE_ID" 'rebased, please retest'
tuios ask-agent -s build:api -w reviewer 'is the retry path right?'
path=$(tuios stash put -s build:api /tmp/flame.png)
```
A message from another machine is marked as such where it lands. A file crosses machines only through the stash, capped at 8 MB. Each host accepts at most 32 unread messages and 32 notices from other machines per session.
### Without ssh
Remote hosts need ssh. Where ssh is blocked, [tuios-courier](https://github.com/Gsirawan/tuios-courier) carries agent mail between machines through a relay over HTTPS. It is a separate community project by [Gsirawan](https://github.com/Gsirawan), not part of TUIOS. It holds new mail until you release it, and agents read the mail inside the same untrusted fence.
## Safety
Every message body and every `ask-agent` reply was written by another program. TUIOS fences it as untrusted content and sets `"untrusted": true` in JSON. Every line inside the fence starts with `│ `, so a body cannot print a fake end of the fence. `read-agent-messages`, `ask-agent`, `peek-prompt` and the mailbox all use this fence. TUIOS removes zero-width and bidirectional control characters from names. A pane named "you" or "human" shows with its short window id. A message that tells an agent to run a command or ignore its instructions should be shown to the user, not obeyed. `--from` is a claim the daemon cannot check.
TUIOS stops the common accidental loops:
* A pane cannot message or ask itself (`loop_refused`).
* An ask that would close a cycle with one already in progress is refused before anything is typed (`loop_refused`).
* A sender gets 10 messages in a burst and 30 a minute after that (`rate_limited`).
It cannot stop a loop you build on purpose across separate calls. Do not wire "read my inbox" straight to "reply automatically" without a limit.
## Limits
* Messages live in memory. They are lost when the daemon stops, and a resurrected session has no mail.
* The ring holds 256 messages or 512 KiB per session, and 8 KiB per message. The oldest are dropped first.
* A message to a pane that has since closed reads back `undeliverable`. It is not handed to a new pane that takes the same name.
* Rings do not cross sessions. A thread id means something in one session only.
* There is no command that stops another agent. To interrupt one, send it Ctrl +C with `send-keys`.
## Related
# Program Status
URL: https://tuios.dev/docs/program-status
> Let any program in a pane report what it does with one escape sequence, OSC 7501, and see it in the rail and the Inbox.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
Any program in a pane can say what it does: a build, a deploy script, a package manager or a coding agent. The program writes one escape sequence. TUIOS shows the state in the [rail](/docs/session-rail#agent-rows), the [Inbox](/docs/agent-inbox) and the pane's title bar. The program needs no hook, no socket and no plugin.
The sequence is the Program Status Protocol, OSC 7501, revision 0.2. Read the [specification](https://www.superlogical.com/rex/docs/build/program-status) for the full grammar.
## What a program sends
A report is one OSC sequence. It holds `key=value` pairs, separated by `:`, and ends with ST (`ESC \`) or BEL.
```sh
printf '\e]7501;state=working:app=build:progress=40:msg=Q29tcGlsaW5n\e\\'
```
| Key | What it holds |
| ---------- | ------------------------------------------------------------------------------------------------------ |
| `state` | `idle`, `working`, `done`, `blocked`, `error` or `clear`. Every report must have it |
| `kind` | What a blocked program waits for: `permission`, `question` or `auth` |
| `progress` | A number from 0 to 100, with `working` or `blocked` |
| `app` | The program's name, such as `cargo` or `terraform` |
| `title` | A short label, in base64 |
| `msg` | One line that says what the program does or waits for, in base64 |
| `id` | The record to address, such as `build` or `build/test`. Without it, the report goes to the root record |
Each report replaces its record. A key that a report does not send is gone from the record. Send `app` and `title` in every report that must keep them.
## Report from a script
`tuios status` writes the sequence and does the base64. It needs no daemon, so a script can use it in any terminal.
```sh
tuios status working --app build --msg 'Compiling' --progress 40
tuios status blocked --kind permission --app deploy --msg 'Approve deploy to production?'
tuios status done --app build --msg 'Built 12 crates'
tuios status error --app build --msg 'Linker failed'
tuios status --clear
```
The command writes to the controlling terminal (`/dev/tty`). The report stays correct when the script's output goes to a file or a pipe. `--stdout` writes to standard output instead. `tuios status` refuses a report that a terminal must discard, and says why.
### A shell function
A script that cannot call `tuios` can use this function:
```sh
status() {
printf '\e]7501;state=%s:msg=%s\e\\' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')"
}
status working "Syncing photos"
rsync -a ~/Photos backup:/photos && status done "Photos synced" || status error "rsync failed"
```
A program that exits right after its work must report `done` or `error` first. That record then stays for you to find.
## The states
TUIOS turns the most urgent record of the pane into the pane's [agent state](/docs/agents#states):
| OSC 7501 state | Agent state | `blocked_by` |
| ---------------------------- | --------------------------- | ------------ |
| `idle` | `idle` | |
| `working` | `working` | |
| `done` | `done`, finished and unread | |
| `blocked`, `kind=permission` | `needs_input` | `approval` |
| `blocked`, `kind=question` | `needs_input` | `question` |
| `blocked`, `kind=auth` | `needs_input` | `auth` |
| `blocked`, no kind | `needs_input` | empty |
| `error` | `errored` | |
| `clear` | Removes the record | |
`state=clear` with an `id` removes that record and every record under it. Without an `id`, it removes every record of the pane.
### More than one record
A program can keep one record for each of its parts. The `id` is a path: `build/test` is under `build`, and `build` is under the root record.
* The pane shows the most urgent record. `blocked` comes first, then `error`, `done`, `working` and `idle`.
* A record without `app` takes the `app` of its nearest parent.
* A pane holds at most 256 records. A new record past that removes the record that changed least recently.
## What TUIOS shows
* **The rail.** The agent row shows the `app` as the agent's name and the progress beside it, such as `build · 40%`. The second line shows the title and the message. The `progress` token of `[appearance.sidebar.agent_row]` sets where the progress goes.
* **The Inbox.** A `blocked` record opens an approval or a question item. An `error` record opens an error item, and a `done` record opens a finished item. The item names the pane by its title and its id, such as `build [3f2a9c1e]`. The detail of the item lists every record of the pane. For `kind=auth` the Inbox gives no answer. Type the login in the pane.
* **Notifications.** A record raises the same alerts, hooks and [push notifications](/docs/push-notifications) as any other agent state. A pane whose state comes from OSC 7501 sends a notification, a bell, a sound or a client hook at most once in 30 seconds.
* **Scripts.** `tuios get-agent-state` and `tuios list-agents` report `source: "program"` and the records as `program_status`.
A harness hook in the same pane wins. When a hook reports, the records still show, but the pane's state is the hook's. When the last record ends, TUIOS clears the state and looks at the pane again.
## How long a record stays
There is no heartbeat. A program does not have to send its state again.
| Event | `working`, `blocked`, `idle` | `done`, `error` |
| -------------------------------------------------- | ----------------------------- | --------------- |
| The shell starts a prompt (OSC 133 A) | Removed | Kept |
| The foreground program that reported exits | Removed, in 2 seconds or less | Kept |
| A background job, or the pane's own process, exits | Kept | Kept |
| You type in the pane | Kept | Removed |
| A full reset (RIS) | Removed | Removed |
A shell without OSC 133 marks does not tell TUIOS when a prompt starts. TUIOS then records the foreground process group with each report. When no process is left in that group, the program has exited and its records go.
A program that reports and exits at once, such as `tuios status working` typed at a prompt, can be gone before TUIOS reads the foreground. Its record then stays until the next prompt mark, the next report or a clear. A shell with OSC 133 marks does not have this gap.
Keys from `tuios send-text` or `tuios send-keys` do not count as your typing. Keys from an attached client do.
## Check for support
A program sends `OSC 7501 ; ? ST`. A terminal that reads the protocol answers with the same sequence. TUIOS answers from the pane's emulator, with the terminator that the query used.
Send a primary device attributes query (`CSI c`) after the question. If that answer comes first, the terminal does not read the protocol.
TUIOS sends back only this fixed answer. A program cannot read the records.
## Limits
A report that breaks a limit is discarded. TUIOS applies nothing from it.
| Item | Limit |
| ------------------- | -------------------------------------------- |
| The whole sequence | 4096 bytes |
| A key | 16 bytes |
| `msg` | 2048 bytes decoded |
| `title` | 192 bytes decoded |
| `app` | 32 bytes |
| `id` | 128 bytes, 8 levels, 32 bytes for each level |
| Records in one pane | 256 |
* A `msg` or `title` that does not decode, is not UTF-8, or holds a control character discards the report.
* A report without `state`, or with an unknown state, is ignored. So is a report with a bad `id`.
* A `kind` with a state other than `blocked` is ignored. So is a `progress` outside 0 to 100.
* TUIOS shows markup as text and removes invisible format characters before it shows a message.
* `app` is only a label. TUIOS does not look it up as a harness.
## TUIOS in a terminal that reads OSC 7501
TUIOS is a program too. In a terminal that reads the protocol, TUIOS reports the agent states of its panes to that terminal. Each pane has one record, with the id `/`. A client asks its terminal `OSC 7501 ; ?` at start, and reports only when the terminal answers. When a client detaches, it clears its records.
To turn this off:
```toml
[agents]
host_program_status = "off" # "auto" is the default
```
TUIOS reads OSC 7501 itself on both terminal backends, the pure Go one and libghostty-vt.
## Related
# Worktrees
URL: https://tuios.dev/docs/worktrees
> One session per git worktree, and one prompt fanned out to several agents at once.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
A git worktree is a second checkout of a repository on its own branch. It is a good place to put one agent: it has its own files and its own branch, and it shares nothing with your working copy. TUIOS makes the worktree and a session inside it with one command, and the rail groups every such session under its repository, labelled by branch.
## Creating one
Run this inside a repository:
```bash
tuios worktree new feat/retry
```
```
Created branch feat/retry in /home/u/.local/share/tuios/worktrees/api/feat-retry.
Created session 'api-feat-retry'.
```
* A branch that does not exist is created from `--base`, or from `HEAD`.
* The worktree goes under `$XDG_DATA_HOME/tuios/worktrees//`.
* The session is named `-`, with every `/` turned into `-`. `--name` picks another name.
* The session attaches at once. `--detach` (`-d`) creates it without attaching.
* `--repo` points at another repository instead of the current directory.
`--agent` starts an agent CLI in the session instead of a shell. Name it the way you type it:
```bash
tuios worktree new feat/retry --base main --agent claude --detach
```
A session you start by hand inside a worktree is recognised too. The daemon reads the directory, so it never runs git to find out.
## Listing and comparing
```bash
tuios worktree ls
tuios worktree ls --repo api
```
Each row shows the session, repository, branch, the agent state inside it, how many uncommitted changes it holds, and its pull request and checks once [`tuios ship pr`](/docs/ship) opened one. A row marked `gone` is a session whose worktree directory was removed. The session is kept, so what the agent printed can still be read.
```bash
tuios worktree diff api-feat-retry
tuios worktree diff api-feat-retry --stat
```
`worktree diff` prints the commits the session made on top of its base, then its uncommitted changes, then its untracked files.
## Removing one
```bash
tuios worktree rm api-feat-retry
```
This runs `git worktree remove` and kills the session. A worktree with uncommitted changes is refused and nothing is removed. Choose what happens to the changes:
| Flag | Effect |
| ---------------- | ------------------------------------------------------------------------------ |
| `--stash` | Move the changes into the repository's stash as `tuios: `, then remove |
| `--force` | Discard the changes. The only option that throws work away |
| `--keep-session` | Remove the worktree and leave the session running |
Removing a worktree also deletes the [checkpoints](/docs/checkpoints) taken in it.
Every commit made in a worktree stays in the repository. TUIOS never deletes a branch and never runs `git worktree prune`.
## Fan-out: one prompt, several agents
`tuios fan` starts the same prompt in several agents at once, each in its own worktree. When they finish, compare the results and keep the best one.
```bash
tuios fan 3 --agent claude 'Add a retry with backoff to the HTTP client.'
```
```
Started 3 agents on fan/add-retry-backoff-http. Each prompt is sent when its agent is ready.
api-fan-add-retry-backoff-http fan/add-retry-backoff-http ...
api-fan-add-retry-backoff-http-2 fan/add-retry-backoff-http-2 ...
api-fan-add-retry-backoff-http-3 fan/add-retry-backoff-http-3 ...
```
* The count is 1 to 16.
* The branches are a stem, then `stem-2`, `stem-3` and so on. The stem is `fan/` plus the first words of the prompt, or `--name`.
* `--agent` is required. Name the agent the way you type it, arguments included: `claude`, `"codex --model o5"`, or any program. Several, separated by commas, are cycled across the sessions, so `--agent claude,codex,gemini` with a count of 3 starts one of each.
* `--prompt`, given once per session, gives each session its own prompt. The count is then how many there are.
* `--env NAME` passes your value of a variable to every agent, and `--env NAME=VALUE` sets one. `PATH` is always sent.
* `--base` sets the ref every branch starts from.
* `--grants` says what every agent may do through TUIOS. See [What a pane may do](/docs/agents#what-a-pane-may-do).
* The prompt is not typed the moment the agent starts. TUIOS waits until the agent is at its prompt, then types it, so it never lands in a start-up screen. An agent that has not shown its prompt after 30 seconds, most often because it asks a first-run question such as whether to trust the folder, puts a question in the [Inbox](/docs/agent-inbox) asking you to look at its pane. Its prompt is typed as soon as it is ready.
* `--wait` makes the command return only when every prompt has been sent or given up on.
* `--host` runs the fan-out on another machine. See [Remote Hosts](/docs/remote-hosts).
Follow the run:
```bash
tuios worktree ls --group fan/add-retry-backoff-http
tuios wait-for agent-state --select 'group:fan/add-retry-backoff-http' --until idle,done --every --timeout 3600000
```
The `--group` listing shows whether each prompt is `pending`, `sent` or `not_sent`.
### Comparing and keeping one
`fan compare` shows every attempt on one line: its agent and state, the files and lines it changed against the fan's base (committed or not, untracked files included), and its last check.
```bash
tuios fan compare api-fan-demo
```
```
fan/demo in api, 2 attempts against main
api-fan-demo bash none 0 files +0 -0 no check yet
api-fan-demo-2 bash none 2 files +2 -0 no check yet
Keep one with 'tuios fan keep '. Compare two with 'tuios fan diff A B'.
```
`fan verify` runs one check in every attempt, in a window named `verify` in each session, and says which passed. It always runs the command you give, never one read from the repository, and the window holds no grants. A window whose check passed closes. One whose check failed stays open so you can read the output. The command exits `1` when any check failed.
```bash
tuios fan verify api-fan-demo -- 'test -f b.txt'
```
```
Running "test -f b.txt" in 2 attempts of fan/demo.
api-fan-demo: failed, exit 1 in 0s. Its verify window is open with the output
api-fan-demo-2: passed in 0s
1 of 2 passed.
```
`fan compare` then shows each check's result in its last column. `fan diff` shows what one attempt did differently from another, and `tuios review` shows one attempt's changes with notes you can send back to its agent. See [Reviewing an agent's changes](/docs/agent-inbox#reviewing-an-agents-changes). The review overlay's compare view (Ctrl +B v , then w ) does all of this from the keyboard.
```bash
tuios fan diff api-fan-demo api-fan-demo-2 --stat
tuios review api-fan-demo-2
```
Keep one and remove the others:
```bash
tuios fan keep api-fan-add-retry-backoff-http-2 --stash
```
The session you name is untouched. Each sibling is removed the way `worktree rm` removes it. A sibling with uncommitted changes is left in place unless you pass `--stash` or `--force`.
To land the kept attempt too, add `--merge`:
```bash
tuios fan keep api-fan-add-retry-backoff-http-2 --merge --squash
```
`--merge` first merges the kept attempt's branch into its base in the main checkout, as [`tuios ship merge`](/docs/ship#merge) does. `--squash`, `--ff-only` and `--into` choose how. When the merge conflicts or is refused, it is undone and no sibling is removed. Uncommitted changes in the kept worktree are not merged, so commit them first with `tuios ship commit`.
## Work from another machine
`worktree new --host` and `fan --host` run on a machine from the [`[hosts]` table](/docs/remote-hosts), and so does `start-agent -s HOST:SESSION`. The repository is the one the current directory is in, found on the host by its origin URL under the host's `repos_root`, and `--clone` clones it there when the host has none. The agent must be installed on that machine.
`worktree pull` brings the work back:
```bash
tuios worktree pull build:api-fan-add-retry-2
tuios worktree pull build:api-fan-add-retry-2 --branch try/retry-build --detach
```
The session's commits come across as a git bundle into a new branch here, and its uncommitted work, untracked files included, as a patch applied in a new worktree on that branch. Nothing on the other machine changes. A branch that already exists here is refused, so nothing is overwritten.
## From a script
The commands are wrappers over the `new-worktree`, `list-worktrees`, `remove-worktree`, `fan`, `compare-fan`, `verify-fan` and `keep-fan` verbs. `--json` on each command gives the result as JSON.
```json
{"id":1,"verb":"fan","params":{"count":3,"agent":"claude","prompt":"Add a retry.","repo":"/src/api"}}
```
See [Control Protocol](/docs/control-protocol) and `tuios list-verbs fan`.
## Related
# Checkpoints
URL: https://tuios.dev/docs/checkpoints
> TUIOS saves an agent's work tree at the end of each turn, so you can read what one turn changed and undo it.
import { Card, Cards } from 'fumadocs-ui/components/card';
When an agent in a git work tree finishes a turn, the daemon saves the work tree as a checkpoint. You can list the checkpoints of a pane, read what one turn changed, and put the files back as an earlier turn left them.
```bash
tuios checkpoint list -w build # one row per saved turn
tuios checkpoint diff -w build 2 # what turn 2 changed
tuios checkpoint restore -w build 1 # put the files back as turn 1 left them
```
```
Checkpoints of api-feat-retry, pane 00d333ce, in /home/u/.local/share/tuios/worktrees/api/feat-retry: 3
N TURN STATE TAKEN COMMIT LABEL
1 1 done 14:02:56 a6752d3 Add a retry to the client
2 2 done 14:09:58 992c80e Make the backoff configurable
3 4 done 14:12:08 3de15e5 safety: before restoring checkpoint 1
```
## When a checkpoint is taken
The daemon takes a checkpoint when a pane goes from `working` to `done`, `idle` or `needs_input`, or to `unknown` at the end of a counted turn. The pane's directory must be in a git work tree. A turn that changed no file since the last checkpoint gets no checkpoint.
Checkpoints need the agent state. A pane that never reports or shows `working` gets none. See [How TUIOS learns the state](/docs/agents#how-tuios-learns-the-state).
## What a checkpoint holds
* Tracked files as they are on disk, and untracked files that git does not ignore. Ignored files are not in it.
* An untracked file larger than `max_untracked_mb` (50 MB by default) is left out. `tuios checkpoint list` names the files left out, and a restore does not change them.
* The checkpoint is a commit under `refs/tuios/checkpoints//`. Its parent is `HEAD` at that time.
* Its label is the turn's prompt, or the line the turn ended with, or the state's message.
The daemon writes the commit through a temporary index. Your index, `HEAD`, your branch and the stash do not change. Git does not push `refs/tuios`, because no default refspec names it.
## Reading one
`tuios checkpoint diff N` compares checkpoint N with the pane's checkpoint before it. The first checkpoint compares with `HEAD` at the time it was taken. Without N it shows the newest.
```bash
tuios checkpoint diff -w build 2 --stat
tuios checkpoint diff -w build 2 --path api/retry.go --context 5
```
```
Checkpoint 2 of api-feat-retry, pane 00d333ce, against checkpoint 1: 2 files, +2 -1
M a.txt +1 -1
M b.txt +1 -0
```
The diff stops at the same limits as a [review](/docs/agent-inbox#reviewing-an-agents-changes).
## Restoring one
```bash
tuios checkpoint restore -w build 1
```
```
Restored checkpoint 1 in api-feat-retry, pane 00d333ce: 2 files written, 0 removed.
Checkpoint 3 holds the work tree as it was before. To undo, run: tuios checkpoint restore -s api-feat-retry -w 00d333ce 3
```
* A restore first saves the work tree as a `safety` checkpoint. To undo the restore, restore that checkpoint.
* The daemon then writes the files that differ, and removes the files that checkpoint N does not have.
* It does not touch ignored files, the index or `HEAD`. `git status` shows the restored files as changes.
* A restore stops while the agent is `working` or `needs_input`, because the agent would write over the files. Wait for the turn to end, or add `--force`.
* A restore that would write over an ignored or new file stops before it changes a file, and names the file. No checkpoint holds that file, so the restore would lose it. Move the file out of the way, then restore again.
* Submodules are skipped.
## Limits
* The git commands run off the daemon's event path, one at a time, with a limit of 30 seconds each. On a work tree of 50,000 files, a checkpoint takes about 100 ms.
* Each pane keeps the newest 50 checkpoints.
* Removing a worktree with `tuios worktree rm` or `tuios fan keep` deletes the checkpoints taken in it.
* Checkpoints of a pane on another machine are not available here. Attach to that machine to use them.
## Configuration
```toml
[agents.checkpoints]
enabled = true # false takes no checkpoints
keep = 50 # checkpoints per pane, 1 to 1000
max_untracked_mb = 50 # a larger untracked file is left out. A negative value sets no limit
```
The daemon reads the table when it starts, and again when the file changes. See [Configuration](/docs/configuration).
## From a pane
`tuios checkpoint list` and `tuios checkpoint diff` need the `read` grant. `tuios checkpoint restore` needs `write`, and works only on a pane that holds no grant the caller does not hold. See [What a pane may do](/docs/agents#what-a-pane-may-do).
The verbs are `list-checkpoints`, `checkpoint-diff` and `restore-checkpoint`. `--json` on each command gives the result as JSON. A missing checkpoint fails with `no_checkpoint`, and the hint lists the ones the pane has.
## Related
# Ship
URL: https://tuios.dev/docs/ship
> Take an agent's work from its worktree to a merged change. Commit it, merge it, push it and open a pull request, with your own git identity.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
When the work in an agent's [worktree](/docs/worktrees) is good, `tuios ship` takes it to a merged change. You do not need to go to the worktree's directory.
```bash
tuios ship commit -s api-feat-retry -m 'Add a retry to the client'
tuios ship merge -s api-feat-retry # into the base, in the main checkout
tuios ship pr -s api-feat-retry --draft # push, then open a pull request with gh
tuios ship status -s api-feat-retry # the pull request and its checks
tuios fan keep api-fan-retry-2 --merge # keep one attempt, merge it, remove the others
```
`-s` names the session and `-w` the pane. From inside a pane, a command that names no pane is about that pane. A pane on another machine is refused.
## Commit
```bash
tuios ship commit -s api-feat-retry -m 'Add a retry to the client'
```
```
Committed 55cb537 on feat/retry: Add a retry to the client
```
* `ship commit` stages every change in the pane's work tree, untracked files included and ignored files not, and commits it on the worktree's branch.
* git runs in the daemon with your environment. Your name, email, signing setup and hooks make the commit. TUIOS sets no identity and adds nothing to the message.
* Without `-m`, the message is the pane's last prompt, or the line its last turn ended with.
* When a hook or the signing program refuses the commit, the index goes back to what it was.
* A commit stops while the agent is `working` or `needs_input`. Wait for the turn to end, or add `--force`.
* A clean work tree fails with `nothing_to_commit`.
## Merge
```bash
tuios ship merge -s api-feat-retry
tuios ship merge -s api-feat-retry --squash -m 'Add a retry to the client'
```
```
Merged feat/retry into main in /src/api: fast-forward, 1 commit, now at 55cb537.
```
The branch merges into the base the worktree was made from, in the main checkout. `--into` names another branch.
* The main checkout must have that branch checked out, and no uncommitted change to a tracked file. If not, nothing is merged and the command fails with `checkout_dirty` and lists the files.
* When the merge conflicts, TUIOS undoes it with `git reset --merge` and lists the files (`merge_conflict`). The main checkout is then as it was. Rebase the branch in the worktree, resolve the conflicts there, and merge again.
* `--squash` makes one commit. `--ff-only` refuses a branch that cannot be fast-forwarded.
* Uncommitted changes in the worktree are not merged. The command says how many there are.
* Nothing is forced.
## Push and pull request
```bash
tuios ship push -s api-feat-retry
tuios ship pr -s api-feat-retry --draft
tuios ship pr -s api-feat-retry --base main --title 'Add a retry' --body 'Retries with backoff.'
```
```
tuios ship will:
push feat/retry at 55cb537 to origin (github.com/acme/api.git)
open a draft pull request into main with gh, title: from the commits
Push and open the pull request? [y/N]
```
A push and a pull request send work off this machine, so each one asks first.
* The command shows what it will send: the branch, its commit, the remote's address, the commits and the pull request. At a terminal it asks `[y/N]`. Elsewhere it needs `--yes`.
* The answer is bound to that commit. TUIOS pushes the commit you were shown, even when the branch moves on while the question waits.
* The address shows the host and path the remote pushes to. Credentials, the query and the fragment are removed.
* A push is never forced.
* `--remote` names the remote. The default is the one the branch follows, else the only one, else `origin`.
* `ship pr` pushes, then runs your own `gh pr create`. Without `--title`, gh fills the title and body from the commits. `--body` needs `--title`. When the branch has an open pull request already, the push updates it.
* TUIOS holds no GitHub token. Without gh, or when gh is not logged in, the command stops with `gh_unavailable` before anything is pushed. Run `gh auth login`.
### When an agent asks to push
A push or a pull request from a process inside a pane also needs your yes in the [Inbox](/docs/agent-inbox). The same applies to a connection limited with `restrict-connection`, such as `tuios mcp`.
The question shows on the client that shows the pane, and waits under Questions:
```
Questions
6s #8 api-feat-retry/claude Push feat/retry (55cb537) to origin (github.com/acme/api.git)? (1 allow, 2 deny; answer in the Inbox)
```
* The command waits for your answer, 2 minutes by default (`--wait`).
* When the wait ends first, the command fails with `not_ready` and the question stays. `--request ` waits for the same question again.
* A deny fails the command with `forbidden`.
* Over a link from another machine, a push is refused.
An agent can ask to push. It cannot answer its own question: only a person at an attached client can.
## The pull request on the rail
`ship pr` and `ship status --refresh` record the pull request on the worktree's session.
```bash
tuios ship status -s api-feat-retry
```
```
feat/retry: PR #7 open pending
https://github.com/acme/api/pull/7
Checks: 0 passed, 0 failed, 1 pending.
```
* The agent rows of that session show the `pr` token: `PR #12 open pending`, `PR #12 open pass`, `PR #12 open fail` or `PR #12 merged`. It is red for failing checks, amber for pending ones, and green for passing ones and a merge. See [Session Rail](/docs/session-rail#agent-rows).
* `tuios worktree ls` shows it in a PR column. `tuios worktree ls --json` carries it as `pr`, and `tuios ls --json` carries it under each session's `worktree`.
* While a client is attached and a recorded pull request is open, the daemon reads it again with `gh pr view` once a minute, one gh call at a time. A merged or closed pull request is not read again. With no open pull request, no client or no gh, nothing is read.
## Keep one attempt of a fan and merge it
```bash
tuios fan keep api-fan-retry-2 --merge
tuios fan keep api-fan-retry-2 --merge --squash
```
`--merge` first merges the kept attempt into its base, the way `ship merge` does. `--squash`, `--ff-only` and `--into` choose how. When the merge conflicts or is refused, it is undone and no other attempt is removed. See [Comparing and keeping one](/docs/worktrees#comparing-and-keeping-one).
## From a pane
| Command | Needs |
| --------------------------- | --------------------------------------------------------------- |
| `ship commit`, `ship merge` | `write`, on a pane that holds no grant the caller does not hold |
| `ship push`, `ship pr` | `write`, and your yes in the Inbox |
| `ship status` | `read` |
The verbs are `ship-commit`, `ship-merge`, `ship-push`, `ship-pr` and `ship-status`. `--json` on each command gives the result as JSON. A failure carries `code`, and for a conflict or a dirty checkout, `files`. See [What a pane may do](/docs/agents#what-a-pane-may-do) and [Control Protocol](/docs/control-protocol).
## Related
# Push Notifications
URL: https://tuios.dev/docs/push-notifications
> Get an Inbox item on your phone through ntfy, Pushover or a webhook, and open the Inbox on it from the notification.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
The `[notify]` table sends a push notification when the [Inbox](/docs/agent-inbox) gets an item that waits for you. The daemon sends it, so it works when no client is attached. The link in the notification opens the Inbox on that item in [tuios-web](/docs/web).
## Setting up
Add one provider or more to `config.toml`. Each provider gets every notification.
```toml
[notify]
web_url = "https://term.example.com/" # tuios-web, for a link to the item
[notify.ntfy]
url = "https://ntfy.sh/a-long-random-topic"
```
Then apply it and try it:
```bash
tuios config apply # from a terminal outside TUIOS
tuios notify test
```
```
ntfy (ntfy.sh): sent.
pushover (api.pushover.net): sent.
webhook (hooks.example.com): failed. The server answered 401 Unauthorized. Check the token.
```
`tuios notify test` sends a test notification through each provider and prints one line per provider. It exits `1` when a provider fails. `--json` prints `provider`, `host`, `ok` and `error` for each one. The command sends from its own process, not from the daemon, so it reads a `token_env` variable from your shell. The daemon reads it from its own environment.
## When a notification is sent
* An item sends a notification when its kind is on in `[notify.triggers]`. By default, the kinds that wait for you are on: `approval`, `plan`, `question` and `ask` (a question from `tuios ask-human`). The kinds that only report are off: `mail`, `errored` and `finished`.
* While you typed into a pane at an attached client in the last `quiet_active_seconds`, the notification waits. When you stay away that long and the item is still open, TUIOS sends it.
* Each item sends one notification at most. A repeated state report, or a new line on the same item, sends nothing.
* `cooldown_seconds` is the shortest time between two notifications for the same pane and kind. `max_per_hour` limits all notifications together.
* An item from a linked host sends nothing here. That host sends its own.
## The full table
```toml
[notify]
enabled = true
web_url = "https://term.example.com/"
content = "summary" # or "title"
quiet_active_seconds = 120
cooldown_seconds = 60
max_per_hour = 30
allow_http_redirects = false
[notify.triggers]
approval = true
plan = true
question = true
ask = true
mail = false
errored = false
finished = false
[notify.ntfy]
url = "https://ntfy.sh/a-long-random-topic"
token_file = "~/.config/tuios/ntfy-token" # optional
priority = 4 # optional, 1 to 5
[notify.pushover]
user_env = "PUSHOVER_USER"
token_file = "~/.config/tuios/pushover-token"
[notify.webhook]
url = "https://hooks.example.com/tuios"
token_env = "TUIOS_HOOK_TOKEN" # optional, sent as a bearer token
```
| Key | Default | What it does |
| ---------------------- | --------- | ----------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true` | `false` turns every notification off. Nothing is sent without a provider |
| `web_url` | empty | The address of tuios-web. Each notification links to `web_url/inbox?item=ID` |
| `content` | `summary` | `summary` sends the kind, the pane name and the line the Inbox shows. `title` sends only the kind and the session |
| `quiet_active_seconds` | `120` | Holds a notification while you are active at a client. `0` sends at once |
| `cooldown_seconds` | `60` | The shortest time between two notifications for the same pane and kind |
| `max_per_hour` | `30` | The most notifications in one hour. `0` sets no limit |
| `allow_http_redirects` | `false` | Lets a provider redirect to a plain `http` address |
## Providers
* **ntfy**: `url` is the topic address. `priority` is 1 to 5. When it is unset, an item that waits for you gets 4 and other items get 3. The link goes in the `Click` header.
* **Pushover**: your user key and an application token. `url` replaces the Pushover API address, for a relay.
* **Webhook**: TUIOS sends a JSON `POST` to `url`. A token goes in an `Authorization: Bearer` header.
The webhook body has these fields: `event` (`tuios.inbox`), `kind`, `title`, `body`, `link`, `urgent`, `item_id`, `session`, `window`, `harness`, and `test` for a test notification. At `content = "title"` the body is empty.
```json
{"event":"tuios.inbox","kind":"ask","title":"Question for you: ask-human","body":"Deploy to staging?\nSession main.","link":"https://term.example.com/inbox?item=10","urgent":true,"item_id":"10","session":"main"}
```
## Secrets
Give each token in one of three ways. TUIOS uses the first one that is set.
| Key | Source |
| ------------ | ---------------------------------------------------------------------------------------------------- |
| `token` | The value, in `config.toml` |
| `token_env` | An environment variable of the daemon. The daemon keeps the environment of the shell that started it |
| `token_file` | A file that only you can read (mode 600 or 400) |
Pushover reads the user key from `user`, `user_env` or `user_file` in the same way. TUIOS reads the value each time it sends, so a new token in the file applies at once.
TUIOS never writes a token to its logs or its output. On a public ntfy server the topic address is a secret too, so the logs and `tuios notify test` show only the host.
## How a notification is sent
TUIOS sends each notification itself and needs no other program, such as curl.
* It uses `HTTPS_PROXY`, `NO_PROXY` and the system certificates.
* Each send waits at most 10 seconds.
* It follows at most 3 redirects. A redirect to a plain `http` address is refused, unless you set `allow_http_redirects`.
* A redirect to another host is always refused, for every provider, because the message and its token would go there. Set `url` to the address the server redirects to.
## Applying a change
The daemon reads `[notify]` when it starts, and again when the file changes.
* A change that turns notifications off, or changes the triggers, applies at once.
* A new address, or a new source for a secret, waits for `tuios config apply` from a terminal outside TUIOS, or a daemon restart. The daemon log says so.
A program in a pane can write `config.toml`. The wait stops it from sending your Inbox to another address. Like `[hosts]`, `[notify]` is not in `tuios list-options`, and `tuios set-config` cannot change it.
## Opening the Inbox from a notification
When `web_url` is the address of tuios-web, the link in a notification is `web_url/inbox?item=ID`. tuios-web answers it with a cookie that names the item, and sends the browser to the page. When the page connects, the Inbox opens with the cursor on that item. Answer it there as at any other client.
* The link needs the same password as the page.
* The cookie holds only the item number. It expires after 60 seconds.
* An item that closed before you open the link leaves the cursor on the first item.
* Behind a reverse proxy, set `web_url` to the address with the proxy's path. The link sends the browser back to that path.
* With `--no-auth` and TLS, a browser can connect over WebTransport. Some browsers do not send the cookie there. Then the Inbox does not open by itself.
See [Web Terminal](/docs/web).
## Your own message
For a message of your own, use a hook. The daemon runs `after-agent-state` with nobody attached too. See [Hooks](/docs/hooks).
## Related
# Turn Off Agent Features
URL: https://tuios.dev/docs/agents-off
> One switch, agents.enabled, turns off every agent feature and keeps only the multiplexer.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
If you want only the multiplexer, turn off the agent features:
```toml
[agents]
enabled = false
```
The default is `true`. You can also change it on the settings page (, in window mode), where it is the first row, "Agent features". While a TUIOS client is open, you can also run this from a shell outside TUIOS:
```bash
tuios set-config agents.enabled false
```
The change applies at once, in both directions. You do not have to restart TUIOS.
## What turns off
**The daemon:**
* It does not look for agents. It does not read the process table, pane titles, screens or agent transcripts, and it drops the OSC 9;4 progress and the agent state that programs send.
* The stall timer does not run.
* It runs no agent hook, such as `after-agent-state`.
* It types no queued message, and it offers to resume no agent conversation after a restart.
**The client:**
* The rail shows no agents section. The other sections move up to fill the space.
* The Inbox, agent mail, approvals and agent alerts are off. The Inbox key shows one line, "Agent features are off. Turn them on in Settings.", and does nothing else.
* The prefix menu, the command palette, the help and the settings page do not show agent items.
**Commands and verbs:**
* Agent commands stop with one line: "Agent features are off. Set agents.enabled = true in the config to use this command."
* A program that calls an agent verb gets the error code `agents_disabled`. `--json` carries the code.
* The agent verbs cover agents and fans (`start-agent`, `fan`, `fan compare`, `fan verify`, `fan keep`, `list-agents`, `resume-agent`, `ask-agent`, the `explain-agent-*` commands), agent state and metadata, agent mail, the Inbox and approvals (`list-attention`, `peek-prompt`, `respond`, `ask-human`), the queue, and `review send`.
* `wait-for` still works. Only its `agent-state` and `agent-message` conditions are refused.
* `tuios mcp` started with the features off leaves the agent tools out of its tool list.
* An agent command aimed at another machine, such as `tuios list-agents -s build:work`, follows this machine's switch.
With the Inbox off, [push notifications](/docs/push-notifications) have no item to send.
## What happens when you turn it off
* The daemon clears the agent state of every pane, and closes every Inbox item.
* A held approval goes back to the agent, which asks in its own pane.
* Queued messages are dropped, so a message queued before cannot be typed later.
When you turn the features on again, the daemon looks at every pane at once.
## What stays
* Sessions, panes, workspaces, layouts and the rest of the rail.
* [Worktrees](/docs/worktrees): `tuios worktree new`, `ls`, `diff`, `rm` and `pull`.
* `tuios review` and its notes. Only sending the notes to an agent is off.
* [Ship](/docs/ship): commit, merge, push and pull request.
* [Checkpoints](/docs/checkpoints) already saved can still be listed, read and restored. No new checkpoint is taken, because the daemon sees no agent turn end.
* The tmux shim, hooks for other events, and `tuios notify test`.
## Typing between panes is stricter
A pane can answer another pane's prompt if it types into that pane. Usually TUIOS knows which pane waits on a prompt. With the features off it does not know. So a pane that does not hold the `respond` grant can type only into:
* A pane that it opened, for example with `tuios new-window` or the tmux shim's `split-window`.
* A pane whose own shell is at its prompt, with no program running.
It cannot type into other panes. This includes `send-keys`, `send-text` and `run` from a script in a pane. Your own keys, and commands from a shell outside TUIOS, are not affected.
To let every pane type into other panes, give panes `respond`:
```toml
[agents.permissions]
grants = ["admin", "respond"]
```
See [What a pane may do](/docs/agents#what-a-pane-may-do).
Change it on the settings page, in `config.toml`, or with `tuios set-config` from a shell outside TUIOS. A process in a pane that tries to change it gets `forbidden`.
## Related
# Control Protocol
URL: https://tuios.dev/docs/control-protocol
> Drive a TUIOS daemon from scripts and programs with line-delimited JSON on its socket.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
The TUIOS daemon accepts JSON requests on its Unix socket. Send one JSON object per line and read one JSON object back per line. No client library is needed: a shell, `nc` and `jq` are enough.
Use it when a script or program needs structured errors, blocking waits, or a live stream of events. For quick one-liners, the [CLI commands](/docs/sessions#remote-control) such as `tuios send-text` and `tuios wait-for` do the same things and are built on this protocol.
## Connecting
The protocol shares the socket the TUIOS client uses. There is no separate port and no handshake to negotiate: connect and write JSON. The daemon tells the two apart by the first byte, since a JSON request starts with `{` and a binary client frame never does.
```bash
if [ -n "$XDG_RUNTIME_DIR" ]; then
SOCK="$XDG_RUNTIME_DIR/tuios/tuios.sock"
else
SOCK="/tmp/tuios-$(id -u)/tuios.sock"
fi
printf '{"id":1,"verb":"list-sessions"}\n' | nc -U "$SOCK" | jq .
```
`socat - UNIX-CONNECT:"$SOCK"` works in place of `nc -U "$SOCK"`. The socket path for Windows is in [Sessions](/docs/sessions#daemon-location).
You can send several requests on one connection. They are answered in order, one response line per request line. To run requests in parallel, open more connections.
The socket is only accessible to your user: its directory is created with mode `0700`, and TUIOS refuses a directory that another user owns or that is a symlink. There is no token or password on top of that. What a program inside a pane may do over the socket is limited by the pane's [grants](/docs/agents#what-a-pane-may-do).
## Discovering the protocol
The daemon describes itself, so you do not need this page to use it:
```bash
tuios list-verbs # every verb, its parameters and example requests
tuios list-verbs capture-pane # one verb
tuios list-verbs --json # the same, as JSON
```
The output includes the protocol version, every parameter with its type, default and accepted values, the error codes, and the request and response shapes. The same catalogue is available over the socket as the `list-verbs` verb.
The accepted values come from the same lists the daemon checks requests against, so they cannot disagree with what it will actually take.
## Requests and responses
A request has an optional `id`, a `verb`, and `params` when the verb takes any:
```json
{"id":1,"verb":"send-text","params":{"session":"work","window":"build","text":"make\n"}}
```
The `id` can be a number or a string. It is echoed back on the response, and left out if you left it out.
A successful response carries a `result`. Every result has a `type` field naming its shape:
```json
{"id":1,"result":{"type":"hello","protocol":1,"min_protocol":1,"daemon_version":"0.8.0","pid":4242,"sessions":2}}
```
A failure carries an `error` with a stable `code`, a readable `message`, and usually a `hint`:
```json
{"id":2,"error":{"code":"invalid_params","message":"verb capture-pane has no parameter foo","hint":{"verb":"list-verbs","command":"tuios list-verbs capture-pane","param":"foo","accepted":["session","window","source","styled","scrollback","ansi","resolved","palette","lines","start","end"],"detail":"An unknown parameter is refused rather than silently ignored. Fix the name and retry."}}}
```
The hint says what to do next: the parameter that was wrong, the values it accepts, the closest match to what you typed (`did_you_mean`), the names that do exist (`available`), or a command to run. Every hint field is optional.
A few rules hold for every verb:
* **Unknown parameters are refused**, not ignored. A misspelt parameter fails with `invalid_params` instead of doing something you did not ask for.
* **`session` can usually be left out.** It then means the most recently active session.
* **`window` takes a window ID or name.** Left out, it means the focused window.
* **A request that cannot do what it asked never reports success.**
## Versioning
`hello` reports the protocol version the daemon speaks and the oldest one it accepts. The current version is 1. Calling it first is optional: every other verb works without it.
```json
{"id":1,"verb":"hello","params":{"client":"my-script","version":"1.0.0","protocol":1}}
```
A caller whose version is outside the accepted range gets `protocol_mismatch`. New verbs are added without changing the version; it changes only when an existing verb or the envelope changes incompatibly.
## Verbs
The daemon currently implements 115 verbs, some of them only for one daemon to call on another. `tuios list-verbs` always reflects the binary you are talking to.
### Discovery
| Verb | Purpose |
| ------------ | ------------------------------------------------------------- |
| `hello` | Report the protocol version and the range the daemon accepts. |
| `list-verbs` | Describe every verb, or one named verb. |
### Sessions
| Verb | Purpose |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list-sessions` | List every session the daemon holds. |
| `list-clients` | List every connection to the daemon: its client id, the process id the kernel reports, and the session it is attached to. See [Clients](#clients). |
| `new-session` | Create a session, detached, with its first window. Optional `name`, `width`, `height` (default 80x24), `window`, `window_name`, `cwd` and `command`. `cwd` is also where later windows with no pane to take a directory from start. |
| `switch-session` | Switch an attached client to another session, in place. `name`, and optional `host`, `create`, `cwd` (with `create`), `session` (the session the client shows) and `client` (a client id). A pane needs `admin`. |
| `detach-client` | Detach attached clients. The session continues to run. Optional `client`, `session` and `all_other`. A pane needs `admin`. See [Clients](#clients). |
| `ssh-agent-path` | The ssh agent link of a session, with `follow` and the socket it points at (`target`). `session`. See [SSH Agent](/docs/ssh-agent). |
| `session-info` | Details about one session. |
| `kill-session` | End a session and every window in it. |
| `rename-session` | Rename a session. Panes that already run still reach it by the old name. |
| `set-session-name` | Set a display name. The session keeps its real name for addressing. |
| `set-session-accent` | Set or clear a session's accent colour. |
### Windows
| Verb | Purpose |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `list-windows` | List a session's windows with ID, index, name, workspace, size, focus and agent state. A window whose process runs on this machine also has `pid` and `tty`. A zoomed window has `zoomed: true`. |
| `get-window` | Describe one window. |
| `new-window` | Create a window. Optional `name`, `workspace`, `cwd`, `focus`, `command` (an argv run instead of a shell), `host` (run it on another machine) and `grants`. |
| `close-window` | Close a window. |
| `close-workspace` | Close every pane on a workspace, like tmux `kill-window`. Scratch panes stay unless their scratch workspace is the target. A pane needs `admin`. |
| `focus-window` | Focus a window by `window`, `relative` (`next`, `prev`) or `direction` (`left`, `right`, `up`, `down`). Direction needs an attached client. |
| `move-window` | Move a window to another workspace, optionally following it. |
| `set-window` | Rename a window or change its minimized state. |
| `split-window` | Split a window `horizontal` or `vertical` and open a new one beside it. Needs an attached client with tiling on. |
| `resize` | Resize a window's PTY to `width` by `height`. |
| `popup` | Run a command in a floating window that closes when the command exits. Needs an attached client. |
| `screenshot` | Render a window to a `png`, `svg`, `ansi`, `html` or `txt` file. |
| `pip` | Pin a pane as the attached client's picture-in-picture view. Needs an attached client. |
### Workspaces and layout
| Verb | Purpose |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list-workspaces` | List workspaces with their names, window counts and which one is showing. `focus_history` lists a workspace's windows, the most recently focused first. |
| `select-workspace` | Show a workspace. |
| `set-workspace-name` | Name a workspace. It keeps its number for addressing. |
| `set-workspace-order` | Set the order workspaces are displayed in. |
| `set-layout` | Turn tiling on or off, equalize splits, or rotate the focused split. Needs an attached client. |
| `run-command` | Run a tape command, or any keybinding action by its name. Prefer a dedicated verb where one exists: they report what changed. |
### Input and output
| Verb | Purpose |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `send-text` | Write text to a window's PTY exactly as given. A trailing `\n` presses Enter. |
| `paste-image` | Write an image to a file on the machine where a window's process runs, and return the path. Only the person's attached client can call it. |
| `send-keys` | Send key names such as `"ctrl+b,n"`. Keys are split on spaces and commas, so use `send-text` for ordinary text. |
| `list-keys` | List the key names, modifiers and other tokens `send-keys` takes. A name that is not in the list is refused. |
| `capture-pane` | Read a window's contents. See [Capturing windows](#capturing-windows). |
| `run` | Type one command line at a pane's shell prompt, wait for it to finish, and return its exit code and output. Needs a shell that sends OSC 133 marks. |
### Options, themes and the dock
| Verb | Purpose |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `list-options` | Every settable option path with its type, default, accepted values and description. |
| `get-option` | Read the value in effect, and whether it comes from the session or the defaults. |
| `apply-config` | Apply `config.toml` now, including changes that give panes or linked machines more. Only the person may call it. |
| `set-option` | Set an option by path, for example `appearance.dockbar_position`. The path and value are checked first. An attached client applies it live. |
| `list-themes` | List the themes, built-in and imported. Name one to get its colours and their contrast. |
| `list-glyphs` | List the glyph sets, and describe one. |
| `list-hooks` | List the hooks and what each last did. |
| `list-dock-components` | List the dock's components and what each last did. |
| `refresh-dock` | Re-run one dock component, or all of them. |
### Agents and messages
| Verb | Purpose |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `list-agents` | List the windows running coding agents, with their state and unread mail. |
| `get-agent-state` | Read a window's agent state. |
| `set-agent-state` | Report an agent's state: `working`, `needs_input`, `idle`, `done`, `errored`, `unknown`, or `none` to clear. |
| `explain-agent-detect` | Explain why a window was or was not detected as running an agent. |
| `explain-agent-screen` | Show what the screen-reading rules make of a window. |
| `send-agent-message` | Leave a message for another window, or a notice for the whole session. |
| `read-agent-messages` | Read a session's messages, or one window's inbox. |
| `ask-agent` | Type a question into another agent's window and wait for its answer. |
| `ask-human` | Ask the person a question with fixed answers, and wait for the answer. |
| `set-agent-meta` | Record display metadata about a pane's agent, such as its model or context use. |
| `set-agent-session` | Store a harness's conversation id for a pane without changing its state. |
| `report-agent-activity` | Report a hook event that changes no state, such as a subagent's start or stop. It moves the pane's subagent count. |
| `start-agent` | Start an agent in a new pane and answer once it is ready for a prompt. |
| `resume-agent` | Type a pane's resume command for the conversation a hook recorded. |
| `agent-activity` | Read what a pane's agent has been doing, from the activity the daemon keeps. |
| `resolve-pane` | Name the pane a process runs in, from its terminal session and ancestors. Hooks use it. |
| `pane-grants` | Say what the caller's pane may do through TUIOS. |
| `set-pane-grants` | Give a pane grants, or reset it to the default. |
| `restrict-connection` | Give up authority on this connection for as long as it is open. `tuios mcp` uses it. |
### The Inbox, the queue and review
| Verb | Purpose |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `list-attention` | List the Inbox: everything waiting for the person, on this machine and every linked host. |
| `peek-prompt` | Read the prompt an agent is blocked on. |
| `respond` | Answer that prompt with the keys its harness declares. Acts as the person. |
| `get-approval` | Read a held approval or plan whole, with the risk rules it matched. |
| `request-approval` | Hold a pane's permission prompt for an answer from the Inbox. The hooks call it for their own pane. |
| `reply-approval` | Answer a held approval. Only an attached client can. |
| `answer-ask` | Answer an `ask-human` question. Only an attached client can. |
| `dismiss-attention` | Close an Inbox item. Only an attached client can. |
| `mark-attention` | Snooze, wake, mark unread or restore an Inbox item. Only an attached client can. |
| `release-agent-message` | Pass on mail from another machine that was held for the person. |
| `queue-prompt` | Queue a message for a pane's agent, typed when it comes to rest. |
| `list-queued` | List the messages waiting in a pane's queue. |
| `cancel-queued` | Drop queued messages before they are typed. |
| `review-diff` | Read what the agent in a pane changed against its base. |
| `review-note` | Add, edit, remove, list or clear review notes on a pane's changes. |
| `send-review` | Send a pane's unsent review notes to its agent, through the queue. |
| `list-checkpoints` | List a pane's checkpoints: its git work tree, saved each time its agent finished a turn that changed a file. |
| `checkpoint-diff` | Read what one turn changed. Nothing in the repository changes. |
| `restore-checkpoint` | Put a pane's work tree back to a checkpoint, after a safety checkpoint. Refused while the agent works, unless `force`. |
The verbs that act as the person need the nonce a client receives when it attaches, and refuse a caller inside a pane even when it has a copy.
### Files and worktrees
| Verb | Purpose |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `stash-put` | Copy a file into the session's own store and return the stored path. |
| `stash-get` | Read a stored file back. |
| `stash-list` | List the files in a session's store. |
| `new-worktree` | Create a git worktree and a session in it. |
| `list-worktrees` | List the sessions whose directory is a git worktree. |
| `remove-worktree` | Remove a worktree session's worktree and kill the session. Refuses uncommitted changes unless told to stash or discard them. |
| `fan` | Start one prompt in several agents, each in its own worktree and session. |
| `compare-fan` | Compare the attempts of a fan: changes against the base and the last check. |
| `verify-fan` | Run one check in every attempt of a fan. |
| `keep-fan` | Keep one attempt of a fan and remove the others. With `merge`, first merge its branch into its base, as `ship-merge` does. |
| `bundle-worktree` | Read a worktree session's work out in chunks, for `tuios worktree pull`. |
| `ship-commit` | Commit every change in a pane's work tree on its branch, with your own git identity, signing and hooks. |
| `ship-merge` | Merge a worktree's branch into its base in the main checkout. A conflict is undone and fails with `merge_conflict`. |
| `ship-push` | Push a pane's branch and set its upstream. Never forced. |
| `ship-pr` | Push a pane's branch and open a pull request with your own `gh` login. |
| `ship-status` | Read the pull request of a pane's branch and its checks. `refresh` asks `gh` now. |
`ship-push` and `ship-pr` send nothing on the first call. They answer `confirm_required`: the hint's `available` lists what would be sent, and `confirm` carries a token. Call again with that token. A caller inside a pane or on a restricted connection also needs the person's `allow` in the Inbox, and the call waits for it (`wait`, default 120000 ms). See [Ship](/docs/ship) and [Checkpoints](/docs/checkpoints).
### Other machines
| Verb | Purpose |
| ---------------------- | --------------------------------------------------------------------------- |
| `list-hosts` | List the machines in the `[hosts]` config table and the state of each link. |
| `list-host-sessions` | List sessions here and on every configured host. |
| `list-host-agents` | List agent windows here and on every configured host. |
| `open-host-connection` | Turn this connection into a connection to the daemon on a host. |
More verbs, `open-pane`, `pane-agent`, `pane-cwd`, `resize-pane`, `read-dir`, `wait-dir`, `close-pane`, `pane-calls`, `paste-pane-image` and `link-peer`, are what one daemon calls on another to run a window's process on a different machine. You do not normally call them yourself.
With `[agents] enabled = false`, every agent verb fails with `agents_disabled`, and so do the agent conditions of `wait-for`. See [Turn off agent features](/docs/agents-off).
### Events and waiting
| Verb | Purpose |
| ------------- | -------------------------------------------------------- |
| `subscribe` | Turn this connection into a stream of events. |
| `unsubscribe` | Stop this connection's event stream. |
| `wait-for` | Block until a condition matches, or fail with `timeout`. |
## Clients
`list-clients` takes no params:
```json
{"verb": "list-clients"}
```
```json
{"result": {"type": "client_list", "clients": [
{"client_id": "client-1790941960197517900", "pid": 4242, "session": "work"}
]}}
```
* `session` is empty while the connection is not attached.
* `pid` is `0` on Windows and the BSDs.
* For an SSH or web client, `pid` is the local server process, not the remote SSH process or the browser.
The `client-session-changed` event follows the same connections. It carries `client_id`, `pid`, `session` and `attached`. `attached` is `true` when a client enters a session and `false` when it leaves, a disconnect while attached included. A session rename sends one event with the new name and `attached: true` for each client in the session.
### Detaching clients
`detach-client` detaches clients, as tmux `detach-client` does. The sessions continue to run.
* `client` names one client from `list-clients`.
* `session` names a session. Every client of it detaches.
* Give one of the two, or neither. With neither, the verb acts on the client used last in the session of the caller's pane, else in the only session with a client.
* `all_other` keeps the named client, or the client used last, and detaches every other client of its session.
```json
{"verb": "detach-client", "params": {"session": "work", "all_other": true}}
```
```json
{"result": {"type": "clients_detached", "detached": ["client-1790941960197517900"]}}
```
Each detached client exits with status 0. An attach with `detach_others` set (`tuios attach -d`), or any attach while `[daemon] single_client` is on, detaches the other clients of the session in the same way. An unknown `client` fails with `invalid_params`. A pane needs the `admin` grant. Over a link the verb needs `write`.
## Capturing windows
`capture-pane` reads one of two buffers, chosen by `source`:
* `visible` (default): the screen as it is now.
* `recent`: the screen plus the scrollback above it.
Set `styled` to keep ANSI colours and attributes, and `resolved` to turn the 16 theme colours into 24-bit RGB. Limit the output with `lines` (the last N lines) or with `start` and `end` (a 1-based inclusive range, which overrides `lines`).
```json
{"id":1,"verb":"capture-pane","params":{"session":"work","window":"build","source":"recent","lines":50}}
```
Long lines come back split at the window's width. The emulator does not record which rows were soft-wrapped, so there is no unwrapped capture.
## Waiting for conditions
`wait-for` replaces poll loops. It blocks until the condition matches, then returns a `wait_result`. If the timeout passes first, it fails with the `timeout` error code.
| Condition | Matches when |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `session-exists` | A session named `session` exists. |
| `window-output` | Text matching the regular expression `pattern` is in the window. Checked at once, then on every new output. |
| `window-exit` | The window's process exits. |
| `window-idle` | The window prints nothing for `idle` milliseconds (default 500). |
| `agent-state` | A window's agent state becomes one of the states in `until` (comma-separated). Without `window`, any window in the session counts. |
| `agent-message` | A message arrives. With `window`, in that window's inbox. `thread` narrows it to one thread. |
| `command-finished` | A shell with OSC 133 marks finishes a command. With `window`, that pane's next one, or with `command_seq` the first after that many. |
`agent-state` also takes `any_session`, or a `select` selector with `every` to wait for all the panes it matches.
`timeout` defaults to 30000 milliseconds. For `window-output`, `source` defaults to `recent`, so output that has already scrolled off the screen still matches.
```json
{"id":1,"verb":"wait-for","params":{"condition":"window-output","session":"work","window":"build","pattern":"build (ok|failed)","timeout":120000}}
```
## Event stream
`subscribe` turns the connection into a stream. The first line back is an acknowledgement carrying the current sequence number, and every line after it is an event:
```json
{"id":1,"verb":"subscribe","params":{"session":"work","types":["window-created","window-closed","bell"]}}
```
```json
{"id":1,"result":{"type":"subscribed","seq":42}}
{"seq":43,"type":"window-created","session":"work","window":"1f3c...","pty_id":"9ab2...","title":"build","time":1790102604545781000}
```
Filter with `session`, `window` and `types`. Leave `types` out to receive everything.
The acknowledgement carries `seq`, the last event number when the stream went live, and `boot_id`, a random id for this daemon start. Every event carries both. To resume after a disconnect, subscribe with `after_seq` and the `boot_id` it came with: the daemon replays the events it still holds after that seq, then carries on live. When it cannot replay everything you missed, a `gap` event comes first and names the reason (`evicted`, `boot_changed`, `not_retained` or `overflow`). Output events are never replayed. After a gap, read the current state again with `list-windows` or `list-agents`.
The event types are `window-created`, `window-closed`, `window-exit`, `window-retitled`, `window-focused`, `window-moved`, `window-minimized`, `window-restored`, `workspace-switched`, `workspace-renamed`, `agent-state`, `agent-message`, `agent-activity`, `attention`, `output`, `bell`, `notification`, `mode-changed`, `prompt`, `command-started`, `command-finished`, `session-created`, `session-closed`, `client-session-changed`, `host-changed` and `gap`. With `hosts`, the stream also carries the agent-state and session events of every linked host.
A `workspace-renamed` event fires when a workspace's name is set, changed or cleared. It carries `session`, `workspace` and `title`, the new name. `title` is absent when the name was cleared.
Events fire the same way whether a change came from a verb or from a person using the TUI. Window geometry changes do not produce events.
If you read too slowly, the daemon drops events rather than wait for you, and sends a `gap` event with the number dropped before the next one it delivers. `queue` sets how many events buffer before that happens (default 256).
## Verbs that need an attached client
Some verbs change what is drawn, which needs a client to draw it: `split-window`, `set-layout`, `popup`, and `focus-window` with a `direction`. On a headless session, such as one made with `tuios new --detach`, they fail with `needs_client` instead of pretending to succeed. Attach a client first if you need them.
## Error codes
Codes are stable, so it is safe to branch on them.
| Code | Meaning |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request` | The line is not a valid request, or the connection is in the wrong state for the verb. |
| `unknown_verb` | No verb has that name. The hint has the closest match and the full list. |
| `invalid_params` | A parameter is missing, malformed, unknown, or outside its accepted values. The hint names it. |
| `session_not_found` | No session has that name. The hint lists the sessions that exist. |
| `session_exists` | `new-session` was given a name that is already taken. |
| `window_not_found` | The window target matched nothing. The hint lists the windows. |
| `no_windows` | The session has no windows to act on. |
| `pty_not_found` | The window's process has already exited. |
| `needs_client` | The verb needs an attached client and none is attached. |
| `option_not_found` | No option has that path. The hint has the closest match. |
| `command_failed` | The attached client ran the request and it failed. |
| `timeout` | A `wait-for` condition did not match in time. |
| `not_ready` | The target agent is busy, so the call did not type into it. |
| `loop_refused` | The call would loop, for example a window messaging itself. |
| `rate_limited` | The sender is over the message rate limit. |
| `no_keyboard` | The target is the person's inbox, which has no window to type into. |
| `protocol_mismatch` | The caller's protocol version is outside the range the daemon accepts. |
| `unknown_host` | No host by that name is configured. |
| `host_unreachable` | The host is configured but not answering. |
| `host_refused` | The link to the host is up but cannot take another connection. |
| `unknown_pane` | The daemon is not running a pane with that ID. |
| `not_worktree` | The session is not in a git worktree. |
| `worktree_dirty` | The worktree has uncommitted changes and neither stash nor force was passed. |
| `git_failed` | A git command failed. The message is git's own. |
| `repo_not_found` | No checkout on this machine has the named origin, and `clone` was not passed. |
| `agent_blocked` | The target agent is on `needs_input`, and text typed now would answer its prompt. Nothing was typed. |
| `prompt_stalled` | The text was typed and the agent did not show it took it in time. Look at the pane before sending again. |
| `forbidden` | The caller may not do this: its pane's grants, or a linked machine's policy, do not allow it. |
| `not_human` | Only the person at an attached client may make this call. |
| `prompt_changed` | `respond` pressed nothing: the prompt is gone, is not the one `prompt_id` names, or was already answered. |
| `risk_unacknowledged` | An allow of a risky approval did not name exactly the risk rules it matched. |
| `confirm_required` | A write by selector, a `ship-push` or a `ship-pr` carried no `confirm` token, or one for something other than what would be sent now. Nothing was sent. |
| `queue_full` | The pane's queue holds as many messages as `[agents.queue] max` allows. |
| `not_resumable` | No conversation the pane can resume was recorded. |
| `no_shell_integration` | The pane's shell sends no OSC 133 marks, so `run` cannot tell where a command ends. |
| `not_at_prompt` | `run` typed nothing because a command is running in the pane. |
| `not_repo` | No git repository is under the pane, so there is nothing to review. |
| `no_notes` | `send-review` found no unsent notes. |
| `no_checkpoint` | The pane has no checkpoint by that number, or none at all. The hint lists the numbers it has. |
| `nothing_to_commit` | `ship-commit` found no change, or `ship-push` or `ship-pr` found a branch with no commit. |
| `merge_conflict` | `ship-merge` stopped on conflicts and undid the merge. The hint lists the files. |
| `checkout_dirty` | `ship-merge` refused: the main checkout has uncommitted changes, a merge or rebase in progress, or another branch checked out. |
| `no_remote` | `ship-push` or `ship-pr` found no remote to push to. |
| `gh_unavailable` | The `gh` CLI is not installed or not logged in. |
| `agents_disabled` | The verb is an agent feature, and `[agents] enabled = false` turned those off. |
| `internal` | An unexpected failure inside the daemon. |
## Examples
```bash
# Create a headless session and run a build in it
printf '{"verb":"new-session","params":{"name":"ci"}}\n' | nc -U "$SOCK"
printf '{"verb":"send-text","params":{"session":"ci","text":"make test\\n"}}\n' | nc -U "$SOCK"
# Block until it finishes, then read the last 20 lines
printf '{"verb":"wait-for","params":{"condition":"window-output","session":"ci","pattern":"(PASS|FAIL)","timeout":600000}}\n' | nc -U "$SOCK"
printf '{"verb":"capture-pane","params":{"session":"ci","source":"recent","lines":20}}\n' \
| nc -U "$SOCK" | jq -r .result.content
# Watch for bells and exits in every session
printf '{"verb":"subscribe","params":{"types":["bell","window-exit"]}}\n' | nc -U "$SOCK" | jq -c .
```
## Related
# herdr Compatibility
URL: https://tuios.dev/docs/herdr-compatibility
> Use tools built for herdr, such as Collie and herdr plugins, with TUIOS through herdr's socket API.
import { Callout } from 'fumadocs-ui/components/callout';
[herdr](https://herdr.dev) is another multiplexer for coding agents. Tools that are built for it talk to its socket: [Collie](https://github.com/AltanS/collie)'s herdr adapter, herdr plugins, bar widgets and editor bridges. TUIOS answers herdr's socket API on a socket of its own, so these tools work with TUIOS.
TUIOS follows the API of herdr **0.9.3**.
## Connect a tool
Set `HERDR_SOCKET_PATH` to the TUIOS herdr socket. It is the daemon socket with `.herdr` added:
```bash
export HERDR_SOCKET_PATH="$XDG_RUNTIME_DIR/tuios/tuios.sock.herdr"
```
When `XDG_RUNTIME_DIR` is not set, the socket is `/tmp/tuios-/tuios.sock.herdr`. In a TUIOS pane, `HERDR_SOCKET_PATH` is set already.
To run Collie against TUIOS, start its bridge with the herdr adapter:
```bash
COLLIE_MUX=herdr HERDR_SOCKET_PATH="$XDG_RUNTIME_DIR/tuios/tuios.sock.herdr" collie start
```
herdr's own commands, such as `herdr pane list`, `herdr pane read` and `herdr api snapshot`, also work against this socket.
A `ping` answers with version `0.9.3+tuios` and `server: "tuios"`, so a tool can tell that it talks to TUIOS.
## How TUIOS maps onto herdr
| herdr | TUIOS |
| --------- | --------- |
| workspace | session |
| tab | workspace |
| pane | window |
A pane id has herdr's form, `w:p`. The ids stay the same while the session and the window exist, and a rename does not change them.
A TUIOS workspace is one of a session's numbered slots. A workspace shows as a tab when it holds a window, has a name, or is the one that shows.
An agent's state shows as herdr's `agent_status`: `needs_input` is `blocked`, so a tool that shows the agents that need you shows these panes. `working`, `idle` and `done` keep their names, and `errored` shows as `done`.
## What a tool can do
* Read everything in one call with `session.snapshot`, or with the lists.
* Read a pane with `pane.read`.
* Type into a pane with `pane.send_text` and `pane.send_keys`.
* Rename, focus, split and close panes.
* Create, rename, focus, move and close tabs.
* Create, rename and close workspaces.
* List, create, open and remove git worktrees.
* Follow changes with `events.subscribe`.
* Report an agent's own state with `pane.report_agent` and the other report methods.
* Start an agent in a pane with `agent.start`, and wait for its state with `agent.wait`.
`agent.start` types the agent's command only into a pane that is at its shell prompt with no agent in it. Any other pane answers `agent_pane_busy`, and nothing is typed. That includes a pane whose foreground program TUIOS cannot read.
Other herdr methods, such as the plugin and layout methods, answer the error `unsupported`. The full table of methods is in [AGENT\_STATE.md](https://github.com/Gaurav-Gosain/tuios/blob/main/docs/AGENT_STATE.md#herdr-compatibility).
## herdr's command line
Plugins and tools built for herdr run herdr's CLI through `$HERDR_BIN_PATH`. In a TUIOS pane, that variable names a link called `herdr` that runs TUIOS. It answers `pane`, `tab`, `workspace`, `agent` (except `attach`), `worktree`, `notification show` and `api snapshot`, prints herdr's JSON and uses herdr's exit codes.
```bash
"$HERDR_BIN_PATH" pane split --pane "$HERDR_PANE_ID" --direction right --focus
"$HERDR_BIN_PATH" notification show "Build done" --body "All tests pass"
```
`herdr agent start` returns when the agent is at rest (`idle` or `done`), as in herdr. The link is not on `PATH`, so a real herdr on your `PATH` still runs as herdr.
## Crush permission prompts
Crush reports its state over herdr's protocol. When Crush shows its "Permission Required" dialog, TUIOS reads the dialog from the screen:
* The pane goes to `needs_input`, and the Inbox lists it under Approvals.
* The message names the tool and what it acts on, such as `approve bash: touch hello.txt`. When the dialog does not show the whole call, the message ends with `(not all shown)`.
* In the Inbox, `a` allows, `A` allows for the rest of the Crush session, and `d` denies. TUIOS presses Crush's own keys: `a`, `s` and `d`. `tuios respond` answers it too.
* The risk rules read the message. A call that matches a rule, or a message that ends with `(not all shown)`, takes a second press.
* TUIOS answers only when it sees a Crush process in the pane.
Crush v0.97.1 and earlier can report `working` while the dialog is still up. TUIOS then reads the screen, and the pane stays on `needs_input` until the dialog closes.
## Who may do what
Each herdr method runs the TUIOS command that does the same work, with the same checks.
* A tool that runs outside every pane has your full rights.
* A tool that runs in a pane holds that pane's [grants](/docs/agents#what-a-pane-may-do). A read needs `read`. Typing needs `write`. A create, close, rename or focus needs `admin`.
A pane may not type into another pane that waits on a prompt, unless it holds `respond`. The keys would answer the prompt. `admin` does not give `respond`.
# tmux Shim
URL: https://tuios.dev/docs/tmux-shim
> Run tools that drive tmux, such as Claude Code agent teams and fzf --tmux, inside TUIOS. Their panes open as TUIOS panes.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
Some tools drive tmux to open panes for their own workers. Claude Code agent teams is the main one: it opens one tmux pane per teammate, starts the teammate in it, and closes it when the teammate is done. Inside TUIOS there is no tmux, so those teammates cannot get panes.
`tuios tmux-shim` runs one command with a `tmux` that answers in the TUIOS session you ran it from. Each teammate then opens as a TUIOS pane: on the rail, in the Inbox, with its agent state, beside the pane that started it.
The shim is off until you run it. It changes nothing outside the command it starts.
## Turn it on
In a TUIOS pane:
```bash
tuios tmux-shim -- env CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 claude
```
Claude Code sees `TMUX`, picks its tmux backend, and opens every teammate as a pane on the same workspace, named after the teammate. The panes close when the teammates finish.
* Flags after the command belong to the command: `tuios tmux-shim claude --resume` passes `--resume` to `claude`.
* With no command, the shim starts your shell. Every `tmux` call from that shell goes to the shim.
* `tuios tmux ` asks the shim directly, from any TUIOS pane: `tuios tmux list-panes -F '#{pane_id} #{pane_title}'`.
`tuios tmux-shim [--log FILE] [--log-all] [-- command [args...]]` does three things:
* It puts a link named `tmux`, which points to the TUIOS binary, first on `PATH`. The link is in `tmux/bin/` beside the daemon socket.
* It sets `TMUX` to name the shim as the tmux server, and `TMUX_PANE` to the pane you ran it in.
* It runs the command with that environment.
A `tmux` call whose `TMUX` is unset or names a real tmux server, or whose `-L` or `-S` names another server, goes to the next `tmux` on `PATH`. So a real tmux keeps working inside the command.
The shim needs a TUIOS pane (`TUIOS_SESSION` and `TUIOS_PANE_ID`). It is not available on Windows.
## Tools outside TUIOS
A tool outside TUIOS can drive TUIOS as a tmux server, for example a phone bridge such as [Collie](https://github.com/AltanS/collie) or a script in another terminal. Make a link named `tmux` to the TUIOS binary, and give the shim's socket to `-S`:
```bash
mkdir -p ~/.local/lib/tuios-tmux
ln -sf "$(command -v tuios)" ~/.local/lib/tuios-tmux/tmux
~/.local/lib/tuios-tmux/tmux -S "$XDG_RUNTIME_DIR/tuios/tmux/socket" list-sessions
```
Without `XDG_RUNTIME_DIR`, the socket is `/tmp/tuios-/tmux/socket`. Do not put the link on your `PATH`: a call without `-S` and without `TMUX` goes to the real tmux. A call with `-S` that names the shim's socket always goes to the shim.
For Collie, set `COLLIE_MUX=tmux`, `COLLIE_TMUX_BIN` to the link and `COLLIE_MUX_ENDPOINT_TMUX` to the socket path. Its panes show as shells, because tmux has no agent state.
Outside a pane, every TUIOS session is a tmux session:
* A session's id is `$N`. N comes from the TUIOS session id, so a rename does not change it.
* A window's id is `@N`, where N is the session's number times 1000 plus the workspace number.
* `new-session -d` starts a TUIOS session.
* `list-clients` lists one client for each session that a TUIOS client shows.
## How tmux maps onto TUIOS
| tmux | TUIOS |
| ----------------------------- | ------------------------------------------------------------------------------- |
| the server's one session | the TUIOS session the command runs in |
| every session, outside a pane | every TUIOS session |
| a window, `@N` | workspace N |
| a pane, `%N` | a TUIOS window. N comes from the window id, so a pane keeps its id across calls |
A target names a pane by `%N`, by the TUIOS window id after a `%` (or a prefix of at least four characters), or the tmux way: `session:window.pane`, `@N`, `:N.M`, a workspace name, a session name or `$N`. In a pane, nothing reaches another session. A target that names one fails with `can't find session`.
## Commands
| Command | What the shim does |
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `split-window` | Opens a pane on the target pane's workspace. `-d`, `-c`, `-e` and `-P -F` work. The TUIOS layout decides where the pane goes, so `-h`, `-v`, `-b`, `-f`, `-l` and `-p` change nothing |
| `new-window` | Opens a pane on the lowest empty workspace, or the empty one `-t` names. `-n` names the workspace |
| `send-keys` | Types into a pane. Key names (`Enter`, `C-c`, `M-x`, `Up`, `F1`, `BSpace`) become the bytes a terminal sends. `-l` types text, `-H` takes hex bytes, `-N` repeats |
| `capture-pane -p` | Prints a pane. `-S` and `-E` take tmux line numbers, `-e` keeps the colours |
| `display-message -p` | Prints a format for the target pane. Without `-p` it does nothing |
| `list-panes`, `list-windows`, `list-sessions`, `list-clients` | List panes (`-s` of the session, `-a` of every session), workspaces that hold panes, sessions, and clients |
| `has-session` | Succeeds for a session the shim serves |
| `new-session -d` | Outside a pane, starts a TUIOS session. Refused in a pane |
| `kill-pane`, `kill-window` | Close a pane, or every pane of a workspace |
| `select-pane` | Focuses a pane, or with `-L -R -U -D` the target's neighbour, found from the pane positions. `-T` names the pane |
| `last-pane` | Focuses the pane that was active before, from the workspace's focus history |
| `select-window`, `next-window`, `previous-window`, `rename-window` | Show a workspace, or the next or previous one that holds panes, and name a workspace |
| `break-pane` | Moves a pane to the lowest empty workspace, or the empty one `-t` names |
| `join-pane`, `move-pane` | Move the `-s` pane to the workspace of the `-t` pane |
| `rename-session` | Renames a session |
| `detach-client` | Detaches TUIOS clients. The session continues to run. See [detach-client](#detach-client) |
| `respawn-pane -k` | Replaces the process of a pane the shim opened. See [The pane holder](#the-pane-holder) |
| `display-popup` | Opens a TUIOS popup and returns when its command exits |
| `run-shell`, `if-shell` | Run a shell command as you |
| `wait-for` | Waits on a channel, signals it (`-S`), or locks (`-L`) and unlocks (`-U`) it |
| `set-buffer`, `load-buffer`, `paste-buffer`, `show-buffer`, `save-buffer`, `list-buffers`, `delete-buffer` | Paste buffers |
| `show-environment`, `set-environment` | The environment of the panes the shim opens |
| `show-options`, `show-window-options` | Print the options that describe TUIOS, such as `window-size`, `base-index 1` and `history-limit` |
| `set-option window-size` | Sets the session's `daemon.window_size` to `smallest`, `largest` or `latest` |
| `-V` | Prints `tmux 3.4` |
* `set-option` of any other option, `set-hook`, `refresh-client`, `select-layout`, `resize-pane` and `start-server` succeed and do nothing. TUIOS owns the layout, the styling and the options.
* `swap-pane`, `kill-session`, `kill-server`, `attach-session` (outside control mode) and `switch-client` are refused. The shim never attaches a terminal or ends a session.
* Every other command fails with `unknown command`. A flag that is not listed fails with `unknown flag`. The shim does not accept a flag and then ignore it.
* `show-options history-limit` prints the session's `appearance.scrollback_lines`, or `10000` when nothing set it.
### detach-client
`detach-client` detaches TUIOS clients, as in tmux 3.7c. The session continues to run, and each detached client exits with a message. `list-clients` names one client for each session, `tuios-SESSION`.
* `-s SESSION` detaches every client of that session. `-s` wins: the shim ignores `-a` and `-t` with it, as tmux 3.7c does.
* `-t tuios-SESSION` detaches every client of that session.
* With neither, the client used last in the caller's session detaches.
* `-a` keeps the client used last in the session and detaches the others. In tmux, `-a` acts on every client of the server. In the shim, `-a` acts on one session only: the session of `-t`, else the caller's session.
* `-P` and `-E` fail with `unknown flag`.
A session out of the shim's reach is not found. From a pane, the command needs the `admin` grant.
Under `[agents.permissions]` mode `open`, the default, every pane holds `admin`. A tool in a pane can then detach your client through the shim, as a tool under tmux can. To prevent this, give the pane fewer grants or use mode `strict`.
In control mode, `detach-client` with no flags ends the control client, as in tmux. With flags, it detaches TUIOS clients as above. A read-only control client cannot detach TUIOS clients.
`attach-session -d` in control mode attaches the control client and detaches no TUIOS client. A control client is not a TUIOS client. To detach the other clients when you attach, use `tuios attach -d`.
### Command names and command lines
A command name can be any prefix that names one command, as in tmux: `show-option` is `show-options`, and `list-pa` is `list-panes`. A prefix of more than one command fails with tmux's error:
```
ambiguous command: kill-se, could be: kill-server, kill-session
```
A line can hold several commands, separated by `;` (`tmux a \; b`).
## Formats
Format strings (`-F`, `display-message -p`) follow the format language of tmux 3.4:
* `#{name}`, the one-letter aliases (`#D #F #H #h #I #P #S #T #W`), and the escapes `##`, `#,` and `#}`.
* Conditionals: `#{?cond,then,else}`.
* Comparisons: `#{==:a,b}`, `#{!=:a,b}`, `#{<:a,b}`, `#{>:a,b}`, `#{<=:a,b}`, `#{>=:a,b}`, `#{||:a,b}` and `#{&&:a,b}`.
* Matches: `#{m:pattern,text}`, with `m/r` for a regular expression and `i` to ignore case.
* Modifiers: `l` (literal), `b` and `d` (base and directory name), `=N`, `=-N` and `=/N/marker` (truncate), `pN` and `p-N` (pad), `n` (length), `w` (width), `q` (quote for the shell), `E` and `T` (expand again), `t` and `t/p` (time), `a` (a character), and `s/pattern/with/flags` (substitute). Join several with `;`, as in `#{=10;s/x/y/:pane_title}`.
```bash
tuios tmux list-panes -F '#{pane_id} #{?pane_active,*, } #{=20:pane_title}'
```
The loop modifiers (`S`, `W`, `P`, `L`), `N`, `C`, `c`, `e`, `q/e` and the strftime form of `t` are not supported. They expand to nothing and are logged. `#(command)` runs no command.
The shim fills the session, window and pane variables tools use, among them `session_name`, `session_id`, `window_id`, `window_index`, `window_name`, `window_layout`, `window_zoomed_flag`, `pane_id`, `pane_index`, `pane_title`, `pane_current_path`, `pane_current_command`, `pane_active`, the pane size and edge variables, `pane_pid`, `pane_tty`, `history_size`, `pid`, `version` and `tuios_window_id`. `list-clients` and `list-buffers` add their own variables. A variable the shim cannot fill expands to nothing, as in tmux, and is logged. Some values come from TUIOS facts:
* `pane_current_command` is the program in the pane's foreground. At a shell prompt it is the base name of `$SHELL`.
* `pane_pid` is the process the pane started, and `pane_tty` its terminal device. A pane the shim opened runs its pane holder first, so its `pane_pid` is the holder's. A pane on another machine has neither.
* `pid` is the daemon's process id: the daemon is the shim's tmux server.
* `window_zoomed_flag` is `1` when a pane of the workspace is zoomed, and `window_flags` then holds `Z`.
* `window_layout` is a tmux layout string built from the pane positions. `select-layout` with such a string changes nothing.
* `client_tty` is empty. TUIOS does not name its clients' terminals.
## Popups, shell commands and channels
### display-popup
`display-popup` opens a TUIOS popup on the target pane's workspace and returns when the popup's command exits. This is what `fzf --tmux` needs: fzf runs itself in the popup and reads the choice when the `tmux` call returns.
* One argument is a shell command line, several are an argv, and none starts your shell.
* `-d` sets the directory, `-w` and `-h` the size (cells, or a percentage such as `80%`), `-T` the name, and `-e` the environment.
* The popup needs a TUIOS client attached to the session.
* TUIOS places the popup and draws its border, so `-x`, `-y`, `-b`, `-B`, `-s` and `-S` are accepted and logged. `-C` is refused.
### run-shell and if-shell
`run-shell` runs a command with `/bin/sh` and prints its output. `-C` runs a tmux command line instead, `-b` returns at once, and `-d` waits that many seconds first. A command that fails prints `'command' returned N`, and the shim exits with N.
`if-shell` runs its first tmux command when the shell command succeeds, or with `-F` when the format is true. It runs the second, if given, when it does not. The shim waits for the shell command even with `-b`, and logs that.
The shim runs these commands itself, as you, from the process that called it. They reach nothing you could not reach by running the command yourself. Formats in the command are expanded first. A value that a program in a pane sets, such as a title, lands in the command as it is, so quote it with `#{q:...}`.
### wait-for
`wait-for CHANNEL` blocks until another call runs `wait-for -S CHANNEL`. A signal sent while nobody waits is kept for the next wait, as in tmux. `wait-for -L CHANNEL` takes the channel's lock and blocks while another call holds it. `wait-for -U CHANNEL` hands the lock to the next caller, or frees it.
```bash
tuios tmux wait-for build-done & # in one shell
tuios tmux wait-for -S build-done # in another
```
### Paste buffers
A paste buffer is a file in `tmux/buffers/` beside the daemon socket, readable only by you, so a buffer stays between calls. Without `-b`, the newest buffer is used. A new buffer without `-b` is named `buffer0000`, `buffer0001` and so on.
`paste-buffer` types the buffer into the target pane:
* Each line feed becomes a carriage return. `-s` sets another separator, and `-r` keeps the line feeds.
* Control characters other than tab, line feed and carriage return are removed.
* With `-p`, the text is wrapped in bracketed paste when the program in the pane turned it on.
* `-d` deletes the buffer after the paste.
The paste goes through `send-text`, so the daemon holds it to the caller's pane grants. `save-buffer` writes the file as you. It refuses a path in the shim's own directory.
### Environment
`set-environment NAME VALUE` sets a variable for the panes the shim opens in the session. `-g` sets it in every session.
* `-r` removes it from new panes, and `-u` forgets it.
* `-h` hides it from `show-environment` without `-h`.
* `-F` expands the value as a format.
The panes that `split-window`, `new-window`, `respawn-pane`, `new-session` and `display-popup` start get the global variables, then the session's, then the ones `-e` gives. `show-environment` prints `NAME=value`, `-NAME` for a removed one, or shell commands with `-s`. TUIOS copies nothing into a session's environment when a client attaches, so tmux's `update-environment` has no counterpart.
## Control mode
`tmux -C` and `tmux -CC` start a control client. It reads commands from standard input, one per line, and attaches to the session that `attach-session -t` names, or to the caller's session.
```bash
printf 'list-windows\n' | tuios tmux -C attach-session -t work
```
* Each command's output comes between `%begin` and `%end`, or `%error` when the command fails.
* A command with no target acts on the attached session and its active pane.
* `attach-session -r` or `-f read-only` makes a read-only client. It can run only commands that change nothing.
* `attach-session -f no-output` stops the `%output` lines. `-CC` wraps the output in the DCS sequence that iTerm2 expects.
The shim sends `%session-changed`, `%output`, `%window-add`, `%window-close`, `%window-renamed`, `%layout-change`, `%window-pane-changed`, `%session-window-changed`, the `%unlinked-window-*` notifications, `%sessions-changed`, `%session-renamed` and `%exit`.
The daemon's event stream says that a pane printed, not what it printed. So a `%output %N` line carries no bytes, and a client that draws panes from `%output`, such as iTerm2 with `-CC`, shows each pane as it was when it last read it with `capture-pane`.
The shim does not send `%pause`, `%continue`, `%extended-output`, `%subscription-changed`, `%pane-mode-changed`, `%client-session-changed`, `%client-detached`, `%paste-buffer-changed`, `%paste-buffer-deleted`, `%message` or `%config-error`. It does not support flow control (`refresh-client -A`, `-f pause-after`) or format subscriptions (`refresh-client -B`).
## The pane holder
Claude Code opens each teammate's pane with `cat` as a placeholder, then replaces it with `respawn-pane -k` and the teammate's command. A TUIOS window's process cannot be swapped from outside, so every pane the shim opens runs `tuios tmux-pane`, a small holder.
* The holder runs the pane's command as its child, in the terminal's foreground. So Ctrl +C reaches the command, and agent detection sees the command, not the holder.
* On `respawn-pane`, the holder ends the child's process group (SIGHUP, then SIGKILL after two seconds) and starts the new command.
* When the command exits, the holder exits with its status and the pane closes.
`respawn-pane` works only on panes the shim opened. The pane's processes see `TMUX` and `TMUX_PANE`, so a tool that calls `tmux` from such a pane reaches the shim too.
## The log
Every call the shim could not fully answer is one JSON line in `$XDG_STATE_HOME/tuios/tmux-shim.log`, or the file `--log` names. `--log-all` records every call.
```json
{"time":"2026-09-23T16:19:27Z","argv":["tmux","bind-key","<2 redacted>"],"outcome":"unsupported","detail":["unknown command: bind-key"]}
```
`outcome` is `ok`, `ignored`, `partial`, `unsupported` or `error`. The log never records what was typed or run: text arguments, `VAR=value` pairs and the arguments of unknown commands are replaced with a marker. The file has mode `0600` and moves to `tmux-shim.log.1` past 1 MiB.
## What it can reach
The shim grants nothing. It runs as you and calls verbs the TUIOS CLI already has. In a pane, every call names the caller's own session, so a stray `-t` cannot touch another session.
The daemon holds every verb the shim calls to the caller's [pane grants](/docs/agents#what-a-pane-may-do). A pane without `admin` cannot `split-window`, `new-window`, `kill-pane`, `kill-window`, `select-pane`, `select-window` or `rename-window`: they fail with `forbidden`. From such a pane, `respawn-pane` works only on the caller's own pane. `run-shell`, `if-shell`, `save-buffer` and `wait-for` do not go through the daemon. They reach only what the calling process could reach without the shim.
A process under the shim can still run the TUIOS CLI and reach what its pane's grants allow. To hold an agent to its own session, use `tuios mcp`, or give its pane fewer grants.
With `[agents] enabled = false`, a pane without the `respond` grant can type only into a pane it opened, or a pane whose own shell is at its prompt. So `split-window` and then `send-keys` into the new pane still work. To let every pane type into other panes, give panes `respond`:
```toml
[agents.permissions]
grants = ["admin", "respond"]
```
See [Turn off agent features](/docs/agents-off).
## Related
# Hooks
URL: https://tuios.dev/docs/hooks
> Run shell commands when windows, workspaces, sessions and agents change.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
A hook is a shell command TUIOS runs when something happens: a window opens, focus moves, you switch workspace, you attach or detach, an agent finishes. Hooks live in the `[hooks]` table of your [config file](/docs/configuration).
```toml
[hooks]
after-new-window = "notify-send 'TUIOS' \"opened $TUIOS_WINDOW_NAME\""
after-workspace-switch = "echo \"$TUIOS_PREV_WORKSPACE -> $TUIOS_WORKSPACE\" >> ~/.tuios-ws.log"
```
Other config keys use `snake_case`. Hook event names use hyphens and are case-sensitive: `after-new-window`, not `after_new_window`. An unknown name is not an error. TUIOS logs one line and the hook never runs. `tuios list-hooks` shows what was actually loaded.
## Events
| Event | Fires when | Runs in |
| ------------------------ | -------------------------------------------------------------------------------- | ------- |
| `after-new-window` | A window is created | daemon |
| `after-close-window` | A window closes, by keybinding or because its process exited | daemon |
| `after-focus-change` | Focus moves to a different window | daemon |
| `after-workspace-switch` | The session switches to a different workspace | daemon |
| `after-agent-state` | An agent in a pane changes state, filtered by the `[notifications.agent]` policy | daemon |
| `after-command-finished` | A pane's shell reports, through its OSC 133 marks, that a command finished | daemon |
| `after-attach` | A client attaches to a session, including a switch between sessions | client |
| `after-detach` | A client detaches from a session | client |
| `after-layout-change` | The tiling layout changes | client |
| `after-resize` | A window is resized, by keyboard or by dragging its border | client |
A hook runs on the side that owns the fact it reports:
* **Daemon events** describe the session. The daemon runs them, so they fire when nobody is attached, and they fire once however many clients are attached.
* **Client events** describe one client. Each attached client runs them from its own config. Three clients attaching is three `after-attach` firings.
* A standalone TUIOS (`tuios --standalone`) has no daemon and runs every hook itself.
`after-agent-state` follows the same `[notifications.agent]` settings as the other agent alerts, including the settle delay, so it does not fire on every brief change. `suppress_focused` only applies while a client is attached. A command in `[notifications.agent].command` is registered as one more `after-agent-state` hook. See [Notifications](/docs/configuration#notifications).
## One command or several
A value is one string, or an array of strings:
```toml
[hooks]
after-attach = "my-greeting"
after-new-window = [
"logger -t tuios \"new window $TUIOS_WINDOW_ID\"",
"touch ~/.cache/tuios/last-window",
]
```
The commands in an array run at the same time, not in order. If order matters, put the steps in one string joined with `&&`.
## What a hook receives
TUIOS runs the string with `sh -c`. There are no arguments and no stdin. Everything arrives as environment variables.
Every variable is set for every event. A value that does not apply is empty for text and `0` for numbers, so a script can read any of them without checking first.
| Variable | Meaning |
| ----------------------------- | ------------------------------------------------------------------------------------------- |
| `TUIOS_EVENT` | The event name |
| `TUIOS_WINDOW_ID` | Window ID |
| `TUIOS_WINDOW_NAME` | Window name or title |
| `TUIOS_WORKSPACE` | Workspace number |
| `TUIOS_SESSION_ID` | Session name. Empty in a standalone TUIOS |
| `TUIOS_PREV_WORKSPACE` | The workspace you came from |
| `TUIOS_LAYOUT` | `bsp`, `master-stack`, `scrolling` or `floating` |
| `TUIOS_WIDTH`, `TUIOS_HEIGHT` | The window's new size in cells |
| `TUIOS_AGENT_STATE` | The agent's new state |
| `TUIOS_AGENT_PREV_STATE` | The state it left |
| `TUIOS_AGENT_HARNESS` | The detected harness, for example `claude-code` |
| `TUIOS_AGENT_MESSAGE` | The last message the agent reported |
| `TUIOS_COMMAND` | For `after-command-finished`, the command line, cut to 512 bytes with likely secrets masked |
| `TUIOS_EXIT_CODE` | For `after-command-finished`, the exit status. Empty when the shell sent none |
| `TUIOS_DURATION_MS` | For `after-command-finished`, how long the command ran |
Which variables carry data for each event:
| Event | Variables |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `after-new-window`, `after-close-window`, `after-focus-change` | `TUIOS_WINDOW_ID`, `TUIOS_WINDOW_NAME`, `TUIOS_WORKSPACE`, `TUIOS_SESSION_ID` |
| `after-workspace-switch` | `TUIOS_WORKSPACE`, `TUIOS_PREV_WORKSPACE`, `TUIOS_SESSION_ID` |
| `after-attach`, `after-detach` | `TUIOS_WORKSPACE`, `TUIOS_SESSION_ID` |
| `after-layout-change` | `TUIOS_LAYOUT`, `TUIOS_WORKSPACE`, `TUIOS_SESSION_ID` |
| `after-resize` | `TUIOS_WINDOW_ID`, `TUIOS_WINDOW_NAME`, `TUIOS_WIDTH`, `TUIOS_HEIGHT`, `TUIOS_WORKSPACE`, `TUIOS_SESSION_ID` |
| `after-agent-state` | `TUIOS_WINDOW_ID`, `TUIOS_WINDOW_NAME`, `TUIOS_AGENT_STATE`, `TUIOS_AGENT_PREV_STATE`, `TUIOS_AGENT_HARNESS`, `TUIOS_AGENT_MESSAGE`, `TUIOS_SESSION_ID` |
| `after-command-finished` | `TUIOS_WINDOW_ID`, `TUIOS_COMMAND`, `TUIOS_EXIT_CODE`, `TUIOS_DURATION_MS`, `TUIOS_SESSION_ID` |
A hook the daemon runs gets the daemon's environment, not your current shell's. A daemon started by `tuios new` keeps the environment of the shell that started it. Use full paths when in doubt.
## When a hook does not fire
```bash
tuios list-hooks
```
This lists every registered command with the side that runs it, how many times it ran, its last exit code, when it last ran and its last error. The error is the tail of the command's stderr.
* **No row**: the hook was never loaded. Check the event name.
* **0 runs**: the command is registered and the event has not happened.
* **Non-zero exit**: the command ran and failed. The error says why.
`tuios list-hooks --event after-close-window` shows one event, and `--json` gives machine-readable output. Client hooks are listed only while a client is attached.
The daemon also logs a warning for every failing hook. Run `tuios daemon --log-level=basic` to log one line per firing as well.
## Limits
* **Hooks load at startup.** Editing `[hooks]` does not reload them. Restart the client for client hooks, and run `tuios kill-server` so the daemon restarts for daemon hooks. This stops every session and the processes in them. The next `tuios` restores the saved layouts, but not the running programs.
* **No timeout.** A hook that never exits keeps running. On detach, and when the daemon shuts down, TUIOS waits at most 2 seconds for running hooks.
* **Stdout is discarded.** Only the tail of stderr (1 KiB) and the exit code are kept, for `list-hooks`. Redirect to a file if you need the output.
* **No ordering between hooks.** Each command runs in its own goroutine, so do not rely on the order of two events that happen together.
* **Restored windows count as new.** When the daemon restores a saved session, `after-new-window` fires for each window it brings back.
* **`after-detach` needs a daemon session.** A standalone TUIOS has nothing to detach from.
* **Wrong types are ignored.** A number or a boolean where a string or array belongs is dropped without a message.
## Examples
Log every event, to see what fires and when:
```toml
[hooks]
after-new-window = "echo \"$TUIOS_EVENT $TUIOS_WINDOW_ID $TUIOS_WINDOW_NAME\" >> ~/.tuios-hooks.log"
after-close-window = "echo \"$TUIOS_EVENT $TUIOS_WINDOW_ID\" >> ~/.tuios-hooks.log"
after-focus-change = "echo \"$TUIOS_EVENT $TUIOS_WINDOW_NAME\" >> ~/.tuios-hooks.log"
after-workspace-switch = "echo \"$TUIOS_EVENT $TUIOS_PREV_WORKSPACE->$TUIOS_WORKSPACE\" >> ~/.tuios-hooks.log"
after-layout-change = "echo \"$TUIOS_EVENT $TUIOS_LAYOUT\" >> ~/.tuios-hooks.log"
after-resize = "echo \"$TUIOS_EVENT ${TUIOS_WIDTH}x${TUIOS_HEIGHT}\" >> ~/.tuios-hooks.log"
```
Show attach state in a status bar:
```toml
[hooks]
after-attach = "echo attached > ~/.cache/tuios-state"
after-detach = "echo detached > ~/.cache/tuios-state"
```
Send a desktop notification when an agent needs you:
```toml
[hooks]
after-agent-state = "[ \"$TUIOS_AGENT_STATE\" = needs_input ] && notify-send \"$TUIOS_WINDOW_NAME needs input\""
```
To reach your phone without a script, use the `[notify]` table. It sends Inbox items through ntfy, Pushover or a webhook, with a link to the item on `tuios-web`. See [Push notifications](/docs/push-notifications).
With `[agents] enabled = false`, agent hooks such as `after-agent-state` do not run. See [Turn off agent features](/docs/agents-off).
## Related
# Tape Scripting
URL: https://tuios.dev/docs/tape-scripting
> Script TUIOS with .tape files, and build project layouts with .tuios.tape.
import { Callout } from 'fumadocs-ui/components/callout';
import { Card, Cards } from 'fumadocs-ui/components/card';
A tape is a plain text file of TUIOS commands, one per line: open a window, type into it, switch workspace, wait for output. You can write tapes by hand or [record them](/docs/tape-recording).
**Try it live:** the [Automation track](/learn?t=automation) plays a tape in your browser.
```bash
# demo.tape
EnableTiling
NewWindow
Type "htop"
Enter
SmartSplit
Type "git log --oneline"
Enter
```
## Running a tape
| Command | What it does |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| `tuios tape play ` | Starts a standalone TUIOS and plays the tape in it |
| `tuios tape validate ` | Parses the tape and lists its commands, without running anything |
| `tuios tape exec ` | Plays the tape in a running session that has a client attached, and waits for it to end |
`tuios tape list`, `show`, `delete` and `dir` manage recorded tapes. See [Tape Recording](/docs/tape-recording#managing-recordings).
All three run the tape the same way:
* Commands run one at a time. Each command waits for animations to finish, and for the pane the command before it asked for.
* `Wait`, `WaitUntilRegex` and `WaitFor` hold playback.
* The first command that fails stops the tape. The rest of the tape does not run. TUIOS shows the line and the reason, and the progress indicator says `FAILED`.
### tape play
```bash
tuios tape play demo.tape
```
`play` starts its own TUIOS, not attached to a daemon session, and runs the tape in it. It reads your config file, and `--theme` and `--show-keys` work as they do for `tuios`.
* Animations start off, so playback is the same on every machine. A tape can turn them back on with `EnableAnimations`.
* Ctrl +P pauses and resumes playback.
* A progress indicator shows the current command and the total.
* TUIOS stays open when the tape ends. Quit it as usual, with Ctrl +B q . If the tape failed, `tape play` then exits with an error that names the line.
### tape validate
```bash
tuios tape validate demo.tape
```
Prints each parsed command, or the errors with their line and column:
```
✗ demo.tape: line 4, column 7: Split: "diagonal" is not one of horizontal, vertical, h, v
```
It exits non-zero if the tape does not parse. It checks every argument: a word left over after a command, an action name that does not exist, and a pattern that is not a regular expression are all errors. A tape that validates can still fail at run time, for example `LoadLayout` with a name that does not exist.
### tape exec
```bash
tuios tape exec demo.tape # the most recently active session
tuios tape exec -s work demo.tape # a named session
tuios tape exec --timeout 1h long.tape # wait longer than 30 minutes
```
`exec` sends the tape to the daemon, which hands it to the TUI client attached to that session. The client plays it and shows its progress on screen. The command returns when the tape ends:
* It exits 0 and prints `Tape finished` when every command ran.
* It exits non-zero when the tape has an error or a command fails. The message gives the file, line and column, also for a line in a file the tape includes with `Source`.
* It gives up after `--timeout`, 30 minutes by default. The tape may still be playing then.
A detached session has no renderer to run a tape. `tuios tape exec` against one fails with `tape scripts need an attached client`. For a detached session, use the control commands directly. See [Scripting a detached session](#scripting-a-detached-session).
### Scripting a detached session
For CI or a script that drives a headless session, skip tapes and use the control commands. They work with no client attached, and `wait-for` blocks on real output instead of a fixed sleep:
```bash
#!/bin/sh
set -eu
tuios new ci --detach
tuios new-window -s ci tests
tuios send-text -s ci -w tests 'npm test
'
tuios wait-for window-output -s ci -w tests --pattern 'Tests passed' --timeout 120000
tuios kill-session ci
```
`wait-for` exits non-zero on timeout, so the script fails if the tests never print the marker. See [Sessions](/docs/sessions) and the [Control Protocol](/docs/control-protocol) for the full set of commands.
A single tape command can also be sent with `tuios run-command`, and so can any keybinding action by its name:
```bash
tuios run-command -s work Split horizontal
tuios run-command -s work toggle_spotlight
tuios run-command -s work Press "ctrl+b ?"
```
Run `tuios run-command --list` for the commands, and `tuios keybinds list` for the action names. An action and `Press` need a client attached.
## Syntax
### Lines and comments
One command per line. `#` starts a comment, on its own line or after a command:
```bash
# Build the editor pane
NewWindow # comments can follow a command
```
Keywords are not case-sensitive: `NewWindow`, `newwindow` and `NEWWINDOW` are the same command. Arguments keep their case.
### Arguments
An argument is a quoted string, a word, a number or a duration. A word can hold dots and dashes, so `appearance.border_style`, `lib.tape` and `even-horizontal` are each one word. Quote an argument that has spaces.
Every command checks its arguments. A missing argument, a value the command does not take, and a word left over at the end of the line are errors that `tape validate` reports.
### Strings
Double quotes, single quotes and backticks all work:
```bash
Type "hello world"
Type 'hello world'
Type `hello world`
```
### Durations
A number followed by a unit, as Go writes durations: `ms`, `s`, `m`, `h`. Decimals are allowed.
```bash
Sleep 500ms
Sleep 2s
Sleep 1.5s
```
### Repeat counts
A key command takes an optional count and sends the key that many times:
```bash
Down 5
Backspace 10
Enter 2
```
Counts work on `Enter`, `Space`, `Tab`, `Backspace`, `Delete`, `Escape`, `Up`, `Down`, `Left`, `Right`, `Home` and `End`. Other commands do not take a count: `ToggleTiling 3` is an error.
### The @ delay
An `@` and a duration after `Type` or a key command set the pause between the keys it sends:
```bash
Type@50ms "hello" # one character every 50 ms
Down@100ms 3 # three Down keys, 100 ms apart
```
Other commands do not take an `@` delay. Use a `Sleep` line between them.
## Commands
### Modes
| Command | Effect |
| ---------------------- | -------------------------------- |
| `WindowManagementMode` | Switch to window management mode |
| `TerminalMode` | Switch to terminal mode |
`Type`, `Run` and the key commands write to the focused pane in either mode. A mode command changes what TUIOS shows and how it treats the keys you press. Recorded tapes include them so the replay ends in the same state.
### Windows
| Command | Effect |
| -------------------------- | ----------------------------------------------------------------------- |
| `NewWindow ["name"]` | Open a window, with a name if you give one |
| `CloseWindow ["name"]` | Close the focused window, or every window with that name |
| `NextWindow`, `PrevWindow` | Move focus |
| `FocusWindow ` | Focus a window by name or ID. `Focus` is the same command |
| `RenameWindow "name"` | Name the focused window. `RenameWindow "old" "new"` renames another one |
| `MinimizeWindow ["name"]` | Minimize the focused or the named window |
| `RestoreWindow ["name"]` | Restore the focused or the named window, if it is minimized |
```bash
NewWindow "server"
Run "npm run dev"
```
Minimizing moves focus to another window, so a bare `RestoreWindow` right after `MinimizeWindow` does not bring the minimized one back. Name the window, or use `Action restore_all`.
### Tiling and layout
| Command | Effect |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `EnableTiling`, `DisableTiling`, `ToggleTiling` | Turn tiling on or off |
| `Split horizontal`, `Split vertical` | Split the focused pane. `h` and `v` also work. Needs tiling on |
| `SmartSplit` | Split the focused pane. TUIOS picks the direction. Needs tiling on |
| `Preselect left` | Put the next split on that side: `left`, `right`, `up` or `down` |
| `RotateSplit` | Rotate the direction of the focused split |
| `EqualizeSplits` | Reset every split to an even ratio |
| `ArrangePanes tiled` | Lay the panes out again: `tiled`, `even-horizontal` or `even-vertical`, then optional window names |
| `FocusDirection left` | Focus the pane on that side |
| `ToggleZoom` | Zoom the focused pane, or unzoom it |
| `SnapLeft`, `SnapRight` | Floating: snap the focused window to half the screen. Tiled: focus the neighbour on that side |
| `SnapFullscreen` | Floating only. Snap the focused window to the full screen |
| `SetMultifocus [names...]` | Make the multifocus set exactly these windows. No names clears it |
| `SaveLayout "name"`, `LoadLayout "name"` | Save the current layout under a name, or apply a saved one |
The master-stack layout has its own commands: `SetMasterPosition left` (`left`, `right`, `top`, `bottom` or `center`), `SetMasterCount 2`, `CycleMasterPosition`, `AddMaster`, `RemoveMaster`, `SwapWithMaster` and `FocusMaster`. See [Layout Modes](/docs/layout-modes).
### Workspaces
| Command | Effect |
| ---------------------------- | ------------------------------------------ |
| `SwitchWorkspace ` | Show workspace `n` |
| `MoveToWorkspace ` | Move the focused window to workspace `n` |
| `MoveAndFollowWorkspace ` | Move the focused window and switch with it |
`n` runs from 1 to the number of workspaces, 9 by default.
### Any action
`Action ` runs a keybinding action by the name your config binds a key to. It does what the key does, so every feature with a key is scriptable:
```bash
Action toggle_scratch # the scratch pane
Action toggle_spotlight # spotlight
Action prefix_inbox # the Inbox
Action hints # link and path hints
Action restore_all # restore every minimized window
Action command:build # a [[keybindings.command]] entry named build
```
Run `tuios keybinds list` for the names. A name that is not an action is an error in `tape validate`. A few keys answer only inside their own view: the Inbox and mail keys, and the rail's agent keys. `Action` refuses those and says so. Open the view and use `Press`.
### Keys and text
| Command | Sends |
| -------------------------------------------------------- | ----------------------------------------------- |
| `Type "text"` | The text, to the focused pane |
| `Run "command"` | The command line and Enter, to the focused pane |
| `Enter`, `Space`, `Tab`, `Backspace`, `Delete`, `Escape` | That key, to the focused pane |
| `Up`, `Down`, `Left`, `Right`, `Home`, `End` | That key, to the focused pane |
| `Ctrl+c`, `Alt+x`, `Shift+Tab`, `Ctrl+Backspace` | A key with modifiers, to the focused pane |
| `Press "keys"` | Keys to TUIOS itself, as if you pressed them |
The first five rows write bytes to the focused pane. They do not go through TUIOS keybindings: `Ctrl+b` sends byte `0x02` to the pane and does not start the TUIOS prefix. The one exception is `Alt+1` to `Alt+9`, which switch workspace.
`Press` sends keys through the TUIOS key handling instead. The leader opens the prefix, copy mode gets its motions, and an open dialog gets its keys. Separate keys with spaces or commas:
```bash
Press "ctrl+b ?" # open the key help
Press "esc" # close it
Press "ctrl+b [" # enter copy mode
Press "k k k v" # move up three lines and start a selection
```
### Waiting and checking
| Command | Effect |
| ------------------------------- | -------------------------------------------------------------------------------------------- |
| `WaitFor [timeout]` | Hold playback until the condition holds. Fail the tape at the timeout, 10 seconds by default |
| `Expect ` | Fail the tape if the condition does not hold now |
| `Sleep ` | Pause. `Wait ` is the same |
| `WaitUntilRegex "pattern" [ms]` | The older text wait. It shows a warning at the timeout and does not stop the tape |
The conditions:
| Condition | Holds when |
| ------------------------------ | ------------------------------------------------------------------------------- |
| `text "pattern" [in "pane"]` | The pane's screen matches the regular expression |
| `pane "name"` | A pane with this name or ID exists |
| `gone "name"` | No pane has this name or ID |
| `focus "name"` | The focused pane has this name or ID |
| `panes ` | The current workspace has `n` panes |
| `agent "state" [in "pane"]` | The pane's agent reports this state, such as `working`, `needs_input` or `done` |
| `workspace ` | Workspace `n` is on screen |
| `mode terminal`, `mode window` | TUIOS is in this mode |
Without `in`, `text` and `agent` read the focused pane. The timeout is a duration, as in `WaitFor text "ready" 30s`.
```bash
Run "make build"
WaitFor text "BUILD (OK|FAILED)" 2m
Expect text "BUILD OK"
Split vertical
WaitFor panes 2
```
Prefer `WaitFor` to `Sleep`. A sleep that is long enough on your machine is too short on a slow one. A wait holds exactly as long as it needs.
The command you type is on the screen too. `Run "echo ready"` followed by `WaitFor text "ready"` matches the typed line at once. Wait for something only the output has, such as `Run "echo ready-$((40+2))"` and `WaitFor text "ready-42"`.
### Including another tape
`Source "file.tape"` runs the commands of another tape at that point. The path is relative to the tape that names it. A tape can include a tape that includes another, up to 16 deep. A file that includes itself is an error. Errors in an included file name that file and its line.
`Source` works in `tape play`, `tape validate`, `tape exec` and the tape manager. It does not work in a project tape.
### Settings and appearance
| Command | Effect |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `Set ` | Change a setting, like `tuios set-config`. `SetConfig` is the same |
| `SetTheme ` | Change the color theme |
| `SetBorderStyle