This file is for an agent working on the TUIOS codebase: how it is laid out, how to build it, and the conventions to follow when changing it.
An agent running inside a TUIOS pane wants the other document. Run
tuios --skill for the core that drives a running session: addressing panes,
reading and writing them, running work and waiting on it, reporting agent
state, and talking to other agents safely. tuios --skill TOPIC prints one
topic, tuios --skill all prints every topic, and a name it does not know
lists the topics. The source is skills/tuios/SKILL.md
and the other files in skills/tuios/, embedded in the binary so the printed
copy always matches the build. cmd/tuios tests resolve every command the
skill shows against the command tree.
TUIOS (Terminal UI Operating System) is a terminal-based window manager built in Go using the Charm stack (Bubble Tea v2, Lipgloss v2). It provides vim-like modal interface, workspace support, mouse interaction, and SSH server mode.
Note: The web terminal functionality is provided by the separate tuios-web binary for security isolation. See cmd/tuios-web/ and docs/WEB.md for details.
# Build from source
go build -o tuios ./cmd/tuios
go build -o tuios-web ./cmd/tuios-web
# Run directly
go run ./cmd/tuios
go run ./cmd/tuios-web
# Run with debug logging
go run ./cmd/tuios --debug
go run ./cmd/tuios-web --debug
# Run the unit tests of every module. ./... stops at each library's go.mod,
# so the libraries are named one by one. `just test` runs this line under the
# limits below.
go test ./... ./progstatus/... ./vt/... ./swatch/... ./shot/... ./tml/... \
./plot/... ./loom/... ./sketch/...
# Leave the machine usable. The maintainer works on this box while the suite
# runs, and an uncapped build or test run takes every core. taskset confines the
# whole process tree to half the cores, and the affinity is inherited, so it
# also holds for the daemons and shells the e2e suite spawns. GOMAXPROCS and
# `go test -p` do not, because they only bind the Go runtime.
nice -n 10 taskset -c 0-7 go test ./... -timeout 20m
# Run specific package tests
go test ./internal/config/...
go test ./internal/tape/...
# Run with race detection
go test -race ./...
# Run the tests of the ghostty backend. `go build -tags ghostty ./...` compiles
# it and runs none of its tests, so a change that passes the plain suite can
# still be wrong on the backend `scripts/install.sh ghostty` builds. The
# ghostty-vt job runs the packages whose tests link vt and that the change
# reaches; these are the ones that matter most, the renderer in internal/app
# included. `just ghostty-test` builds libghostty-vt first (needs zig).
PKG_CONFIG_PATH="$PWD/.ghostty-vt/native/pkgconfig" \
go test -tags ghostty -count=1 -short \
./vt/ ./internal/session/ ./internal/terminal/ \
./internal/app/ ./internal/input/ ./cmd/tuios/The justfile at the root wraps these commands for local work. just lists
the recipes. They build into .dev/, run tests under nice and taskset,
and run throwaway daemons under a scratch directory. Only install,
install-ghostty, deploy, deploy-hosts and restart touch the live
daemon. just deploy runs tuios hosts sync --dev --src <checkout> --local --restart --yes.
Run just check --changed before you mark a pull request ready. It runs
scripts/check.sh on the packages the branch changed against origin/main.
just check runs it on every module, which is the form for main.
| Check | What it runs | Fails the gate |
|---|---|---|
fmt |
gofmt -l and goimports -l |
yes |
vet |
go vet, also with -tags ghostty when libghostty-vt is built |
yes |
lint |
golangci-lint with .golangci.yml, at the CI version |
yes |
tuioslint |
the internal/lint tests |
yes |
vuln |
govulncheck, for each module the branch touched |
yes |
fix |
the go fix modernizers, one line per finding |
no, report |
gopls |
gopls check on the changed Go files |
yes |
tidy |
go mod tidy -diff per module, scripts/library-pins.sh check |
yes |
cross |
go build for linux, darwin, windows and freebsd |
yes |
deadcode |
functions no binary or test reaches, outside pkg/ |
no, report |
--only vet,lint runs a subset. Each check writes its full output to
.dev/check/NAME.log. The tools run through go run at pinned versions, so
the gate needs nothing installed beyond Go. gopls fails only on the lines
the branch added. lint covers the whole of each changed package. Fix a
finding at the root. A //nolint needs its reason on the same line.
The gate runs no unit tests beyond internal/lint. Run the tests of the
packages you changed as well, and see Source checks below.
Apply go fix one package at a time, and read every hunk before you keep it.
go fix -diff ./... in a module prints the patches. Keep the index form of a
loop that deletes from the slice it walks, and of a loop on a render path
that would copy a cell per step under range. Leave out the files an open
pull request changes, and a library folder unless you will move its pin.
.github/workflows/ci.yml starts with a changes job. The job runs
scripts/ci-changes.sh, which reads the diff and names the jobs and the Go
packages the change can affect. A package is affected when it changed, or when
its test binary builds a package that changed. A job that the changes job does
not name shows as skipped. A change to a module file beyond the library pins,
to a CI file, or to a path no rule names runs everything. A push to main runs
every job, except ghostty-vt, learn-web, clienttests, the image build and the
Nix vendor hash. Those run on main only when the push changed their inputs.
The e2e suite selects its own tests (see Testing Approach). See what a branch
runs:
CHANGES_BASE=origin/main scripts/ci-changes.shA test that fails now and then on code that cannot reach it goes in
.github/flaky-tests.txt. A failed run whose failures are all on that list
retries them once. Any other failure fails the job.
Main runs every Go test job and the whole e2e suite, so it catches what a
pull request's selection left out. The idle-cost tests (TestIdleCostStaysLow,
TestDockIdleCostWithComponentsStaysLow and the like) and the timing budgets
in the unit tests fail there first. When one fails on main after your change
lands, fix the cause at once. Do not retry it away, add it to the flaky list,
or loosen its budget to make it pass.
A timing test waits on a condition, never on a fixed sleep. A clock budget
compares the fastest of several short runs, because load only adds time, and
does not apply under -race.
The binary-size job builds tuios as the release does (CGO_ENABLED=0,
-trimpath, -ldflags "-s -w") for linux/amd64 and darwin/arm64. It fails
when either is over the 47 MB ceiling in scripts/binary-size.sh, and it
comments the size change against main on the pull request. just size runs
the same check. State the size cost of a change in the pull request body.
Raise the ceiling only on purpose, in its own commit, with docs/perf.md
("Binary size budget").
cmd/tuios-wasm is tuios compiled to WebAssembly for the guided tour at
tuios.dev/learn: the real app in Learn mode, with a fake shell
(internal/webshell) in every pane and an event stream for lessons
(internal/learn). Browser-only code is behind js build tags or in those
three directories, so the native build is unchanged. The page API and event
contract are in cmd/tuios-wasm/README.md.
The lessons are data in internal/learn/lessons: tracks.json and one
tracks/<id>.json per track, with a rule per step that says when it is done.
The lesson engine there is the only one, and the page draws its lesson.*
events. Edit a track there, never in tuios-docs, then run go generate ./internal/learn/lessons, which packs the files into the archive the binary
embeds. The tuioslint gate checks every action, overlay, event and key a lesson names, so a rename that breaks
a lesson fails the change that renames it.
cmd/tuios-wasm/build.sh out/ # the files the docs site needs
node cmd/tuios-wasm/serve.mjs out/ 8765 # try it at http://127.0.0.1:8765
go test ./internal/webshell/ ./internal/learn/... # native tests of the Go side
node cmd/tuios-wasm/smoke-lessons.mjs out/ frames/ basics # a track by show meA change to internal/app or internal/input can break the js build without
breaking any native one. The learn-web job builds it on every pull request
that reaches the js build.
nix develop # Enter development shell
nix build # Build package
nix run # Run directlydocker build -t tuios .
docker run -it --rm tuiosSome parts of tuios are Go modules of their own, published under
tuios.dev/<name> import paths. tuios.dev serves the go-import meta tag
that points the go command at the folder in this repository. They are
progstatus/ (tuios.dev/progstatus), vt/ (tuios.dev/vt), swatch/
(tuios.dev/swatch), shot/ (tuios.dev/shot), tml/ (tuios.dev/tml),
plot/ (tuios.dev/plot), loom/ (tuios.dev/loom) and sketch/
(tuios.dev/sketch). go.work lists them all.
-
go.workmakes the app,e2e/tuiand each library one workspace, so a change to a library and to the app lands in one pull request. Never add areplacefor a library togo.mod:go install .../cmd/tuios@vXignoresgo.workand must resolve the library by its pin. -
go.modpins each library at a commit or a library tag. The pinned version must hold the same files as the folder. After a change to a library folder, commit it, then move the pin:GOWORK=off go get tuios.dev/progstatus@<commit> scripts/library-pins.sh sync # point go.work at the pin, tidy both modules scripts/library-pins.sh check # what CI and the release check
syncalso tidiese2e/tui/go.mod, which carries the same pins. A pin move also changes the NixvendorHashintuios.nix. The nix-vendor-hash job fails and prints the new hash to put there. -
Rebase a branch that moves a pin right before it lands, then move the pin again to the library commit on the rebased branch. A rebase gives that commit a new hash, so the old pin names a commit main does not hold. When main changed the library in between, the old pin also lacks that change. Either way
library-pins.sh checkfails on main. -
A new library joins
go.workfirst. It gets its pin with the first change that imports it, becausego mod tidydrops a requirement nothing imports. Until thenscripts/library-pins.shskips it. A library may require another one at a commit before tuios imports either, as loom and sketch require plot.scripts/library-pins.sh proxyserves that version too, so the tidy check needs no network for it. -
./...from the root stops at a nested module. Name a library to test it:go test ./progstatus/... ./vt/... ./tml/.... -
A library imports no
github.com/Gaurav-Gosain/tuios/...package. -
A branch made before
internal/vtbecamevt/crosses the move withscripts/vt-rename.shafter a rebase. -
A library tag has the folder as a prefix, such as
progstatus/v0.1.0. Tagging is the maintainer's call.
Some packages embed an archive that go generate packs from the files beside
it: skills/ (skills/tuios.tar.gz), internal/learn/lessons,
internal/harness and internal/integration. Run go generate in the
package after you edit those files. A test in each package fails when the
archive and the files differ. Rebase with scripts/rebase.sh origin/main. It
regenerates skills/tuios.tar.gz at each stop where that archive is the only
conflict.
tuios/
├── cmd/tuios/ # CLI entry point (main.go with cobra commands)
├── cmd/tuios-web/ # Web terminal server binary (separate for security)
├── cmd/tuios-wasm/ # Browser build for the Learn tuios tour (js/wasm)
├── cmd/sketch-wasm/ # tuios.dev/sketch for the browser: the Mermaid playground at tuios.dev/sketch
├── cmd/tuios-fuzz/ # The property fuzzer, drawn while it runs; kept out of the shipped binary
├── internal/
│ ├── app/ # Core window manager, OS model, rendering
│ │ ├── os.go # Central state (OS struct), window lifecycle
│ │ ├── render.go # View generation, layer composition
│ │ ├── update.go # Bubble Tea Update() handler
│ │ ├── stylecache.go # LRU style caching (40-60% allocation reduction)
│ │ ├── workspace.go # Multi-workspace support (1-9)
│ │ └── animations.go # Visual transitions
│ ├── config/ # Configuration and keybindings
│ │ ├── userconfig.go # TOML config loading, defaults
│ │ ├── registry.go # Keybind action lookup
│ │ └── validation.go # Config validation
│ ├── input/ # Input handling and modal routing
│ │ ├── handler.go # Main input coordinator
│ │ ├── keyboard.go # Key event dispatch
│ │ ├── mouse.go # Mouse interactions
│ │ ├── actions.go # Action handlers (the prefix handlers are in prefix_actions.go)
│ │ └── copymode_*.go # Vim-style copy mode (50+ motions)
│ ├── terminal/ # Terminal window management
│ │ ├── window.go # Window struct, PTY lifecycle
│ │ └── window_unix.go / window_windows.go # Platform-specific window and PTY glue
│ ├── ptyspawn/ # The one path every PTY-backed process is spawned through (spawn_unix.go, spawn_windows.go)
│ ├── session/ # The daemon: sessions, PTYs (session.go), wire protocol, JSON verbs
│ ├── childproc/ # Starts the daemon's helper programs (git, ssh, gh, hooks) with no console window
│ ├── serverscope/ # Names the daemon a process belongs to, for named servers
│ ├── sockpath/ # The daemon's socket folder and the longest socket path
│ ├── procinfo/ # What the kernel says about a process: socket peer, start time, parent
│ ├── restartplan/ # What a daemon restart stops and brings back (restart-server --json)
│ ├── guibridge/ # A client with no screen that streams layout and pane bytes to the GPU client (tuios gui-bridge)
│ ├── transcriptview/ # Decodes an agent's transcript into a conversation, for agent-transcript only
│ ├── federation/ # The link layer between this daemon and the daemons on other machines
│ ├── worktree/ # Git worktrees: detect, create, and remove without losing uncommitted work
│ ├── gitstate/ # Branch and upstream drift for the sidebar
│ ├── dirwatch/ # Tells the rail's files section when the listed folder's entries change
│ ├── review/ # Reading a worktree's diff, and the notes left on it
│ ├── diffview/ # Drawing diff lines: chroma highlighting, split layout, changed words
│ ├── markdown/ # Reads the Markdown agents write into blocks and styled runs; draws nothing
│ ├── mermaid/ # Finds Mermaid diagrams, themes them, draws them as kitty graphics or text through sketch
│ ├── tmldraw/ # Draws a TML document into text the way an attached client draws a surface, with loom
│ ├── ghpr/ # Reads and opens GitHub pull requests through the person's own gh
│ ├── capture/ # Turns a screenshot request and config into what shot renders
│ ├── lookshare/ # Builds what "Share my look" hands the person (tuios look share)
│ ├── release/ # Finds published releases and verifies a downloaded binary (tuios update)
│ ├── netutil/ # Small network helpers the servers share
│ ├── memtrim/ # Gives the heap a burst left behind back to the OS once the process settles
│ ├── dnd/ # The kitty drag and drop protocol (OSC 72) on the wire: parse, join chunks, write
│ ├── harness/ # Agent harness manifests and detection
│ ├── herdrplugin/ # herdr plugins: manifest, discovery, the command runner (the host is internal/session/plugin_host.go)
│ ├── herdrcli/ # herdr's command line, answered by tuios
│ ├── integration/ # Wires harness hooks, plugins and MCP entries (tuios integration)
│ ├── mcp/ # The MCP server behind tuios mcp
│ ├── risk/ # Marks an approval risky by the shipped and configured rules
│ ├── pushnotify/ # Sends Inbox items to a phone through ntfy, Pushover or a webhook
│ ├── agentproto/ # Headless agents over ACP and the Codex app-server: the pane program of start-agent --protocol
│ ├── learn/ # Learn tuios: tour model, event contract, page commands, lessons/ (the tracks as data and the engine)
│ ├── webshell/ # In-memory pty and fake shell for the browser build
│ ├── tutor/ # tuios tutor: Learn mode in this terminal, in a sandbox, with the coach card
│ ├── hooks/ # Shell hooks on window/session/agent events
│ ├── tmuxcompat/ # The opt-in tmux shim (tuios tmux-shim) and its pane holder; see docs/TMUX_SHIM.md
│ ├── shimlink/ # The tmux and herdr links that run tuios as another program
│ ├── stopevent/ # The Windows event tuios kill-server sets to stop a daemon
│ ├── scrollback/ # OSC 133 scrollback browser
│ ├── hints/ # Finds the things on screen a person may copy and labels them, for hints mode
│ ├── pastebuf/ # Paste buffers of recent yanks, newest first, after tmux's
│ ├── overlay/ # Panel and dialog primitives for chrome
│ ├── composer/ # The editor a person writes to an agent in: multiline, undo, paste blocks
│ ├── textedit/ # The editing rules every text field shares
│ ├── explore/ # The full-screen browser behind keybinds browse, config browse and help -i
│ ├── sessiontree/ # Sidebar session tree model
│ ├── tape/ # Tape scripting automation
│ │ ├── lexer.go # Tokenizer
│ │ ├── parser.go # AST generation
│ │ ├── executor.go # Command execution
│ │ └── player.go # Playback engine
│ ├── server/ # SSH server (Wish v2)
│ ├── served/ # The model a server (SSH, web) hands to one remote client
│ ├── theme/ # Color theming
│ ├── layout/ # Window tiling algorithms
│ ├── pool/ # Memory pooling
│ ├── refreshrate/ # Finds the displays' refresh rate, for max_fps = "auto"
│ ├── crashexit/ # Puts the terminal back and exits when a goroutine tuios started panics
│ ├── invisible/ # Finds characters that draw nothing but change how text reads
│ ├── tarpack/ # Reads the gzipped archives a package embeds (see Generated files)
│ ├── lint/ # Source checks that run as tests (see Source checks)
│ └── ui/ # Animation system
│ # (plus cliflags, debuglog, fang, listnav, sound,
│ # transcript, guestenv, perf, fuzz, testutil,
│ # atomicfile, cellsize, lazyre, linediff, pathroot,
│ # plural, sortx, units, winacl, winjob, winpath)
├── loom/ # tuios.dev/loom, a Go module of its own: draws TML into cells; flex layout, retained tree, palettes per depth, keys and mouse
├── plot/ # tuios.dev/plot, a Go module of its own: eleven chart kinds (bar, line, area, heatmap, pie, world map and more) as cells with theme roles
├── progstatus/ # tuios.dev/progstatus, a Go module of its own: OSC 7501 parser, record store, encoder
├── shot/ # tuios.dev/shot, a Go module of its own: a cell grid to PNG, SVG, HTML, ANSI or text; mosaic turns pictures into cells
├── sketch/ # tuios.dev/sketch, a Go module of its own: Mermaid diagrams to SVG in pure Go, a port of pele; raster/ and text/ outputs
├── swatch/ # tuios.dev/swatch, a Go module of its own: desktop theme palettes (Omarchy), followed live
├── tml/ # tuios.dev/tml, a Go module of its own: the TML markup for surfaces (codecs, validator, expressions, JSON Patch); draws nothing
├── vt/ # tuios.dev/vt, a Go module of its own: the terminal emulator, pure Go, plus libghostty-vt behind -tags ghostty
│ ├── emulator.go # The pure Go Emulator and its write path
│ ├── parser.go # Parser state machine
│ ├── screen.go # Screen buffer management
│ ├── scrollback.go # History ring (10,000 lines unless configured)
│ ├── snapshot/ # Capture, pack, apply and replay a pane's state (the daemon's wire snapshot)
│ └── vtgen/ # Grammar-based terminal input generator for the fuzzers
├── pkg/ # Embeddable facade (tuios), applist, fuzzy
├── docs/ # Documentation
│ ├── ARCHITECTURE.md # Technical architecture diagrams
│ ├── KEYBINDINGS.md # Complete keybinding reference
│ ├── CONFIGURATION.md # Config options
│ └── CLI_REFERENCE.md # CLI flags and commands
├── examples/ # Tape script examples, and dock components under examples/dock/
├── plugins/ # First-party plugins; the binary embeds only template/, which tuios plugins new copies
├── scripts/ # check.sh, ci-changes.sh, library-pins.sh, binary-size.sh, rebase.sh, install.sh
├── skills/ # The tuios skill (skills/tuios/SKILL.md and its topics), embedded and printed by tuios --skill [topic]
├── integrations/ # Harness integrations, such as the claude-code agent-state shim
├── e2e/ # End-to-end tests; e2e/tui is its own Go module
├── clienttests/ # Playwright tests for the web client
└── nix/ # Nix packaging
TUIOS follows Model-View-Update:
- Model:
app.OSstruct ininternal/app/os.go - View:
OS.View()ininternal/app/render.go - Update:
OS.Update()ininternal/app/update.go
Two primary modes:
- WindowManagementMode: Window manipulation, navigation
- TerminalMode: Input forwarded to focused terminal PTY
Input routing: internal/input/handler.go → mode-specific handlers
Leader key (Ctrl+B by default) activates prefix mode with sub-menus:
Ctrl+Bthenw→ Workspace prefixCtrl+Bthenm→ Minimize prefixCtrl+Bthent→ Window prefixCtrl+BthenL→ Layout prefixCtrl+BthenD→ Debug prefixCtrl+BthenT→ Tape manager prefix
Tiling itself toggles on Ctrl+B Space (or bare t in window-management mode).
A plain tuios is a daemon session: the daemon (internal/session) owns the
PTYs and one emulator per pane, and the client keeps a second emulator per
window. docs/ARCHITECTURE.md draws both sides.
OS.AddWindow()(internal/app/os_window.go) sends aNewWindowintent to the daemon- The daemon spawns the pane's process through
ptyspawnand pushes the new state - The client places the window when the push lands, and subscribes to the pane's stream from a snapshot of the daemon's emulator
- Each chunk of the stream feeds the window's emulator and marks it dirty
OS.DeleteWindow()asks the daemon to close the window, and the client drops its copy when the push lands
tuios --standalone skips the daemon: the window spawns and owns its PTY.
- Bubble Tea v2 (
charm.land/bubbletea/v2): TUI framework - Lipgloss v2 (
charm.land/lipgloss/v2): Styling - Wish v2 (
charm.land/wish/v2): SSH server - Ultraviolet (
github.com/charmbracelet/ultraviolet): Terminal emulation base - Cobra (
github.com/spf13/cobra): CLI commands - xpty (
github.com/charmbracelet/x/xpty): Cross-platform PTY - libghostty-vt (
go.mitchellh.com/libghostty, behind-tags ghostty): Alternative VT emulation backend;scripts/install.sh ghosttybuilds it (seedocs/ghostty-vt.md) - sip (
github.com/Gaurav-Gosain/sip): WebGL terminal serving fortuios-web
Note: Bubble Tea, Lipgloss, Wish, ssh, log and tone import from
charm.land/*. Ultraviolet, colorprofile and thex/*packages stay undergithub.com/charmbracelet/*.internal/fangis a trimmed copy ofgithub.com/charmbracelet/fang.
- Follow standard Go conventions (Effective Go)
- Run
go fmtbefore committing, andjust check --changedbefore a pull request is marked ready - Package comments on all packages (see existing
internal/*/packages) - Meaningful variable names (avoid single letters except loop indices)
- Take every chrome colour from
theme.UI()(dialogs) ortheme.GroundUI()(the rail and the dock), both anoverlay.Palette, or from a named token ininternal/theme. Never write a literal:internal/lintrejectslipgloss.Color("#..."), constantcolor.RGBA{...}, CharmTone colours (tone.*) and constantansi.BasicColor/IndexedColorininternal/app,internal/overlayandinternal/composer. A deliberate exception carries//tuios:allow-color <reason>. - The palette is built per colour depth (
overlay.Depth: truecolor, 256, 16). At 16 colours every ground isoverlay.NoColor, so a state that a ground would show has to go throughpal.Roworpal.Mark. - Lists follow one rule:
overlay.RowState{Cursor, Focused, Hover}, the row's ground frompal.Ground(st, base), and the finished row throughpal.Row(content, width, st, base). An unfocused list keeps its cursor. - Blend with
overlay.MixColors(OKLab); measure withoverlay.ReadableandReadableAt, which measure the colour the depth will actually show.
Some tests read the source and reject what the compiler accepts. These are the ones a change trips most often. Each test's doc comment gives the reason.
| Rule | Test | Exception marker |
|---|---|---|
| No colour literal in render code | internal/lint TestNoHardCodedColoursInRenderCode |
//tuios:allow-color <reason> |
Daemon code starts a process with childproc.Command, never exec.Command or os.StartProcess |
internal/lint TestDaemonChildrenGoThroughChildproc |
//tuios:allow-exec <reason> |
Every git starts through childproc.Git, which turns off repository hooks and filters |
internal/lint TestGitGoesThroughChildprocGit |
//tuios:allow-exec <reason> |
A client quits through OS.QuitCmd, never a bare tea.Quit |
internal/lint TestEveryClientQuitGoesThroughQuitCmd |
//tuios:allow-quit <reason> |
| Lessons name real actions, overlays, events and keys | internal/lint TestLessonsMatchTheApp |
none |
Every tea.NewProgram takes app.ProgramOptions(), with no other tea.With* option than WithOutput, and a transport's MakeOptions before it |
internal/app TestEveryProgramTakesTheSharedOptions |
none |
No emoji in a string or rune literal under internal/ and cmd/ |
internal/app TestNoEmojiInSourceStrings |
emojiSourceAllowed |
A new overlay kind has a place in overlayKindOrder |
internal/app TestEveryOverlayKindHasAPlaceInTheStack |
none |
An ErrVerb* code is in the list-verbs catalogue |
internal/session TestErrorCodeCatalogHasEveryErrVerbConstant |
none |
A package with tests has func TestMain(m *testing.M) { os.Exit(testutil.RunIsolated(m)) } |
internal/testutil TestEveryTestPackageIsolatesTheXDGTree |
none |
Only the internal/lint rows run in the check gate. The others run with
their package's tests.
- Wrap errors with context:
fmt.Errorf("failed to X: %w", err) - Log warnings for non-fatal issues:
log.Printf("Warning: ...") - Return early on errors
- Package-level doc comments required
- Exported types/functions need doc comments
- Use godoc-style comments
Follow the Testing rules under Testing Approach. For the unit tests that stay:
- Test file naming:
*_test.go - Benchmarks with
Benchmark*prefix, and only for a perf budget - Use
t.Run()for subtests
- Never write unit tests after you write code.
- Highly prefer E2E tests as the sole testing mechanism. Use them to verify complex features work. At the end of E2E tests, produce a verifiable and repeatable artifact.
- If you must test a system in isolation, first write down all the ways it could fail, then write the code.
A unit test is kept only when it catches a real bug the E2E suite misses. The kinds that stay are VT conformance, fuzz, wire compatibility, security boundaries, deterministic race regressions and perf budgets. Do not add one outside those kinds.
The E2E suite runs a real tuios binary against real daemons. It is under
e2e/tui, its own Go module, and skips unless TUIOS_E2E is set. Without
TUIOS_E2E_BIN it builds the binary itself.
go build -o /tmp/tuios ./cmd/tuios
cd e2e/tui && TUIOS_E2E=1 TUIOS_E2E_BIN=/tmp/tuios go test -count=1 -timeout 40m .TestDnDDesktopBridge runs the drag and drop bridge against a real kitty
under Xvfb. It skips unless TUIOS_E2E_KITTY names a kitty binary, and
needs Xvfb, xdotool and xclip; TUIOS_E2E_YAZI adds yazi as a source.
The e2e-kitty workflow runs it on request and once a week.
A pull request runs only the e2e tests its change can reach. e2e/tui/testselect
reads the diff and a test map, which records the functions each test ran in a
coverage run. The e2e-tui test map workflow builds the map every night and
keeps it as the e2e-test-map artifact. CI reads the newest one from main.
The committed e2e/tui/testselect/testmap.txt is an old fallback. CI files,
the harness, go.mod beyond library pins, and any path no rule names run the
whole suite.
What the selector does with a change:
- A changed function selects the tests that ran it. A changed function that no test ran selects nothing, and the selector reports no error. When your change needs e2e cover, add to a test that reaches the code. A changed test file selects the tests it changes.
- A test newer than the map runs on every change that reaches the binary, until the next night's map knows it.
- A draft pull request skips the shards. Mark it ready for review to run them.
- Main runs the whole suite. It skips the run only when the same commit already passed the whole suite as a pull request head.
Run the selection with the current map:
id=$(gh run list -R Gaurav-Gosain/tuios --workflow e2e-tui-map.yml --branch main \
--status success --limit 1 --json databaseId --jq '.[0].databaseId')
rm -rf .dev/map && gh run download "$id" -R Gaurav-Gosain/tuios -n e2e-test-map -D .dev/map
cd e2e/tui && TUIOS_E2E=1 go test -list '^Test' . |
go run ./testselect -base origin/main -map ../../.dev/map/testmap.txtA test that claims to cover a bug must fail on a build with the fix removed.
e2e/tui/NEGATIVE_CONTROLS.md records those runs and the rules for them.
# Every module outside e2e/tui (go test ./... covers the app module only)
just test
# One package
go test ./vt/
# Perf budgets
go test -bench=. ./internal/app/...vt/ carries a table-driven conformance corpus: an input byte
sequence, and the screen it should produce, on a screen small enough that a
diff is readable. It runs with the ordinary suite.
# The corpus, the unicode sweeps and the generated-input sweep
go test ./vt/
# Skips the exhaustive write-boundary sweep, which is the slow part
go test -short ./vt/Three things in it are worth knowing about before adding a case:
- A case that leaves
unhandledfalse asserts the emulator recognised every sequence in its input. A sequence the emulator ignores leaves a screen indistinguishable from one it handled by doing nothing, which is how NEL and DECALN stayed missing under green tests. - A case marked
knownBugis expected to fail, and the test complains if it starts passing, so a fixed bug cannot sit on the list pretending to still be one. - The unicode sweeps are driven by pinned UCD data files under
vt/testdata/unicode/, with provenance and licence in the README there.
vt/vtgen generates terminal input by grammar rather than by byte,
so the parser reaches the code past its ground state. A failing run reduces by
delta debugging to a script of named sequences a person can read.
# Deterministic seeds, part of the ordinary suite
go test ./vt/ -run TestVTGen
# Coverage-guided, for a real campaign
go test ./vt/ -run XXX -fuzz FuzzEmulatorScript -fuzztime 10mEvery Fuzz* target's seeds, and every file under a package's
testdata/fuzz/, run in the ordinary suite. The mutator runs only when asked,
and nightly in .github/workflows/fuzz.yml, which keeps each package's corpus
in the Actions cache and uploads the failing input when a target finds
something. go test -list 'Fuzz' ./... lists the targets. Besides the
emulator ones above:
internal/app:FuzzModeldrives the whole window manager through theinternal/fuzzaction alphabet; the clip and line-width targets check the compositor againstansi.StringWidth.internal/session: the frame reader, the gob payloads (decode, bound, round trip), the JSON verb dispatcher, and selectors.internal/harness:ClassifyagainstExplainover the bundled manifests and over fuzzed ones.internal/configandinternal/tape: key normalisation, the config pipeline, and the tape lexer and parser.vt: the kitty payload decoder and command parser, and with-tags ghosttythe differential against libghostty-vt.
Turn a finding into a regression the suite can keep:
- A vtgen target prints the reduced script as JSON. Save it under
vt/testdata/vtgen-repros/with awhy. Do not keep the input bytes instead: they decode through the generator, and the next change to vtgen turns them into a different script that passes. FuzzModeland the otherinternal/fuzzruns print a--- script ---block. Save it underinternal/app/testdata/fuzz-repros/.- Any other target: keep the file
go testwrote undertestdata/fuzz/.
A repro printed with NOT REPRODUCIBLE came from a target whose replays
differ; fix the target before trusting the script.
An independent implementation catches what a hand-written expectation cannot,
because a test can be wrong in the same way the code is. Needs the tmux binary
and spawns processes, so it is behind a build tag.
go test -tags differential ./vt/ -run TestDifferential -vCases where tmux is the one diverging are listed in tmuxDiffers with the
reasoning. The test fails if one of them starts agreeing, so the list cannot
rot.
When testing UI/UX changes:
- Create/close multiple windows
- Switch between workspaces (Alt+1-9)
- Test tiling mode (t key)
- Test copy mode (Ctrl+B, [)
- Verify keybindings work
- Check terminal output rendering
- Test mouse interactions (drag, resize)
# Validate tape syntax
go run ./cmd/tuios tape validate examples/demo.tape
# Run tape with visible TUI
go run ./cmd/tuios tape play examples/demo.tape- Use
tea.KeyPressMsgnottea.KeyMsg(v2 change) - Mouse events are separate types:
tea.MouseClickMsg,tea.MouseMotionMsg, etc. View()returns atea.View, not a string- Program options live in
app.ProgramOptions()(internal/app/program_options.go): the frame rate, no signal handler, and the onetea.WithFilterslot (motion filter and size filter). Spread it into everytea.NewProgram(see Source checks)
- Theme colors only apply to ANSI colors 0-15
- RGB/truecolor passes through unchanged
- Background is transparent (nil) for TUI app compatibility.
appearance.backgroundand the per-surface background options paint default-background cells in the compositor (internal/app/background.go), never in the emulator, so the cells themselves stay nil
- Style cache in
internal/app/stylecache.go: check hit rates withCtrl+B, D, c - Object pools in
internal/pool/pool.goreduce GC pressure - Viewport culling skips off-screen windows
- Rendering is event-driven: idle sessions schedule no timer renders (see
docs/perf.mdfor the measured baselines)
- PTY handling differs:
internal/terminal/window_unix.govswindow_windows.go, andinternal/ptyspawn/spawn_unix.govsspawn_windows.go. The daemon'sPTYininternal/session/session.gohas no per-platform file: it resizes throughptyspawn.SetWinsize, which falls back to a plain resize on ConPTY. The daemon's platform split is ininternal/session/daemon_unix.govsdaemon_windows.goandmanager_unix.govsmanager_windows.go - Allocating a pane's pty differs:
internal/ptyspawn/newpty_native.govsnewpty_windows.go. On Windows a pane opens on theconpty.dllandOpenConsole.exebesidetuios.exewhen both are there, and on the inbox ConPTY otherwise - Opening the host terminal for the capability probe differs:
internal/app/probe_tty_other.goopens/dev/tty, andprobe_tty_windows.goopens the console'sCONIN$andCONOUT$ - Workspace keybinds differ:
opt+Non macOS,alt+Non Linux - See
internal/config/userconfig.go→getDefaultWorkspaceKeybinds()
- Leader key configurable but defaults to
ctrl+b - Check action descriptions in
internal/config/registry.go - Key normalization handles
opt+→alt+conversion on macOS
| Purpose | File |
|---|---|
| Main entry point | cmd/tuios/main.go |
| Central state | internal/app/os.go |
| Window lifecycle | internal/app/os_window.go |
| Bubble Tea options | internal/app/program_options.go |
| Daemon | internal/session/daemon.go |
| Rendering | internal/app/render.go |
| Input handling | internal/input/handler.go |
| Terminal window | internal/terminal/window.go |
| PTY spawn (every path) | internal/ptyspawn/spawn.go |
| VT emulation | vt/emulator.go |
| Configuration | internal/config/userconfig.go |
| Keybind registry | internal/config/registry.go |
| Tape scripting | internal/tape/parser.go |
Use conventional commits:
feat:New featurefix:Bug fixdocs:Documentation changesrefactor:Code refactoringtest:Adding or updating testschore:Maintenance tasks
Examples:
feat: add configurable dockbar position
fix: panic when closing last window on Linux
docs: update keybindings reference
Releases are automated via GitHub Actions with GoReleaser:
- Tag format:
v*.*.*(e.g.,v0.12.1). Tagging is the maintainer's call - Builds for Linux, macOS, Windows, FreeBSD and OpenBSD
- GoReleaser publishes to AUR and Homebrew.
docker-publish.ymlpushes the image, andupdate-nix.ymlmovestuios.nixto the release
- Architecture:
docs/ARCHITECTURE.md. Technical diagrams and component details - Keybindings:
docs/KEYBINDINGS.md. Complete keyboard shortcut reference - Configuration:
docs/CONFIGURATION.md. TOML config options - Contributing:
docs/CONTRIBUTING.md. Contribution guidelines - Tape Scripting:
docs/TAPE_SCRIPTING.md. Automation script syntax - Plugins:
docs/PLUGINS.md. The plugin API, the contract that keeps it stable, and the trust rules - Web Terminal:
docs/WEB.md. Web terminal documentation (tuios-web binary) - VT Backends:
docs/ghostty-vt.md. The pure Go and libghostty-vt emulators, and how to build each - Rehydration:
docs/REHYDRATION.md. The snapshot-vs-stream contract for pane content on attach - Wire protocol:
docs/protocol.md. Binary frames, JSON verbs, and who owns session state - Performance:
docs/perf.md. Measured baselines and the "measured and not changed" ledger - Sip: https://github.com/Gaurav-Gosain/sip. The library serving Bubble Tea apps as web apps (used by
cmd/tuios-web)