Skip to content

feat(core): route renderer output through NativeSpanFeed for custom stdout#958

Open
msmps wants to merge 14 commits intomainfrom
feat/native-span-feed-renderer
Open

feat(core): route renderer output through NativeSpanFeed for custom stdout#958
msmps wants to merge 14 commits intomainfrom
feat/native-span-feed-renderer

Conversation

@msmps
Copy link
Copy Markdown
Collaborator

@msmps msmps commented Apr 20, 2026

Summary

  • Wire NativeSpanFeed into @opentui/core renderer so non-process.stdout consumers can receive rendered ANSI output via a zero-copy feed piped to any Node.js Writable
  • Extract Zig-side OutputBackend tagged union to unify transport dispatch with a single inline else switch
  • Simplify CliRenderer constructor to 5 params — the class auto-detects stream identity and wires the correct backend internally

Motivation

Enables a future @opentui/ssh package (and similar transports) by letting the renderer pipe to any Writable without callers needing to know about NativeSpanFeed, feed pointers, or backend selection. The renderer takes stdin/stdout and figures out the rest.

What changed

TypeScript (renderer.ts, zig.ts, test-renderer.ts)

  • CliRenderer constructor: (stdin, stdout, width, height, config) — if stdout !== process.stdout, a NativeSpanFeed is allocated internally and rendered bytes are piped through it
  • createCliRenderer factory significantly simplified (dimension resolve + new CliRenderer(...) + await setupTerminal())
  • test-renderer.ts renderer construction reduced to a single new CliRenderer(...) call — zero feed-wiring knowledge required
  • rendererTracker gains a processStdinUsers refcount so process.stdin.pause() only fires when the last process.stdin-using renderer is destroyed
  • writeOut routes through the native backend when a feed is wired, regardless of threading state (fixes ANSI interleaving on Linux + custom stdout)
  • Feed teardown ordering documented and hardened: drain → destroyRenderer → drain → detach → close, with explicit memory-lifetime invariant
  • CliRendererConfig gains optional width/height fallback fields for non-TTY stdouts
  • Public resize(w, h) method for external resize signals (e.g. SSH window-change)

Zig (renderer-output.zig, renderer.zig, lib.zig)

  • New OutputBackend tagged union (StdoutBackend + FeedBackend) extracted into renderer-output.zig
  • CliRenderer.render() performs exactly one switch (self.backend) { inline else => ... } — monomorphized writer types, zero vtable cost
  • Per-instance double buffers replace file-scope statics (multi-renderer correctness)
  • Thread termination protocol fixed: shouldTerminate exits the render thread without replaying the stale last-frame buffer after shutdown ANSI
  • dumpStdoutBuffer renamed to dumpOutputBuffer and delegated to OutputBackend.dumpTo(out) — eliminates the last cross-module scattered switch
  • collectFrameStats helper extracted to deduplicate stat collection across 4 render paths
  • Vestigial createWithOptions constructor deleted; createWithFullOptions is the single entry point
  • All PR core: add native split-footer commit path #890 split-footer methods (repaintSplitFooter, commitSplitFooterSnapshotBatched, appendSplitFooterSnapshotCommit, etc.) adapted to use inline else backend dispatch

Tests

  • renderer.custom-stdout.test.ts — 14 behavioral tests: byte routing, shutdown-ANSI regression, backpressure, dimension fallback, duck-typed streams, resize API, teardown resilience
  • renderer.tracker.test.ts — 5 tests for process.stdin refcount semantics across mixed configurations
  • renderer_test.zig — 6 new Zig tests: FeedBackend write-through, shouldSkipFrame at queue saturation, supportsThreading invariant, per-instance buffer isolation, threaded-stdout destroy protocol, allocation-failure cleanup
  • renderer.console-startup.test.ts — removed 2 stale monkey-patch lines (superseded by core: add native split-footer commit path #890's native split-footer)

Example

  • examples/xterm-web-demo/ — working browser demo that renders an opentui app over xterm.js via WebSockets. Each tab gets its own CliRenderer backed by a NativeSpanFeed. Responsive terminal via @xterm/addon-fit. Includes README.md with architecture diagram and run instructions.

Breaking changes

  • CliRenderer.dumpStdoutBuffer() renamed to dumpOutputBuffer() (debug-only method, unlikely to affect consumers)
  • CliRenderer constructor signature changed from 7 params to 5 (internal — createCliRenderer public API unchanged)

Test results

Suite Result
Zig native 1558 pass / 2 pre-existing skips
Core TS 4164 pass / 0 fail / 6 skip
Format clean
Lint 0 warnings / 0 errors

…tdout

When stdout !== process.stdout, CliRenderer auto-wires a NativeSpanFeed
and pipes frame bytes to the user's Writable. Zig side gains an
OutputBackend tagged union with inline-else dispatch to keep the hot
path generic over the transport.
@msmps msmps force-pushed the feat/native-span-feed-renderer branch from 4a8451d to 5e21b24 Compare April 20, 2026 14:44
Write NativeSpanFeed output through the original stdout sink so
renderer-owned ANSI for custom split-footer transports isn't
re-captured as external output.
@simonklee
Copy link
Copy Markdown
Member

/pkg-preview

# Conflicts:
#	packages/core/src/zig/renderer.zig
Wait for feed-backed startup output to drain before returning a renderer so immediate teardown on custom transports can't race in-flight writes. Also preserve fallback dimensions, clean up failed setup consistently, and fix non-threaded stdout debug dumps
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.

2 participants