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
- macOS host running Ghostty (
echo \$TERM → xterm-ghostty).
ahjo init, ahjo repo add …, ahjo create … to get an outer container.
- From inside the outer,
ahjo create … to get an inner.
ahjo shell <inner> from inside the outer.
- 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
Root cause
Two facts combine:
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.
- 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:
- Reads `AHJO_TERMINFO_SRC` from its own process env.
- Pipes the decoded source through `tic -x -o ~/.terminfo -` in the next-hop container before attaching the user's interactive shell.
- 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.
Summary
When the user's terminal emulator on macOS uses a
TERMvalue that isn't shipped in Ubuntu's minimalncurses-base(Ghostty'sxterm-ghostty, Kitty'sxterm-kitty, WezTerm'swezterm, Alacritty'salacritty, foot, st-256color, …), TUIs inside ahjo containers render with degraded styling and emitWARNING: 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
echo \$TERM→xterm-ghostty).ahjo init,ahjo repo add …,ahjo create …to get an outer container.ahjo create …to get an inner.ahjo shell <inner>from inside the outer.Observed:
```
ubuntu ➜ /repo $ tput colors
tput: unknown terminal "xterm-ghostty"
ubuntu ➜ /repo $ git log
WARNING: terminal is not fully functional
```
Root cause
Two facts combine:
incus exec --force-interactiveauto-forwards the caller'sTERMinto the inner container, the same waydocker exec -itdoes. SoTERM=xterm-ghosttypropagates Mac → Lima → outer → inner without any ahjo wiring.xterm-ghostty. Ubuntu'sncurses-baseonly 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
xterm-ghostty)xterm-kitty)wezterm)alacritty)xterm-256color,screen-256color, plainxtermAll ahjo entry paths affected: `ahjo shell` (incus exec), `ahjo claude`, `ahjo ssh` (real SSH — note that the in-container sshd doesn't have
AcceptEnv TERMeither, 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_SRCenv-passthrough (recommended)Mac shim computes
infocmp -x \$TERMonce 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: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
\$TERMtoxterm-256colorAt 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:
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.