Skip to content

Commit 2c92f68

Browse files
committed
Ship xterm-ghostty terminfo, advertise full capability set
Default TERM=xterm-256color hides ghostel's actual capabilities from TUI apps that consult terminfo. Most visibly, Claude Code only emits DEC 2026 (synchronized output) when terminfo advertises `Sync`, so without it M-x with large scrollback exhibits a choppy cascading redraw — every small write triggers an unsynchronized partial repaint. Ship the compiled `xterm-ghostty` entry under terminfo/ (both Linux x/g/ and macOS 78/67/ hashed-dir layouts; identical bytes), set TERM=xterm-ghostty + TERMINFO=<bundled> + TERM_PROGRAM=ghostty for both `ghostel--start-process' and `ghostel-compile' via a shared helper. The bundled entry intentionally omits `Ms' so apps don't silently dispatch to OSC 52 when `ghostel-enable-osc52' is off. `defcustom ghostel-term' (default \"xterm-ghostty\") opts out by flipping back to xterm-256color and dropping all related env. Remote-host coverage via `defcustom ghostel-ssh-install-terminfo' (default `auto', follows `ghostel-tramp-shell-integration'): - TRAMP-launched ghostel: push compiled terminfo to a remote temp dir over the existing TRAMP connection (no extra round-trip); set TERMINFO=<remote dir> in the spawned shell's env; clean up on exit. - Outbound `ssh' from a local buffer: bundled bash/zsh/fish integration shadows `ssh' with a function that resolves the target via `ssh -G', looks up ${XDG_CACHE_HOME:-~/.cache}/ghostel/ssh-terminfo-cache (key includes hash of local terminfo for auto-invalidation on libghostty bumps), and on miss runs one combined probe-and-install ssh (`infocmp | ssh ... 'tic -x -''), then exec's the user's real ssh with the resolved TERM. Mkdir-as-lock serializes concurrent first-time calls to the same target. Skips install when the user passed a remote command (would clash with the pipe), when ssh -G can't resolve a host, or when local `infocmp' is missing. Per-call escape: GHOSTEL_SSH_KEEP_TERM=1. `M-x ghostel-ssh-clear-terminfo-cache' forces re-probe when a remote's terminfo changes out of band. Maintainer-only `make regen-terminfo' regenerates the compiled files from terminfo/xterm-ghostty.terminfo and verifies cross-layout parsing via `infocmp'.
1 parent ad8536e commit 2c92f68

13 files changed

Lines changed: 878 additions & 20 deletions

Makefile

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ EVIL_DIR ?= $(XDG_CACHE_HOME)/evil
66

77
ELC := ghostel.elc ghostel-debug.elc ghostel-compile.elc ghostel-eshell.elc
88

9-
.PHONY: all build test test-native test-all test-evil lint melpazoid byte-compile bench bench-quick clean
9+
.PHONY: all build test test-native test-all test-evil lint melpazoid byte-compile bench bench-quick clean regen-terminfo
1010

1111
all: build test-all test-evil lint
1212

@@ -67,7 +67,7 @@ melpazoid:
6767
@if [ ! -d "$(MELPAZOID_DIR)" ]; then \
6868
git clone https://github.com/riscy/melpazoid.git "$(MELPAZOID_DIR)"; \
6969
fi
70-
RECIPE='(ghostel :fetcher github :repo "dakra/ghostel" :files ("ghostel.el" "ghostel-debug.el" "ghostel-compile.el" "ghostel-module.*"))' \
70+
RECIPE='(ghostel :fetcher github :repo "dakra/ghostel" :files ("ghostel.el" "ghostel-debug.el" "ghostel-compile.el" "ghostel-module.*" "terminfo"))' \
7171
LOCAL_REPO=$(CURDIR) \
7272
make -C "$(MELPAZOID_DIR)"
7373

@@ -81,3 +81,22 @@ clean:
8181
rm -f ghostel-module.dylib ghostel-module.so
8282
rm -f $(ELC)
8383
rm -rf zig-out .zig-cache
84+
85+
# Maintainer-only: regenerate the bundled compiled terminfo from
86+
# `terminfo/xterm-ghostty.terminfo'. Run after bumping libghostty
87+
# (the source file should be re-extracted from a fresh Ghostty install
88+
# via `infocmp -x xterm-ghostty') and commit the resulting binaries.
89+
# `tic' on macOS emits the BSD hashed-dir layout (78/, 67/); the
90+
# binary file format is identical to Linux ncurses, so we mirror the
91+
# compiled entries into the Linux layout (x/, g/) by copying.
92+
regen-terminfo:
93+
rm -rf terminfo/x terminfo/g terminfo/78 terminfo/67
94+
tic -x -o terminfo/ terminfo/xterm-ghostty.terminfo
95+
@if [ -d terminfo/78 ]; then \
96+
mkdir -p terminfo/x terminfo/g; \
97+
cp terminfo/78/xterm-ghostty terminfo/x/xterm-ghostty; \
98+
cp terminfo/67/ghostty terminfo/g/ghostty; \
99+
fi
100+
@TERMINFO=$(CURDIR)/terminfo infocmp xterm-ghostty >/dev/null \
101+
|| (echo "ERROR: regenerated terminfo failed to round-trip"; exit 1)
102+
@find terminfo -type f | sort

README.md

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ ghostty dependency automatically.
124124
Alternatively, download a **pre-built binary** via `M-x ghostel-download-module`
125125
(or `C-u M-x ghostel-download-module` to pick a specific release).
126126

127+
The compiled `xterm-ghostty` terminfo entry ships pre-built in
128+
`terminfo/` and is identical to what `tic` would produce locally —
129+
no build step needed, and the file format is portable across BSD
130+
and ncurses systems. Maintainers regenerate it via `make
131+
regen-terminfo` after bumping libghostty.
132+
127133
## Shell Integration
128134

129135
Shell integration (directory tracking via OSC 7, prompt navigation via OSC 133,
@@ -211,6 +217,7 @@ history — even outside copy mode.
211217
### Terminal Emulation
212218
- Full VT terminal emulation via libghostty-vt
213219
- 256-color and RGB (24-bit true color) support
220+
- **`TERM=xterm-ghostty` with bundled terminfo** — apps that consult terminfo for capabilities (Claude Code, neovim, tmux, modern TUIs) discover synchronized output (DEC 2026), Kitty keyboard protocol, true color, colored underlines, focus reporting, etc., and use their fast paths. Synchronized output in particular eliminates the choppy partial-redraw effect when Claude Code repaints over a large scrollback. OSC 52 (clipboard) is supported but intentionally not advertised in the bundled terminfo — see Clipboard below. Override via `ghostel-term`.
214221
- **OSC 4 / 10 / 11 color queries** — TUI programs can query the current palette, foreground, and background colors, so tools like `duf`, `btop`, `delta`, and anything else using `termenv` auto-detect the right light/dark theme from the Emacs face colors
215222
- **OSC 9 / OSC 777** — desktop notifications and ConEmu progress reports (percentage shown in the mode line; see [Notifications and Progress](#notifications-and-progress))
216223
- Text attributes: bold, italic, faint, underline (single/double/curly/dotted/dashed with color), strikethrough, inverse
@@ -224,7 +231,7 @@ history — even outside copy mode.
224231
- **File path detection** — patterns like `/path/to/file.el:42` become clickable, opening the file at the given line (toggle with `ghostel-enable-file-detection`)
225232

226233
### Clipboard
227-
- **OSC 52 clipboard** — terminal programs can set the Emacs kill ring and system clipboard (opt-in via `ghostel-enable-osc52`, useful for remote SSH sessions)
234+
- **OSC 52 clipboard** — terminal programs can set the Emacs kill ring and system clipboard (opt-in via `ghostel-enable-osc52`, useful for remote SSH sessions). Note: the bundled `xterm-ghostty` terminfo intentionally **does not** advertise the `Ms` capability, so apps don't auto-discover it. This avoids silent clipboard drops when `ghostel-enable-osc52` is at its default `nil`. If you enable OSC 52 and want apps (neovim, tmux) to auto-detect, install upstream Ghostty's terminfo on the same path or override `TERMINFO`.
228235
- **Bracketed paste** — yank from kill ring sends text as a bracketed paste so shells handle it correctly
229236

230237
### Input
@@ -313,6 +320,116 @@ test "$INSIDE_EMACS" = 'ghostel'; and source ~/.local/share/ghostel/ghostel.fish
313320
The integration scripts provide directory tracking (OSC 7), prompt
314321
navigation (OSC 133), and `ghostel_cmd` for calling Elisp from the shell.
315322

323+
#### Remote `xterm-ghostty` terminfo
324+
325+
Ghostel sets `TERM=xterm-ghostty` so apps inside the buffer get the
326+
full capability set (synchronized output, Kitty keyboard, etc.).
327+
That same `TERM` value gets inherited by anything spawned inside
328+
the buffer — including `ssh REMOTE` and `M-x ghostel` from a TRAMP
329+
`default-directory`. Remote hosts without the `xterm-ghostty`
330+
entry will then print `Error opening terminal: xterm-ghostty`.
331+
332+
`ghostel-ssh-install-terminfo` (default `auto`) handles both cases.
333+
`auto` is enabled when `ghostel-tramp-shell-integration` is on, so
334+
turning on remote integration also turns on terminfo install — one
335+
switch.
336+
337+
##### TRAMP-launched ghostel
338+
339+
`M-x ghostel` from a TRAMP path (`/ssh:host:/path/`) spawns the
340+
shell on the remote. Ghostel pushes the bundled compiled terminfo
341+
to a remote temp dir over the existing TRAMP connection (no extra
342+
ssh round-trip), sets `TERMINFO=<that dir>` in the remote shell's
343+
env, and cleans up on exit. Both Linux (`x/`, `g/`) and macOS
344+
(`78/`, `67/`) layouts are written so any ncurses or BSD libcurses
345+
finds it. Nothing persists on the remote.
346+
347+
##### Outbound `ssh` from a local ghostel buffer
348+
349+
The bundled bash/zsh/fish integration shadows `ssh` with a function
350+
that:
351+
352+
1. Resolves the canonical target via `ssh -G` (normalises ssh_config
353+
aliases).
354+
2. Looks up the target in `~/.cache/ghostel/ssh-terminfo-cache`.
355+
The cache key includes a hash of the local terminfo, so libghostty
356+
bumps automatically invalidate it. Cache hit → connect with the
357+
remembered `TERM`.
358+
3. On miss, runs a single setup ssh that probes whether the entry
359+
already exists on the remote, and if not, installs it via
360+
`tic -x -` into `~/.terminfo/`. Records `ok` (use
361+
`xterm-ghostty`) or `skip` (use `xterm-256color`) in the cache.
362+
4. Runs the user's actual ssh with the resolved `TERM`.
363+
364+
The setup ssh is one extra connection per new host. Without
365+
ControlMaster you'll see two auth prompts the first time. Strongly
366+
recommended:
367+
368+
```ssh-config
369+
# ~/.ssh/config
370+
Host *
371+
ControlMaster auto
372+
ControlPath ~/.ssh/cm-%r@%h:%p
373+
ControlPersist 60s
374+
```
375+
376+
With this, the setup connection and the real connection share a
377+
single auth. Subsequent connections within `ControlPersist` are
378+
free.
379+
380+
The cache key includes a hash of the **local** terminfo, so
381+
libghostty bumps automatically invalidate the cache. It does NOT
382+
notice when a remote's terminfo changes out-of-band (system update,
383+
manual `tic`). Run `M-x ghostel-ssh-clear-terminfo-cache` to force
384+
re-probe.
385+
386+
Verified working from macOS to Linux remotes. Mixed macOS-to-macOS
387+
or BSD targets inherit `tic`'s native hashed-dir layout
388+
(`~/.terminfo/<hex>/`); `infocmp` reads the same path so they pair
389+
correctly.
390+
391+
Skip-install heuristics:
392+
- `ssh HOST cmd` (user passes a remote command): wrapper skips
393+
install for that call to avoid clashing with the user's command.
394+
Connects with cached `TERM` if known, otherwise `xterm-256color`.
395+
The next interactive `ssh HOST` triggers install.
396+
- `ssh -V`, `ssh -h`, etc. (no host resolved): pass through.
397+
- No `infocmp` locally: pass through.
398+
399+
Per-call escape: prefix with `GHOSTEL_SSH_KEEP_TERM=1` to bypass
400+
the wrapper entirely.
401+
402+
##### Manual install (no auto-machinery)
403+
404+
If you'd rather not have ghostel touch remote hosts (and don't want
405+
the auto-cache), set `(setq ghostel-ssh-install-terminfo nil)` and
406+
install the entry yourself once per host.
407+
408+
Pipe the local entry across:
409+
```bash
410+
infocmp -x xterm-ghostty | ssh REMOTE 'mkdir -p ~/.terminfo && tic -x -'
411+
```
412+
413+
Or copy the bundled compiled binary from the package directory:
414+
```bash
415+
ssh REMOTE 'mkdir -p ~/.terminfo/x'
416+
scp <package-dir>/terminfo/x/xterm-ghostty REMOTE:~/.terminfo/x/
417+
# Ghostty also looks in 78/ on macOS:
418+
ssh REMOTE 'uname' | grep -q Darwin && {
419+
ssh REMOTE 'mkdir -p ~/.terminfo/78'
420+
scp <package-dir>/terminfo/78/xterm-ghostty REMOTE:~/.terminfo/78/
421+
}
422+
```
423+
424+
After this, every shell on the remote sees `xterm-ghostty` and
425+
ghostel's outbound ssh wrapper is unnecessary.
426+
427+
##### Drop the Ghostty advertisement entirely
428+
429+
Set `(setq ghostel-term "xterm-256color")` to drop `TERM=xterm-ghostty`
430+
locally. No advertisement, no terminfo gymnastics, no synchronized
431+
output fast-path either.
432+
316433
### Rendering
317434
- Incremental redraw — only dirty rows are re-rendered
318435
- Timer-based batched updates with adaptive frame rate
@@ -441,6 +558,8 @@ individual faces with `M-x customize-face`.
441558
|----------------------------------|----------------------|----------------------------------------------------------|
442559
| `ghostel-module-auto-install` | `ask` | What to do when native module is missing (`ask`, `download`, `compile`, `nil`) |
443560
| `ghostel-shell` | `$SHELL` | Shell program to run |
561+
| `ghostel-term` | `"xterm-ghostty"` | Value of `TERM` for spawned processes. Default uses the bundled terminfo so apps can detect ghostel's full capability set. Set to `"xterm-256color"` to fall back (drops `TERMINFO` and `TERM_PROGRAM=ghostty` too) |
562+
| `ghostel-ssh-install-terminfo` | `auto` | Install `xterm-ghostty` terminfo on remote hosts as needed. `auto` follows `ghostel-tramp-shell-integration`. Affects both TRAMP-launched ghostel (push terminfo over the existing TRAMP connection) and outbound `ssh` from a local buffer (install via `tic` on first connection, cache in `~/.cache/ghostel/ssh-terminfo-cache`). Per-call ssh override: `GHOSTEL_SSH_KEEP_TERM=1` |
444563
| `ghostel-tramp-shells` | `(see below)` | Shell to use per TRAMP method (with login-shell detection) |
445564
| `ghostel-shell-integration` | `t` | Auto-inject shell integration |
446565
| `ghostel-tramp-default-method` | `nil` | TRAMP method for new remote paths from OSC 7 (nil uses `tramp-default-method`) |
@@ -513,6 +632,7 @@ When `evil-ghostel-mode` is active:
513632
| `M-x ghostel-force-redraw` | Force a full terminal redraw |
514633
| `M-x ghostel-debug-typing-latency` | Measure per-keystroke typing latency |
515634
| `M-x ghostel-sync-theme` | Re-sync color palette after theme change |
635+
| `M-x ghostel-ssh-clear-terminfo-cache` | Clear outbound-ssh terminfo install cache (force re-probe) |
516636
| `M-x ghostel-download-module` | Download pre-built native module |
517637
| `M-x ghostel-module-compile` | Compile native module from source |
518638

@@ -558,6 +678,12 @@ user, so multi-line shell scripts are passed through verbatim and
558678
no shell-integration setup is required. The process sentinel
559679
delivers the real exit status.
560680

681+
`ghostel-compile` inherits the same `TERM=xterm-ghostty` and
682+
`TERMINFO=...` env as `M-x ghostel`, so build output gets
683+
synchronized output, true color, etc. If a test runner or build
684+
tool gets confused by the unfamiliar `TERM`, set
685+
`(setq ghostel-term "xterm-256color")`.
686+
561687
```elisp
562688
(require 'ghostel-compile)
563689

etc/ghostel.bash

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,118 @@ PROMPT_COMMAND="__ghostel_wrapped_prompt_command"
6363

6464
trap '__ghostel_preexec' DEBUG
6565

66+
# Outbound `ssh' wrapper. Activated when the elisp side sets
67+
# `ghostel-ssh-install-terminfo' (which exports
68+
# GHOSTEL_SSH_INSTALL_TERMINFO).
69+
#
70+
# On first connection to a host the wrapper probes whether
71+
# xterm-ghostty terminfo is present, installs it via `tic' if not,
72+
# caches the outcome under $XDG_CACHE_HOME/ghostel/ssh-terminfo-cache
73+
# (key includes a hash of the local terminfo so libghostty bumps
74+
# auto-invalidate the cache), and connects with TERM=xterm-ghostty.
75+
# Subsequent connections hit the cache. Failures (no `tic' on remote,
76+
# no write access) cache a skip marker and downgrade to xterm-256color.
77+
#
78+
# Per-call escape hatch: prefix `ssh' with GHOSTEL_SSH_KEEP_TERM=1 to
79+
# bypass the wrapper entirely.
80+
if [[ -n "$GHOSTEL_SSH_INSTALL_TERMINFO" ]]; then
81+
ssh() {
82+
# Escape hatch + need infocmp locally to do anything useful.
83+
if [[ -n "$GHOSTEL_SSH_KEEP_TERM" ]] || \
84+
! builtin command -v infocmp >/dev/null 2>&1; then
85+
builtin command ssh "$@"
86+
return
87+
fi
88+
89+
# Resolve the canonical target (normalises ssh_config aliases).
90+
local _user="" _host="" _port="" _k _v
91+
while IFS=' ' read -r _k _v; do
92+
case "$_k" in
93+
user) _user=$_v ;;
94+
hostname) _host=$_v ;;
95+
port) _port=$_v ;;
96+
esac
97+
[[ -n $_user && -n $_host && -n $_port ]] && break
98+
done < <(builtin command ssh -G "$@" 2>/dev/null)
99+
100+
# No host (e.g. `ssh -V`, `ssh -h`): pass through.
101+
if [[ -z $_host ]]; then
102+
builtin command ssh "$@"
103+
return
104+
fi
105+
106+
local _target="$_user@$_host:$_port"
107+
local _hash
108+
_hash=$(infocmp -0 -x xterm-ghostty 2>/dev/null \
109+
| cksum 2>/dev/null | awk '{print $1}')
110+
local _cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/ghostel"
111+
local _cache="$_cache_dir/ssh-terminfo-cache"
112+
local _key="$_target:$_hash"
113+
114+
# Cache hit?
115+
if [[ -r $_cache ]]; then
116+
if grep -Fxq "$_key ok" "$_cache" 2>/dev/null; then
117+
TERM=xterm-ghostty builtin command ssh "$@"
118+
return
119+
fi
120+
if grep -Fxq "$_key skip" "$_cache" 2>/dev/null; then
121+
TERM=xterm-256color builtin command ssh "$@"
122+
return
123+
fi
124+
fi
125+
126+
# Skip install when the user passed a remote command — combining
127+
# our install script with their command via the same ssh
128+
# invocation is fragile. The next interactive `ssh HOST' will
129+
# trigger install.
130+
local _positional=0 _skip=0 _arg
131+
for _arg in "$@"; do
132+
if (( _skip )); then _skip=0; continue; fi
133+
case "$_arg" in
134+
-[bcDEeFIiJLlmOoPpQRSWw]) _skip=1 ;;
135+
-*) ;;
136+
*) ((_positional++)) ;;
137+
esac
138+
done
139+
140+
if (( _positional > 1 )); then
141+
TERM=xterm-256color builtin command ssh "$@"
142+
return
143+
fi
144+
145+
# Combined probe + install in a single setup ssh invocation.
146+
# Mkdir-as-lock so concurrent first-time `ssh HOST' from two
147+
# ghostel buffers don't both spawn a setup connection.
148+
builtin command mkdir -p "$_cache_dir" 2>/dev/null
149+
# Lock keyed on (target, hash) so concurrent calls to the same
150+
# target serialize, but different targets run in parallel.
151+
local _lock="$_cache_dir/.lock.$_target.$_hash"
152+
if ! builtin command mkdir "$_lock" 2>/dev/null; then
153+
TERM=xterm-256color builtin command ssh "$@"
154+
return
155+
fi
156+
# No `trap RETURN' — bash's RETURN trap is shell-global and
157+
# would clobber any pre-existing user trap. Cleanup is
158+
# explicit at each return point.
159+
if infocmp -0 -x xterm-ghostty 2>/dev/null \
160+
| builtin command ssh "$@" '
161+
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
162+
command -v tic >/dev/null 2>&1 || exit 1
163+
mkdir -p "$HOME/.terminfo" && tic -x - >/dev/null 2>&1
164+
' >/dev/null 2>&1; then
165+
builtin echo "$_key ok" >> "$_cache"
166+
builtin command rmdir "$_lock" 2>/dev/null
167+
TERM=xterm-ghostty builtin command ssh "$@"
168+
else
169+
builtin echo "ghostel: failed to install xterm-ghostty terminfo on $_host \
170+
(no \`tic' on remote?), using xterm-256color." >&2
171+
builtin echo "$_key skip" >> "$_cache"
172+
builtin command rmdir "$_lock" 2>/dev/null
173+
TERM=xterm-256color builtin command ssh "$@"
174+
fi
175+
}
176+
fi
177+
66178
# Call an Emacs Elisp function from the shell.
67179
# Usage: ghostel_cmd FUNCTION [ARGS...]
68180
# The function must be in `ghostel-eval-cmds'.

0 commit comments

Comments
 (0)