From b0a8f32a10d1d4424482040dff82f6b166697acb Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 10 May 2026 12:15:09 +0200 Subject: [PATCH] feat(tui): wire Ink bridge into TUI.start() and stop() - TUI.useInk() opts into Ink-backed rendering (call before start()) - In start(): if _useInk || process.stdout.isTTY, mount Ink renderer via startInkRenderer() and skip the legacy differential render path entirely - In stop(): unmount Ink handle and return early; legacy terminal cleanup (cursor repositioning, showCursor, terminal.stop) is skipped since Ink handles terminal restoration itself - Passes this.render()/invalidate() via a plain Component wrapper to avoid the private handleInput TypeScript conflict - Two new contract tests: useInk() flag and stop() Ink handle teardown - 80/80 tests pass; legacy path unchanged for non-TTY (CI/tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/tui/src/__tests__/tui.test.ts | 27 ++++++++++++++++++ packages/tui/src/tui.ts | 39 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/tui/src/__tests__/tui.test.ts b/packages/tui/src/__tests__/tui.test.ts index 805a28923..228727703 100644 --- a/packages/tui/src/__tests__/tui.test.ts +++ b/packages/tui/src/__tests__/tui.test.ts @@ -128,3 +128,30 @@ describe("Container", () => { assert.equal(counter.disposed, 2); }); }); + +describe("TUI useInk", () => { + it("useInk_sets_flag_read_by_start", () => { + const tui = new TUI(makeTerminal()); + const anyTui = tui as any; + assert.equal(anyTui._useInk, false, "defaults to false"); + tui.useInk(); + assert.equal(anyTui._useInk, true, "useInk() sets flag to true"); + }); + + it("stop_clears_inkHandle_and_skips_terminal_stop_when_ink_active", () => { + const tui = new TUI(makeTerminal()); + const anyTui = tui as any; + let stopped = false; + // Inject a fake Ink handle as if start() had mounted Ink. + anyTui._inkHandle = { stop: () => { stopped = true; }, invalidate: () => {} }; + // Track whether the legacy terminal.stop() path was taken. + let terminalStopped = false; + (anyTui.terminal as any).stop = () => { terminalStopped = true; }; + + tui.stop(); + + assert.equal(stopped, true, "Ink handle stop() must be called"); + assert.equal(anyTui._inkHandle, null, "_inkHandle cleared after stop"); + assert.equal(terminalStopped, false, "legacy terminal.stop() must not be called when Ink is active"); + }); +}); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2328f6152..fd67d1d9d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5,6 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { startInkRenderer } from "./ink-bridge.js"; import { isKeyRelease, matchesKey } from "./keys.js"; import { applyLineResets, @@ -272,6 +273,8 @@ export class TUI extends Container { private fullRedrawCount = 0; private stopped = false; private _lastRenderedComponents: string[] | null = null; + private _useInk = false; + private _inkHandle: { stop(): void; invalidate(): void } | null = null; // === Sticky bottom scrolling === private isScrolledToBottom = true; // Track if user is scrolled to bottom @@ -303,6 +306,19 @@ export class TUI extends Container { } } + /** + * Opt into the Ink-backed render loop. + * + * Purpose: switch the TUI from the hand-rolled differential renderer to Ink's + * React render loop so components can be migrated to native Ink nodes + * incrementally. Must be called before start(). + * + * Consumer: interactive-mode and any callers that want Ink layout. + */ + useInk(): void { + this._useInk = true; + } + get fullRedraws(): number { return this.fullRedrawCount; } @@ -478,6 +494,21 @@ export class TUI extends Container { if (!this.terminal.isTTY) { return; } + // Ink-backed render path: Ink manages raw mode and input; the legacy + // differential renderer is bypassed entirely. + if (this._useInk || process.stdout.isTTY) { + // Wrap `this` in a plain Component so the private handleInput doesn't + // conflict with the public Component.handleInput? signature. + const root: Component = { + render: (w) => this.render(w), + invalidate: () => this.invalidate(), + }; + this._inkHandle = startInkRenderer( + root, + (data) => this.handleInput(data), + ); + return; + } this.terminal.start( (data) => this.handleInput(data), () => this.requestRender(), @@ -523,6 +554,14 @@ export class TUI extends Container { } this.overlayStack = []; + // Ink-backed path: unmount the Ink renderer and return; Ink restores the + // terminal to cooked mode and shows the cursor itself. + if (this._inkHandle) { + this._inkHandle.stop(); + this._inkHandle = null; + return; + } + // Move cursor to the end of the content to prevent overwriting/artifacts on exit if (this.previousLines.length > 0) { const targetRow = this.previousLines.length; // Line after the last content