Skip to content

feat(pager): static ANSI render for captured pager hosts#9

Merged
amix merged 1 commit into
mainfrom
amix/static-captured-pager
May 16, 2026
Merged

feat(pager): static ANSI render for captured pager hosts#9
amix merged 1 commit into
mainfrom
amix/static-captured-pager

Conversation

@amix
Copy link
Copy Markdown
Owner

@amix amix commented May 16, 2026

Context

Backport of modem-dev/hunk#271. PR 5 of an expert-gated backport series — the largest, so it had a dedicated expert design review before any code and an implementation review after.

Launched as a captured pager (LazyGit, git -c core.pager="dunk pager" log -p — anything that pipes a custom pager into its own panel with TERM=dumb), dunk fell into the app branch and OpenTUI attempted a full alt-screen render against a non-TTY pipe: broken or blank output.

What was changed

  • Two new StartupPlan outcomes — static-diff-pager and passthrough — plus isCapturedPagerHost(env) (TERM=dumb + GIT_PAGER/LV=-c/LAZYGIT*). A known captured host is detected before the generic non-TTY case, so it fires even though its stdout is a non-TTY pipe; any other non-TTY/dumb pipe echoes the raw patch verbatim.
  • src/ui/staticDiffPager.ts — a thin adapter (chosen over upstream's renderRows.tsx → rowStyle.ts extraction, per expert design review). Reuses the existing loadAppBootstrap → Pierre buildStackRows planning path, reads every color from themes.ts (zero renderRows.tsx changes), skips syntax highlighting so a per-selection re-spawn stays snappy, and never renders the comment overlay.
  • main.tsx drains via await Bun.write(Bun.stdout, …) before exit so a large diff isn't truncated. ANSI-colored git input is handled transitively (the shared loader already strips it before Pierre).
  • AGENTS.md/CLAUDE.md updated: pager paths two → four, StartupPlan named as the single enumeration.

Behavior change

<patch> | dunk pager with a non-TTY stdout and no captured-host markers now echoes the patch verbatim instead of attempting a broken TUI (the previous code had no TTY guard and always tried to launch the app). A real TTY still launches the interactive UI; the TUI-path startup test was updated to declare its TTY requirement explicitly — this reflects the fix, it is not a weakened assertion (verified against the pre-change branch, which had no guard).

Verification

  • bun run typecheck, bun run lint: clean.
  • staticDiffPager.test.ts + startup.test.ts: 18 pass — serialization, theme-field drift guard vs stackCellPalette across all built-in themes, 6-hex validation across all themes, hunk-header toggle, no-overlay, empty changeset, per-arm captured-host detection (LAZYGIT*/GIT_PAGER/LV), and the TERM=dumb-gate false-positive guard (global GIT_PAGER must not force static in normal use).
  • test/cli/entrypoint.test.ts: captured host → ANSI, never enters the alt screen; plain non-TTY → verbatim passthrough.
  • Full src/core+src/ui: 333 pass. PTY: 21 pass. Real captured-host smoke shows a colored static diff; plain non-TTY shows verbatim passthrough; neither enters the alt screen.

Out of scope

Upstream's rowStyle.ts extraction from renderRows.tsx — deliberately not ported (would refactor dunk's diverged windowed renderer for no functional gain; expert-endorsed Option B). Syntax highlighting in static output (skipped for hot-path latency). Auto theme detection (separate evaluation).

🤖 Generated with Claude Code

@amix amix marked this pull request as ready for review May 16, 2026 19:37
@amix amix force-pushed the amix/static-captured-pager branch from fd3f44d to b569e18 Compare May 16, 2026 19:41
Launched as a captured pager (LazyGit, `git -c core.pager="dunk pager"
log -p` — anything that pipes a custom pager into its own panel with
`TERM=dumb`), dunk hit the app branch and OpenTUI attempted a full
alt-screen render against a non-TTY pipe: broken or blank output.

Add two `StartupPlan` outcomes — `static-diff-pager` and `passthrough`
— and `isCapturedPagerHost(env)` detection. A known captured host gets
a static, colored ANSI diff (checked before the generic non-TTY case so
it fires even though the host's stdout is a non-TTY pipe); any other
non-TTY/dumb pipe echoes the raw patch verbatim.

`staticDiffPager.ts` is a thin adapter: it reuses the existing
`loadAppBootstrap` → Pierre `buildStackRows` planning path, reads all
colors from `themes.ts` (no `renderRows.tsx` change), skips syntax
highlighting so a per-selection re-spawn stays snappy, and never
renders the comment overlay. `main.tsx` drains via `Bun.write` before
exit so large diffs aren't truncated.

Behavior change: `<patch> | dunk pager` with a non-TTY stdout and no
captured-host markers now echoes the patch instead of attempting a
broken TUI. A real TTY still launches the interactive UI.

Backports modem-dev/hunk#271, redesigned for dunk (Option B, expert
design- and implementation-reviewed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@amix amix force-pushed the amix/static-captured-pager branch from b569e18 to 298cae7 Compare May 16, 2026 19:48
@amix amix merged commit 1955c76 into main May 16, 2026
2 checks passed
@amix amix deleted the amix/static-captured-pager branch May 16, 2026 19:51
@amix amix mentioned this pull request May 16, 2026
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