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:
parent
8c764f6c98
commit
9e55528c95
8 changed files with 3 additions and 261 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?.(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,11 +19,8 @@
|
|||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"useDefineForClassFields": false,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"types": [
|
||||
"node",
|
||||
"react"
|
||||
"node"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue