revert(tui): remove Ink bridge, restore pure custom differential renderer

The Ink bridge added today was a misguided gradual-migration wrapper:
- Components still rendered via the old string-line protocol (no Ink layout)
- Key decodes were re-encoded to escape sequences → keys.ts decoded again (double round-trip bug)
- The _useInk / _inkHandle path blocked TTY start unconditionally via process.stdout.isTTY check

Removed: ink-bridge.tsx, ink-bridge.test.ts, useInk() method, _useInk/_inkHandle fields,
startInkRenderer import/export, Ink branch in start()/stop()/requestRender().

Removed ink and react from packages/tui dependencies and peerDependencies.
Reverted tsconfig.extensions.json jsx settings (only needed for the .tsx bridge file).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-10 21:38:54 +02:00
parent 8c764f6c98
commit 9e55528c95
8 changed files with 3 additions and 261 deletions

View file

@ -23,17 +23,11 @@
"dependencies": {
"chalk": "^5.6.2",
"get-east-asian-width": "^1.3.0",
"ink": "^7.0.2",
"marked": "^18.0.3",
"mime-types": "^3.0.1",
"react": "^19.2.6"
"mime-types": "^3.0.1"
},
"devDependencies": {
"@types/mime-types": "^2.1.4",
"@types/react": "^19.2.14"
},
"peerDependencies": {
"react": ">=19"
"@types/mime-types": "^2.1.4"
},
"optionalDependencies": {
"koffi": "^2.9.0"

View file

@ -1,33 +0,0 @@
import { describe, expect, test } from "vitest";
import type { Component } from "../tui.js";
/** Minimal component used to verify the bridge's rendering contract. */
class HelloComponent implements Component {
render(_width: number): string[] {
return ["Hello from Ink bridge!"];
}
invalidate() {}
}
describe("ink-bridge", () => {
test("LegacyComponentView_renders_Component_lines", () => {
// The bridge rendering itself requires a TTY — verify the Component
// protocol works as expected before wiring it into Ink.
const comp = new HelloComponent();
const lines = comp.render(80);
expect(lines).toEqual(["Hello from Ink bridge!"]);
});
test("Component_render_respects_width", () => {
const comp = new HelloComponent();
// Width is ignored by this stub, but callers may pass any positive value.
expect(comp.render(40)).toHaveLength(1);
expect(comp.render(120)).toHaveLength(1);
});
test("startInkRenderer_is_exported_from_package", async () => {
// Verify the public API surface exists without running it (no TTY in CI).
const mod = await import("../ink-bridge.js");
expect(typeof mod.startInkRenderer).toBe("function");
});
});

View file

@ -153,41 +153,3 @@ describe("Container", () => {
}
});
});
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

@ -48,8 +48,6 @@ export { TruncatedText } from "./components/truncated-text.js";
export type { EditorComponent } from "./editor-component.js";
// Fuzzy helpers are imported from `@singularity-forge/tui/fuzzy` (package subpath —
// avoids barrel regressions dropping exports). See package.json `"exports"` map.
// Ink bridge — gradual migration infrastructure
export { startInkRenderer } from "./ink-bridge.js";
// Keybindings
export {
DEFAULT_EDITOR_KEYBINDINGS,

View file

@ -1,123 +0,0 @@
/**
* ink-bridge.tsx Ink render loop adapter for the legacy Component interface.
*
* Purpose: host the existing Component tree inside Ink's React render loop,
* enabling gradual migration to native Ink components without rewriting
* the entire interactive mode at once.
*
* Consumer: TUI class (replaces the custom differential renderer in a future step).
*/
import React, { useEffect, useState } from "react";
import { Box, Text, render, useInput, useWindowSize } from "ink";
import type { Component } from "./tui.js";
/**
* Renders a legacy Component tree inside Ink.
*
* Purpose: bridge the existing string-line rendering protocol into Ink's
* React tree so that components can be migrated one-by-one.
*
* Consumer: InkApp below.
*/
function LegacyComponentView({
component,
width,
}: {
component: Component;
width: number;
}) {
const lines = component.render(width);
return (
<Box flexDirection="column">
{lines.map((line, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable index is fine for sequential lines
<Text key={i}>{line}</Text>
))}
</Box>
);
}
/**
* Root Ink app that drives the legacy Component render loop.
*
* Purpose: accept keyboard input from Ink and route it to the active
* component, then trigger a re-render so the updated state is displayed.
* Invalidation is event-driven: external callers invoke the returned
* invalidate() handle, which fires the tick signal registered here.
*
* Consumer: startInkRenderer.
*/
function InkApp({
root,
onInput,
onRegisterTick,
}: {
root: Component;
onInput: (data: string) => void;
onRegisterTick: (tick: () => void) => void;
}) {
const [, tick] = useState(0);
const { columns } = useWindowSize();
// Register the tick function so that startInkRenderer's invalidate() can
// trigger a React re-render without a polling interval.
useEffect(() => {
onRegisterTick(() => tick((n) => n + 1));
}, [onRegisterTick]);
useInput((input, key) => {
// Reconstruct the escape sequences that the legacy key handlers expect.
let data = input;
if (key.escape) data = "\x1b";
else if (key.return) data = "\r";
else if (key.backspace || key.delete) data = "\x7f";
else if (key.tab) data = "\t";
else if (key.upArrow) data = "\x1b[A";
else if (key.downArrow) data = "\x1b[B";
else if (key.leftArrow) data = "\x1b[D";
else if (key.rightArrow) data = "\x1b[C";
onInput(data);
tick((n) => n + 1);
});
return <LegacyComponentView component={root} width={columns ?? 80} />;
}
/**
* Ink-backed TUI runtime.
*
* Purpose: drop-in replacement for the legacy TUI render engine. Mounting
* this drives the entire Ink React tree and forwards terminal input to
* the root Component's handleInput chain. invalidate() triggers an
* immediate React re-render via an event-driven tick signal no polling.
*
* Consumer: TUI class; standalone callers can use this to render any
* Component tree under Ink.
*
* @param root - The root Component whose render() output fills the screen.
* @param onInput - Called with each decoded key string for legacy handlers.
* @returns Handle with stop() to unmount and invalidate() to request repaint.
*/
export function startInkRenderer(
root: Component,
onInput: (data: string) => void,
): { stop: () => void; invalidate: () => void } {
// Mutable signal populated by InkApp via onRegisterTick once the React
// tree has mounted. invalidate() fires this to trigger a synchronous tick.
let _tick: (() => void) | null = null;
const onRegisterTick = (tick: () => void) => {
_tick = tick;
};
const { unmount } = render(
<InkApp root={root} onInput={onInput} onRegisterTick={onRegisterTick} />,
{ exitOnCtrlC: false },
);
return {
stop: () => {
_tick = null;
unmount();
},
invalidate: () => _tick?.(),
};
}

View file

@ -11,7 +11,6 @@ import {
AutonomousStatusBar,
} from "./components/autonomous-status-bar.js";
import { syncHardwareCursorForIME } from "./hardware-cursor.js";
import { startInkRenderer } from "./ink-bridge.js";
import { isKeyRelease, matchesKey } from "./keys.js";
import {
applyLineResets,
@ -205,8 +204,6 @@ 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
@ -232,19 +229,6 @@ 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;
}
@ -420,27 +404,6 @@ export class TUI extends Container {
if (!this.terminal.isTTY) {
return;
}
// Ink-backed render path: Ink manages raw mode, input, and screen output.
// The legacy differential renderer (doRender) is bypassed entirely on TTY.
// process.stdout.isTTY guards this path — Ink requires a real interactive
// TTY to mount. useInk() is kept as an explicit opt-in for callers that
// want Ink on non-standard terminal configurations. Use PI_LEGACY_TUI=1
// to force the legacy renderer for debugging.
if (
(this._useInk || process.stdout.isTTY) &&
process.env.PI_LEGACY_TUI !== "1"
) {
// 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(),
@ -486,14 +449,6 @@ 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
@ -513,12 +468,6 @@ export class TUI extends Container {
requestRender(force = false): void {
// Skip rendering on non-TTY stdout to prevent CPU burn (issue #3095)
if (!this.terminal.isTTY) return;
// Ink-backed path: Ink owns the terminal — delegate to the Ink handle and
// do NOT call doRender(), which would write conflicting ANSI escapes.
if (this._inkHandle) {
this._inkHandle.invalidate();
return;
}
if (force) {
this.previousLines = [];
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear

View file

@ -19,11 +19,8 @@
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"useDefineForClassFields": false,
"jsx": "react-jsx",
"jsxImportSource": "react",
"types": [
"node",
"react"
"node"
],
"outDir": "./dist",
"rootDir": "./src"

View file

@ -8,8 +8,6 @@
"ignoreDeprecations": "6.0",
"target": "ES2024",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "react",
"rootDir": ".",
"paths": {
"@singularity-forge/coding-agent": [