feat: implement proper interactive terminal with WebSocket

Replace hacky SSE polling with real-time WebSocket for bidirectional
terminal communication. This is a proper interactive terminal - type
directly, like tmux attach in the browser.

Architecture:
- WebSocket server on port 3001 alongside Next.js
- Uses tmux pipe-pane for real-time output streaming
- Sends input character-by-character via tmux send-keys
- Handles terminal resize events
- Connection status indicator

Implementation:
- packages/web/src/server/terminal-websocket.ts: WebSocket server
- Terminal component now fully interactive (not read-only)
- Runs both servers via concurrently in dev mode
- Green dot = connected, red dot = disconnected
- Proper cursor, no more clunky input box

Benefits:
- Real-time streaming (not 2-second polling)
- Type directly into terminal
- Proper terminal control sequences
- Handles resize
- Like native tmux attach

Dependencies added:
- ws (WebSocket server)
- @types/ws
- concurrently (run multiple servers)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-14 19:05:13 +05:30
parent 7c099c10eb
commit c07cbc000a
4 changed files with 345 additions and 75 deletions

View File

@ -5,7 +5,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"dev": "concurrently \"npm:dev:next\" \"npm:dev:ws\"",
"dev:next": "next dev",
"dev:ws": "tsx src/server/terminal-websocket.ts",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
@ -26,7 +28,8 @@
"@xterm/xterm": "^6.0.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
@ -34,7 +37,9 @@
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.0",
"concurrently": "^9.2.1",
"jsdom": "^25.0.0",
"playwright": "^1.49.0",
"tailwindcss": "^4.0.0",

View File

@ -11,26 +11,24 @@ interface TerminalProps {
/**
* Terminal embed using xterm.js.
* Streams tmux pane output via SSE. Interactive terminal with direct input.
* Proper interactive terminal via WebSocket - type directly, real-time streaming.
*/
export function Terminal({ sessionId }: TerminalProps) {
const [fullscreen, setFullscreen] = useState(false);
const [isInteractive, setIsInteractive] = useState(false);
const [connected, setConnected] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const lastContentRef = useRef<string>("");
const wsRef = useRef<WebSocket | null>(null);
// Initialize xterm.js
useEffect(() => {
if (!terminalRef.current) return;
// Create terminal instance
// Create terminal instance - fully interactive
const term = new XTerm({
cursorBlink: true,
cursorStyle: "block",
disableStdin: true, // Will be enabled when interactive mode is on
theme: {
background: "#000000",
foreground: "#d0d0d0",
@ -70,88 +68,84 @@ export function Terminal({ sessionId }: TerminalProps) {
// Refit terminal when fullscreen changes
useEffect(() => {
if (fitAddonRef.current) {
setTimeout(() => fitAddonRef.current?.fit(), 150);
setTimeout(() => {
fitAddonRef.current?.fit();
// Send resize to server
const term = xtermRef.current;
const ws = wsRef.current;
if (term && ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
cols: term.cols,
rows: term.rows,
}),
);
}
}, 150);
}
}, [fullscreen]);
// Handle interactive mode
// Connect to WebSocket for interactive terminal
useEffect(() => {
const term = xtermRef.current;
if (!term) return;
if (isInteractive) {
term.options.disableStdin = false;
term.options.cursorBlink = true;
// Connect to WebSocket server
const wsUrl = `ws://localhost:${process.env.NEXT_PUBLIC_WS_PORT ?? "3001"}?session=${sessionId}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
// Handle keyboard input
const disposable = term.onData((data) => {
// Send each keystroke to the session
void (async () => {
try {
await fetch(`/api/sessions/${sessionId}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: data }),
});
} catch (err) {
console.error("[Terminal] Failed to send input:", err);
}
})();
});
ws.addEventListener("open", () => {
setConnected(true);
console.log("[Terminal] WebSocket connected");
return () => {
disposable.dispose();
};
} else {
term.options.disableStdin = true;
term.options.cursorBlink = false;
}
}, [isInteractive, sessionId]);
// Send initial terminal size
ws.send(
JSON.stringify({
type: "resize",
cols: term.cols,
rows: term.rows,
}),
);
});
// Connect to SSE stream
useEffect(() => {
const term = xtermRef.current;
if (!term) return;
ws.addEventListener("message", (event) => {
// Write output from server
term.write(event.data);
});
const eventSource = new EventSource(`/api/sessions/${sessionId}/terminal`);
eventSourceRef.current = eventSource;
ws.addEventListener("error", (err) => {
console.error("[Terminal] WebSocket error:", err);
term.writeln("\r\n\r\n[Connection error]");
setConnected(false);
});
eventSource.addEventListener("message", (event) => {
try {
const data = JSON.parse(event.data) as
| { type: "snapshot" | "update"; content: string }
| { type: "exit" };
ws.addEventListener("close", () => {
console.log("[Terminal] WebSocket closed");
term.writeln("\r\n\r\n[Connection closed]");
setConnected(false);
});
if (data.type === "snapshot" || data.type === "update") {
const content = data.content;
// Only update if content changed
if (content !== lastContentRef.current) {
lastContentRef.current = content;
// Reset terminal and write new content
term.reset();
term.write(content);
// Scroll to bottom
term.scrollToBottom();
}
} else if (data.type === "exit") {
term.writeln("\r\n\r\n[Session exited]");
}
} catch (err) {
console.error("[Terminal] Failed to parse SSE event:", err);
// Handle keyboard input - send to server
const inputDisposable = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "input", data }));
}
});
eventSource.addEventListener("error", () => {
eventSource.close();
term.writeln("\r\n\r\n[Connection lost]");
// Handle terminal resize - send to server
const resizeDisposable = term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
});
return () => {
eventSource.close();
eventSourceRef.current = null;
inputDisposable.dispose();
resizeDisposable.dispose();
ws.close();
wsRef.current = null;
};
}, [sessionId]);
@ -164,15 +158,25 @@ export function Terminal({ sessionId }: TerminalProps) {
>
<div className="flex items-center gap-2 border-b border-[var(--color-border-default)] bg-[var(--color-bg-tertiary)] px-3 py-2">
<div className="flex gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-[#f85149]" />
<div
className={cn(
"h-2.5 w-2.5 rounded-full transition-colors",
connected ? "bg-[#3fb950]" : "bg-[#f85149]",
)}
/>
<div className="h-2.5 w-2.5 rounded-full bg-[#d29922]" />
<div className="h-2.5 w-2.5 rounded-full bg-[#3fb950]" />
<div className="h-2.5 w-2.5 rounded-full bg-[#484f58]" />
</div>
<span className="font-[var(--font-mono)] text-xs text-[var(--color-text-muted)]">
{sessionId}
</span>
<span className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">
Read-only
<span
className={cn(
"text-[10px] font-medium uppercase tracking-wide",
connected ? "text-[var(--color-accent-green)]" : "text-[var(--color-text-muted)]",
)}
>
{connected ? "Interactive" : "Connecting..."}
</span>
<button
onClick={() => setFullscreen(!fullscreen)}

View File

@ -0,0 +1,153 @@
/**
* WebSocket server for interactive terminal sessions.
*
* Runs alongside Next.js on port 3001.
* Provides bidirectional streaming for tmux sessions.
*/
import { WebSocketServer, type WebSocket } from "ws";
import { spawn, type ChildProcess } from "node:child_process";
import { createServer } from "node:http";
interface TerminalSession {
sessionId: string;
ws: WebSocket;
captureProcess: ChildProcess | null;
}
const sessions = new Map<string, TerminalSession>();
const server = createServer();
const wss = new WebSocketServer({ server });
wss.on("connection", (ws, req) => {
const url = new URL(req.url ?? "/", "http://localhost");
const sessionId = url.searchParams.get("session");
if (!sessionId) {
ws.close(1008, "Missing session parameter");
return;
}
console.log(`[WebSocket] Client connected to session: ${sessionId}`);
// Create session
const session: TerminalSession = {
sessionId,
ws,
captureProcess: null,
};
sessions.set(sessionId, session);
// Start streaming tmux output using tmux pipe-pane
// This continuously pipes output to a command
const captureProcess = spawn("tmux", [
"pipe-pane",
"-t",
sessionId,
"-O",
"cat",
]);
session.captureProcess = captureProcess;
// Stream output to WebSocket
captureProcess.stdout?.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data.toString("utf-8"));
}
});
captureProcess.stderr?.on("data", (data: Buffer) => {
console.error(`[WebSocket] tmux pipe-pane error:`, data.toString());
});
captureProcess.on("exit", (code) => {
console.log(`[WebSocket] pipe-pane exited for ${sessionId} with code ${code}`);
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, "Session ended");
}
});
// Handle input from client
ws.on("message", (message) => {
const data = message.toString("utf-8");
// Parse message: could be input or control command
try {
const msg = JSON.parse(data) as { type: string; data?: string; cols?: number; rows?: number };
if (msg.type === "input" && msg.data) {
// Send input to tmux session
const sendProcess = spawn("tmux", [
"send-keys",
"-t",
sessionId,
"-l",
msg.data,
]);
sendProcess.on("error", (err) => {
console.error(`[WebSocket] Failed to send keys:`, err);
});
} else if (msg.type === "resize" && msg.cols && msg.rows) {
// Resize tmux pane
const resizeProcess = spawn("tmux", [
"resize-pane",
"-t",
sessionId,
"-x",
String(msg.cols),
"-y",
String(msg.rows),
]);
resizeProcess.on("error", (err) => {
console.error(`[WebSocket] Failed to resize:`, err);
});
}
} catch {
// Not JSON, treat as raw input
const sendProcess = spawn("tmux", ["send-keys", "-t", sessionId, "-l", data]);
sendProcess.on("error", (err) => {
console.error(`[WebSocket] Failed to send keys:`, err);
});
}
});
// Handle disconnect
ws.on("close", () => {
console.log(`[WebSocket] Client disconnected from session: ${sessionId}`);
// Stop pipe-pane
if (session.captureProcess) {
spawn("tmux", ["pipe-pane", "-t", sessionId]);
session.captureProcess.kill();
}
sessions.delete(sessionId);
});
// Send initial output (capture recent history)
spawn("tmux", ["capture-pane", "-t", sessionId, "-p", "-S", "-100"])
.stdout?.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data.toString("utf-8"));
}
});
});
const PORT = parseInt(process.env.WS_PORT ?? "3001", 10);
server.listen(PORT, () => {
console.log(`[WebSocket] Terminal server listening on port ${PORT}`);
});
// Graceful shutdown
process.on("SIGINT", () => {
console.log("[WebSocket] Shutting down...");
wss.close();
server.close();
process.exit(0);
});

View File

@ -460,6 +460,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.4(react@19.2.4)
ws:
specifier: ^8.19.0
version: 8.19.0
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.0.0
@ -476,9 +479,15 @@ importers:
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14)
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitejs/plugin-react':
specifier: ^4.3.0
version: 4.7.0(vite@5.4.21(@types/node@25.2.3)(lightningcss@1.30.2))
concurrently:
specifier: ^9.2.1
version: 9.2.1
jsdom:
specifier: ^25.0.0
version: 25.0.1
@ -1626,6 +1635,9 @@ packages:
'@types/wrap-ansi@3.0.0':
resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.55.0':
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1931,6 +1943,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -1963,6 +1979,11 @@ packages:
langchain: '>=0.2.11'
openai: '>=4.50.0'
concurrently@9.2.1:
resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
engines: {node: '>=18'}
hasBin: true
console-table-printer@2.15.0:
resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==}
@ -2226,6 +2247,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.4.0:
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
engines: {node: '>=18'}
@ -2761,6 +2786,10 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
resolve-package-path@4.0.3:
resolution: {integrity: sha512-SRpNAPW4kewOaNUt8VPqhJ0UMxawMwzJD8V7m1cJfdSTK9ieZwS6K7Dabsm4bmLFM96Z5Y/UznrpG5kt1im8yA==}
engines: {node: '>= 12'}
@ -2821,6 +2850,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@ -2885,6 +2918,10 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@ -2952,6 +2989,10 @@ packages:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
@ -3219,6 +3260,10 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
@ -3238,6 +3283,10 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -3246,6 +3295,14 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -4186,6 +4243,10 @@ snapshots:
'@types/wrap-ansi@3.0.0': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.2.3
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -4543,6 +4604,12 @@ snapshots:
client-only@0.0.1: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@ -4582,6 +4649,15 @@ snapshots:
transitivePeerDependencies:
- debug
concurrently@9.2.1:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.8.3
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
console-table-printer@2.15.0:
dependencies:
simple-wcswidth: 1.1.2
@ -4863,6 +4939,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.4.0: {}
get-intrinsic@1.3.0:
@ -5348,6 +5426,8 @@ snapshots:
indent-string: 4.0.0
strip-indent: 3.0.0
require-directory@2.1.1: {}
resolve-package-path@4.0.3:
dependencies:
path-root: 0.1.1
@ -5450,6 +5530,8 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
@ -5503,6 +5585,10 @@ snapshots:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
symbol-tree@3.2.4: {}
tailwindcss@4.1.18: {}
@ -5550,6 +5636,8 @@ snapshots:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@ -5818,16 +5906,36 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
ws@8.19.0: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yaml@2.8.2: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.3: {}