feat(app): UDS bridge (channel/invoke/emit) + xterm.js terminal, M0 e2e works
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { SurfaceList } from "./SurfaceList";
|
||||
import { openWorkspace, newSurface, getStatus, onDaemonEvent, onDaemonRawEvent } from "./socketBridge";
|
||||
|
||||
export function App() {
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const ws = await getStatus();
|
||||
const flat = ws.flatMap((w) => w.surfaces);
|
||||
setSurfaces(flat);
|
||||
if (flat.length) setActive(flat[0]);
|
||||
})();
|
||||
|
||||
const unlisten = onDaemonEvent((evt) => {
|
||||
if (evt.evt === "surface_created") {
|
||||
setSurfaces((s) => [...s, evt.data.surface_id]);
|
||||
} else if (evt.evt === "surface_closed" || evt.evt === "exit") {
|
||||
// exit leaves the surface visible; surface_closed removes it.
|
||||
if (evt.evt === "surface_closed") {
|
||||
setSurfaces((s) => s.filter((id) => id !== evt.data.surface_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => {
|
||||
// Force a remount of the active TerminalView by toggling the key.
|
||||
setActive((cur) => cur);
|
||||
void getStatus().then((ws) => {
|
||||
const flat = ws.flatMap((w) => w.surfaces);
|
||||
setSurfaces(flat);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlisten.then((f) => f());
|
||||
void reconnect.then((f) => f());
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleNewSurface() {
|
||||
let ws = workspaceId;
|
||||
if (!ws) {
|
||||
ws = await openWorkspace(".");
|
||||
setWorkspaceId(ws);
|
||||
}
|
||||
const id = await newSurface(ws, 80, 24);
|
||||
setActive(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", background: "#000" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", width: 160 }}>
|
||||
<button onClick={handleNewSurface} style={{ margin: 8 }}>
|
||||
+ surface
|
||||
</button>
|
||||
<SurfaceList surfaces={surfaces} active={active} onSelect={setActive} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{active ? <TerminalView key={active} surfaceId={active} /> : <div style={{ color: "#666", padding: 16 }}>no surface</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export function SurfaceList({
|
||||
surfaces,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
surfaces: string[];
|
||||
active: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ width: 160, background: "#1a1a1a", color: "#ccc", padding: 8 }}>
|
||||
<div style={{ opacity: 0.6, fontSize: 11, marginBottom: 8 }}>SURFACES</div>
|
||||
{surfaces.map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
onClick={() => onSelect(id)}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
cursor: "pointer",
|
||||
borderRadius: 4,
|
||||
background: id === active ? "#333" : "transparent",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge";
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function TerminalView({ surfaceId }: { surfaceId: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const term = new Terminal({ fontFamily: "monospace", fontSize: 13, convertEol: false });
|
||||
try {
|
||||
term.loadAddon(new WebglAddon());
|
||||
} catch {
|
||||
// webgl unavailable → fall back to canvas/dom renderer silently
|
||||
}
|
||||
term.open(ref.current);
|
||||
|
||||
// Input → daemon.
|
||||
const inputDisposable = term.onData((data) => {
|
||||
void sendInput(surfaceId, encoder.encode(data));
|
||||
});
|
||||
|
||||
let disposed = false;
|
||||
|
||||
// Attach: fresh xterm instance, write snapshot, then stream live output.
|
||||
void attachSurface(surfaceId, (bytes) => {
|
||||
if (!disposed) term.write(decoder.decode(bytes));
|
||||
}).then((res) => {
|
||||
if (disposed) return;
|
||||
if (res.snapshot) term.write(res.snapshot);
|
||||
if (res.cols && res.rows) {
|
||||
term.resize(res.cols, res.rows);
|
||||
void resizeSurface(surfaceId, res.cols, res.rows);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
inputDisposable.dispose();
|
||||
void detachSurface(surfaceId);
|
||||
term.dispose();
|
||||
};
|
||||
}, [surfaceId]);
|
||||
|
||||
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
|
||||
}
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<div style={{ color: "#ddd", fontFamily: "monospace", padding: 16 }}>
|
||||
spacesh — scaffold OK
|
||||
</div>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export interface WorkspaceStatus {
|
||||
workspace_id: string;
|
||||
path: string;
|
||||
surfaces: string[];
|
||||
}
|
||||
|
||||
export async function openWorkspace(path: string): Promise<string> {
|
||||
const data = await invoke<{ workspace_id: string }>("open", { path });
|
||||
return data.workspace_id;
|
||||
}
|
||||
|
||||
export async function newSurface(
|
||||
workspaceId: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
command?: string,
|
||||
args: string[] = []
|
||||
): Promise<string> {
|
||||
const data = await invoke<{ surface_id: string }>("new_surface", {
|
||||
workspaceId,
|
||||
command: command ?? null,
|
||||
args,
|
||||
cols,
|
||||
rows,
|
||||
});
|
||||
return data.surface_id;
|
||||
}
|
||||
|
||||
export async function sendInput(surfaceId: string, data: Uint8Array): Promise<void> {
|
||||
await invoke("input", { surfaceId, data: Array.from(data) });
|
||||
}
|
||||
|
||||
export async function resizeSurface(surfaceId: string, cols: number, rows: number): Promise<void> {
|
||||
await invoke("resize", { surfaceId, cols, rows });
|
||||
}
|
||||
|
||||
export interface AttachResult {
|
||||
snapshot: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export async function attachSurface(
|
||||
surfaceId: string,
|
||||
onOutput: (bytes: Uint8Array) => void
|
||||
): Promise<AttachResult> {
|
||||
const channel = new Channel<number[]>();
|
||||
channel.onmessage = (msg) => onOutput(new Uint8Array(msg));
|
||||
return await invoke<AttachResult>("attach", { surfaceId, onOutput: channel });
|
||||
}
|
||||
|
||||
export async function detachSurface(surfaceId: string): Promise<void> {
|
||||
await invoke("detach", { surfaceId });
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<WorkspaceStatus[]> {
|
||||
const data = await invoke<{ workspaces: WorkspaceStatus[] }>("status");
|
||||
return data.workspaces;
|
||||
}
|
||||
|
||||
export type DaemonEvt =
|
||||
| { evt: "exit"; data: { surface_id: string; code: number } }
|
||||
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
|
||||
| { evt: "surface_closed"; data: { surface_id: string } };
|
||||
|
||||
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
|
||||
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
|
||||
return listen(name, () => handler());
|
||||
}
|
||||
Reference in New Issue
Block a user