fix(runtime): use terminal-size fallback when stdout reports 0 cols/rows (Ink parity, G12) (#33)

Replace three `stdout.columns ?? 80` / `stdout.rows ?? 24` spots in render.ts with
`resolveSize(stdout).columns/rows`. The `??` guard only falls back on null/undefined,
not on 0 — so non-TTY environments where stdout reports 0 columns would collapse Yoga
layout to width 0. Ink's `getWindowSize` (utils.ts:8-23) uses a truthy guard
(`if (columns && rows)`) and a fallback chain through terminal-size → 80/24 defaults.
`resolveSize()` in useTerminalSize.ts already implements this chain; now exported and
used by the renderer. The non-TTY viewportRows → 24 branch is preserved (Ink-aligned).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-30 00:24:02 +08:00
committed by GitHub
parent 78ec54977e
commit 144db33d0b
4 changed files with 46 additions and 22 deletions
+18 -18
View File
@@ -29,24 +29,24 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
`status` ∈ `todo · in-progress · pr-open · merged · blocked · refuted`. Priority: correctness/behavior first, omissions next.
| id | area | summary | priority | status | branch | PR |
| --- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ------------------------ | --- |
| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | todo | — | — |
| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | todo | — | — |
| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | todo | — | — |
| G04 | box-layout-border | Border edges incorrectly inherit the Box backgroundColor | P2 | merged | `fix/parity-border-bg` | #30 |
| G05 | box-layout-border | Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) | P2 | todo | — | — |
| G06 | text-wrap-transform | Nested <Transform>/<Text> transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — |
| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | todo | — | — |
| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 |
| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | todo | — | — |
| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — |
| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — |
| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | todo | — | — |
| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | todo | — | — |
| G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | todo | — | — |
| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | todo | — | — |
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | todo | — | — |
| id | area | summary | priority | status | branch | PR |
| --- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ------- | -------------------------- | --- |
| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | todo | — | — |
| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | todo | — | — |
| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | todo | — | — |
| G04 | box-layout-border | Border edges incorrectly inherit the Box backgroundColor | P2 | merged | `fix/parity-border-bg` | #30 |
| G05 | box-layout-border | Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) | P2 | todo | — | — |
| G06 | text-wrap-transform | Nested <Transform>/<Text> transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — |
| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | todo | — | — |
| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 |
| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | todo | — | — |
| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | todo | — | — |
| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | todo | — | — |
| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | pr-open | `fix/parity-renderer-size` | #33 |
| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | todo | — | — |
| G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | todo | — | — |
| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | todo | — | — |
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | todo | — | — |
## Gap details
@@ -332,6 +332,26 @@ test("clears aspectRatio on rerender", async () => {
expect(lastFrame({ trimLines: true })).toBe("┌──────┐\n│X │\n└──────┘\nY");
});
// Ink parity G12: Ink's getWindowSize() uses a truthy guard (if (columns && rows))
// so that a 0 value from stdout in non-TTY environments falls back to terminal-size
// and then 80/24 defaults. vue-tui's renderer was using `stdout.columns ?? 80`
// which only falls back for null/undefined — not 0 — collapsing layout to width 0.
// References: Ink /tmp/ink-40b3a75/src/utils.ts lines 8-23.
test("falls back to default width when stdout reports 0 columns (Ink parity G12)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width="100%">
<Text>hello</Text>
</Box>
)),
// columns: 0 — simulates non-TTY where stdout.columns is 0 (not null/undefined).
// With the bug: width resolves to 0, yoga collapses to 0-width, "hello" disappears.
// With the fix: resolveSize() truthy-guards 0, falls back to terminal-size / 80.
{ columns: 0, rows: 0 },
);
expect(lastFrame()).toContain("hello");
});
test.skip("set max width in percent — known Yoga issue", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
@@ -8,7 +8,7 @@ import { AppContextKey } from "../context.ts";
* 2. terminal-size package (works even when stdout is redirected)
* 3. Hardcoded defaults (80x24)
*/
function resolveSize(stdout: NodeJS.WriteStream): { columns: number; rows: number } {
export function resolveSize(stdout: NodeJS.WriteStream): { columns: number; rows: number } {
const cols = stdout.columns;
const rowsVal = stdout.rows;
if (cols && rowsVal) return { columns: cols, rows: rowsVal };
+7 -3
View File
@@ -38,6 +38,7 @@ import {
import { devState, DevStateKey, initHmrBridge } from "./hmr.ts";
import { createDevOverlayWrapper } from "./overlay.ts";
import { ErrorOverview } from "./components/ErrorOverview.ts";
import { resolveSize } from "./composables/useTerminalSize.ts";
export interface MountOptions {
stdout?: NodeJS.WriteStream;
@@ -459,7 +460,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
const tuiRoot = createRoot(appContext);
attachYoga(tuiRoot);
tuiRoot.yoga.setWidth(stdout.columns ?? 80);
tuiRoot.yoga.setWidth(resolveSize(stdout).columns);
mountedRoot = tuiRoot;
// Reset accumulated static output when the <Static> identity changes
@@ -483,7 +484,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
function renderInteractiveFrame(output: string, outputHeight: number, staticOutput: string) {
const hasStaticOutput = staticOutput !== "";
const isTty = !!stdout.isTTY;
const viewportRows = isTty ? (stdout.rows ?? 24) : 24;
// Keep non-TTY → 24 fallback (matching Ink: non-tty viewportRows is always 24).
// Use resolveSize for TTY to handle the 0-columns/rows case (Ink parity G12).
const viewportRows = isTty ? resolveSize(stdout).rows : 24;
// Fullscreen: output fills or exceeds terminal height — no trailing newline.
// Only apply when writing to a real TTY — piped output always gets trailing newlines.
@@ -543,7 +546,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
// Capture static output as a string (for both interactive and non-interactive paths)
const w = stdout.columns ?? 80;
// Use resolveSize to handle 0-columns case from non-TTY stdout (Ink parity G12).
const w = resolveSize(stdout).columns;
let staticOutput = "";
for (const stat of findStatics(tuiRoot)) {
const fresh = stat.children.slice(stat.writtenCount);