turbo-editors/turbo-corepublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

🛟 Updated. 28d5985 · on v1.0.0 · k33g · 13h ago
README.md · 103 lines · 9.1 KBmarkdown
Blame HistoryOpen raw

terminal

Runs a shell in a window: a pseudo-terminal, a VT/ANSI emulator, and the widget that draws it.

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.

The three layers

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.

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.

View — a ui widget: reads the session on a goroutine, feeds the parser, draws the grid through the theme, and encodes key presses back.

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.

Emulation is deliberately partial

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.

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.

Two details of the emulation are easy to get wrong and are worth naming:

  • 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.
  • 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.

Platforms

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:

File Platform What it does
session_unix.go linux, darwin exec.Cmd with the slave as its controlling terminal; $SHELL, then /bin/sh
pty_linux.go linux TIOCSPTLCK, TIOCGPTN/dev/pts/N
pty_darwin.go darwin TIOCPTYGRANT, TIOCPTYUNLK, TIOCPTYGNAME
pty_windows.go windows Two pipes, CreatePseudoConsole, CreateProcess with the pseudo-console attribute, ResizePseudoConsole; %COMSPEC%, then cmd.exe
windows.go all The environment block, the command line cmd.exe wants, C-runtime quoting for anything else
pty_other.go everything else Returns ErrUnsupported

Callers check errors.Is(err, ErrUnsupported) to tell "this platform cannot" from "this attempt failed".

Three things about the Windows side are worth knowing before touching it:

  • 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.
  • 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.
  • 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.

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.

Concurrency

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.

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.

OnChange and OnExit are called from the reading goroutine. They must only wake the event loop, never touch the desktop.

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.

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.

Public API

Name What it does
Start(Options) (*Session, error) Opens a pseudo-terminal and starts the shell in it
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.
ErrUnsupported Returned by Start on a platform with no pseudo-terminals
(*Session) Read/Write/Resize/Close/Command The pseudo-terminal, and the name of the program in it
NewView(ViewOptions) (*View, error) Starts a session and returns the widget showing it
ViewOptions{Options, Name, OnChange, OnExit} Everything the view needs, given before the goroutines start
(*View) Title() string The title the program asked for, else the shell's name
(*View) Draw/SetBounds/HandleKey/HandleMouse/Close The ui widget contract
(*View) ScrollBy/ScrollToBottom/ScrollOffset Reading back through the history
(*View) Exited() bool Whether the program has gone
NewScreen(w, h) *Screen The cell grid on its own
NewParser(*Screen) *Parser An io.Writer that never fails and always consumes
(*Parser) Title/TakeBell What the program set, and whether it rang
Encode(*tcell.EventKey, bool) []byte A key press as the bytes a terminal program expects
view, err := terminal.NewView(terminal.ViewOptions{
    Options:  terminal.Options{Dir: "."},
    OnChange: wakeTheEventLoop,
})
if err != nil {
    if errors.Is(err, terminal.ErrUnsupported) {
        return // this platform has no pseudo-terminals
    }
    return err
}
window := ui.NewWindow(view.Title(), view)

Tests

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.

Two traps this package's own tests fell into, both worth remembering:

  • 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.
  • 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.
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
# terminal

Runs a shell in a window: a pseudo-terminal, a VT/ANSI emulator, and the widget that draws it.

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.

## The three layers

**`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.

**`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.

**`View`** — a `ui` widget: reads the session on a goroutine, feeds the parser, draws the grid through the theme, and encodes key presses back.

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.

## Emulation is deliberately partial

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.

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`.

Two details of the emulation are easy to get wrong and are worth naming:

- **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.
- **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.

## Platforms

`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:

| File | Platform | What it does |
| --- | --- | --- |
| `session_unix.go` | linux, darwin | `exec.Cmd` with the slave as its controlling terminal; `$SHELL`, then `/bin/sh` |
| `pty_linux.go` | linux | `TIOCSPTLCK`, `TIOCGPTN``/dev/pts/N` |
| `pty_darwin.go` | darwin | `TIOCPTYGRANT`, `TIOCPTYUNLK`, `TIOCPTYGNAME` |
| `pty_windows.go` | windows | Two pipes, `CreatePseudoConsole`, `CreateProcess` with the pseudo-console attribute, `ResizePseudoConsole`; `%COMSPEC%`, then `cmd.exe` |
| `windows.go` | all | The environment block, the command line cmd.exe wants, C-runtime quoting for anything else |
| `pty_other.go` | everything else | Returns `ErrUnsupported` |

Callers check `errors.Is(err, ErrUnsupported)` to tell "this platform cannot" from "this attempt failed".

Three things about the Windows side are worth knowing before touching it:

- **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.
- **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.
- **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.

> 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.

## Concurrency

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.

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.

`OnChange` and `OnExit` are called from the reading goroutine. They must only wake the event loop, never touch the desktop.

**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.

**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.

## Public API

| Name | What it does |
| --- | --- |
| `Start(Options) (*Session, error)` | Opens a pseudo-terminal and starts the shell in it |
| `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. |
| `ErrUnsupported` | Returned by `Start` on a platform with no pseudo-terminals |
| `(*Session) Read/Write/Resize/Close/Command` | The pseudo-terminal, and the name of the program in it |
| `NewView(ViewOptions) (*View, error)` | Starts a session and returns the widget showing it |
| `ViewOptions{Options, Name, OnChange, OnExit}` | Everything the view needs, given **before** the goroutines start |
| `(*View) Title() string` | The title the program asked for, else the shell's name |
| `(*View) Draw/SetBounds/HandleKey/HandleMouse/Close` | The `ui` widget contract |
| `(*View) ScrollBy/ScrollToBottom/ScrollOffset` | Reading back through the history |
| `(*View) Exited() bool` | Whether the program has gone |
| `NewScreen(w, h) *Screen` | The cell grid on its own |
| `NewParser(*Screen) *Parser` | An `io.Writer` that never fails and always consumes |
| `(*Parser) Title/TakeBell` | What the program set, and whether it rang |
| `Encode(*tcell.EventKey, bool) []byte` | A key press as the bytes a terminal program expects |

```go
view, err := terminal.NewView(terminal.ViewOptions{
    Options:  terminal.Options{Dir: "."},
    OnChange: wakeTheEventLoop,
})
if err != nil {
    if errors.Is(err, terminal.ErrUnsupported) {
        return // this platform has no pseudo-terminals
    }
    return err
}
window := ui.NewWindow(view.Title(), view)
```

## Tests

`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.

Two traps this package's own tests fell into, both worth remembering:

- **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.
- **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.