diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index bcc4fd6..3f6bbcb 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -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 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 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 diff --git a/packages/runtime-tests/integration/layout/width-height.test.tsx b/packages/runtime-tests/integration/layout/width-height.test.tsx index 6fb7c3c..aef4c55 100644 --- a/packages/runtime-tests/integration/layout/width-height.test.tsx +++ b/packages/runtime-tests/integration/layout/width-height.test.tsx @@ -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(() => () => ( + + hello + + )), + // 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(() => () => ( diff --git a/packages/runtime/src/composables/useTerminalSize.ts b/packages/runtime/src/composables/useTerminalSize.ts index 20ccdb7..5b6b52e 100644 --- a/packages/runtime/src/composables/useTerminalSize.ts +++ b/packages/runtime/src/composables/useTerminalSize.ts @@ -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 }; diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index f8a97e4..4993793 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -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 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);