Terminal windows — explanation
What is this about?
F8 opens a window with a shell in it. That sentence hides most of the work: to put a shell in a window, an editor has to become a terminal emulator, and this page is about what that involved and which of the cheaper alternatives were turned down on the way.
Why a real pseudo-terminal
The obvious cheap version is to run a command with exec.Command, capture its output, and show it in a read-only pane. Many editors ship exactly that, and it fails on the things people actually want a terminal for.
A program behaves differently when its output is a pipe rather than a terminal. Coloured output goes plain. git log does not page. ls prints one name per line. Nothing interactive works at all: no vim, no ssh, no git rebase -i, no answering a prompt, and no Ctrl-C, because with no controlling terminal there is no signal to send. For Node the case is sharper still: node with no file is a read-eval-print loop, npm start is very often a server that runs until Ctrl-C stops it, and a script that reads process.stdin waits for the keyboard — none of them is usable on a captured pipe, which is why the starter tools file sends Run and Start to a terminal window rather than a popup.
So the shell gets a real pseudo-terminal: /dev/ptmx on both supported platforms, the child in a session of its own with the slave as its controlling terminal, and TIOCSWINSZ whenever the window is resized. That buys job control, isatty, SIGWINCH and colour, all for free, because they are the same mechanisms every other terminal uses.
The cost is that the editor must then read back what a terminal is expected to understand — which is the emulator.
Why write the emulator rather than borrow one
Go has terminal emulator libraries. Taking one would have meant a third dependency, against a project that has exactly two and a stated reluctance to add a third.
The thing being weighed is not "emulator" against "no emulator" but against how much emulator. What a shell, node, git, less, htop and vim need is a well-bounded list: cursor movement, the erase and insert-delete family, a scroll region, SGR in all three colour depths, the alternate screen, auto-wrap, cursor visibility and application cursor keys. That is about six hundred lines, it is written down in ECMA-48, and it is testable by writing bytes in and reading a grid out — no shell, no timing, no screen.
Compare that with what a general-purpose library brings: character sets, mouse reporting protocols, sixel, bracketed paste, DEC status reports. All real, none of it needed here, and all of it surface to keep working.
So the emulator is hand-written and deliberately partial, and the reference says exactly where it stops. A program that asks for something absent gets silence rather than corruption, which is the failure mode worth having: htop renders, sixel output simply does not appear.
Who gets the key press
This is the decision with the most consequence for how the editor feels, and the first version got it wrong.
The editor's global shortcuts are checked before the window in front sees anything. That is right for an editor and wrong the moment the window in front is a shell, because the two disagree about the same keys. Ctrl-W closes a window in Turbo C and deletes a word in every shell. Ctrl-F is Find here and forward-a-character in readline. Ctrl-C is copy, and also the only way to stop a runaway command.
The rule chosen inverts the usual order, but only for the keys that are genuinely contested:
A focused terminal gets everything except the function keys, Alt-X, and Alt-0…Alt-9.
Those exceptions are not a compromise between the two claims — they are the way out. A full-screen program like vim covers the window and takes the mouse; without a reserved key there would be no way to reach the menu bar, switch windows or leave the editor short of quitting the program inside. Function keys are the natural reservation because a terminal user reaches for them least, and Alt-X because leaving an editor should never be in doubt.
What this costs is real and worth naming: Alt-B and Alt-F reach the shell, so readline's word movement works, but a program inside a terminal window can never see F1…F12. htop's function-key menu is unreachable. That is the trade, and it was made in favour of always being able to get out.
Why closing a terminal asks nothing
Closing a modified file asks whether to save it. Closing a terminal does not ask anything at all, and that asymmetry is deliberate.
A window with unsaved work holds something that would be lost. A terminal holds a running process, and closing the window is the ordinary way to say you are done with it — the same as closing a terminal emulator's tab. Asking "are you sure?" every time would train the answer out of anyone, which is the general problem with confirmations that fire on the common case.
Leaving the editor closes every terminal for the same reason in reverse: a window is the only handle on those shells, so letting them outlive the editor would strand the processes with nothing able to reach them.
Why the redraws are on a clock
The shell writes on a goroutine of its own; the editor draws on the main one. Waking the event loop per chunk of output looked obvious and was wrong twice over.
A build writes far faster than a screen can usefully be repainted, so most of those redraws are wasted. Worse, the mechanism for waking the loop from another goroutine is tcell's PostEvent, which drops events when its queue is full — so the burst that most needs a redraw is the one whose final wake-up gets discarded, and the window freezes mid-build showing stale text. That exact bug had already been found once elsewhere in this editor, over the language server.
So the view sets a flag and a ticker asks for a redraw sixty times a second while the flag is set. A dropped wake-up cannot strand anything, because the next tick is sixteen milliseconds away.
Windows: a pseudo-console, and why it is a file of its own
Pseudo-terminals are the one part of this that is not portable. Linux and macOS both go through /dev/ptmx and differ only in which ioctl grants the slave. Windows has no such device: it has pseudo-consoles — ConPTY, since Windows 10 version 1809 — an object owned by conhost.exe and wired to two pipes of the editor's. What the shell prints arrives on one pipe as the same VT sequences a Unix shell writes to a pty, which is why the emulator on this side needed no Windows code at all; what the editor writes to the other pipe reaches the shell as keystrokes.
Three things made it a file of its own rather than a variant of the Unix one. The process has to be created by hand, because attaching it to a pseudo-console takes an extended startup record that Go's os/exec cannot carry. The shell is %COMSPEC% — cmd.exe — rather than $SHELL, and cmd.exe reads its command line by rules of its own, so the line that runs a menu command is composed for it verbatim, the command inside one pair of quotes, rather than escaped the way every other program expects. And conhost.exe holds the output pipe open until the console is closed, whatever the shell does, so a goroutine waits for the shell to exit and then closes the console — that is what turns a command finishing into the end of input the window relies on to say so. Job control is cmd.exe's rather than the kernel's: Ctrl-C interrupts the running program as it would in a console window.
The platform files stay split so that each platform has one honest implementation behind one small interface, and a platform with neither — the BSDs, today — gets ErrUnsupported, F8 says so plainly, and nothing else in the editor is affected.
The Windows path has been built and vetted, not run. turbo-core is developed on Linux and its author works on macOS. The pure parts — the environment block, the command line cmd.exe wants — are unit-tested on every platform, and the API calls compile and pass go vet under GOOS=windows; nobody has yet pressed F8 on a Windows machine. The how-to says what to try first.
How it relates to the rest
- The exact list of what is implemented: Terminal windows reference
- Using one: How to run shell commands without leaving the editor
- Where
terminalsits among the packages, and why the graph runs one way: Architecture - The dependency count this page keeps invoking: Design decisions
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 |
|