fix(tui-image): keep a herdr pane off iTerm2 and Sixel - #227
Conversation
herdr is a multiplexer: the bytes we write land in its own VT (libghostty), not in the terminal that launched it. It overrides TERM and COLORTERM but leaves the host's identity variables behind, so detection saw WEZTERM_EXECUTABLE and negotiated iTerm2 inline images with a VT that parses OSC 1337 and discards it as unimplemented. Nothing was drawn. Detect the pane from HERDR_PANE_ID / HERDR_ENV / HERDR_SOCKET_PATH and pin the capability set to Kitty, which libghostty does implement, including the Unicode placeholders herdr needs to re-emit a placement clipped to the pane rectangle. The live DA1 answer is filtered the same way, since it comes from libghostty and cannot speak for the host. The bridge's isInHerdrPane requires all three variables because it is about to open a socket; here the loose check is the correct one — a pane started with only HERDR_PANE_ID still cannot render iTerm2 images.
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
| sixel: detected.has(Protocol.SIXEL), | ||
| iterm2: detected.has(Protocol.ITERM2), | ||
| terminal: termProgram ?? term, | ||
| terminal: insideHerdr(env) ? "herdr" : (termProgram ?? term), |
There was a problem hiding this comment.
Minor consistency nit: applyLiveCapabilities caches the predicate as const inHerdr = insideHerdr(env) (line 149), but detectCapabilities calls insideHerdr(env) twice — once at line 224 in the clear/add branch and again at line 238 in the returned terminal field. Hoisting it to a local would match the other function and skip one env-var scan; the call is cheap so this is purely stylistic.
nikomatt69
left a comment
There was a problem hiding this comment.
Review (local verification, 2026-08-18)
Correct diagnosis for #226. herdr leaks host identity (WEZTERM_*, ITERM_SESSION_ID) while the pane VT is libghostty, so pinning to Kitty and forcing Unicode placeholders is the protocol that actually survives the multiplexer.
NIKCLI_KITTY_PLACEHOLDERS=0 is still checked first in supportsKittyUnicodePlaceholders, so the explicit override still wins.
insideHerdr() is deliberately looser than HerdrBridge.isInHerdrPane() (HERDR_PANE_ID || HERDR_ENV || HERDR_SOCKET_PATH vs all three plus HERDR_ENV==="1"). That is the right test for protocol negotiation: a pane that only exports HERDR_PANE_ID still cannot render OSC 1337.
Verified
cd packages/tui-image && bun teston this branch → 83 pass, 0 fail (matches the PR)- CI
typecheckpassed - Merges clean onto current
live-main(no conflicts)
CI that is red is not this PR — same Windows Bun remap 255 / duplicate-pr nikcli missing / linux test queued as on #225.
Nit only (not blocking): detectCapabilities calls insideHerdr(env) twice; applyLiveCapabilities already caches it. Cheap, skip.
Merge: yes, after #225 (no file overlap; order is only so the TUI crash lands first).
Issue for this PR
Closes #226
Type of change
What does this PR do?
Detection assumed the environment describes the terminal we write to. Under a multiplexer it does not: herdr leaves
WEZTERM_EXECUTABLE,TERM_PROGRAM,ITERM_SESSION_IDand friends in the child environment while the bytes actually land in its own VT, libghostty. So nikcli sent OSC 1337 inline images into a VT that parses them and drops them as unimplemented, and the user got an empty box.insideHerdr()checksHERDR_PANE_ID/HERDR_ENV/HERDR_SOCKET_PATH. When it is true:detectCapabilitiespins the set to Kitty — the one protocol libghostty implements — and reportsterminal: "herdr"applyLiveCapabilitiesdrops Sixel and iTerm2 from the live answer, because that answer is libghostty's DA1 reply and cannot speak for the host terminalsupportsKittyUnicodePlaceholdersreturns true, since placeholders are what herdr re-emits to the host clipped to the pane rectangle; a raw placement would not survive its repaintsNote the predicate is deliberately looser than
HerdrBridge.isInHerdrPane, which requires all three variables because it is about to open a socket. A pane that only exportsHERDR_PANE_IDstill cannot render iTerm2 images, so for protocol negotiation the OR is the correct test.How did you verify your code works?
Reproduced on Windows 11 with herdr 0.8.0-preview hosted in WezTerm: before the change the preview frame is empty, after it the image draws through Kitty placeholders. The leaked environment is in #226.
Added two tests in
packages/tui-image/test/capabilities.test.ts— one feedingHERDR_PANE_IDtogether with a full WezTerm/iTerm2 environment and asserting only Kitty survives, one asserting asixel: truelive answer is ignored inside a pane — plus one inkitty-placeholder.test.ts.bun test packages/tui-imageis 83/83,tsc --noEmitclean, prettier clean.Screenshots / recordings
Terminal rendering, before/after described above; the empty-frame case is in #226.
Checklist