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:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user