Two separate problems made the terminal run the wrong shell:
Pty.create took a single argv slice and used argv[0] as both the exec path
and the argument vector's first entry. Session passed {shell, "-shell"},
so the shell received "-zsh" as an ordinary argument rather than as its
argv[0]. Bash quietly tolerated it; zsh rejects it and exits immediately,
which closed the tab and took the window down with it. The exec path and
argv are now separate parameters.
Shell resolution preferred $SHELL, which describes whichever shell launched
us rather than the one the user configured. Inside 'nix develop' that is
Nix's own minimal bash, so the terminal never opened the user's zsh. We now
read the login shell from the passwd database and keep $SHELL only as a
fallback for systems without a usable passwd entry.
shot.sh no longer forces a shell, so screenshots reflect real behavior.
128 lines
5.2 KiB
Markdown
128 lines
5.2 KiB
Markdown
# vtabs
|
|
|
|
A proof-of-concept terminal emulator with **vertical tabs**, built on
|
|
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita.
|
|
|
|
The sidebar holds the window controls, a new-tab button, and one row per tab —
|
|
the layout Zen Browser uses for vertical tabs — with the terminal inset to its
|
|
right.
|
|
|
|
## Quick start
|
|
|
|
```sh
|
|
nix develop # Zig 0.16 + GTK4 + libadwaita, no host toolchain needed
|
|
zig build run
|
|
```
|
|
|
|
Everything is pinned by `flake.nix`; nothing needs to be installed on the host.
|
|
|
|
## Which "libghostty" this uses, and why
|
|
|
|
Ghostty ships two different C APIs, and only one of them is usable here:
|
|
|
|
| | `include/ghostty.h` | `include/ghostty/vt.h` |
|
|
|---|---|---|
|
|
| name | "libghostty-internal" | **libghostty-vt** |
|
|
| scope | whole terminal incl. renderer | VT core only |
|
|
| platforms | `GHOSTTY_PLATFORM_MACOS`, `GHOSTTY_PLATFORM_IOS` | any |
|
|
| intended for | Ghostty's own macOS app | external embedders |
|
|
|
|
`ghostty.h` is the one that would hand you a ready-made terminal surface, but
|
|
it has no Linux platform tag at all — its `ghostty_platform_e` enum only knows
|
|
macOS and iOS, and its own header says it is "tailored to the needs of the
|
|
macOS app". On Linux, Ghostty does not embed itself through that API; it builds
|
|
its GTK apprt directly into the binary.
|
|
|
|
So this project uses **libghostty-vt**, the API Ghostty documents for external
|
|
embedders. That means we get, for free and battle-tested:
|
|
|
|
- escape sequence parsing and full terminal state
|
|
- screen, scrollback, line wrapping, and reflow on resize
|
|
- key and mouse event encoding (including the Kitty keyboard protocol)
|
|
- paste safety checking and bracketed paste encoding
|
|
|
|
and we supply everything above it: process management, rendering, and the UI.
|
|
|
|
We consume it as a **Zig module** rather than through the C ABI. Ghostty's
|
|
`build.zig` detects that it is being used as a dependency and, in that mode,
|
|
builds only libghostty-vt — no GTK, no executable — so `zig build` pulls in
|
|
just the VT core.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
main.zig AdwApplication, CSS loading
|
|
Window.zig sidebar + GtkStack of terminals, tab management, shortcuts
|
|
Terminal.zig GtkDrawingArea: Cairo/Pango renderer + input handling
|
|
Session.zig libghostty-vt Terminal + parser, fed by the PTY
|
|
Pty.zig openpt/fork/exec, controlling terminal setup
|
|
key.zig GDK keyval -> libghostty-vt key mapping
|
|
theme.zig colors libghostty-vt has no opinion about
|
|
```
|
|
|
|
Two design choices worth calling out:
|
|
|
|
**No IO thread.** The PTY is read on the GLib main loop through a unix fd
|
|
watch (`g_unix_fd_add`). Terminal state is therefore only ever touched from the
|
|
main thread, so the renderer reads the screen with no locking. Ghostty itself
|
|
uses a dedicated IO thread; that is the right answer for a real terminal, but
|
|
this is dramatically simpler and is not a bottleneck at interactive speeds.
|
|
|
|
**No GPU renderer.** Ghostty rasterizes glyphs into an atlas and draws on the
|
|
GPU. Here, each frame walks the visible rows, groups cells into runs of
|
|
identical style, and hands each run to Pango. That is far more work per frame
|
|
in principle, but a terminal grid is small.
|
|
|
|
## What works
|
|
|
|
- Your login shell (from the passwd database, not `$SHELL`) on a real PTY,
|
|
started as a login shell, with a controlling terminal so job control,
|
|
Ctrl-C and SIGWINCH behave
|
|
- Full SGR rendering: 16/256/true color, bold, italic, underline,
|
|
strikethrough, inverse, and the bright-on-bold convention
|
|
- Block / bar / underline / hollow cursor styles
|
|
- Scrollback via mouse wheel; typing snaps back to the prompt
|
|
- Resize reflows the grid and notifies the child
|
|
- Window title (OSC 0/2) becomes the tab label
|
|
- Tabs: create, close, switch; closing the last one closes the window;
|
|
a child exiting closes its own tab
|
|
|
|
### Shortcuts
|
|
|
|
| | |
|
|
|---|---|
|
|
| `Ctrl+Shift+T` | new tab |
|
|
| `Ctrl+Shift+W` | close tab |
|
|
| `Ctrl+Shift+V` | paste (bracketed-paste aware, refuses unsafe pastes) |
|
|
| `Ctrl+PageUp/PageDown` | previous / next tab |
|
|
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last |
|
|
|
|
## Not implemented
|
|
|
|
This is a proof of concept, and the following are deliberately absent:
|
|
|
|
- **Mouse selection and copy.** libghostty-vt provides the selection and
|
|
mouse-encoding primitives, but nothing here is wired to them yet, so there is
|
|
no way to copy text. Paste works.
|
|
- **Ligatures and complex shaping.** Each run is drawn independently at a
|
|
fixed grid offset, so text that needs shaping across cell boundaries won't
|
|
look right.
|
|
- **Kitty graphics, hyperlinks, tab reordering, split panes, config file.**
|
|
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
|
|
`ghostty`, since we don't install a terminfo entry.
|
|
|
|
## Development
|
|
|
|
`./b.sh` wraps `nix develop --command zig build` and strips the enormous
|
|
command line Zig prints on failure.
|
|
|
|
`./shot.sh out.png "text to type"` runs the app inside a throwaway **headless
|
|
Sway** and screenshots it. This keeps UI checks entirely out of your real
|
|
Wayland session — nothing appears on screen, and it works while the session is
|
|
locked.
|
|
|
|
One caveat when driving it: `wtype` loses the first keystroke of every
|
|
invocation while the compositor adopts its freshly uploaded keymap, so scripted
|
|
input should begin with a throwaway key. That is a quirk of the injection tool,
|
|
not of the terminal.
|