Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

128 lines
3.9 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef } from "react";
import { Terminal, useTerminal, type WTerm } from "@wterm/react";
import { render, type Instance } from "ink";
import { waitForYogaInit } from "ink-web";
import { createInkStreams, type InkStreams } from "./ink-streams";
import { InkApp } from "./ink-app";
import "@wterm/dom/src/terminal.css";
const inkApp = <InkApp />;
export default function InkTerminal() {
const { ref, write } = useTerminal();
const wrapperRef = useRef<HTMLDivElement>(null);
const streamsRef = useRef<InkStreams | null>(null);
const instanceRef = useRef<Instance | null>(null);
const startedRef = useRef(false);
const disposedRef = useRef(false);
const colsRef = useRef(0);
const scrollPendingRef = useRef(false);
const writeRef = useRef(write);
writeRef.current = write;
// wterm does not auto-scroll on new output (vercel-labs/wterm#61); follow
// the tail after its coalesced write -> setTimeout(0) -> rAF render pass.
// Skipped while the visitor has scrolled up to read scrollback.
const scheduleScroll = useCallback(() => {
if (scrollPendingRef.current) return;
const el = wrapperRef.current?.querySelector(".wterm");
if (el && el.scrollHeight - el.scrollTop - el.clientHeight > 100) return;
scrollPendingRef.current = true;
setTimeout(() => {
requestAnimationFrame(() => {
scrollPendingRef.current = false;
const target = wrapperRef.current?.querySelector(".wterm");
if (target) target.scrollTop = target.scrollHeight;
});
}, 0);
}, []);
const getStreams = useCallback(() => {
streamsRef.current ??= createInkStreams((data) => {
writeRef.current(data);
scheduleScroll();
});
return streamsRef.current;
}, [scheduleScroll]);
const handleReady = useCallback(
(wt: WTerm) => {
const streams = getStreams();
colsRef.current = wt.cols;
streams.setSize(wt.cols, wt.rows);
// ink components draw their own cursor; the bundled ink's cli-cursor
// shim is a no-op, so hide wterm's grid cursor here.
wt.write("\x1b[?25l");
if (!startedRef.current) {
startedRef.current = true;
void waitForYogaInit().then(() => {
if (disposedRef.current) return;
instanceRef.current = render(inkApp, {
stdout: streams.stdout,
stderr: streams.stdout,
stdin: streams.stdin,
patchConsole: false,
exitOnCtrlC: false,
});
});
} else if (instanceRef.current) {
instanceRef.current.clear();
instanceRef.current.rerender(inkApp);
}
},
[getStreams],
);
// wterm encodes the Backspace key as \x7f, which the bundled ink 6 parses
// as key.delete (forward delete); rewrite to \x08, which it parses as
// backspace. \x03 (Ctrl+C) is dropped so it stays free for copy.
const handleData = useCallback(
(data: string) => {
const mapped = data.replaceAll("\x7f", "\x08").replaceAll("\x03", "");
if (mapped.length === 0) return;
getStreams().pushInput(mapped);
},
[getStreams],
);
const handleResize = useCallback(
(cols: number, rows: number) => {
if (cols < colsRef.current && instanceRef.current) {
instanceRef.current.clear();
writeRef.current("\x1b[2J\x1b[3J\x1b[H");
}
colsRef.current = cols;
getStreams().setSize(cols, rows);
},
[getStreams],
);
useEffect(() => {
disposedRef.current = false;
return () => {
disposedRef.current = true;
instanceRef.current?.unmount();
instanceRef.current = null;
startedRef.current = false;
};
}, []);
return (
<div className="terminal-center">
<div ref={wrapperRef} className="terminal-host">
<Terminal
ref={ref}
autoResize
onReady={handleReady}
onData={handleData}
onResize={handleResize}
/>
</div>
</div>
);
}