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>
This commit is contained in:
Mikael Hugo 2026-05-10 12:15:09 +02:00
parent 4e97058d7e
commit b0a8f32a10
2 changed files with 66 additions and 0 deletions

View file

@ -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");
});
});

View file

@ -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