From 9e55528c95cf01d1484d9d6850d89d484b82bc0a Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 10 May 2026 21:38:54 +0200 Subject: [PATCH] revert(tui): remove Ink bridge, restore pure custom differential renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- packages/tui/package.json | 10 +- packages/tui/src/__tests__/ink-bridge.test.ts | 33 ----- packages/tui/src/__tests__/tui.test.ts | 38 ------ packages/tui/src/index.ts | 2 - packages/tui/src/ink-bridge.tsx | 123 ------------------ packages/tui/src/tui.ts | 51 -------- packages/tui/tsconfig.json | 5 +- tsconfig.extensions.json | 2 - 8 files changed, 3 insertions(+), 261 deletions(-) delete mode 100644 packages/tui/src/__tests__/ink-bridge.test.ts delete mode 100644 packages/tui/src/ink-bridge.tsx diff --git a/packages/tui/package.json b/packages/tui/package.json index 4a8d189c5..a3d386f99 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -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" diff --git a/packages/tui/src/__tests__/ink-bridge.test.ts b/packages/tui/src/__tests__/ink-bridge.test.ts deleted file mode 100644 index fb7c2e829..000000000 --- a/packages/tui/src/__tests__/ink-bridge.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/tui/src/__tests__/tui.test.ts b/packages/tui/src/__tests__/tui.test.ts index 6248dc52b..5667b9c9d 100644 --- a/packages/tui/src/__tests__/tui.test.ts +++ b/packages/tui/src/__tests__/tui.test.ts @@ -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", - ); - }); -}); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index ff8f8ac13..9d7b5085b 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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, diff --git a/packages/tui/src/ink-bridge.tsx b/packages/tui/src/ink-bridge.tsx deleted file mode 100644 index 1041ee00e..000000000 --- a/packages/tui/src/ink-bridge.tsx +++ /dev/null @@ -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 ( - - {lines.map((line, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: stable index is fine for sequential lines - {line} - ))} - - ); -} - -/** - * 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 ; -} - -/** - * 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( - , - { exitOnCtrlC: false }, - ); - return { - stop: () => { - _tick = null; - unmount(); - }, - invalidate: () => _tick?.(), - }; -} diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index c1d9a569a..1e1511262 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 diff --git a/packages/tui/tsconfig.json b/packages/tui/tsconfig.json index 434a219b5..2af393ca5 100644 --- a/packages/tui/tsconfig.json +++ b/packages/tui/tsconfig.json @@ -19,11 +19,8 @@ "resolveJsonModule": true, "allowImportingTsExtensions": false, "useDefineForClassFields": false, - "jsx": "react-jsx", - "jsxImportSource": "react", "types": [ - "node", - "react" + "node" ], "outDir": "./dist", "rootDir": "./src" diff --git a/tsconfig.extensions.json b/tsconfig.extensions.json index d84893e5b..4236ca0e9 100644 --- a/tsconfig.extensions.json +++ b/tsconfig.extensions.json @@ -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": [