Add terminal tabs: run a shell in a tab - #45
Open
jaivial wants to merge 1 commit into
Open
Conversation
Opens $SHELL in a real tab via "Open terminal in new tab" in the ≡ menu (or Esc-`), so tests and git are one keystroke away instead of a second tmux pane. The shell runs on a PTY (creack/pty) and is rendered by an embedded VT emulator (hinshun/vt10x) into the editor pane. We own the emulation rather than passing escape codes through to the host terminal — that's what lets the shell live in a sub-rectangle without fighting the editor for cursor position and scroll region, and it means no tmux passthrough config is needed. Both deps are pure Go, so the single static no-CGO binary is preserved. Notes on the less obvious decisions: - Adds Tab.IsTextual() and moves the existing !IsImage() guards onto it. Nearly every one of those guards meant "is this a normal text tab"; with only one alternate mode that was accidentally the same thing. Terminal tabs would otherwise have slipped through save, find, the git gutter, disk reconciliation, and the dirty-quit prompt. - The PTY reader goroutine never touches UI state. It parses into the emulator (internally locked) and posts termOutputEvent so the main loop redraws, matching the existing autoScroll/treeRefresh pattern. - Close() hangs up the child's process *group* with SIGHUP before escalating to SIGKILL. SIGKILLing the shell directly means bash never runs its exit path and every backgrounded job is orphaned. It also returns early if the child was already reaped, so a recycled PID can never be signalled. - The Esc-leader table stands down inside a terminal. A shell prompt is where users hit Esc by reflex, and swallowing the next rune to run an editor action is destructive: Esc then q would quit the editor and hang up every running shell. Double-Esc still opens the menu, so nothing becomes unreachable. - glyphStyle deliberately ignores the reverse-video bit: vt10x already swaps FG/BG into the stored cell while leaving the bit set, so honouring it double-swaps and cancels the highlight. Relatedly, termColor resolves the default-colour sentinels by meaning rather than by slot, or a reversed default cell collapses back to the normal pair. Terminal tabs are unix-only; the menu row is disabled on Windows, where creack/pty returns ErrUnsupported. Verified on linux/darwin/windows for amd64/arm64.
jaivial
commented
Aug 12, 2026
jaivial
left a comment
Author
There was a problem hiding this comment.
PR Review: #45 — Add terminal tabs: run a shell in a tab
Author: @jaivial · Base: main ← feat/terminal-tab
Files: 15 changed · +1807 / −28 · 1 commit
Short Summary
Adds a real shell in an editor tab: $SHELL on a PTY (creack/pty), rendered by an embedded VT emulator (hinshun/vt10x) blitted into the pane. Owns emulation rather than passing escapes through, so it works over SSH/tmux with no passthrough. Reachable via ≡ menu or `Esc-``.
Critical Issues
None.
Important Blockers
None.
Medium Blockers
None.
Non-Blocker Nits
internal/app/app.gocloseAllTerminals— iteratesa.tabswithout thetab == nilguard thatrefreshGitLineChangescarries. Currently unreachable (tabs are never nil), but inconsistent with the sibling loop.internal/editor/terminal.goshellCommand—-ionly, no-l. Comment says "login-ish"; interactive rc files load, but login-shell scripts (.profile,.zprofile) won't. Fine in practice, wording slightly overstates.internal/app/app.gomenuOpenTerminal— thenotifyclosure would nil-deref ifa.screenwere nil; unreachable in production since the menu requires a live screen, but a defensive guard would be cheap.internal/editor/terminal.goreadLoop—notify→ blockingPostEventcan park the goroutine at shutdown once the main loop stops draining; harmless because the process is exiting, but worth a comment.
Verification performed
go test -race ./internal/editor/ ./internal/app/— pass.- Full
go test ./...— pass. - Concurrency audit against vt10x source:
State.Lock()and terminallock()share one mutex;Cell()reads fields directly and is correctly called under the heldLock().Resize/Writeself-lock. No deadlock/race. - Manual PTY e2e: tab opens, prompt renders, commands echo,
Esc-qdoes not quit from the shell, double-Escopens the menu, quit reaps shell + background job.
Verdict
Approve
No blocking issues. Concurrency discipline is correct, teardown (SIGHUP→grace→SIGKILL to the process group) is well-reasoned and regression-tested, and the IsTextual() refactor closes a real class of "terminal tab slips through file guards" bugs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
Open terminal in new tab — runs your
$SHELLin a real editor tab, sogo test ./...,git, and friends are one keystroke away instead of asecond tmux pane. Reachable from the
≡menu orEsc `.The terminal starts in the folder you're working in (the selected file's
directory, falling back to the project root), so relative commands land
where you expect.
How it works
The shell runs on a PTY (
creack/pty) and its output is parsed by anembedded VT emulator (
hinshun/vt10x) whose cell grid is blitted into theeditor pane.
The key decision is that we own the emulation rather than passing the
child's escape codes through to the host terminal. Passthrough would fight
the editor for cursor position and scroll region; owning a real emulator is
what lets the shell live in a sub-rectangle of the layout, and it means this
works over SSH and inside tmux with no passthrough configuration.
Both dependencies are pure Go, so the single static no-CGO binary is
preserved. Verified building for linux/darwin/windows × amd64/arm64.
Notes on the less obvious bits
Tab.IsTextual()— the existing!IsImage()guards almost all meant"is this a normal text tab"; with only one alternate mode that was
accidentally the same thing. I moved them onto a positive predicate, since
terminal tabs would otherwise have slipped through save, find, the git
gutter, disk reconciliation, and the dirty-quit prompt. New modes should
extend
IsTextualrather than adding another negation at each call site.Goroutine discipline — the PTY reader never touches UI state. It parses
into the emulator (internally locked) and posts a
termOutputEventso themain loop redraws, matching the existing
autoScroll/treeRefreshpattern.
Teardown hangs up the process group.
SIGKILLing the shell directlymeans bash never runs its exit path, so every backgrounded job is
orphaned — I reproduced this with
sleep 987 &surviving a quit. Close nowsends
SIGHUPto the group, waits briefly, then escalates. It also returnsearly if the child was already reaped, so a recycled PID can't be signalled.
The
Esc-leader table stands down inside a terminal. A shell prompt isexactly where users press
Escby reflex, and swallowing the next rune torun an editor action is destructive:
Escthenqwould quit the editorand hang up every running shell. Double-
Escstill opens the menu, so noaction becomes unreachable.
Reverse video.
glyphStyledeliberately ignores the reverse bit:vt10x already swaps FG/BG into the stored cell while leaving the bit set,
so honouring it double-swaps and cancels the highlight (breaking
less'sstatus line,
git add -p, fzf selections). RelatedlytermColorresolvesthe default-colour sentinels by meaning rather than by slot, or a
reversed default cell collapses back to the normal pair.
No
Ctrl+editor shortcuts were added. Ctrl keys are forwarded to the shell,where they mean "signal the foreground process" — that's the one place they
don't fight tmux.
Testing
make test(i.e.go test -race ./...) passes; new same-package tests ininternal/editor/terminal_test.goandinternal/app/terminal_test.gocovermode predicates, mutator no-ops, key encoding, resize clamping, render
bounds, teardown, and the menu/leader wiring.
Three of those tests are regression pins for bugs found while building this,
each verified to fail against the buggy version: the orphaned-background-job
leak, the double-applied reverse video, and
Esc qquitting from a shell.Also driven end-to-end against the real binary in a PTY harness: the tab
opens, the bash prompt renders, commands echo,
Ctrl+Cinterrupts asleep 300without killing the shell, truecolor and reverse output render,editor chrome stays intact, and no shell or background job is left behind
after quitting.
Terminal tabs are unix-only; the menu row is disabled on Windows, where
creack/ptyreturnsErrUnsupported.