v1.10: booterm container — xterm.js + tmux + node-pty
This commit is contained in:
41
apps/booterm/Dockerfile
Normal file
41
apps/booterm/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---- Build stage: compile TypeScript ----
|
||||
FROM node:20-alpine AS builder
|
||||
ENV COREPACK_DEFAULT_TO_LATEST=0
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /build
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY apps/server/package.json ./apps/server/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY apps/booterm/package.json ./apps/booterm/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY apps/booterm ./apps/booterm
|
||||
RUN pnpm --filter=@boocode/booterm build
|
||||
|
||||
# ---- Prod-deps stage: hoisted, native built via npm rebuild ----
|
||||
FROM node:20-alpine AS proddeps
|
||||
ENV COREPACK_DEFAULT_TO_LATEST=0
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /prod
|
||||
COPY apps/booterm/package.json ./package.json
|
||||
RUN pnpm install --prod --config.node-linker=hoisted --config.strict-peer-dependencies=false
|
||||
# pnpm 10 ignores build scripts; force compile with npm directly.
|
||||
# node-gyp is bundled with npm in the node:20-alpine image.
|
||||
RUN cd node_modules/node-pty && npm run install
|
||||
# Sanity check — fail the build if the artifact still isn't there
|
||||
RUN test -f node_modules/node-pty/build/Release/pty.node && echo "pty.node OK" || (echo "pty.node MISSING" && exit 1)
|
||||
|
||||
# ---- Runtime ----
|
||||
FROM node:20-alpine AS runtime
|
||||
RUN apk add --no-cache tmux libstdc++
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/apps/booterm/dist ./dist
|
||||
COPY --from=proddeps /prod/package.json ./package.json
|
||||
COPY --from=proddeps /prod/node_modules ./node_modules
|
||||
COPY apps/booterm/tmux.conf /etc/booterm/tmux.conf
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/index.js"]
|
||||
27
apps/booterm/package.json
Normal file
27
apps/booterm/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@boocode/booterm",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"fastify": "^4.28.1",
|
||||
"node-pty": "^1.0.0",
|
||||
"pg": "^8.13.0",
|
||||
"tslib": "^2.6.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/pg": "^8.11.10",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
11
apps/booterm/src/auth.ts
Normal file
11
apps/booterm/src/auth.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
// Mirrors the boocode pattern: there is no app-layer auth — Authelia handles
|
||||
// it at the reverse proxy (CLAUDE.md). All broker.publishUser calls use
|
||||
// 'default' as the user key. We accept Remote-User when present (set by the
|
||||
// proxy in prod) and fall back to 'default' on direct Tailscale access.
|
||||
export function getUser(req: FastifyRequest): string {
|
||||
const header = req.headers['remote-user'];
|
||||
if (typeof header === 'string' && header.length > 0) return header;
|
||||
return 'default';
|
||||
}
|
||||
26
apps/booterm/src/config.ts
Normal file
26
apps/booterm/src/config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
let cached: Config | null = null;
|
||||
|
||||
export function loadConfig(): Config {
|
||||
if (cached) return cached;
|
||||
const parsed = ConfigSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment configuration:');
|
||||
console.error(parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
46
apps/booterm/src/db.ts
Normal file
46
apps/booterm/src/db.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
let pool: pg.Pool | null = null;
|
||||
|
||||
export function getPool(databaseUrl: string): pg.Pool {
|
||||
if (pool) return pool;
|
||||
pool = new Pool({ connectionString: databaseUrl, max: 5, idleTimeoutMillis: 30_000 });
|
||||
return pool;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_path: string;
|
||||
}
|
||||
|
||||
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
|
||||
if (!pool) throw new Error('db pool not initialized');
|
||||
const res = await pool.query<SessionInfo>(
|
||||
`SELECT s.id, s.project_id, p.path AS project_path
|
||||
FROM sessions s
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE s.id = $1`,
|
||||
[sessionId],
|
||||
);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function pingDb(): Promise<boolean> {
|
||||
if (!pool) return false;
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
60
apps/booterm/src/index.ts
Normal file
60
apps/booterm/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import Fastify from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import { loadConfig } from './config.js';
|
||||
import { getPool, closeDb } from './db.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerTerminalRoutes } from './routes/terminals.js';
|
||||
import { registerWsAttachRoute } from './ws/attach.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.LOG_LEVEL },
|
||||
});
|
||||
|
||||
app.removeContentTypeParser(['application/json']);
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||
const str = (body as string) ?? '';
|
||||
if (str.trim().length === 0) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
done(null, JSON.parse(str));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
getPool(config.DATABASE_URL);
|
||||
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
registerHealthRoutes(app);
|
||||
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
||||
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
app.log.info(`received ${signal}, shutting down`);
|
||||
try {
|
||||
await app.close();
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
|
||||
await app.listen({ port: config.PORT, host: config.HOST });
|
||||
app.log.info(`booterm listening on http://${config.HOST}:${config.PORT}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal startup error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
102
apps/booterm/src/pty/manager.ts
Normal file
102
apps/booterm/src/pty/manager.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case.
|
||||
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
export function sanitizeId(raw: string): string | null {
|
||||
if (!ID_RE.test(raw)) return null;
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
|
||||
export function tmuxSessionName(sessionId: string): string {
|
||||
return `bc-${sessionId}`;
|
||||
}
|
||||
|
||||
export function tmuxWindowName(paneId: string): string {
|
||||
return `term-${paneId}`;
|
||||
}
|
||||
|
||||
interface CmdResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
// Wrap child_process.spawn with shell:false so each argv element is passed
|
||||
// as a separate argument — no shell interpolation, no injection surface.
|
||||
function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
|
||||
child.on('error', (err) => {
|
||||
resolve({ stdout, stderr: stderr + String(err), code: 1 });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
resolve({ stdout, stderr, code: code ?? 0 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasSession(tmuxConfPath: string, sessionName: string): Promise<boolean> {
|
||||
const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> {
|
||||
const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']);
|
||||
if (res.code !== 0) return [];
|
||||
return res.stdout.trim().split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
export async function killWindow(
|
||||
tmuxConfPath: string,
|
||||
sessionName: string,
|
||||
windowName: string,
|
||||
): Promise<boolean> {
|
||||
const res = await runTmux(tmuxConfPath, ['kill-window', '-t', `${sessionName}:${windowName}`]);
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
// Idempotent. Creates the tmux session if it doesn't exist, then ensures the
|
||||
// named window is present. The session's initial window is created with the
|
||||
// target name (via `-n`) so we don't need a separate rename step.
|
||||
export async function ensureWindow(
|
||||
tmuxConfPath: string,
|
||||
sessionName: string,
|
||||
windowName: string,
|
||||
projectRoot: string,
|
||||
log: FastifyBaseLogger,
|
||||
): Promise<void> {
|
||||
if (!(await hasSession(tmuxConfPath, sessionName))) {
|
||||
log.info({ sessionName, windowName, projectRoot }, 'creating tmux session');
|
||||
const res = await runTmux(tmuxConfPath, [
|
||||
'new-session', '-d',
|
||||
'-s', sessionName,
|
||||
'-n', windowName,
|
||||
'-c', projectRoot,
|
||||
]);
|
||||
if (res.code !== 0) {
|
||||
log.error({ res }, 'tmux new-session failed');
|
||||
throw new Error(`tmux new-session failed: ${res.stderr}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const windows = await listWindows(tmuxConfPath, sessionName);
|
||||
if (windows.includes(windowName)) return;
|
||||
|
||||
const res = await runTmux(tmuxConfPath, [
|
||||
'new-window',
|
||||
'-t', sessionName,
|
||||
'-n', windowName,
|
||||
'-c', projectRoot,
|
||||
]);
|
||||
if (res.code !== 0) {
|
||||
log.error({ res }, 'tmux new-window failed');
|
||||
throw new Error(`tmux new-window failed: ${res.stderr}`);
|
||||
}
|
||||
}
|
||||
41
apps/booterm/src/pty/pty.ts
Normal file
41
apps/booterm/src/pty/pty.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as pty from 'node-pty';
|
||||
import type { IPty } from 'node-pty';
|
||||
|
||||
export interface AttachPtyOptions {
|
||||
sessionName: string;
|
||||
windowName: string;
|
||||
projectRoot: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
tmuxConfPath: string;
|
||||
}
|
||||
|
||||
function cleanEnv(): { [key: string]: string } {
|
||||
const out: { [key: string]: string } = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
out['TERM'] = 'screen-256color';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Spawns a tmux client attached to the given session+window. `-d` detaches any
|
||||
// other client so a browser refresh takes over the same window without
|
||||
// duplicate input. tmux server (and the window) persists across PTY exits.
|
||||
export function attachPty(opts: AttachPtyOptions): IPty {
|
||||
return pty.spawn(
|
||||
'tmux',
|
||||
[
|
||||
'-f', opts.tmuxConfPath,
|
||||
'attach-session', '-d',
|
||||
'-t', `${opts.sessionName}:${opts.windowName}`,
|
||||
],
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
cwd: opts.projectRoot,
|
||||
env: cleanEnv(),
|
||||
},
|
||||
);
|
||||
}
|
||||
9
apps/booterm/src/routes/health.ts
Normal file
9
apps/booterm/src/routes/health.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { pingDb } from '../db.js';
|
||||
|
||||
export function registerHealthRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/term/health', async () => {
|
||||
const dbOk = await pingDb();
|
||||
return { ok: true, db: dbOk };
|
||||
});
|
||||
}
|
||||
88
apps/booterm/src/routes/terminals.ts
Normal file
88
apps/booterm/src/routes/terminals.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getSessionInfo } from '../db.js';
|
||||
import {
|
||||
sanitizeId,
|
||||
tmuxSessionName,
|
||||
tmuxWindowName,
|
||||
ensureWindow,
|
||||
killWindow,
|
||||
hasSession,
|
||||
listWindows,
|
||||
} from '../pty/manager.js';
|
||||
import { resizePane } from '../ws/attach.js';
|
||||
|
||||
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
|
||||
const ResizeBodySchema = z.object({
|
||||
cols: z.coerce.number().int().min(1).max(2000),
|
||||
rows: z.coerce.number().int().min(1).max(2000),
|
||||
});
|
||||
|
||||
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
|
||||
app.post<{ Params: { sid: string; pid: string } }>(
|
||||
'/api/term/sessions/:sid/panes/:pid/start',
|
||||
async (req, reply) => {
|
||||
const p = ParamsSchema.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
||||
const sid = sanitizeId(p.data.sid);
|
||||
const pid = sanitizeId(p.data.pid);
|
||||
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
||||
|
||||
const session = await getSessionInfo(sid);
|
||||
if (!session) return reply.code(404).send({ error: 'unknown_session' });
|
||||
|
||||
const sessionName = tmuxSessionName(sid);
|
||||
const windowName = tmuxWindowName(pid);
|
||||
|
||||
try {
|
||||
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
|
||||
} catch (err) {
|
||||
req.log.error({ err }, 'ensureWindow failed');
|
||||
return reply.code(500).send({ error: 'tmux_failed' });
|
||||
}
|
||||
return reply.code(200).send({ tmux_window: windowName });
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { sid: string; pid: string }; Body: { cols: number; rows: number } }>(
|
||||
'/api/term/sessions/:sid/panes/:pid/resize',
|
||||
async (req, reply) => {
|
||||
const p = ParamsSchema.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
||||
const b = ResizeBodySchema.safeParse(req.body);
|
||||
if (!b.success) return reply.code(400).send({ error: 'bad_body' });
|
||||
const sid = sanitizeId(p.data.sid);
|
||||
const pid = sanitizeId(p.data.pid);
|
||||
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
||||
|
||||
const ok = resizePane(pid, b.data.cols, b.data.rows);
|
||||
if (!ok) return reply.code(404).send({ error: 'no_active_pty' });
|
||||
return reply.code(200).send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { sid: string; pid: string } }>(
|
||||
'/api/term/sessions/:sid/panes/:pid/kill',
|
||||
async (req, reply) => {
|
||||
const p = ParamsSchema.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
||||
const sid = sanitizeId(p.data.sid);
|
||||
const pid = sanitizeId(p.data.pid);
|
||||
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
||||
|
||||
const sessionName = tmuxSessionName(sid);
|
||||
const windowName = tmuxWindowName(pid);
|
||||
|
||||
if (!(await hasSession(tmuxConfPath, sessionName))) {
|
||||
return reply.code(404).send({ error: 'unknown_session' });
|
||||
}
|
||||
const windows = await listWindows(tmuxConfPath, sessionName);
|
||||
if (!windows.includes(windowName)) {
|
||||
return reply.code(404).send({ error: 'unknown_pane' });
|
||||
}
|
||||
const killed = await killWindow(tmuxConfPath, sessionName, windowName);
|
||||
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
|
||||
return reply.code(200).send({ ok: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
128
apps/booterm/src/ws/attach.ts
Normal file
128
apps/booterm/src/ws/attach.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { IPty } from 'node-pty';
|
||||
import { getSessionInfo } from '../db.js';
|
||||
import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js';
|
||||
import { attachPty } from '../pty/pty.js';
|
||||
import { getUser } from '../auth.js';
|
||||
|
||||
// Registry of currently-attached PTYs keyed by paneId. Used by the resize REST
|
||||
// route to find the active node-pty handle so it can call pty.resize(cols, rows).
|
||||
const active = new Map<string, IPty>();
|
||||
|
||||
export function resizePane(paneId: string, cols: number, rows: number): boolean {
|
||||
const handle = active.get(paneId);
|
||||
if (!handle) return false;
|
||||
try {
|
||||
handle.resize(cols, rows);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||
app.get<{
|
||||
Params: { sid: string; pid: string };
|
||||
Querystring: { cols?: string; rows?: string };
|
||||
}>(
|
||||
'/ws/term/sessions/:sid/panes/:pid',
|
||||
{ websocket: true },
|
||||
async (socket, req) => {
|
||||
const sid = sanitizeId(req.params.sid);
|
||||
const pid = sanitizeId(req.params.pid);
|
||||
if (!sid || !pid) {
|
||||
socket.close(1008, 'bad_id_format');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUser(req);
|
||||
req.log.info({ user, sid, pid }, 'ws attach');
|
||||
|
||||
const session = await getSessionInfo(sid);
|
||||
if (!session) {
|
||||
socket.close(1008, 'unknown_session');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionName = tmuxSessionName(sid);
|
||||
const windowName = tmuxWindowName(pid);
|
||||
try {
|
||||
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
|
||||
} catch (err) {
|
||||
req.log.error({ err }, 'ensureWindow failed in WS handler');
|
||||
socket.close(1011, 'tmux_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = parseInt(req.query.cols ?? '', 10) || 80;
|
||||
const rows = parseInt(req.query.rows ?? '', 10) || 24;
|
||||
|
||||
let handle: IPty;
|
||||
try {
|
||||
handle = attachPty({
|
||||
sessionName,
|
||||
windowName,
|
||||
projectRoot: session.project_path,
|
||||
cols,
|
||||
rows,
|
||||
tmuxConfPath,
|
||||
});
|
||||
} catch (err) {
|
||||
req.log.error({ err }, 'attachPty failed');
|
||||
socket.close(1011, 'pty_spawn_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
active.set(pid, handle);
|
||||
|
||||
const onData = (data: string) => {
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
try {
|
||||
socket.send(Buffer.from(data, 'utf8'), { binary: true });
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'ws send failed');
|
||||
}
|
||||
};
|
||||
handle.onData(onData);
|
||||
|
||||
socket.on('message', (data: Buffer | string) => {
|
||||
try {
|
||||
if (typeof data === 'string') {
|
||||
handle.write(data);
|
||||
} else {
|
||||
handle.write(data.toString('utf8'));
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'pty write failed');
|
||||
}
|
||||
});
|
||||
|
||||
handle.onExit(({ exitCode }) => {
|
||||
try {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'exit', code: exitCode }));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
socket.close(1000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (active.get(pid) === handle) active.delete(pid);
|
||||
});
|
||||
|
||||
// WS close kills the local PTY (the tmux client). The tmux server and
|
||||
// window persist so a refresh resumes with full scrollback.
|
||||
socket.on('close', () => {
|
||||
if (active.get(pid) === handle) active.delete(pid);
|
||||
try {
|
||||
handle.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
6
apps/booterm/tmux.conf
Normal file
6
apps/booterm/tmux.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
set -g default-terminal "screen-256color"
|
||||
set -g history-limit 50000
|
||||
set -g mouse on
|
||||
setw -g mode-keys vi
|
||||
set -g status off
|
||||
set -g destroy-unattached off
|
||||
15
apps/booterm/tsconfig.json
Normal file
15
apps/booterm/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS session_panes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser')),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser', 'terminal')),
|
||||
state JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
UNIQUE (session_id, position)
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
"shiki": "^1.29.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
|
||||
@@ -261,4 +261,26 @@ export const api = {
|
||||
sidebar: {
|
||||
get: () => request<SidebarResponse>('/api/sidebar'),
|
||||
},
|
||||
|
||||
// v1.10 booterm: REST control plane for terminal panes. WebSocket attach
|
||||
// lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
|
||||
// TerminalPane). All three endpoints are tolerant of empty bodies on the
|
||||
// POSTs that don't take parameters.
|
||||
terminals: {
|
||||
start: (sessionId: string, paneId: string) =>
|
||||
request<{ tmux_window: string }>(
|
||||
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
resize: (sessionId: string, paneId: string, cols: number, rows: number) =>
|
||||
request<{ ok: true }>(
|
||||
`/api/term/sessions/${sessionId}/panes/${paneId}/resize`,
|
||||
{ method: 'POST', body: JSON.stringify({ cols, rows }) },
|
||||
),
|
||||
kill: (sessionId: string, paneId: string) =>
|
||||
request<{ ok: true }>(
|
||||
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Children, cloneElement, isValidElement, useState } from 'react';
|
||||
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -7,9 +7,19 @@ import { toast } from 'sonner';
|
||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||
import { CapHitSentinel } from './CapHitSentinel';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,6 +29,57 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
|
||||
// Used by the right-click "Send to terminal" submenu so it always reflects
|
||||
// currently-open terminal panes without prop drilling from Workspace.
|
||||
function useTerminals(): TerminalRegistration[] {
|
||||
const [list, setList] = useState(() => terminalsRegistry.list());
|
||||
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
|
||||
return list;
|
||||
}
|
||||
|
||||
// Wrap a message body with a right-click context menu offering "Send to
|
||||
// terminal → <pane name>". The submenu is disabled when nothing is selected
|
||||
// or no terminal panes are open; clicking a target emits a sendToTerminal
|
||||
// event that TerminalPane subscribes to (filtered by pane_id).
|
||||
function SendToTerminalMenu({ children }: { children: ReactNode }) {
|
||||
const [selection, setSelection] = useState('');
|
||||
const terminals = useTerminals();
|
||||
const canSend = selection.length > 0 && terminals.length > 0;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
|
||||
setSelection(sel);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{terminals.length === 0 ? (
|
||||
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
|
||||
) : (
|
||||
terminals.map((t) => (
|
||||
<ContextMenuItem
|
||||
key={t.paneId}
|
||||
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
|
||||
>
|
||||
{t.label}
|
||||
</ContextMenuItem>
|
||||
))
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// v1.8.2: human labels for the machine-readable error reasons that ride on
|
||||
// failed assistant messages via metadata.kind === 'error'. Kept short so the
|
||||
// inline render under "message failed" stays a single muted line.
|
||||
@@ -507,9 +568,11 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="group flex flex-col items-end gap-1">
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||
{message.content}
|
||||
</div>
|
||||
<SendToTerminalMenu>
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||
{message.content}
|
||||
</div>
|
||||
</SendToTerminalMenu>
|
||||
<ActionRow message={message} />
|
||||
</div>
|
||||
);
|
||||
@@ -529,12 +592,14 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
return (
|
||||
<div className="group flex flex-col gap-2">
|
||||
{(hasContent || isStreaming) && (
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<SendToTerminalMenu>
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
</SendToTerminalMenu>
|
||||
)}
|
||||
{failed && (
|
||||
<div className="text-xs text-destructive">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react';
|
||||
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
|
||||
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
|
||||
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { ChatPane } from '@/components/panes/ChatPane';
|
||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||
import { TerminalPane } from '@/components/panes/TerminalPane';
|
||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||
import {
|
||||
@@ -115,6 +116,20 @@ export function Workspace({
|
||||
.filter((c): c is Chat => c !== undefined);
|
||||
}
|
||||
|
||||
// v1.10 booterm: per-terminal label used by the registry that powers the
|
||||
// MessageBubble "Send to terminal" submenu. Numbered in workspace order.
|
||||
const terminalLabels = useMemo(() => {
|
||||
const out = new Map<string, string>();
|
||||
let n = 0;
|
||||
for (const p of panes) {
|
||||
if (p.kind === 'terminal') {
|
||||
n += 1;
|
||||
out.set(p.id, `Terminal ${n}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [panes]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{!isMobile && (
|
||||
@@ -165,6 +180,7 @@ export function Workspace({
|
||||
>
|
||||
{panes.map((pane, idx) => {
|
||||
const isSettings = pane.kind === 'settings';
|
||||
const isTerminal = pane.kind === 'terminal';
|
||||
// v1.9: when maximized, hide every pane except the settings one.
|
||||
// display:none keeps the React tree mounted so streams / drafts
|
||||
// survive the toggle without re-mount cost.
|
||||
@@ -176,6 +192,9 @@ export function Workspace({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
|
||||
// are not drag-reorderable for now — keeps the layout grid simple.
|
||||
const isChromeless = isSettings || isTerminal;
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
@@ -187,19 +206,18 @@ export function Workspace({
|
||||
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
|
||||
)}
|
||||
onClick={() => setActivePaneIdx(idx)}
|
||||
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
|
||||
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
|
||||
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
|
||||
>
|
||||
<div
|
||||
draggable={!isMobile && !isSettings && panes.length > 1}
|
||||
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
draggable={!isMobile && !isChromeless && panes.length > 1}
|
||||
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
|
||||
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
|
||||
>
|
||||
{/* Hidden on mobile per v1.8; settings panes own their own
|
||||
section nav / maximize toggle so they skip ChatTabBar
|
||||
entirely. */}
|
||||
{!isMobile && !isSettings && (
|
||||
{/* Hidden on mobile per v1.8; settings + terminal panes own
|
||||
their own header (no chats, so no ChatTabBar). */}
|
||||
{!isMobile && !isChromeless && (
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
@@ -214,6 +232,28 @@ export function Workspace({
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
)}
|
||||
{isTerminal && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<Terminal size={12} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||
</span>
|
||||
{panes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePane(idx);
|
||||
}}
|
||||
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close terminal pane"
|
||||
title="Close terminal pane"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
@@ -226,6 +266,12 @@ export function Workspace({
|
||||
onClose={() => removePane(idx)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
) : isTerminal ? (
|
||||
<TerminalPane
|
||||
sessionId={sessionId}
|
||||
paneId={pane.id}
|
||||
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||
/>
|
||||
) : pane.kind === 'chat' && pane.chatId ? (
|
||||
<ChatPane
|
||||
sessionId={sessionId}
|
||||
|
||||
167
apps/web/src/components/panes/TerminalPane.tsx
Normal file
167
apps/web/src/components/panes/TerminalPane.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { api } from '@/api/client';
|
||||
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
paneId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Minimal dark theme. xterm.js renders against its own canvas; CSS variables
|
||||
// don't reach it, so we hardcode. Matches the obsidian-dark base in spirit.
|
||||
const XTERM_THEME = {
|
||||
background: '#0b0f14',
|
||||
foreground: '#d6deeb',
|
||||
cursor: '#82aaff',
|
||||
selectionBackground: '#1d3b53',
|
||||
black: '#011627',
|
||||
red: '#ef5350',
|
||||
green: '#22da6e',
|
||||
yellow: '#c5e478',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#7fdbca',
|
||||
white: '#d6deeb',
|
||||
brightBlack: '#575656',
|
||||
brightRed: '#ef5350',
|
||||
brightGreen: '#22da6e',
|
||||
brightYellow: '#ffeb95',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#7fdbca',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
export function TerminalPane({ sessionId, paneId, label }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let disposed = false;
|
||||
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
scrollback: 10_000,
|
||||
theme: XTERM_THEME,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(container);
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* container not yet sized */
|
||||
}
|
||||
|
||||
// POST start kicks the tmux window into existence before the WS upgrade.
|
||||
// It's idempotent so a refresh just no-ops. Failures fall through to the
|
||||
// WS handler which will also call ensureWindow.
|
||||
api.terminals.start(sessionId, paneId).catch(() => {
|
||||
/* surfaced by WS error if it matters */
|
||||
});
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const initialCols = term.cols;
|
||||
const initialRows = term.rows;
|
||||
const wsUrl =
|
||||
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
|
||||
`?cols=${initialCols}&rows=${initialRows}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
if (typeof e.data === 'string') {
|
||||
// Control frame from server (e.g. {"type":"exit","code":0}).
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
|
||||
if (parsed.type === 'exit') {
|
||||
term.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON — fall through and write as text */
|
||||
}
|
||||
term.write(e.data);
|
||||
} else {
|
||||
term.write(new Uint8Array(e.data));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (disposed) return;
|
||||
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
const fireResize = () => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
api.terminals.resize(sessionId, paneId, cols, rows).catch(() => {
|
||||
/* transient — next resize will catch up */
|
||||
});
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
||||
resizeDebounceTimer = setTimeout(fireResize, 100);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
const unregister = terminalsRegistry.register(paneId, label);
|
||||
|
||||
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
|
||||
if (pane_id !== paneId) return;
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = text.endsWith('\n') ? text : `${text}\n`;
|
||||
ws.send(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
unregister();
|
||||
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
|
||||
ro.disconnect();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
term.dispose();
|
||||
};
|
||||
}, [sessionId, paneId, label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full bg-[#0b0f14] overflow-hidden"
|
||||
data-testid="terminal-pane"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,14 @@ function chatPane(chatId: string): WorkspacePane {
|
||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the
|
||||
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
|
||||
// persist in localStorage along with chat panes so a refresh resumes the
|
||||
// same tmux window via the idempotent start endpoint.
|
||||
function terminalPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'terminal', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||
// SettingsPane component renders Session/Project sections from the
|
||||
// surrounding session/project.
|
||||
@@ -234,10 +242,6 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
}, []);
|
||||
|
||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
||||
if (kind === 'terminal') {
|
||||
toast('Terminal panes coming in BooTerm');
|
||||
return;
|
||||
}
|
||||
if (kind === 'agent') {
|
||||
toast('Agent panes coming in BooCoder');
|
||||
return;
|
||||
@@ -248,7 +252,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev, emptyPane()];
|
||||
const newPane = kind === 'terminal' ? terminalPane() : emptyPane();
|
||||
const next = [...prev, newPane];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
|
||||
80
apps/web/src/lib/events.ts
Normal file
80
apps/web/src/lib/events.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Minimal pub/sub for ephemeral UI events that don't belong on the sessionEvents
|
||||
// bus (sessionEvents is for DB-state changes; this file is for UI-only signals
|
||||
// like "user clicked send-to-terminal on selected text").
|
||||
//
|
||||
// Also exposes a tiny registry of currently-mounted terminal panes so the
|
||||
// MessageBubble context menu can list them. TerminalPane registers on mount,
|
||||
// unregisters on unmount.
|
||||
|
||||
type Listener<T> = (payload: T) => void;
|
||||
|
||||
interface EventBus<T> {
|
||||
emit(payload: T): void;
|
||||
subscribe(listener: Listener<T>): () => void;
|
||||
}
|
||||
|
||||
function createEvent<T>(): EventBus<T> {
|
||||
const listeners = new Set<Listener<T>>();
|
||||
return {
|
||||
emit(payload) {
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l(payload);
|
||||
} catch {
|
||||
/* one bad listener shouldn't break others */
|
||||
}
|
||||
}
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendToTerminalPayload {
|
||||
pane_id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const sendToTerminal = createEvent<SendToTerminalPayload>();
|
||||
|
||||
export interface TerminalRegistration {
|
||||
paneId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const terminalRegistry = new Map<string, TerminalRegistration>();
|
||||
const registryListeners = new Set<Listener<void>>();
|
||||
|
||||
function notifyRegistry(): void {
|
||||
for (const l of registryListeners) {
|
||||
try {
|
||||
l();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalsRegistry = {
|
||||
register(paneId: string, label: string): () => void {
|
||||
terminalRegistry.set(paneId, { paneId, label });
|
||||
notifyRegistry();
|
||||
return () => {
|
||||
terminalRegistry.delete(paneId);
|
||||
notifyRegistry();
|
||||
};
|
||||
},
|
||||
list(): TerminalRegistration[] {
|
||||
return Array.from(terminalRegistry.values());
|
||||
},
|
||||
subscribe(listener: Listener<void>): () => void {
|
||||
registryListeners.add(listener);
|
||||
return () => {
|
||||
registryListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,24 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Booterm runs on a separate port (9501 in compose). Order matters:
|
||||
// /api/term/* and /ws/term/* must be listed before the broader /api
|
||||
// entry so Vite matches the more specific prefix first.
|
||||
'/api/term': {
|
||||
target: process.env.BOOTERM_DEV_URL ?? 'http://127.0.0.1:9501',
|
||||
changeOrigin: true,
|
||||
headers: {
|
||||
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
|
||||
},
|
||||
},
|
||||
'/ws/term': {
|
||||
target: process.env.BOOTERM_DEV_URL ?? 'http://127.0.0.1:9501',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
headers: {
|
||||
'Remote-User': process.env.DEV_REMOTE_USER ?? 'sam',
|
||||
},
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user