Skip to content

Add terminal tabs: run a shell in a tab - #45

Open
jaivial wants to merge 1 commit into
cloudmanic:mainfrom
jaivial:feat/terminal-tab
Open

Add terminal tabs: run a shell in a tab#45
jaivial wants to merge 1 commit into
cloudmanic:mainfrom
jaivial:feat/terminal-tab

Conversation

@jaivial

@jaivial jaivial commented Aug 12, 2026

Copy link
Copy Markdown

What this adds

Open terminal in new tab — runs your $SHELL in a real editor tab, so
go test ./..., git, and friends are one keystroke away instead of a
second tmux pane. Reachable from the menu or Esc `.

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 an
embedded VT emulator (hinshun/vt10x) whose cell grid is blitted into the
editor 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 IsTextual rather 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 termOutputEvent so the
    main loop redraws, matching the existing autoScroll / treeRefresh
    pattern.

  • Teardown hangs up the process group. SIGKILLing the shell directly
    means bash never runs its exit path, so every backgrounded job is
    orphaned — I reproduced this with sleep 987 & surviving a quit. Close now
    sends SIGHUP to the group, waits briefly, then escalates. It also returns
    early 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 is
    exactly where users press 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 no
    action becomes unreachable.

  • Reverse video. glyphStyle deliberately 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's
    status line, git add -p, fzf selections). Relatedly termColor resolves
    the 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 in
internal/editor/terminal_test.go and internal/app/terminal_test.go cover
mode 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 q quitting 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+C interrupts a
sleep 300 without 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/pty returns ErrUnsupported.

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 jaivial left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: #45 — Add terminal tabs: run a shell in a tab

Author: @jaivial · Base: mainfeat/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.go closeAllTerminals — iterates a.tabs without the tab == nil guard that refreshGitLineChanges carries. Currently unreachable (tabs are never nil), but inconsistent with the sibling loop.
  • internal/editor/terminal.go shellCommand-i only, 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.go menuOpenTerminal — the notify closure would nil-deref if a.screen were nil; unreachable in production since the menu requires a live screen, but a defensive guard would be cheap.
  • internal/editor/terminal.go readLoopnotify → blocking PostEvent can 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 terminal lock() share one mutex; Cell() reads fields directly and is correctly called under the held Lock(). Resize/Write self-lock. No deadlock/race.
  • Manual PTY e2e: tab opens, prompt renders, commands echo, Esc-q does not quit from the shell, double-Esc opens 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant