feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
159
frontend/src/features/terminals/TerminalView.tsx
Normal file
159
frontend/src/features/terminals/TerminalView.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* xterm.js wrapper (L3). Mounts a `Terminal`, wires it bidirectionally to the
|
||||
* {@link TerminalGateway} port (or a custom opener), and fits it to its container:
|
||||
*
|
||||
* - PTY output (gateway `onData`) → `term.write(bytes)`.
|
||||
* - xterm `onData` (keystrokes) → `handle.write(bytes)`.
|
||||
* - container resize (fit addon) → `handle.resize(rows, cols)`.
|
||||
*
|
||||
* Pure presentation: it only knows the port, never `invoke()`/`Channel`
|
||||
* (ARCHITECTURE §1.3). The cwd it opens in is supplied by the caller (the
|
||||
* project tab passes the project root).
|
||||
*
|
||||
* An optional `open` prop can override the default `terminal.openTerminal` call,
|
||||
* enabling the agent terminal to reuse this component with `agent.launchAgent`.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||
|
||||
interface TerminalViewProps {
|
||||
/** Working directory the shell opens in (typically the project root). */
|
||||
cwd: string;
|
||||
/**
|
||||
* Optional custom opener. When provided, it is used instead of the terminal
|
||||
* gateway's `openTerminal`. This lets agent terminals reuse the same xterm
|
||||
* wrapper with a different backend opener (e.g. `launchAgent`).
|
||||
* When absent, falls back to `terminal.openTerminal` from the DI context.
|
||||
*/
|
||||
open?: (
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
) => Promise<TerminalHandle>;
|
||||
}
|
||||
|
||||
export function TerminalView({ cwd, open }: TerminalViewProps) {
|
||||
const { terminal } = useGateways();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// The opener (`open` or the terminal gateway) is read through a ref so the
|
||||
// effect does NOT depend on its identity. Otherwise every parent re-render
|
||||
// (e.g. App's domain-event counter bumping on `AgentLaunched`) would create a
|
||||
// fresh `open` closure, re-run the effect, close + relaunch the PTY, emit
|
||||
// another event, and so on — an infinite launch loop (black terminal, events
|
||||
// skyrocketing). The terminal is re-mounted by a `key` when the agent changes,
|
||||
// so the correct opener is always captured at mount.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const terminalRef = useRef(terminal);
|
||||
terminalRef.current = terminal;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const tgw = terminalRef.current;
|
||||
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
|
||||
if (!container || !opener) return;
|
||||
|
||||
const term = new Terminal({
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
// xterm needs a real layout engine; in non-DOM environments `open` throws.
|
||||
// Bail gracefully so a headless render (jsdom tests) doesn't break the view.
|
||||
try {
|
||||
term.open(container);
|
||||
} catch {
|
||||
term.dispose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* container not laid out yet; a resize will retry */
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let handle: TerminalHandle | null = null;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Buffer keystrokes that arrive before the PTY finished opening.
|
||||
let pending = "";
|
||||
const onKey = term.onData((data) => {
|
||||
if (handle) void handle.write(encoder.encode(data));
|
||||
else pending += data;
|
||||
});
|
||||
|
||||
opener(
|
||||
{ cwd, rows: term.rows, cols: term.cols },
|
||||
(bytes) => {
|
||||
if (!disposed) term.write(bytes);
|
||||
},
|
||||
)
|
||||
.then((h) => {
|
||||
if (disposed) {
|
||||
void h.close();
|
||||
return;
|
||||
}
|
||||
handle = h;
|
||||
if (pending) {
|
||||
void h.write(encoder.encode(pending));
|
||||
pending = "";
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!disposed) {
|
||||
term.write(
|
||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Refit + propagate size to the PTY on container resize.
|
||||
const ro = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (handle) void handle.resize(term.rows, term.cols);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
ro.disconnect();
|
||||
onKey.dispose();
|
||||
if (handle) void handle.close();
|
||||
term.dispose();
|
||||
};
|
||||
// Only re-open on cwd change (or mount). The opener is read from a ref, and
|
||||
// agent switches re-mount via `key`, so we must NOT depend on `open`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cwd]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="terminal-view"
|
||||
style={{ width: "100%", height: "100%", minHeight: "16rem" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
Reference in New Issue
Block a user