TUIOSTUIOS

Configuration

Customize TUIOS keybindings and appearance

TUIOS reads a single TOML file, ~/.config/tuios/config.toml, following the XDG Base Directory specification. It has twelve top-level tables: [appearance], [notifications], [keybindings], [daemon], [startup], [tape], [hooks], [debug], [screenshot], [screensaver], [dock], and [hosts].

Two things are configured outside that file, in directories of their own. Custom colour themes are JSON files in ~/.config/tuios/themes/. Custom glyph sets are JSON files in ~/.config/tuios/glyphs/. See Custom themes and Glyph sets.

Quick Start

Find your configuration

tuios config path

Edit configuration

tuios config edit

View current keybindings

tuios keybinds list

Reset to defaults

tuios config reset

Configuration file location

Default path: ~/.config/tuios/config.toml

On first launch, TUIOS writes a default configuration file. The exact location follows the XDG Base Directory specification:

  • Linux/macOS: ~/.config/tuios/config.toml
  • Custom: $XDG_CONFIG_HOME/tuios/config.toml (if XDG_CONFIG_HOME is set)

You only need to specify what you want to change. Missing keys and whole missing sections are filled in with defaults at load time, so a three-line file is a perfectly valid config.

# ~/.config/tuios/config.toml

[keybindings.window_management]
new_window = ["ctrl+t"]
close_window = ["ctrl+w"]

Applying changes

TUIOS watches the config file and reloads it automatically. A write is debounced for 200ms, the file is re-parsed, and the result is applied on the render loop. There is also a Reload Config entry in the command palette (Ctrl+P) if you want to force it.

Most of the config reloads

Hot reload now applies essentially the whole [appearance] table live, including theme, border_style, zen_mode, dockbar_position, the [appearance.sidebar] and [appearance.scrollbar] tables, the dock and window-button options, scrollback_lines, max_fps, mouse behaviour options, and leader_key.

Still needs a restart: every keybinding other than leader_key, preferred_shell, the whole [daemon] table, and the whole [hooks] table. Hooks in particular are read once at startup.

If the reloaded file fails to parse or has errors, the reload is abandoned and the running config stays in place. The failure is shown as an error notification in the running TUI, and written to the log.

Appearance

The [appearance] table controls visual presentation. tuios list-options describes every settable path with its type, default, and accepted values, and is always in sync with the binary. The flat keys:

KeyTypeDefaultCLI flag
themestring""--theme
border_styleenum"rounded"--border-style
border_focused_colorhex string""none
border_unfocused_colorhex string""none
dockbar_positionenum"bottom"--dockbar-position
hide_window_buttonsboolfalse--hide-window-buttons
hide_scrollbarboolfalse--hide-scrollbar
shared_bordersboolfalse--shared-borders
window_title_positionenum"bottom"--window-title-position
window_title_formatstring""none
scrollback_linesint, 100 to 100000010000--scrollback-lines
scroll_linesint, 1 to 503none
zoom_max_widthint, 0 means fullscreen0--zoom-max-width
max_fpsint, 10 to 2400, meaning 60none
animations_enabledbooltrue--no-animations
confirm_quitboolfalse--confirm-quit
whichkey_enabledbooltruenone
whichkey_positionenum"bottom-right"none
show_clockboolfalse--show-clock
show_cpuboolfalse--show-cpu
show_ramboolfalse--show-ram
hide_clockbool, deprecatedfalse--hide-clock
preferred_shellstring""none
niri_reverse_scrollboolfalsenone
zen_modeenum: disabled, always, mouse"disabled"none
window_button_styleenum: pill, dots"pill"--window-button-style
window_button_positionenum: right, left"right"--window-button-position
copy_on_selectbooltruenone
focus_follows_mouseboolfalsenone
alt_dragbooltruenone
click_to_typeenum: single, double, off"single"none
word_charactersstring"@-./_~?&=%+#"none
dock_workspace_tabsbooltruenone
dock_workspace_tab_formatstring, {index} and {name}"", meaning {name}none
dock_workspace_tooltipbooltruenone
dock_pill_capsboolfalsenone
session_colorsbooltruenone
linksenum: off, marked, all"all"none
glyphsstring"default"none
clock_formatGo time layout"15:04:05"none
dim_unfocusedint, 0 to 90, percent0none
gapint, cells0none
master_ratioint, percent50none
scroll_column_widthint, percent55none
panel_paddingint, columns2none

Two nested tables hold the rest. [appearance.scrollbar] takes style (thin or track), thumb and track (one-cell glyphs), and tint (quiet, border, muted, or a #RRGGBB literal). [appearance.sidebar] configures the session rail and has a section of its own below. The old flat sidebar_* keys are migrated into the table when an older file is read.

border_style

Valid values: "rounded" (default), "normal", "thick", "double", "hidden", "block", "ascii", "outer-half-block", "inner-half-block", "glyphs".

"glyphs" means "whatever border the active glyph set draws". See Glyph sets.

"hidden" does more than remove the border characters. It also suppresses the window control buttons and the scrollbar thumb, because both are drawn on the border and there is no border left to draw them on. You do not need to set hide_window_buttons or hide_scrollbar alongside it.

border_focused_color and border_unfocused_color

Hex colours that override the theme's border colours, for example "#89b4fa" and "#585b70". Empty clears the override and restores the theme's own colours.

dockbar_position

Valid values: "bottom" (default), "top", "hidden".

window_title_position

Valid values: "bottom" (default), "top", "hidden".

When hidden, the rename window keybinding is disabled.

window_title_format

A template applied to every window title. Three placeholders are expanded:

  • {title} - the window's custom name, or the title the terminal set
  • {index} - the window's 1-based position in its workspace
  • {cwd} - the window's working directory
[appearance]
window_title_format = "{index}: {title} ({cwd})"

Anything written in braces that is not one of those three produces a validation warning and is rendered literally in the title bar. A format that mentions only {index} or {cwd} still renders for a window that has no title of its own.

scrollback_lines

Lines kept per terminal window. Clamped to 100 minimum, 1,000,000 maximum.

This is read when a window is constructed. Changing it applies to windows you open afterwards, never to windows already on screen.

scroll_lines

Lines moved per mouse wheel notch, in scrollback, copy mode, and the scrollback browser. Clamped to 1 minimum, 50 maximum.

zoom_max_width

Maximum width in cells for zoom mode. 0 means fill the screen. Setting it to, say, 120 centres the zoomed window at 120 columns instead of stretching it across an ultrawide display.

max_fps

Render frame-rate cap. 0 leaves it at the default of 60. Any other value is clamped to the range 10 to 240.

The comment on this field in the source says the maximum is 120. The code clamps to 240, and 240 is what you get.

hide_clock

Deprecated. The clock is hidden by default now, so use show_clock to turn it on. hide_clock still takes precedence when true.

The clock is drawn during tape recording and while the leader key is pending regardless of this setting.

preferred_shell

The shell to launch in new windows. Empty means auto-detect for the platform.

niri_reverse_scroll

Reverses the mouse scroll direction in the scrolling (niri-style) layout.

Valid values: "all" (default), "marked", "off".

What counts as a link the pointer may pick up.

A program that emits an OSC 8 escape has said outright that a run of cells is a link and where it points. "marked" trusts only that. Almost no program does it, though, and the links you actually read in a pane are plain text, so "all" also finds bare http, https and file URLs. "off" leaves pane content alone.

Shift and the left mouse button on a link acts on it. Shift is the terminal's own "this click is mine, not the program's" modifier, so the gesture reaches past a program that is tracking the mouse. A plain click is deliberately not this: a left press on a pane already focuses it, starts typing in it, and selects text in it.

A link is opened by the desktop's own handler, and only these schemes are handed to it: http, https, mailto, ftp, ftps. Anything else is copied to the clipboard instead, with a notification saying so. The restriction exists because a marked link's address is chosen by the program that printed it, while its visible label is chosen by the same program.

file:// never reaches the desktop handler. A file opens in a new pane running your editor, and a directory opens in the rail's files section. A path that is gone is reported rather than guessed at.

A remote client cannot open a link on your behalf. It copies the address and says so.

The session rail

[appearance.sidebar] configures the rail down the side of the screen.

KeyTypeDefaultMeaning
enabledboolfalseShow the rail
positionenum: left, right, hidden"left"Edge the rail sits on
widthint28Preferred width in columns on a wide screen
sectionsstring"sessions:25,terminals,files:25,agents:34"The layout. See below
show_glyphsbooltrueAgent-state glyph on each row
show_countsbooltrueWindow count on each session row
marqueebooltrueScroll a hovered row's overflowing title
tooltipsbooltrueLabel the collapsed strip on hover
file_iconsbooltrueA Nerd Font icon per file type in the files section
file_icon_colorsbooltrueDraw each file icon in its file type's own colour
folder_clickenum: navigate, cd, both"navigate"What a click on a folder row does
file_actionsbooltrueLet the files section create, rename, delete, copy, cut and paste
file_deleteenum: trash, permanent"trash"Where a delete sends the file

sections

The rail's layout is one string. It owns three things at once: which sections the rail draws, the order it stacks them in from the top, and the share of the rail each may claim.

[appearance.sidebar]
sections = "sessions:25,terminals,files:25,agents:34"

One section per comma, each optionally followed by : and a percent. The four section names are sessions, terminals, files, and agents. A name left out of the list is a section the rail does not draw, and that is the only way to turn one off.

The percent is a ceiling, not a reservation. A section only ever claims the lines its own rows can fill, so the three quarters the default spends on sessions, files and agents are spent only when there are that many of each. A section with no percent is flexible and takes whatever the others leave.

spacer is a fifth name. It draws nothing and takes lines, which is how you put a gap between two sections or push what follows it to the bottom. It is the one name the layout may carry more than once, because it names a place rather than a section. A spacer with a share takes that percent and keeps it. A spacer with no share takes the lines nothing else wants.

[appearance.sidebar]
# Sessions at the top, files pushed to the bottom
sections = "sessions:30,terminals,spacer,files:40"

The parser is forgiving. An unknown name, a percent that is not a number, and a repeated section name are all dropped rather than refused, because a rail that refuses to lay itself out is worse than one that lays itself out slightly differently from what you typed. What was dropped is reported as a config warning.

show_windows and show_agents are deprecated

There used to be a boolean per section. show_windows and show_agents still parse and still work, and they fold into sections when the file is read: setting one to false leaves that section out of the layout.

They are deprecated because a boolean cannot say where a thing goes, and two spacers would have no boolean to share. Leave the section out of sections instead.

There is an editor for this, reached from the settings page: the Sections row under Sidebar, on Enter. It shows two lists, "On the rail" and "Not on the rail", and uses the dock editor's keys: arrows select, Shift+arrows move the selected entry, Enter puts a section on the rail or takes it off, / walk the share, r restores the defaults and u undoes the session's edits. Every edit is applied and saved as it is made.

The files section

The files section lists what is in the focused pane's directory. It follows the focus: when a shell cds, when the focus moves, and when a client attaches, the listing is read again. Nothing polls, so a client sitting on an open rail does no filesystem work at all. A file written into the directory by something else does not appear until the listing is asked for again.

With file_icons on, a Nerd Font codepoint is drawn per file type, in that file type's own colour unless file_icon_colors is off. With it off, or in --ascii-only mode, the glyph set's folder, parent and file roles are drawn instead.

folder_click decides what clicking a folder row does. navigate walks the listing into it. cd sends the focused pane there. both does the two together. A cd is only ever typed into a pane sitting at a shell prompt, never into one that is running something.

The six file actions are keyboard-driven, from [keybindings.sidebar_files], and are live only while the rail owns the keyboard and the cursor is on a row of the listing:

KeyAction
afile_create - a name to make. A trailing slash makes a folder
rfile_rename
dfile_delete
Dfile_delete_forever
yfile_copy
xfile_cut
pfile_paste

Each prompt is a centred dialog, not a row in the rail, because a confirmation that does not fit its own question is not a confirmation. The delete dialog opens on Cancel, so Enter on a dialog nobody has touched destroys nothing.

A delete goes to the trash by default, following the freedesktop.org Trash specification directly rather than shelling out to gio, so a file it puts in the trash is the file your desktop's own trash shows and is restorable by the usual means. D is the permanent delete, and it is a key of its own rather than a second answer inside the dialog: a file on another disk cannot go to the home trash at all, and somebody who means it should not have to edit a config file. Set file_delete = "permanent" to make d permanent too.

It is not a file manager

It lists, it walks in and out, it hands a path to the clipboard and a directory to a shell, and it does those six actions. There is no multi-select, no tree, no filter and no drag and drop. Twenty-six columns beside a terminal is not the place for those.

Only the home trash is implemented, not the per-volume trash the specification also describes.

Glyph sets

A theme decides what colour the chrome is. A glyph set decides what shape it is: which corner the border turns, what the window controls are pictures of, what a rule and a separator are drawn with, which mark the rail wears on the row you are on.

That is one option, appearance.glyphs, plus a file format for writing your own.

[appearance]
glyphs = "heavy"
# At runtime, with no restart
tuios set-config appearance.glyphs heavy

# What is available, and what each one draws
tuios list-glyphs
tuios list-glyphs heavy

Four sets ship, and they are also the sets to inherit from.

IdWhat it is
defaultWhat TUIOS ships: rounded frame, Nerd Font powerline caps, and controls
unicodeBox drawing and geometric shapes, with no Nerd Font private-use glyphs among them, for a good font that is not a patched one
heavyOne stroke weight heavier throughout, border and junctions included
asciiNothing outside 7-bit ASCII

The border is opt-in

A set may carry a border and most do not. The border that draws is whichever border_style names, and glyphs is the value meaning "the active set's".

[appearance]
glyphs = "heavy"
border_style = "glyphs"

A set could have been allowed to win whenever it defines a border. That would also mean selecting a set silently turned a setting you had already made into a no-op, with nothing on screen to say why. A set that names only some of the border's runes gets the rounded border for the rest.

Writing a set

Write <id>.json into ~/.config/tuios/glyphs/. The exact path is printed by tuios list-glyphs.

{
  "display_name": "Mine",
  "inherits": "heavy",
  "bullet": "◦",
  "focus": "▐",
  "border": {
    "top_left": "╔",
    "top_right": "╗",
    "bottom_left": "╚",
    "bottom_right": "╝"
  }
}

Every field is optional. An absent id is taken from the filename, and an absent role keeps whatever the inherited set says, falling through in the end to the glyph TUIOS ships. Inheritance is followed up to eight levels, and a loop stops rather than hangs.

The roles, in groups:

  • Window controls: close, maximize, minimize, dot, pill_left, pill_right
  • Rules and separators: rule, separator, arrow_left, arrow_right
  • Rail marks: focus, attention, bullet, add, collapse, expand
  • Files section: folder, parent, file
  • Scrollbar: scrollbar_thumb, scrollbar_track
  • Text: ellipsis, sigil, dash_rule
  • Border: border.top, border.bottom, border.left, border.right, the four corners, and the five junctions middle, middle_top, middle_bottom, middle_left, middle_right

Most roles must be exactly one cell. A glyph that misses is dropped back to the default and reported:

tuios list-glyphs mine --json | jq -r '.problems[]?'

The reason is the window controls: their press rectangles are fixed offsets from the border's trailing corner, so a two-cell emoji in close would move every cell after it and put the button under a different column than the one the pointer is tested against. separator, ellipsis, collapse and expand take any width.

Unlike the themes directory, the glyphs directory is re-read whenever a set is looked up, so a file you have just written is selectable immediately. A file that does not parse is skipped rather than applied, and list-glyphs reports it under problems with the reason. Only *.json files directly under the directory are loaded.

--ascii-only overrules a set per role rather than throwing the set away, so a set keeps every role it happened to spell in ASCII and gives up only the ones it did not.

A set changes shape, not colour

Every glyph is still drawn in the ink the contrast model picks for the surface it lands on, so a set cannot make chrome illegible. The dock's semantic icons are not roles either: the mode chip, the counts and the session controls are pictures of a meaning rather than shapes in a frame, and a glyph set does not touch them.

The settings page has a Glyph set row directly under Theme, with a searchable picker. Each row draws that set's own corner, controls and rail marks, and moving the selection applies the set so the chrome behind the panel is the preview.

Themes

TUIOS ships 342 built-in themes, supplied by the bubbletint dependency.

# List every theme
tuios --list-themes

# Preview one theme's 16 ANSI colors
tuios --preview-theme dracula

# Launch with a theme
tuios --theme dracula
[appearance]
theme = "dracula"

The theme picker

There is a searchable theme picker with fuzzy matching, an 8-colour swatch per theme, and live preview as you move through the list. Esc restores whatever theme you had before you opened it.

The picker has no keybinding

There is no prefix command and no configurable action for the theme picker. The only two ways to open it are the Theme row on the settings page (press Enter on it) and the Theme Picker entry in the Ctrl+P command palette.

Custom themes

Custom themes are JSON, one theme per file, in ~/.config/tuios/themes/. The directory is flat: subdirectories are skipped, not searched.

{
  "id": "my-theme",
  "display_name": "My Theme",
  "dark": true,
  "fg": "#e0def4",
  "bg": "#191724",
  "black": "#26233a",
  "red": "#eb6f92",
  "green": "#31748f",
  "yellow": "#f6c177",
  "blue": "#9ccfd8",
  "purple": "#c4a7e7",
  "cyan": "#ebbcba",
  "white": "#e0def4"
}

Save that as ~/.config/tuios/themes/my-theme.json and select it with theme = "my-theme" or --theme my-theme.

  • id defaults to the filename with the extension stripped and lowercased, so it can be omitted.
  • display_name defaults to id.
  • Missing colours are filled with xterm defaults rather than rejected.
  • Each bright_* colour falls back to its non-bright counterpart when absent, which is why the example above omits all eight.
  • A file that does not parse is logged and skipped. It does not stop the other themes loading, and it does not stop TUIOS starting. Nothing appears on screen, so check the log viewer if a theme does not show up.

The themes directory is read once per process, behind a one-shot guard. Adding a new .json file while TUIOS is running will not make it appear, and neither will the config reload. Restart.

Keybindings

leader_key

The tmux-style prefix key for prefix commands.

[keybindings]
# Ctrl+A, like GNU Screen
leader_key = "ctrl+a"

Another useful value: "ctrl+space". "alt+space" is taken by default: it opens the app launcher, via [keybindings.global].

The 19 sections

Every one of these is a table under [keybindings], and every one resolves through the keybind registry, so every action in every one of them is rebindable.

SectionContext
window_managementWindow mode: create, close, rename, minimize, cycle, select by number
workspacesSwitch workspace, move window to workspace and follow
layoutSnapping, tiling toggle, swaps, resizes, BSP splits, preselect
mode_controlEnter terminal mode, enter window mode, toggle help, quit
navigationArrow navigation and selection extension
restore_minimizedRestore minimized window 1-9
systemShips empty by default
prefix_modeAfter the leader key
window_prefixAfter leader then t
minimize_prefixAfter leader then m
workspace_prefixAfter leader then w
debug_prefixAfter leader then D
tape_prefixAfter leader then T
layout_prefixAfter leader then L
terminal_modeDirect binds in terminal mode, no prefix needed
globalConsulted in window mode and terminal mode alike
scriptLive only while a .tape is playing back
sidebarWhile the session rail owns the keyboard
sidebar_filesWhile the rail's cursor is on a row of the files section

The prefix sections used to be inert

Six of these sections were parsed and then ignored. Rebinding anything under the leader key was a silent no-op: your config was accepted, and the hard-coded key kept working instead.

prefix_mode, window_prefix, minimize_prefix, workspace_prefix, debug_prefix, and tape_prefix now all resolve through the registry, so they do what they say. terminal_mode was fixed at the same time, though it is not a prefix section.

If you wrote bindings for these sections against an older version and gave up on them, they work now.

Section contents

window_management actions: new_window, close_window, rename_window, minimize_window, restore_all, toggle_zoom, start_screensaver, copy_selection, focus_sidebar, next_window, prev_window, next_session, prev_session, select_window_1 through select_window_9.

sidebar_files actions: file_create, file_rename, file_delete, file_delete_forever, file_copy, file_cut, file_paste. They are looked up before sidebar, and only while the rail's cursor is on a file row, because three of them collide with a rail key that already exists. See The files section.

workspaces actions: switch_workspace_1 through switch_workspace_9, move_and_follow_1 through move_and_follow_9.

layout actions: snap_left, snap_right, snap_fullscreen, unsnap, snap_corner_1 through snap_corner_4, toggle_tiling, swap_left, swap_right, swap_up, swap_down, resize_master_shrink, resize_master_grow, resize_height_shrink, resize_height_grow, resize_master_shrink_left, resize_master_grow_left, resize_height_shrink_top, resize_height_grow_top, split_horizontal, split_vertical, rotate_split, equalize_splits, preselect_left, preselect_right, preselect_up, preselect_down.

mode_control actions: enter_terminal_mode, enter_window_mode, toggle_help, quit.

navigation actions: nav_up, nav_down, nav_left, nav_right, extend_up, extend_down, extend_left, extend_right.

restore_minimized actions: restore_minimized_1 through restore_minimized_9.

global actions: command_palette (default Ctrl+P) and launcher (default Alt+Space). They answer to one key wherever you are, which is why they are not entries in terminal_mode.

terminal_mode actions: terminal_next_window, terminal_prev_window, terminal_exit_mode, and terminal_focus_left, terminal_focus_right, terminal_focus_up, terminal_focus_down (default Alt+arrows).

The prefix sections are listed in full on the Keybindings reference, alongside their default keys.

terminal_mode defaults are platform-specific

# macOS
[keybindings.terminal_mode]
terminal_next_window = ["opt+tab", "alt+n"]
terminal_prev_window = ["opt+shift+tab", "alt+p"]
terminal_exit_mode = ["opt+esc"]

# Linux and elsewhere
[keybindings.terminal_mode]
terminal_next_window = ["alt+n"]
terminal_prev_window = ["alt+p"]
terminal_exit_mode = ["alt+esc"]

One key that config cannot claim

A bare , in window mode is intercepted for the settings page before any keybinding lookup happens.

Two consequences. The default resize_master_shrink_left binding on , is unreachable in window mode. And rebinding , under [keybindings.layout] to something else will still open settings, because the intercept is a hardcoded comparison rather than a registry lookup. This is the one place where the config does not win.

Key syntax

Modifiers

  • ctrl+
  • alt+
  • shift+
  • opt+, option+ (macOS only)

cmd+ and super+ are not supported; they are usually captured by the OS before TUIOS sees them.

Special keys

enter, return, esc, escape, tab, space, backspace, delete, up, down, left, right, home, end, pgup, pgdown, f1 through f12.

Multiple keys per action

new_window = ["n", "ctrl+n", "ctrl+t"]

Removing a binding

close_window = []

An empty array unbinds the action. It produces a warning, not an error, since some actions are reasonably left unbound.

Shifted keys have two spellings, and both match

shift+1 and ! are treated as the same key. So are shift+a and A. Binding either spelling binds both, on every platform.

This is convenient until it is not. If you bind shift+1 to one action and ! to another, they collide, and one of them wins non-deterministically. Pick one spelling per key. The default config leans on this deliberately, which is why restore_minimized_1 lists both "shift+1" and "!".

Platform notes

macOS

Workspace switching defaults to the Option key:

[keybindings.workspaces]
switch_workspace_1 = ["opt+1"]
switch_workspace_2 = ["opt+2"]

opt+1, option+1, and alt+1 all work. On macOS, writing alt+ produces an advisory warning suggesting opt+ instead, for consistency with what is printed on the key. It is advice, not an error, and the binding works either way.

Linux and elsewhere

Use alt+ and ctrl+. opt+ and option+ are not valid.

The in-app settings page

TUIOS has a settings page that edits the config for you. It covers everything the config file holds as a scalar option, in eleven categories: appearance, sidebar, dock, behavior, notifications, startup, screenshot, screensaver, advanced, daemon, and tape. Two of its rows open editors of their own rather than a field: the rail's sections and the dock's components. Colour-valued options, the two border colours and the scrollbar tint among them, open a colour picker rather than a text field, and every row shows the value in force, so an unset colour shows what it inherits.

Three ways in:

  • Ctrl+B then , (the prefix_settings action)
  • a bare , in window mode
  • the Settings entry in the Ctrl+P command palette

The settings page rewrites your config file

Every adjustment you make persists immediately, by serialising the in-memory config and overwriting ~/.config/tuios/config.toml wholesale. There is no merge and no round-trip.

That means your comments are gone, your key ordering is gone, your formatting is gone, and every field that was previously absent gets written out with its default value. A hand-tuned 10-line config comes back as a full dump.

If you maintain the file by hand, keep a copy in version control before you touch the settings page.

Most of the page applies as you adjust it. Two exceptions worth knowing:

  • Scrollback lines applies to windows opened afterwards, not to windows already on screen.
  • Theme, if you point it at a custom .json you just added, needs a restart. The themes directory is only read once per process.

Notifications

The [notifications] table has four flat keys and an [notifications.agent] sub-table.

[notifications]
duration = 0          # seconds an info or success message stays up; 0 uses the built-in default
warning_duration = 0  # same, for warnings
error_duration = 0    # seconds an error stays up when error_sticky is false
error_sticky = true   # errors wait for esc instead of expiring

[notifications.agent] governs agent-state alerts, the "your agent needs you" machinery:

KeyDefaultMeaning
enabledtrueMaster switch for agent-state alerts
notifytrueSend a desktop notification to the attached terminal (OSC 9, so it survives SSH)
soundfalseMake an alert audible
sound_modeHow it sounds: a played cue, a BEL, or both
sound_cooldown_seconds3Shortest gap between two cues across every pane; 0 uses 3
docktrueShow the alert in the dock, where it jumps to the pane that raised it
commandShell command run on an alert; empty runs nothing
settle_seconds2Hold an alert this long and drop it if the pane leaves the state
suppress_focusedtrueDrop alerts for the pane you are already looking at
quiet_hoursLocal-time window written HH:MM-HH:MM in which nothing alerts

Per-state switches live under [notifications.agent.states]: needs_input (default true), errored (true), done (true), idle (false), and working (false).

The after-agent-state hook fires under this same policy, settle window included.

The daemon table

[daemon]
log_level = "off"

log_level sets the daemon's debug verbosity. Valid values, in increasing order: off (default), errors, basic, messages, verbose, trace. It is read when a foreground daemon starts, and only if the level was not already set another way.

Three more daemon keys govern agent detection. agent_autodetect (default true) turns the foreground-process scan on or off. agent_detect_seconds sets seconds between detector polls, where 0 (the default) means 2 and a negative value turns detection off. agent_binaries is a list of extra binary names to treat as agents, merged with the built-in set rather than replacing it.

[daemon]
agent_binaries = ["my-agent", "aider"]

default_codec and socket_path are declared and inert

The default config file that TUIOS writes for you contains two more daemon keys:

[daemon]
default_codec = "gob"
socket_path = ""

Both are parsed, both are given defaults, both are written into every user's config file, and neither is read by anything. Setting default_codec = "json" does not change the wire codec. Setting socket_path does not move the socket.

They are documented here only so you do not spend an evening working out why they have no effect. The socket location is fixed; see Sessions.

The startup table

[startup] decides what a new session looks like when it opens. None of it reaches a session that is already running.

KeyTypeDefaultMeaning
open_default_windowboolfalseOpen one terminal automatically when a session starts empty
tiledboolfalseStart a new session tiled instead of floating
start_in_terminal_modeboolfalseStart focused in terminal mode, when a window is present, so typing goes straight to the shell
layoutenum: bsp, master-stack, scrolling"bsp"Tiling scheme a new session starts in
daemonboolfalseMake a bare tuios attach to a daemon-backed session instead of running standalone

layout only ever decides where a session starts. Once one is running, the scheme is the session's own and travels in its state, so attaching to a session laid out one way never rearranges it to match the config of whoever attached.

daemon = true changes a bare tuios and nothing else. Every subcommand already says which it wants. TUIOS_NO_DAEMON=1 and --standalone both override it, so a daemon that will not start never leaves you without a way in.

The tape table

[tape] governs project tapes, the .tape file a directory can carry. See Tape scripting.

KeyTypeDefaultMeaning
autorunenum: off, ask, auto"ask"What happens on entering a directory with a project tape
auto_reviewboolfalseOpen the review dialog on detection instead of only badging it

The debug table

[debug] has one key.

KeyTypeDefaultMeaning
show_key_eventsboolfalseShow the on-screen keycast of recent keypresses

This is the same overlay --show-keys turns on. See Showkeys.

The screenshot table

[screenshot] sets the defaults for tuios screenshot and for the in-app capture. Every key here has a flag on the command that overrides it for one call.

KeyTypeDefaultMeaning
formatenum: png, svg, ansi, html, txt"png"Default output format
directorypath"~/Pictures/tuios"Where capture files are saved
copybooltrueTry to copy the capture to the clipboard
previewbooltrueOpen the preview panel after a capture
frameenum: window, plain, none"window"A window card, a plain card, or nothing
backgroundstring"auto"Wash behind the card. auto derives it from the theme. none, a hex colour, or hex..hex for a gradient also work
paddingint, 0 to 12848Space around the card, in pixels
radiusint, 0 to 3210Card corner radius, in pixels
shadowbooltrueDraw a soft shadow under the card
controlsenum: auto, macos, glyphs, none"auto"Window control marks: the macOS lights, your glyph set, or none
title_formatstring"{title}"Title bar text, with {title}, {index} and {cwd}
font_familystring"JetBrains Mono, monospace"Font to draw with when your terminal does not say which it uses
font_filepath""Font file to draw a PNG with, also embedded in SVG and HTML. It wins over every other font choice
scaleint, 1 to 42PNG size multiplier
cursorboolfalseDraw the cursor cell
[screenshot]
format = "svg"
frame = "plain"
background = "none"
padding = 24

png and svg carry the frame. ansi and txt are the bare stream, so the frame keys do not apply to them.

The screensaver table

[screensaver] animates the screen after a spell with no input. It is off by default, because a screen that starts animating on its own is not something to give someone without asking.

KeyTypeDefaultMeaning
enabledboolfalseStart after a spell with no input
idle_minutesint, 1 to 24010Minutes of quiet before it starts
effectstring"random"Which effect runs, or random for a different one each time
while_busyboolfalseStart even when a pane is running a command or an agent
[screensaver]
enabled = true
idle_minutes = 5
effect = "matrix"

There are 35 text effects, supplied by the tuiffects dependency, plus random, which resolves to a real one each time it is built. tuios list-options screensaver.effect prints the names.

Because the names say very little about what lands on the screen, the settings page opens a picker instead of a stepper: it captures the screen you opened it from and runs the selected effect over that real capture, so what you see previewed is the screen you have. The list is fuzzy-searchable.

Ctrl+B then S starts the screen saver now, whatever enabled says. The action is start_screensaver, in [keybindings.window_management].

The dock table

[dock] is the bar, as three ordered lists of named components plus a table per component that needs one. It is the one part of the config that is not a set of scalar options, so tuios list-options does not carry it and set-config cannot reach it. It is a file edit, or the Dock → Components editor on the settings page.

[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  = "~/.config/tuios/dock/git-branch.sh"
refresh  = "event:after-focus-change"
on-click = "tuios new-window log git log --oneline -20"

Those three lists are the default. Omit the whole table and the bar is unchanged. A missing list takes the default arrangement, and an explicitly empty one draws nothing on that side, so left = [] and no left key at all are different answers.

The built-in component names are mode, workspaces, trail, tape, windows, notifications, copy-help, cpu, ram, clock, and session-controls. Naming a component makes it eligible to draw, not certain to: each keeps whatever condition it always had, so the workspace strip still needs two workspaces and the meters still need show_cpu.

[dock.clock] takes format, a Go time layout. The refresh cadence is derived from it: a layout showing seconds is scheduled to the next second, one without to the next minute.

[dock.custom.NAME] puts your own cell on the bar, and a list entry of custom/NAME places it. The contract is small enough to have no version: environment variables in, one line of text out.

KeyDefaultMeaning
commandRun through sh -c. Its first line of stdout is the cell
refresh"once"When to run it: once, a duration such as "30s", push (the command stays running and each line it writes is an update), or event:TYPE[,TYPE]
on-clickRun through sh -c when the cell is clicked, like a hook
max-width24Cap the cell in cells

A custom component is hidden when its command fails. tuios list-dock-components says which and why. examples/dock/README.md in the repository is the full contract with working recipes.

The hosts table

[hosts] names the other machines this daemon may ask for listings. It is the whole configuration surface of federation stage 1.

[hosts.build]
addr = "gaurav@buildbox"

[hosts.work]
addr = "workstation.local"
connect_timeout = 5
KeyDefaultMeaning
addrrequiredAnything ssh understands, ssh_config aliases included. A host with no addr is ignored, and the daemon logs why
connect_timeout10Seconds one dial may take before the host is called unreachable. Also handed to ssh, so a machine that is powered off is reported rather than waited on
command"tuios"The TUIOS binary on the far side. Set it when a per-user install is not on the non-interactive PATH
ssh_optionsExtra arguments passed to ssh before the address, for a host that needs a flag ssh_config cannot carry

Listings cross the link and nothing else

You cannot start a session on another machine, attach to one, type into one, message it, or kill it. There is no verb that would let you. Three read verbs cross a link and that is all: list hosts, list sessions, list agents. Remote rows on the rail are shown and never selected.

To work on another machine, ssh to it and run TUIOS there.

The daemon holds one ssh link per host and reads the table when it starts, so a new host needs a daemon restart. It runs ssh with BatchMode on, so a link never asks for a password and never asks about a host key. Ssh to the host once by hand to accept its key.

Reaching a host you cannot ssh to directly

A host entry says how to reach a machine, and ssh_options is where you say it. So a machine behind NAT, with no port forwarding and no jump host, is a reachability problem to solve below tuios rather than inside it. Any tool that pipes stdio to a remote port works as an ssh ProxyCommand.

Tailscale's 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:

tailcat --serve=22

then name it in the host entry:

[hosts.laptop]
addr = "gaurav@laptop"
ssh_options = ["-o", "ProxyCommand=tailcat <token> 22"]

Ssh still decides who may connect. The tunnel only carries the bytes to sshd, which asks for your key as it always does. A leaked tailcat token costs reachability, not access.

This holds for any transport of that shape. TUIOS adds no second identity system and opens no new listening port.

Hosts are not discovered. A host exists because you named it, and a name resolves exactly or not at all. [hosts] is also outside the option registry for the same reason [hooks] and [keybindings] are: it is a map of named tables, not a scalar with a settable value.

See tuios hosts for the link states, and --all-hosts on ls and list-agents.

Hooks

The [hooks] table runs shell commands on window, workspace, and session events. It has its own page: Hooks.

Two things to carry over from there: hook event names are hyphenated rather than snake_case, and a misspelled event name is silently ignored.

Validation

The config is validated at load. Errors and warnings are treated differently.

Errors are things like an unparseable key ("cmd+t", "ctrl+"). They are printed to stderr:

Config error in [window_management]: cmd+t - invalid modifier: cmd

An error does not stop TUIOS from starting, and it does not fall back for just the broken key. The whole config file is discarded and the full built-in defaults are used instead. One bad key costs you every customisation in the file.

Since this is printed before the alternate screen is entered, the first rendered frame usually wipes it. Run tuios keybinds list to see it without the TUI on top.

Warnings do not discard anything. They cover unknown enum values (which fall back to their defaults), unknown title-format placeholders, actions bound to no keys, keys bound to more than one action in the same context, essential actions left unbound, and the macOS alt+ advisory. They are surfaced inside the running TUI as a notification pointing you at the log viewer.

Auditing your customisations

tuios keybinds list-custom

This is not a complete audit

list-custom diffs 9 of the 19 keybinding sections: window_management, workspaces, layout, mode_control, system, prefix_mode, window_prefix, minimize_prefix, workspace_prefix.

It does not look at navigation, restore_minimized, debug_prefix, tape_prefix, layout_prefix, terminal_mode, global, script, sidebar, or sidebar_files, and it does not report a customised leader_key at all. A config that changes only those will print "No custom keybindings configured", which is wrong.

Treat the output as a partial view. For the whole picture, read the file.

Example configurations

Browser-style

[keybindings.window_management]
new_window = ["ctrl+t"]
close_window = ["ctrl+w"]
next_window = ["ctrl+tab"]
prev_window = ["ctrl+shift+tab"]

Vim-style

[keybindings.mode_control]
enter_terminal_mode = ["i", "a"]
enter_window_mode = ["esc"]

[keybindings.window_management]
new_window = ["ctrl+t"]
close_window = ["ctrl+w"]

Screen-style leader with a rebound prefix menu

[keybindings]
leader_key = "ctrl+a"

[keybindings.prefix_mode]
prefix_new_window = ["c"]
prefix_split_horizontal = ["S"]
prefix_split_vertical = ["V"]
prefix_settings = ["g"]

A quieter, denser look

[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

Troubleshooting

Configuration not loading

# Confirm the path TUIOS is actually reading
tuios config path

# Parse the file and print any errors, without the TUI covering them
tuios keybinds list

If keybinds list prints Using default keybindings..., your file was rejected and everything in it is being ignored.

Invalid key syntax

Common mistakes:

  • "cmd+t" and "super+t" - not supported
  • "opt+1" on Linux - opt is macOS only
  • "ctrl+" - incomplete combination

A setting changed nothing

Work through, in order:

  1. Is it in the reload subset? Most keys are not. See Applying changes. Restart before concluding anything.
  2. Did the file parse? A reload that fails is silent. Run tuios keybinds list.
  3. Is it a keybinding under the leader key? Those work now, but only on a current build.
  4. Is it , in window mode? That key is claimed by the settings page and cannot be rebound.
  5. Is it a hook? Check the event name spelling. Hyphens, not underscores.

On this page