| 🛟 Updated. 28d5985 k33g 16h ago | 1 | # terminal |
| 2 | |
| 3 | Runs a shell in a window: a pseudo-terminal, a VT/ANSI emulator, and the widget that draws it. |
| 4 | |
| 5 | Depends on `ui` for its place on screen and `theme` for its colours, and on nothing above it. `app` opens and owns the windows; this package knows nothing about menus, files or language servers. |
| 6 | |
| 7 | ## The three layers |
| 8 | |
| 9 | **`Session`** — the pseudo-terminal and the process on the other end of it. On Unix it opens `/dev/ptmx`, puts the child in a session of its own with the slave as its controlling terminal, and resizes it with `TIOCSWINSZ`; on Windows it creates a pseudo-console (ConPTY) wired to two pipes and starts the child attached to it. A real controlling terminal is what buys job control, `isatty`, `SIGWINCH` and colours; a captured pipe buys none of them. Both sit behind one small `child` interface, so the two layers above know nothing about which one is running. |
| 10 | |
| 11 | **`Parser` + `Screen`** — bytes in, a grid out. The parser is a state machine over CSI, OSC and the one-byte escapes; the screen is the cell grid, the cursor, the scroll region, the scrollback and the alternate-screen aside. |
| 12 | |
| 13 | **`View`** — a `ui` widget: reads the session on a goroutine, feeds the parser, draws the grid through the theme, and encodes key presses back. |
| 14 | |
| 15 | The split is what makes the emulator testable without a shell. `Parser` and `Screen` take bytes and return a grid — no process, no timing, no screen — and that is where most of this package's tests are. |
| 16 | |
| 17 | ## Emulation is deliberately partial |
| 18 | |
| 19 | What a shell, `go test`, `git`, `less`, `htop` and `vim` need is a bounded list: cursor movement, erase and insert-delete, a scroll region, SGR in all three colour depths, the alternate screen, auto-wrap, cursor visibility, and application cursor keys. That is what is here. |
| 20 | |
| 21 | Mouse reporting, bracketed paste, character sets, sixel and the DEC status reports are not. A program that asks for one gets silence rather than corruption — the failure mode worth having. The exact boundary is documented, in both languages, under `docs/*/reference/terminal.md`. |
| 22 | |
| 23 | Two details of the emulation are easy to get wrong and are worth naming: |
| 24 | |
| 25 | - **A character written in the last column does not scroll by itself.** It sets `wrapPending`, and only the *next* character wraps the line. Getting this wrong makes every full-width line scroll one line too early. |
| 26 | - **Scrollback is only fed from a full-height region on the primary screen.** A program using a scroll region is managing its own window; a full-screen program on the alternate screen should leave no history behind. |
| 27 | |
| 28 | ## Platforms |
| 29 | |
| 30 | `startChild` and `defaultShell` are the whole platform surface. The Unix side is `session_unix.go` over the two `openPTY` files; Windows is `pty_windows.go`; the pure parts Windows needs are in `windows.go`, with no build tag, so they are tested everywhere: |
| 31 | |
| 32 | | File | Platform | What it does | |
| 33 | | --- | --- | --- | |
| 34 | | `session_unix.go` | linux, darwin | `exec.Cmd` with the slave as its controlling terminal; `$SHELL`, then `/bin/sh` | |
| 35 | | `pty_linux.go` | linux | `TIOCSPTLCK`, `TIOCGPTN` → `/dev/pts/N` | |
| 36 | | `pty_darwin.go` | darwin | `TIOCPTYGRANT`, `TIOCPTYUNLK`, `TIOCPTYGNAME` | |
| 37 | | `pty_windows.go` | windows | Two pipes, `CreatePseudoConsole`, `CreateProcess` with the pseudo-console attribute, `ResizePseudoConsole`; `%COMSPEC%`, then `cmd.exe` | |
| 38 | | `windows.go` | all | The environment block, the command line cmd.exe wants, C-runtime quoting for anything else | |
| 39 | | `pty_other.go` | everything else | Returns `ErrUnsupported` | |
| 40 | |
| 41 | Callers check `errors.Is(err, ErrUnsupported)` to tell "this platform cannot" from "this attempt failed". |
| 42 | |
| 43 | Three things about the Windows side are worth knowing before touching it: |
| 44 | |
| 45 | - **The process is created by hand.** Attaching a process to a pseudo-console takes an extended `STARTUPINFO` with the `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` attribute, which `exec.Cmd` cannot carry. `UpdateProcThreadAttribute` is called through a `LazyProc` rather than x/sys's wrapper because the attribute's value is the handle *itself*, and the wrapper takes an `unsafe.Pointer` — the conversion `go vet` exists to flag. |
| 46 | - **conhost holds the output pipe open until the console is closed**, whatever the shell does. A goroutine waits for the process and then closes the pseudo-console; that is what turns a shell exiting into the `io.EOF` the view relies on. Without it a command run in a window would never be seen to finish. |
| 47 | - **cmd.exe does not read its command line the way the C runtime does.** `windowsCommandLine` writes `"cmd.exe" /S /C "<command>"` with the command verbatim, so a quote inside it reaches cmd.exe as a quote; every other program gets the escaping `syscall.EscapeArg` would produce, reimplemented so it can be tested on Linux. |
| 48 | |
| 49 | > The darwin path compiles and passes `go vet` but has never been run — this project is developed on Linux. **The Windows path is the same**: cross-compiled, vetted and unit-tested in its pure parts, never started against a real conhost. The first run on a Windows machine is the test that matters, and `docs/*/how-to/use-a-terminal.md` in each editor says what to try. |
| 50 | |
| 51 | ## Concurrency |
| 52 | |
| 53 | The shell writes from a goroutine of its own while the editor draws from the main one, so `View.mu` guards the parser and its screen. Every exported method of `View` takes it. |
| 54 | |
| 55 | Redraws are **on a clock, not per chunk**. `consume` sets a flag; a 16 ms ticker turns the flag into one `OnChange` call. Waking the event loop per chunk would waste most of the redraws, and worse, tcell's `PostEvent` drops events when its queue is full — so the burst that most needs a redraw is the one whose wake-up gets discarded. A dropped tick cannot strand anything; the next one is 16 ms away. |
| 56 | |
| 57 | `OnChange` and `OnExit` are called from the reading goroutine. They must only wake the event loop, never touch the desktop. |
| 58 | |
| 59 | **They are `ViewOptions` fields rather than assignable ones, and that is a fix rather than a style.** `NewView` starts the goroutine that reads them, so a caller assigning them afterwards is a data race. It hid for a whole feature because a shell takes longer to produce its first output than an assignment takes to run, and only surfaced once a command finished immediately — `sh -c "echo x"`. Taking them as options makes the race impossible rather than unlikely. |
| 60 | |
| 61 | **A finished view stops taking keys**, except the scrolling ones. It used to consume every key and write it to the shell; once the shell had gone the write failed silently and the key was consumed anyway, so `Ctrl-W` could never close a window whose command had ended and the mouse was the only way out. |
| 62 | |
| 63 | ## Public API |
| 64 | |
| 65 | | Name | What it does | |
| 66 | | --- | --- | |
| 67 | | `Start(Options) (*Session, error)` | Opens a pseudo-terminal and starts the shell in it | |
| 68 | | `Options{Shell, Args, Dir, Width, Height, Env}` | Empty `Shell` means `$SHELL`, then `/bin/sh` (Windows: `%COMSPEC%`, then `cmd.exe`); `TERM` is always replaced. `Args` runs one command — `["-c", "go test ./..."]`, or `["/S", "/C", …]` for cmd.exe — instead of an interactive shell. | |
| 69 | | `ErrUnsupported` | Returned by `Start` on a platform with no pseudo-terminals | |
| 70 | | `(*Session) Read/Write/Resize/Close/Command` | The pseudo-terminal, and the name of the program in it | |
| 71 | | `NewView(ViewOptions) (*View, error)` | Starts a session and returns the widget showing it | |
| 72 | | `ViewOptions{Options, Name, OnChange, OnExit}` | Everything the view needs, given **before** the goroutines start | |
| 73 | | `(*View) Title() string` | The title the program asked for, else the shell's name | |
| 74 | | `(*View) Draw/SetBounds/HandleKey/HandleMouse/Close` | The `ui` widget contract | |
| 75 | | `(*View) ScrollBy/ScrollToBottom/ScrollOffset` | Reading back through the history | |
| 76 | | `(*View) Exited() bool` | Whether the program has gone | |
| 77 | | `NewScreen(w, h) *Screen` | The cell grid on its own | |
| 78 | | `NewParser(*Screen) *Parser` | An `io.Writer` that never fails and always consumes | |
| 79 | | `(*Parser) Title/TakeBell` | What the program set, and whether it rang | |
| 80 | | `Encode(*tcell.EventKey, bool) []byte` | A key press as the bytes a terminal program expects | |
| 81 | |
| 82 | ```go |
| 83 | view, err := terminal.NewView(terminal.ViewOptions{ |
| 84 | Options: terminal.Options{Dir: "."}, |
| 85 | OnChange: wakeTheEventLoop, |
| 86 | }) |
| 87 | if err != nil { |
| 88 | if errors.Is(err, terminal.ErrUnsupported) { |
| 89 | return // this platform has no pseudo-terminals |
| 90 | } |
| 91 | return err |
| 92 | } |
| 93 | window := ui.NewWindow(view.Title(), view) |
| 94 | ``` |
| 95 | |
| 96 | ## Tests |
| 97 | |
| 98 | `go test ./terminal/` — the emulator tests need nothing; the session and view tests drive `/bin/sh` and `stty`, so they skip themselves off Linux and macOS, or without `/dev/ptmx`. The Windows pure parts — `windows_test.go` — run everywhere; `GOOS=windows go vet ./terminal && GOOS=windows go test -c ./terminal` is how the rest is checked here. |
| 99 | |
| 100 | Two traps this package's own tests fell into, both worth remembering: |
| 101 | |
| 102 | - **A pseudo-terminal echoes the command line.** Waiting for `red` after typing `printf '\033[31mred\033[0m'` passes before the command has run. Wait for something only the output can produce — a colour, or a string the typed line spells differently. |
| 103 | - **Drawing tests must not race the shell's startup output.** `newOfflineView` builds a `View` with no session behind it, which is enough for `Draw` and is deterministic. |