Skip to content

TUIs degraded inside ahjo containers: host's terminfo entry never propagates through Mac → Lima → outer → inner #9

@lasselaakkonen

Description

@lasselaakkonen

Summary

When the user's terminal emulator on macOS uses a TERM value that isn't shipped in Ubuntu's minimal ncurses-base (Ghostty's xterm-ghostty, Kitty's xterm-kitty, WezTerm's wezterm, Alacritty's alacritty, foot, st-256color, …), TUIs inside ahjo containers render with degraded styling and emit WARNING: terminal is not fully functional / tput: unknown terminal "<term>". The symptom is most consistent in doubly-nested shells (Mac → outer ahjo container → inner ahjo container) because there's one extra hop where nothing installs the missing terminfo entry.

Reproduction

  1. macOS host running Ghostty (echo \$TERMxterm-ghostty).
  2. ahjo init, ahjo repo add …, ahjo create … to get an outer container.
  3. From inside the outer, ahjo create … to get an inner.
  4. ahjo shell <inner> from inside the outer.
  5. In the inner: `git log`, `less /etc/hosts`, or `tput colors`.

Observed:
```
ubuntu ➜ /repo $ tput colors
tput: unknown terminal "xterm-ghostty"
ubuntu ➜ /repo $ git log
WARNING: terminal is not fully functional

  • (press RETURN)
    ```

Root cause

Two facts combine:

  1. incus exec --force-interactive auto-forwards the caller's TERM into the inner container, the same way docker exec -it does. So TERM=xterm-ghostty propagates Mac → Lima → outer → inner without any ahjo wiring.
  2. No layer's terminfo database contains xterm-ghostty. Ubuntu's ncurses-base only ships entries for vt100/vt220/xterm/xterm-256color/screen/screen-256color/linux/dumb. Ghostty's entry lives only on the user's Mac (where Ghostty's installer registered it). The nested chain has the name of the terminal but not the capabilities matching it.

A common misconception (that I held during the spec attempt at branch `ahjo-in-ahjo`): Ghostty's ssh-integration auto-installs its terminfo into the SSH target. That is true, but the integration is a shell function wrapping ssh. `limactl shell` invokes the `ssh` binary directly, bypassing the function. So the Ghostty entry never reaches even the Lima VM, let alone the outer container — singly-nested only "looks fine" because most commands don't probe terminfo (only TUI tools do). The first place users routinely notice it is the doubly-nested shell, where the friction is enough to hit `less` / `git log` quickly.

Affected use cases

Terminal emulator Singly-nested Doubly-nested
Ghostty (xterm-ghostty) Silently broken (no entry, but rarely probed) Broken (visible)
Kitty (xterm-kitty) Silently broken Broken
WezTerm (wezterm) Silently broken Broken
Alacritty (alacritty) Silently broken Broken
foot, st, modern emulators Silently broken Broken
xterm-256color, screen-256color, plain xterm OK OK

All ahjo entry paths affected: `ahjo shell` (incus exec), `ahjo claude`, `ahjo ssh` (real SSH — note that the in-container sshd doesn't have AcceptEnv TERM either, but SSH+pty auto-forwards anyway).

Severity

Medium. Functionality is unimpaired; ahjo itself, builds, tests, network all work. But every TUI tool (less, git pager, nvim, tmux, ranger, fzf, lazygit, htop) misbehaves or warns — high-friction for normal dev workflows. Modern terminal emulator users are the dominant audience.

Potential solutions

A. AHJO_TERMINFO_SRC env-passthrough (recommended)

Mac shim computes infocmp -x \$TERM once per invocation, base64-encodes the source (~2 KB raw, ~3 KB encoded), and adds it to the env pairs that `cmd/ahjo/main_darwin.go` already passes through `limactl shell env … ahjo …`. Each in-container ahjo:

  1. Reads `AHJO_TERMINFO_SRC` from its own process env.
  2. Pipes the decoded source through `tic -x -o ~/.terminfo -` in the next-hop container before attaching the user's interactive shell.
  3. Re-forwards the same env var via `incus exec --env` so the next ahjo layer can do the same.

Pros: single source of truth (the user's actual installed Ghostty terminfo), no staleness, works for any $TERM the user has, no curated list to maintain. Fallback path on Linux bare-metal or in-container `./ahjo shell` invocations: try local `infocmp -x $TERM` before giving up.

Cons: adds ~3 KB env var to every ahjo invocation (orders of magnitude below ARG_MAX); doesn't help users who bypass the Mac shim (e.g. testing dev binaries from inside a container).

B. Bake into ahjo-base

Add `ncurses-term` (Ubuntu's extended terminfo DB) to the apt install line in `internal/ahjoruntime/feature/install.sh`. Covers Kitty/Alacritty/WezTerm/foot/etc. immediately; ~3 MB image delta.

Pros: no runtime push, no env-relay plumbing, no per-invocation cost.
Cons: Ghostty's entry is not in ncurses-term yet (Ghostty is new). Would still need an additional fetch-and-tic step in the Feature build for Ghostty specifically, which goes stale when Ghostty updates its terminfo upstream. Image rebuild required to refresh.

C. Embed terminfo sources in the ahjo binary

`//go:embed` a curated set of *.src files for the popular terminals. Each ahjo can install them into any layer regardless of upstream propagation.

Pros: robust against any chain configuration; no env relay needed; works even when bypassing the Mac shim.
Cons: curation maintenance; entries go stale as terminals evolve; couples ahjo's release cycle to terminal authors'.

D. Remap unknown \$TERM to xterm-256color

At the deepest `ExecAttachWait` site, probe the target's terminfo DB; if $TERM is absent, rewrite the `--env TERM=…` to `xterm-256color` for that attach.

Pros: zero deps, always works.
Cons: lossy — drops truecolor terminfo advertisement (modern apps mostly compensate via `COLORTERM=truecolor` sniffing), undercurl, kitty keyboard protocol hints. Opaque to users who explicitly want their $TERM honored.

Recommended path

A + D as belt-and-suspenders. Plumb `AHJO_TERMINFO_SRC` for the principled fix. Add the `xterm-256color` remap only as a last-resort fallback when source is absent both in env and locally (e.g. user pipes through a CI machine without the right terminfo installed). Keep an explicit one-line stderr warning when the remap kicks in so it's never silent.

Prior investigation (branch ahjo-in-ahjo, reverted)

Wired `SendEnv TERM` + `AcceptEnv TERM` and an `EnsureViaIncus(infocmp-on-caller, tic-in-target)` install across `internal/cli/shell.go`, `internal/ssh/config.go`, `internal/ahjoruntime/feature/install.sh`, `cmd/ahjo/main_darwin.go` and a new `internal/terminfo/` package. Tested empirically: nothing observable changed. Reasons:

  • The TERM env-forwarding was redundant — `incus exec --force-interactive` (and `ssh` over pty) already do it.
  • The install silently no-op'd because `infocmp -x xterm-ghostty` on the caller side (the outer container) failed — the outer doesn't have Ghostty's entry either, so there was no source to push.

Reverted in this branch; this issue captures what was actually learned. The right fix is option A above, which doesn't rely on any intermediate layer happening to have the source locally.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions