♻️ feat: implement session management for PTY sessions in the server

- Add SessionManager class to handle PTY sessions with WebSocket connections.
- Implement methods for creating, retrieving, and destroying sessions.
- Handle PTY output and WebSocket messages for terminal interaction.
- Ensure graceful session destruction and cleanup.

feat: initialize web application with Next.js and Tailwind CSS

- Create initial Next.js application structure with TypeScript support.
- Set up Tailwind CSS for styling with custom theme configurations.
- Add ESLint configuration for code quality and consistency.

feat: implement chat API and UI components

- Create chat API route to handle chat requests and responses.
- Develop chat layout with sidebar, header, chat window, and input components.
- Integrate Zustand for state management of conversations and messages.
- Add utility functions for formatting dates and managing class names.

chore: add environment variables and configuration files

- Create .env.example for environment variable setup.
- Add configuration files for PostCSS, Tailwind CSS, and TypeScript.
- Set up package.json with necessary dependencies and scripts for development.
This commit is contained in:
nirholas
2026-03-31 12:35:31 +00:00
parent d31c2bec03
commit 38648ae5f4
53 changed files with 4177 additions and 4 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies (rebuilt in container)
node_modules
# Git metadata
.git
.github
.gitignore
# Build output (rebuilt in container)
dist
# Env files — never bake secrets into the image
.env
.env.*
# Logs and debug
*.log
npm-debug.log*
bun-debug.log*
# Test artifacts
coverage
.nyc_output
# Editor / OS noise
.DS_Store
Thumbs.db
.vscode
.idea
# Docker context itself
docker

3
.gitignore vendored
View File

@@ -12,6 +12,9 @@ build/
.env.local
.env.*.local
# Prompts
prompts/
# OS files
.DS_Store
Thumbs.db

35
docker/.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
# NOTE: Docker reads .dockerignore from the build context root.
# The canonical copy lives at /.dockerignore — keep both in sync.
# Dependencies (rebuilt in container)
node_modules
# Git metadata
.git
.github
.gitignore
# Build output (rebuilt in container)
dist
# Env files — never bake secrets into the image
.env
.env.*
# Logs and debug
*.log
npm-debug.log*
bun-debug.log*
# Test artifacts
coverage
.nyc_output
# Editor / OS noise
.DS_Store
Thumbs.db
.vscode
.idea
# Docker context itself
docker

83
docker/Dockerfile Normal file
View File

@@ -0,0 +1,83 @@
# ─────────────────────────────────────────────────────────────
# Claude Web Terminal — Production Container
# ─────────────────────────────────────────────────────────────
# Multi-stage build: compiles node-pty native module and bundles
# the Claude CLI, then copies artifacts into a slim runtime image.
#
# Usage:
# docker build -f docker/Dockerfile -t claude-web .
# docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web
# ─────────────────────────────────────────────────────────────
# ── Stage 1: Build ────────────────────────────────────────────
FROM oven/bun:1 AS builder
WORKDIR /app
# Build tools required to compile node-pty's native C++ addon
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Copy manifests first for layer caching
COPY package.json bun.lockb* ./
# Install all deps (triggers node-pty native compilation)
RUN bun install --frozen-lockfile 2>/dev/null || bun install
# Copy source tree
COPY . .
# Bundle the Claude CLI (produces dist/cli.mjs)
RUN bun run build:prod
# ── Stage 2: Runtime ──────────────────────────────────────────
FROM oven/bun:1 AS runtime
WORKDIR /app
# curl for health checks; no build tools needed at runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user that PTY sessions will run under
RUN groupadd -r claude && useradd -r -g claude -m -d /home/claude claude
# Compiled node_modules (includes native node-pty binary)
COPY --from=builder /app/node_modules ./node_modules
# Bundled Claude CLI
COPY --from=builder /app/dist ./dist
# PTY server source (bun runs TypeScript natively)
COPY --from=builder /app/src/server ./src/server
# TypeScript config needed for bun's module resolution
COPY --from=builder /app/tsconfig.json ./tsconfig.json
# Thin wrapper so the PTY server can exec `claude` as a subprocess
RUN printf '#!/bin/sh\nexec bun /app/dist/cli.mjs "$@"\n' \
> /usr/local/bin/claude && chmod +x /usr/local/bin/claude
# Entrypoint script
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Allow the claude user to write its config into its home dir
RUN chown -R claude:claude /home/claude
# ── Defaults ──────────────────────────────────────────────────
ENV NODE_ENV=production \
PORT=3000 \
MAX_SESSIONS=5 \
CLAUDE_BIN=claude
EXPOSE 3000
USER claude
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:${PORT:-3000}/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]

28
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
services:
claude-web:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "${PORT:-3000}:3000"
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- AUTH_TOKEN=${AUTH_TOKEN:-}
- MAX_SESSIONS=${MAX_SESSIONS:-5}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
volumes:
# Persist Claude's config and session data across restarts
- claude-data:/home/claude/.claude
tmpfs:
# PTY processes write temp files here; no persistent storage needed
- /tmp:mode=1777
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
claude-data:

28
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/sh
set -e
# ── Validate required env vars ────────────────────────────────
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "ERROR: ANTHROPIC_API_KEY is not set." >&2
echo "" >&2
echo " docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... claude-web" >&2
echo "" >&2
echo " Or via docker-compose with a .env file:" >&2
echo " ANTHROPIC_API_KEY=sk-ant-... docker-compose up" >&2
exit 1
fi
# The API key is forwarded to child PTY processes via process.env,
# so the claude CLI will pick it up automatically — no config file needed.
echo "Claude Web Terminal starting on port ${PORT:-3000}..."
if [ -n "$AUTH_TOKEN" ]; then
echo " Auth token protection: enabled"
fi
if [ -n "$ALLOWED_ORIGINS" ]; then
echo " Allowed origins: $ALLOWED_ORIGINS"
fi
echo " Max sessions: ${MAX_SESSIONS:-5}"
# Hand off to the PTY WebSocket server
exec bun /app/src/server/web/pty-server.ts

17
package-lock.json generated
View File

@@ -33,6 +33,7 @@
"ignore": "^6.0.0",
"lodash-es": "^4.17.21",
"marked": "^15.0.0",
"node-pty": "^1.1.0",
"p-map": "^7.0.0",
"picomatch": "^4.0.0",
"proper-lockfile": "^4.1.2",
@@ -2411,6 +2412,12 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -2451,6 +2458,16 @@
}
}
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",

View File

@@ -13,6 +13,9 @@
"build": "bun scripts/build-bundle.ts",
"build:watch": "bun scripts/build-bundle.ts --watch",
"build:prod": "bun scripts/build-bundle.ts --minify",
"build:web": "bun scripts/build-web.ts",
"build:web:watch": "bun scripts/build-web.ts --watch",
"build:web:prod": "bun scripts/build-web.ts --minify",
"typecheck": "tsc --noEmit",
"lint": "biome check src/",
"lint:fix": "biome check --write src/",
@@ -30,6 +33,7 @@
"@opentelemetry/sdk-logs": "^0.57.0",
"@opentelemetry/sdk-metrics": "^1.30.0",
"@opentelemetry/sdk-trace-base": "^1.30.0",
"auto-bind": "^5.0.1",
"axios": "^1.7.0",
"chalk": "^5.4.0",
"chokidar": "^4.0.0",
@@ -43,13 +47,13 @@
"ignore": "^6.0.0",
"lodash-es": "^4.17.21",
"marked": "^15.0.0",
"node-pty": "^1.1.0",
"p-map": "^7.0.0",
"picomatch": "^4.0.0",
"proper-lockfile": "^4.1.2",
"qrcode": "^1.5.0",
"react": "^19.0.0",
"react-reconciler": "^0.31.0",
"auto-bind": "^5.0.1",
"semver": "^7.6.0",
"stack-utils": "^2.0.6",
"strip-ansi": "^7.1.0",
@@ -61,7 +65,13 @@
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yaml": "^2.6.0",
"zod": "^3.24.0"
"zod": "^3.24.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-webgl": "^0.18.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
@@ -82,5 +92,3 @@
},
"packageManager": "bun@1.1.0"
}

58
scripts/build-web.ts Normal file
View File

@@ -0,0 +1,58 @@
// scripts/build-web.ts
// Bundles the browser-side terminal frontend.
//
// Usage:
// bun scripts/build-web.ts # dev build
// bun scripts/build-web.ts --watch # watch mode
// bun scripts/build-web.ts --minify # production (minified)
import * as esbuild from 'esbuild'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dir: string =
(import.meta as any).dir ??
(import.meta as any).dirname ??
dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dir, '..')
const ENTRY = resolve(ROOT, 'src/server/web/terminal.ts')
const OUT_DIR = resolve(ROOT, 'src/server/web/public')
const watch = process.argv.includes('--watch')
const minify = process.argv.includes('--minify')
const buildOptions: esbuild.BuildOptions = {
entryPoints: [ENTRY],
bundle: true,
platform: 'browser',
target: ['es2020', 'chrome90', 'firefox90', 'safari14'],
format: 'esm',
outdir: OUT_DIR,
// CSS imported from JS is auto-emitted alongside the JS output
loader: { '.css': 'css' },
minify,
sourcemap: minify ? false : 'inline',
tsconfig: resolve(ROOT, 'src/server/web/tsconfig.json'),
logLevel: 'info',
}
async function main() {
if (watch) {
const ctx = await esbuild.context(buildOptions)
await ctx.watch()
console.log('Watching src/server/web/terminal.ts...')
} else {
const start = Date.now()
const result = await esbuild.build(buildOptions)
if (result.errors.length > 0) {
process.exit(1)
}
console.log(`Web build complete in ${Date.now() - start}ms → ${OUT_DIR}`)
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})

View File

@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { describe, it, afterEach } from "node:test";
import type { IncomingMessage } from "node:http";
import { ConnectionRateLimiter, validateAuthToken } from "../auth.js";
function mockReq(url: string, host = "localhost:3000"): IncomingMessage {
return { url, headers: { host } } as unknown as IncomingMessage;
}
describe("validateAuthToken", () => {
const originalEnv = process.env.AUTH_TOKEN;
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.AUTH_TOKEN;
} else {
process.env.AUTH_TOKEN = originalEnv;
}
});
it("allows all connections when AUTH_TOKEN is not set", () => {
delete process.env.AUTH_TOKEN;
assert.equal(validateAuthToken(mockReq("/ws")), true);
});
it("rejects connections without token when AUTH_TOKEN is set", () => {
process.env.AUTH_TOKEN = "secret123";
assert.equal(validateAuthToken(mockReq("/ws")), false);
});
it("rejects connections with wrong token", () => {
process.env.AUTH_TOKEN = "secret123";
assert.equal(validateAuthToken(mockReq("/ws?token=wrong")), false);
});
it("accepts connections with correct token", () => {
process.env.AUTH_TOKEN = "secret123";
assert.equal(validateAuthToken(mockReq("/ws?token=secret123")), true);
});
});
describe("ConnectionRateLimiter", () => {
it("allows connections under the limit", () => {
const limiter = new ConnectionRateLimiter(3, 60_000);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("1.2.3.4"), true);
});
it("blocks connections over the limit", () => {
const limiter = new ConnectionRateLimiter(2, 60_000);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("1.2.3.4"), false);
});
it("tracks IPs independently", () => {
const limiter = new ConnectionRateLimiter(1, 60_000);
assert.equal(limiter.allow("1.2.3.4"), true);
assert.equal(limiter.allow("5.6.7.8"), true);
assert.equal(limiter.allow("1.2.3.4"), false);
assert.equal(limiter.allow("5.6.7.8"), false);
});
it("cleans up stale entries", () => {
const limiter = new ConnectionRateLimiter(1, 1); // 1ms window
limiter.allow("1.2.3.4");
// Wait for window to expire
const start = Date.now();
while (Date.now() - start < 5) {
// busy wait 5ms
}
limiter.cleanup();
assert.equal(limiter.allow("1.2.3.4"), true);
});
});

View File

@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
import { EventEmitter } from "node:events";
import { SessionManager } from "../session-manager.js";
import type { IPty } from "node-pty";
import type { WebSocket } from "ws";
// --- Mock factories ---
function createMockPty(): IPty & { _dataHandler?: (d: string) => void; _exitHandler?: (e: { exitCode: number; signal: number }) => void } {
const mockPty = {
onData(handler: (data: string) => void) {
mockPty._dataHandler = handler;
return { dispose() {} };
},
onExit(handler: (e: { exitCode: number; signal: number }) => void) {
mockPty._exitHandler = handler;
return { dispose() {} };
},
write: mock.fn(),
resize: mock.fn(),
kill: mock.fn(),
pid: 12345,
cols: 80,
rows: 24,
process: "claude",
handleFlowControl: false,
pause: mock.fn(),
resume: mock.fn(),
clear: mock.fn(),
_dataHandler: undefined as ((d: string) => void) | undefined,
_exitHandler: undefined as ((e: { exitCode: number; signal: number }) => void) | undefined,
};
return mockPty as unknown as IPty & { _dataHandler?: (d: string) => void; _exitHandler?: (e: { exitCode: number; signal: number }) => void };
}
function createMockWs(): WebSocket & EventEmitter {
const emitter = new EventEmitter();
const ws = Object.assign(emitter, {
OPEN: 1,
CONNECTING: 0,
readyState: 1,
send: mock.fn(),
close: mock.fn(),
});
return ws as unknown as WebSocket & EventEmitter;
}
describe("SessionManager", () => {
it("creates a session and tracks it", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
const session = manager.create(ws);
assert.ok(session);
assert.equal(manager.activeCount, 1);
assert.ok(session.id);
assert.equal(session.ws, ws);
assert.equal(session.pty, mockPty);
});
it("enforces max sessions limit", () => {
const manager = new SessionManager(1, () => createMockPty());
const session1 = manager.create(createMockWs());
assert.ok(session1);
const ws2 = createMockWs();
const session2 = manager.create(ws2);
assert.equal(session2, null);
assert.equal(manager.activeCount, 1);
});
it("forwards PTY data to WebSocket", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
// Simulate PTY output
mockPty._dataHandler?.("hello world");
assert.equal((ws.send as ReturnType<typeof mock.fn>).mock.callCount(), 1);
});
it("forwards WebSocket input to PTY", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
// Simulate WebSocket input
ws.emit("message", Buffer.from("ls\n"));
assert.equal((mockPty.write as ReturnType<typeof mock.fn>).mock.callCount(), 1);
});
it("handles resize messages", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
ws.emit("message", JSON.stringify({ type: "resize", cols: 120, rows: 40 }));
assert.equal((mockPty.resize as ReturnType<typeof mock.fn>).mock.callCount(), 1);
});
it("handles ping messages with pong response", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
ws.emit("message", JSON.stringify({ type: "ping" }));
// Should have sent connected + pong
const calls = (ws.send as ReturnType<typeof mock.fn>).mock.calls;
const lastCall = calls[calls.length - 1];
assert.ok(lastCall);
const parsed = JSON.parse(lastCall.arguments[0] as string);
assert.equal(parsed.type, "pong");
});
it("cleans up session on WebSocket close", () => {
const mockPty = createMockPty();
const manager = new SessionManager(5, () => mockPty);
const ws = createMockWs();
manager.create(ws);
assert.equal(manager.activeCount, 1);
ws.emit("close");
assert.equal(manager.activeCount, 0);
});
it("handles PTY spawn failure gracefully", () => {
const manager = new SessionManager(5, () => {
throw new Error("no pty available");
});
const ws = createMockWs();
const session = manager.create(ws);
assert.equal(session, null);
assert.equal((ws.close as ReturnType<typeof mock.fn>).mock.callCount(), 1);
});
it("destroyAll cleans up all sessions", () => {
const manager = new SessionManager(5, () => createMockPty());
manager.create(createMockWs());
manager.create(createMockWs());
assert.equal(manager.activeCount, 2);
manager.destroyAll();
assert.equal(manager.activeCount, 0);
});
});

65
src/server/web/auth.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { IncomingMessage } from "http";
/**
* Validates the auth token from a WebSocket upgrade request.
* If AUTH_TOKEN is not set, all connections are allowed.
*/
export function validateAuthToken(req: IncomingMessage): boolean {
const authToken = process.env.AUTH_TOKEN;
if (!authToken) {
return true;
}
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const token = url.searchParams.get("token");
return token === authToken;
}
/**
* Simple per-IP rate limiter for new connections.
*/
export class ConnectionRateLimiter {
private attempts = new Map<string, number[]>();
private readonly maxPerWindow: number;
private readonly windowMs: number;
constructor(maxPerWindow = 5, windowMs = 60_000) {
this.maxPerWindow = maxPerWindow;
this.windowMs = windowMs;
}
/**
* Returns true if the connection should be allowed.
*/
allow(ip: string): boolean {
const now = Date.now();
const timestamps = this.attempts.get(ip) ?? [];
// Prune old entries
const recent = timestamps.filter((t) => now - t < this.windowMs);
if (recent.length >= this.maxPerWindow) {
this.attempts.set(ip, recent);
return false;
}
recent.push(now);
this.attempts.set(ip, recent);
return true;
}
/**
* Periodically clean up stale entries.
*/
cleanup(): void {
const now = Date.now();
for (const [ip, timestamps] of this.attempts) {
const recent = timestamps.filter((t) => now - t < this.windowMs);
if (recent.length === 0) {
this.attempts.delete(ip);
} else {
this.attempts.set(ip, recent);
}
}
}
}

View File

@@ -0,0 +1,220 @@
import { createHmac, createCipheriv, createDecipheriv, randomBytes } from "crypto";
import type { IncomingMessage, ServerResponse } from "http";
import type { Application, Request, Response, NextFunction } from "express";
// ── Public types ────────────────────────────────────────────────────────────
export interface AuthUser {
id: string;
email?: string;
name?: string;
isAdmin: boolean;
/** Decrypted Anthropic API key (only present for apikey auth provider) */
apiKey?: string;
}
export interface SessionData {
userId: string;
email?: string;
name?: string;
isAdmin: boolean;
/** AES-256-GCM encrypted Anthropic API key */
encryptedApiKey?: string;
createdAt: number;
expiresAt: number;
}
/** Augmented Express request with authenticated user attached. */
export interface AuthenticatedRequest extends Request {
user: AuthUser;
}
/** Auth adapter — pluggable authentication strategy. */
export interface AuthAdapter {
/**
* Authenticate an IncomingMessage (HTTP or WebSocket upgrade).
* Returns null when the request is unauthenticated.
*/
authenticate(req: IncomingMessage): AuthUser | null;
/**
* Register login/callback/logout routes on the Express app.
* Called once during server startup before any requests arrive.
*/
setupRoutes(app: Application): void;
/**
* Express middleware that rejects unauthenticated requests.
* For browser clients it redirects to /auth/login; for API clients it
* returns 401 JSON.
*/
requireAuth(req: Request, res: Response, next: NextFunction): void;
}
// ── Cookie helpers ──────────────────────────────────────────────────────────
const COOKIE_NAME = "cc_session";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
function parseCookies(header: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
const val = part.slice(eq + 1).trim();
if (key) out[key] = decodeURIComponent(val);
}
return out;
}
// ── SessionStore ────────────────────────────────────────────────────────────
/**
* In-memory server-side session store.
*
* Sessions are identified by a random UUID stored in a signed HttpOnly cookie.
* Sensitive values (API keys) are stored encrypted with AES-256-GCM.
*/
export class SessionStore {
private readonly sessions = new Map<string, SessionData>();
/** 32-byte key derived from the session secret. */
private readonly key: Buffer;
constructor(secret: string) {
// Derive a stable 32-byte key so the same secret always produces the
// same key (important if the process restarts while cookies are live).
const hmac = createHmac("sha256", secret);
hmac.update("cc-session-key-v1");
this.key = hmac.digest();
// Purge expired sessions every 5 minutes.
setInterval(() => this.cleanup(), 5 * 60_000).unref();
}
// ── CRUD ──────────────────────────────────────────────────────────────────
create(data: Omit<SessionData, "createdAt" | "expiresAt">): string {
const id = crypto.randomUUID();
this.sessions.set(id, {
...data,
createdAt: Date.now(),
expiresAt: Date.now() + SESSION_TTL_MS,
});
return id;
}
get(id: string): SessionData | undefined {
const s = this.sessions.get(id);
if (!s) return undefined;
if (Date.now() > s.expiresAt) {
this.sessions.delete(id);
return undefined;
}
return s;
}
delete(id: string): void {
this.sessions.delete(id);
}
// ── Cookie signing ────────────────────────────────────────────────────────
sign(id: string): string {
const hmac = createHmac("sha256", this.key);
hmac.update(id);
return `${id}.${hmac.digest("base64url")}`;
}
/**
* Verifies the HMAC and returns the raw session ID, or null on failure.
* Uses constant-time comparison to prevent timing attacks.
*/
unsign(signed: string): string | null {
const dot = signed.lastIndexOf(".");
if (dot === -1) return null;
const id = signed.slice(0, dot);
const provided = signed.slice(dot + 1);
const hmac = createHmac("sha256", this.key);
hmac.update(id);
const expected = hmac.digest("base64url");
if (provided.length !== expected.length) return null;
let diff = 0;
for (let i = 0; i < provided.length; i++) {
diff |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0 ? id : null;
}
// ── Request / response helpers ────────────────────────────────────────────
/** Returns the session data for the current request, or null. */
getFromRequest(req: IncomingMessage): SessionData | null {
const cookies = parseCookies(req.headers.cookie ?? "");
const signed = cookies[COOKIE_NAME];
if (!signed) return null;
const id = this.unsign(signed);
if (!id) return null;
return this.get(id) ?? null;
}
/** Returns the raw session ID from the request cookie, or null. */
getIdFromRequest(req: IncomingMessage): string | null {
const cookies = parseCookies(req.headers.cookie ?? "");
const signed = cookies[COOKIE_NAME];
if (!signed) return null;
return this.unsign(signed);
}
setCookie(res: ServerResponse, sessionId: string): void {
const signed = this.sign(sessionId);
const maxAge = Math.floor(SESSION_TTL_MS / 1000);
res.setHeader("Set-Cookie", [
`${COOKIE_NAME}=${encodeURIComponent(signed)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`,
]);
}
clearCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", [
`${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`,
]);
}
// ── Encryption ────────────────────────────────────────────────────────────
/** Encrypts a plaintext string with AES-256-GCM for session storage. */
encrypt(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// Layout: iv(12) | tag(16) | ciphertext
return Buffer.concat([iv, tag, ciphertext]).toString("base64url");
}
/** Decrypts a value produced by {@link encrypt}. Returns null on failure. */
decrypt(encoded: string): string | null {
try {
const buf = Buffer.from(encoded, "base64url");
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const ciphertext = buf.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8");
} catch {
return null;
}
}
// ── Internals ─────────────────────────────────────────────────────────────
private cleanup(): void {
const now = Date.now();
for (const [id, s] of this.sessions) {
if (now > s.expiresAt) this.sessions.delete(id);
}
}
}

View File

@@ -0,0 +1,276 @@
import { randomBytes } from "crypto";
import { request as nodeRequest } from "https";
import { request as nodeHttpRequest } from "http";
import type { IncomingMessage } from "http";
import type { Application, Request, Response, NextFunction } from "express";
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
import { SessionStore } from "./adapter.js";
interface OIDCDiscovery {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
}
interface TokenResponse {
access_token: string;
id_token?: string;
error?: string;
}
interface UserInfoResponse {
sub: string;
email?: string;
name?: string;
preferred_username?: string;
}
/** Minimal JSON fetch over https/http. */
function fetchJSON<T>(url: string, opts?: { method?: string; body?: string; headers?: Record<string, string> }): Promise<T> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const req = (isHttps ? nodeRequest : nodeHttpRequest)(
{
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: opts?.method ?? "GET",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
...(opts?.headers ?? {}),
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")) as T);
} catch (e) {
reject(e);
}
});
},
);
req.on("error", reject);
if (opts?.body) req.write(opts.body);
req.end();
});
}
/**
* OAuth2 / OIDC authentication adapter.
*
* Performs a server-side authorization code flow against any OIDC-compliant
* identity provider (Google, GitHub, Okta, Auth0, …).
*
* Required env vars:
* OAUTH_CLIENT_ID — client ID registered with the provider
* OAUTH_CLIENT_SECRET — client secret
* OAUTH_ISSUER — issuer base URL, e.g. https://accounts.google.com
*
* Optional:
* OAUTH_CALLBACK_URL — full redirect URI (default: http://localhost:3000/auth/callback)
* OAUTH_SCOPES — space-separated scopes (default: openid email profile)
* ADMIN_USERS — comma-separated user IDs or emails that get admin role
*/
export class OAuthAdapter implements AuthAdapter {
private readonly store: SessionStore;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly issuer: string;
private readonly callbackUrl: string;
private readonly scopes: string;
private readonly adminUsers: ReadonlySet<string>;
/** Cached OIDC discovery document. */
private discovery: OIDCDiscovery | null = null;
/** In-flight state tokens to prevent CSRF. */
private readonly pendingStates = new Map<string, number>();
constructor(store: SessionStore) {
this.store = store;
this.clientId = process.env.OAUTH_CLIENT_ID ?? "";
this.clientSecret = process.env.OAUTH_CLIENT_SECRET ?? "";
this.issuer = (process.env.OAUTH_ISSUER ?? "").replace(/\/$/, "");
this.callbackUrl =
process.env.OAUTH_CALLBACK_URL ?? "http://localhost:3000/auth/callback";
this.scopes = process.env.OAUTH_SCOPES ?? "openid email profile";
this.adminUsers = new Set(
(process.env.ADMIN_USERS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
// Periodically prune stale state tokens (>10 min old).
setInterval(() => {
const cutoff = Date.now() - 10 * 60_000;
for (const [s, t] of this.pendingStates) {
if (t < cutoff) this.pendingStates.delete(s);
}
}, 5 * 60_000).unref();
}
authenticate(req: IncomingMessage): AuthUser | null {
const session = this.store.getFromRequest(req);
if (!session) return null;
return {
id: session.userId,
email: session.email,
name: session.name,
isAdmin:
session.isAdmin ||
this.adminUsers.has(session.userId) ||
(session.email ? this.adminUsers.has(session.email) : false),
};
}
setupRoutes(app: Application): void {
// GET /auth/login — redirect to the identity provider
app.get("/auth/login", async (_req, res) => {
try {
const disc = await this.getDiscovery();
const state = randomBytes(16).toString("hex");
this.pendingStates.set(state, Date.now());
const authUrl = new URL(disc.authorization_endpoint);
authUrl.searchParams.set("client_id", this.clientId);
authUrl.searchParams.set("redirect_uri", this.callbackUrl);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", this.scopes);
authUrl.searchParams.set("state", state);
// Store state in a short-lived cookie for CSRF validation
res.setHeader("Set-Cookie", [
`oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=600`,
]);
res.redirect(authUrl.toString());
} catch (err) {
console.error("[oauth] Failed to initiate login:", err);
res.status(500).send("Authentication provider unavailable.");
}
});
// GET /auth/callback — exchange code for tokens, create session
app.get("/auth/callback", async (req, res) => {
const { code, state, error } = req.query as Record<string, string | undefined>;
if (error) {
console.warn("[oauth] Provider error:", error);
res.status(400).send(`Provider returned error: ${error}`);
return;
}
if (!code || !state) {
res.status(400).send("Missing code or state.");
return;
}
// Validate state against cookie
const storedState = this.extractStateCookie(req as unknown as IncomingMessage);
if (!storedState || storedState !== state || !this.pendingStates.has(state)) {
res.status(400).send("Invalid state parameter.");
return;
}
this.pendingStates.delete(state);
// Clear state cookie
res.setHeader("Set-Cookie", [
`oauth_state=; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=0`,
]);
try {
const disc = await this.getDiscovery();
const tokens = await this.exchangeCode(disc.token_endpoint, code);
const userInfo = await this.getUserInfo(disc.userinfo_endpoint, tokens.access_token);
const isAdmin =
this.adminUsers.has(userInfo.sub) ||
(userInfo.email ? this.adminUsers.has(userInfo.email) : false);
const sessionId = this.store.create({
userId: userInfo.sub,
email: userInfo.email,
name: userInfo.name ?? userInfo.preferred_username,
isAdmin,
});
this.store.setCookie(res as unknown as import("http").ServerResponse, sessionId);
res.redirect("/");
} catch (err) {
console.error("[oauth] Callback error:", err);
res.status(500).send("Authentication failed. Please try again.");
}
});
// POST /auth/logout — destroy session and redirect to login
app.post("/auth/logout", (req, res) => {
const id = this.store.getIdFromRequest(req as unknown as IncomingMessage);
if (id) this.store.delete(id);
this.store.clearCookie(res as unknown as import("http").ServerResponse);
res.redirect("/auth/login");
});
}
requireAuth(req: Request, res: Response, next: NextFunction): void {
const user = this.authenticate(req as unknown as IncomingMessage);
if (!user) {
const accept = req.headers["accept"] ?? "";
if (accept.includes("application/json")) {
res.status(401).json({ error: "Unauthorized" });
} else {
res.redirect(`/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
}
return;
}
(req as AuthenticatedRequest).user = user;
next();
}
// ── OIDC helpers ──────────────────────────────────────────────────────────
private async getDiscovery(): Promise<OIDCDiscovery> {
if (this.discovery) return this.discovery;
const url = `${this.issuer}/.well-known/openid-configuration`;
const doc = await fetchJSON<OIDCDiscovery>(url);
this.discovery = doc;
return doc;
}
private async exchangeCode(tokenEndpoint: string, code: string): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: this.callbackUrl,
client_id: this.clientId,
client_secret: this.clientSecret,
}).toString();
const result = await fetchJSON<TokenResponse>(tokenEndpoint, {
method: "POST",
body,
});
if (result.error) throw new Error(`Token exchange failed: ${result.error}`);
return result;
}
private async getUserInfo(userinfoEndpoint: string, accessToken: string): Promise<UserInfoResponse> {
return fetchJSON<UserInfoResponse>(userinfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
private extractStateCookie(req: IncomingMessage): string | null {
const cookieHeader = req.headers.cookie ?? "";
for (const part of cookieHeader.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === "oauth_state") {
return decodeURIComponent(part.slice(eq + 1).trim());
}
}
return null;
}
}

View File

@@ -0,0 +1,81 @@
import type { IncomingMessage } from "http";
import type { Application, Request, Response, NextFunction } from "express";
import type { AuthAdapter, AuthUser, AuthenticatedRequest } from "./adapter.js";
/**
* Token auth — the original single-token mode.
*
* If `AUTH_TOKEN` is set, callers must supply a matching token via the
* `?token=` query parameter (WebSocket) or `Authorization: Bearer <token>`
* header (HTTP). When `AUTH_TOKEN` is unset every caller is admitted as the
* built-in "default" admin user.
*
* All callers share the same user identity. Use this provider for single-
* user or trusted-network deployments where you just want a simple password.
*/
export class TokenAuthAdapter implements AuthAdapter {
private readonly token: string | undefined;
private readonly adminUsers: ReadonlySet<string>;
constructor() {
this.token = process.env.AUTH_TOKEN;
this.adminUsers = new Set(
(process.env.ADMIN_USERS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
}
authenticate(req: IncomingMessage): AuthUser | null {
if (!this.token) {
return { id: "default", isAdmin: true };
}
if (this.extractToken(req) === this.token) {
return {
id: "default",
isAdmin: this.adminUsers.size === 0 || this.adminUsers.has("default"),
};
}
return null;
}
setupRoutes(_app: Application): void {
// Token auth needs no login/callback/logout routes.
}
requireAuth(req: Request, res: Response, next: NextFunction): void {
const user = this.authenticate(req as unknown as IncomingMessage);
if (!user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
(req as AuthenticatedRequest).user = user;
next();
}
// ── Internals ─────────────────────────────────────────────────────────────
private extractToken(req: IncomingMessage): string | null {
// 1. ?token= query param (used by WebSocket clients and simple links)
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const qp = url.searchParams.get("token");
if (qp) return qp;
// 2. Authorization: Bearer <token>
const auth = req.headers["authorization"];
if (auth?.startsWith("Bearer ")) return auth.slice(7);
// 3. Cookie cc_token (set by token-auth login page if any)
const cookieHeader = req.headers.cookie ?? "";
for (const part of cookieHeader.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === "cc_token") {
return decodeURIComponent(part.slice(eq + 1).trim());
}
}
return null;
}
}

View File

@@ -0,0 +1,159 @@
import express from "express";
import { createServer } from "http";
import path from "path";
import { spawn } from "node-pty";
import { WebSocketServer } from "ws";
import { ConnectionRateLimiter, validateAuthToken } from "./auth.js";
import { SessionManager } from "./session-manager.js";
// Configuration from environment
const PORT = parseInt(process.env.PORT ?? "3000", 10);
const HOST = process.env.HOST ?? "0.0.0.0";
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS ?? "10", 10);
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
const SHELL = process.env.SHELL ?? "bash";
// Resolve the claude CLI binary
const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
const app = express();
const server = createServer(app);
// --- HTTP routes ---
app.get("/health", (_req, res) => {
res.json({
status: "ok",
activeSessions: sessionManager.activeCount,
maxSessions: MAX_SESSIONS,
});
});
// Serve static frontend
const publicDir = path.join(import.meta.dirname, "public");
app.use(express.static(publicDir));
app.get("/", (_req, res) => {
res.sendFile(path.join(publicDir, "index.html"));
});
// --- Session Manager ---
const sessionManager = new SessionManager(MAX_SESSIONS, (cols, rows) =>
spawn(CLAUDE_BIN, [], {
name: "xterm-256color",
cols,
rows,
cwd: process.env.WORK_DIR ?? process.cwd(),
env: {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
},
}),
);
// --- WebSocket server ---
const rateLimiter = new ConnectionRateLimiter();
// Clean up rate limiter every 5 minutes
const rateLimiterCleanup = setInterval(() => rateLimiter.cleanup(), 5 * 60_000);
const wss = new WebSocketServer({
server,
path: "/ws",
verifyClient: ({ req, origin }, callback) => {
// Origin check
if (ALLOWED_ORIGINS.length > 0 && !ALLOWED_ORIGINS.includes(origin)) {
console.warn(`Rejected connection from origin: ${origin}`);
callback(false, 403, "Forbidden origin");
return;
}
// Auth token check
if (!validateAuthToken(req)) {
console.warn("Rejected connection: invalid auth token");
callback(false, 401, "Unauthorized");
return;
}
// Rate limit check
const ip =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
"unknown";
if (!rateLimiter.allow(ip)) {
console.warn(`Rate limited connection from ${ip}`);
callback(false, 429, "Too many connections");
return;
}
callback(true);
},
});
wss.on("connection", (ws, req) => {
const ip =
(req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ??
req.socket.remoteAddress ??
"unknown";
console.log(`New WebSocket connection from ${ip}`);
if (sessionManager.isFull) {
ws.send(
JSON.stringify({
type: "error",
message: "Max sessions reached. Try again later.",
}),
);
ws.close(1013, "Max sessions reached");
return;
}
// Parse initial size from query params
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
const cols = parseInt(url.searchParams.get("cols") ?? "80", 10);
const rows = parseInt(url.searchParams.get("rows") ?? "24", 10);
const session = sessionManager.create(ws, cols, rows);
if (session) {
ws.send(JSON.stringify({ type: "connected", sessionId: session.id }));
}
});
// --- Graceful shutdown ---
function shutdown() {
console.log("Shutting down...");
clearInterval(rateLimiterCleanup);
sessionManager.destroyAll();
wss.close(() => {
server.close(() => {
console.log("Server closed.");
process.exit(0);
});
});
// Force exit after 10 seconds
setTimeout(() => {
console.error("Forced shutdown after timeout");
process.exit(1);
}, 10_000);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// --- Start ---
server.listen(PORT, HOST, () => {
console.log(`PTY server listening on http://${HOST}:${PORT}`);
console.log(` WebSocket: ws://${HOST}:${PORT}/ws`);
console.log(` Max sessions: ${MAX_SESSIONS}`);
if (process.env.AUTH_TOKEN) {
console.log(" Auth: token required");
}
});
export { app, server, sessionManager, wss };

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#1a1b26"/>
<path d="M8 22l4-12h1.5L9.5 22H8zm7 0l4-12h1.5L16.5 22H15z" fill="#7aa2f7"/>
<rect x="7" y="23" width="18" height="2" rx="1" fill="#c0caf5"/>
</svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#1a1b26" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f5f5f5" media="(prefers-color-scheme: light)">
<title>Claude Code</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<!-- xterm.js from CDN -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.8.0/lib/addon-unicode11.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
<link rel="stylesheet" href="terminal.css">
</head>
<body>
<!-- Top bar -->
<div id="top-bar">
<div class="left">
<span id="status-dot" class="status-dot connecting"></span>
<span>Claude Code</span>
<span id="latency">--</span>
</div>
<div class="right">
<button id="bar-btn">Reconnect</button>
</div>
</div>
<button id="toggle-bar" title="Show top bar">&#9662;</button>
<!-- Terminal -->
<div id="terminal-container"></div>
<!-- Loading overlay -->
<div id="loading-overlay">
<div class="spinner"></div>
<div class="message">Connecting to Claude Code...</div>
</div>
<!-- Reconnect overlay -->
<div id="reconnect-overlay">
<div class="spinner"></div>
<div class="message">Connection lost. Reconnecting...</div>
<div class="sub" id="reconnect-sub">Retrying in 1s...</div>
</div>
<script src="terminal.js"></script>
</body>
</html>

View File

@@ -0,0 +1,275 @@
/* Terminal Theme — CSS Custom Properties */
:root {
--term-bg: #1a1b26;
--term-fg: #c0caf5;
--term-cursor: #c0caf5;
--term-selection: rgba(130, 170, 255, 0.3);
--term-black: #15161e;
--term-red: #f7768e;
--term-green: #9ece6a;
--term-yellow: #e0af68;
--term-blue: #7aa2f7;
--term-magenta: #bb9af7;
--term-cyan: #7dcfff;
--term-white: #a9b1d6;
--term-bright-black: #414868;
--term-bright-white: #c0caf5;
--bar-bg: #16161e;
--bar-fg: #565f89;
--bar-accent: #7aa2f7;
--overlay-bg: rgba(26, 27, 38, 0.92);
}
@media (prefers-color-scheme: light) {
:root {
--term-bg: #f5f5f5;
--term-fg: #343b58;
--term-cursor: #343b58;
--term-selection: rgba(52, 59, 88, 0.2);
--term-black: #0f0f14;
--term-red: #8c4351;
--term-green: #485e30;
--term-yellow: #8f5e15;
--term-blue: #34548a;
--term-magenta: #5a4a78;
--term-cyan: #0f4b6e;
--term-white: #343b58;
--term-bright-black: #9699a3;
--term-bright-white: #343b58;
--bar-bg: #e8e8e8;
--bar-fg: #6c7086;
--bar-accent: #34548a;
--overlay-bg: rgba(245, 245, 245, 0.92);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--term-bg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* Top bar */
#top-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 12px;
background: var(--bar-bg);
color: var(--bar-fg);
font-size: 12px;
user-select: none;
-webkit-user-select: none;
transition: transform 0.2s ease;
}
#top-bar.collapsed {
transform: translateY(-100%);
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
#top-bar .left {
display: flex;
align-items: center;
gap: 10px;
}
#top-bar .right {
display: flex;
align-items: center;
gap: 10px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--term-green);
transition: background 0.3s;
}
.status-dot.disconnected {
background: var(--term-red);
}
.status-dot.connecting {
background: var(--term-yellow);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
#latency {
color: var(--bar-fg);
font-variant-numeric: tabular-nums;
}
#bar-btn {
background: none;
border: 1px solid var(--bar-fg);
color: var(--bar-fg);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
transition: color 0.15s, border-color 0.15s;
}
#bar-btn:hover {
color: var(--bar-accent);
border-color: var(--bar-accent);
}
#toggle-bar {
position: fixed;
top: 4px;
right: 4px;
z-index: 20;
background: var(--bar-bg);
border: 1px solid var(--bar-fg);
color: var(--bar-fg);
width: 20px;
height: 20px;
border-radius: 3px;
cursor: pointer;
font-size: 10px;
line-height: 1;
display: none;
align-items: center;
justify-content: center;
}
#top-bar.collapsed ~ #toggle-bar {
display: flex;
}
/* Terminal container */
#terminal-container {
width: 100%;
height: calc(100vh - 32px);
background: var(--term-bg);
}
#top-bar.collapsed ~ #terminal-container {
height: 100vh;
}
#terminal-container .xterm {
padding: 4px;
}
/* Loading overlay */
#loading-overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--overlay-bg);
z-index: 100;
transition: opacity 0.3s ease;
}
#loading-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--term-bright-black);
border-top-color: var(--bar-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#loading-overlay .message {
color: var(--term-fg);
font-size: 14px;
opacity: 0.8;
}
/* Reconnect overlay */
#reconnect-overlay {
position: fixed;
inset: 0;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--overlay-bg);
z-index: 90;
}
#reconnect-overlay.visible {
display: flex;
}
#reconnect-overlay .message {
color: var(--term-fg);
font-size: 14px;
margin-bottom: 8px;
}
#reconnect-overlay .sub {
color: var(--bar-fg);
font-size: 12px;
}
/* Mobile: make terminal touch-friendly */
@media (max-width: 600px) {
#top-bar {
height: 28px;
font-size: 11px;
padding: 0 8px;
}
#terminal-container {
height: calc(100vh - 28px);
}
#top-bar.collapsed ~ #terminal-container {
height: 100vh;
}
}
/* Scrollbar styling for xterm */
.xterm-viewport::-webkit-scrollbar {
width: 8px;
}
.xterm-viewport::-webkit-scrollbar-track {
background: transparent;
}
.xterm-viewport::-webkit-scrollbar-thumb {
background: var(--term-bright-black);
border-radius: 4px;
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background: var(--bar-fg);
}

View File

@@ -0,0 +1,361 @@
/**
* Claude Code — Terminal-in-Browser (xterm.js + WebSocket)
*/
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const PING_INTERVAL_MS = 5000;
// DOM elements
const loadingOverlay = document.getElementById('loading-overlay');
const reconnectOverlay = document.getElementById('reconnect-overlay');
const reconnectSub = document.getElementById('reconnect-sub');
const statusDot = document.getElementById('status-dot');
const latencyEl = document.getElementById('latency');
const barBtn = document.getElementById('bar-btn');
const topBar = document.getElementById('top-bar');
const toggleBar = document.getElementById('toggle-bar');
const terminalContainer = document.getElementById('terminal-container');
// State
let ws = null;
let term = null;
let fitAddon = null;
let searchAddon = null;
let webglAddon = null;
let reconnectDelay = RECONNECT_BASE_MS;
let reconnectTimer = null;
let pingTimer = null;
let lastPingSent = 0;
let connected = false;
// ── Theme ──────────────────────────────────────────────────────────────
function getTheme() {
const s = getComputedStyle(document.documentElement);
const v = (prop) => s.getPropertyValue(prop).trim();
return {
background: v('--term-bg'),
foreground: v('--term-fg'),
cursor: v('--term-cursor'),
selectionBackground: v('--term-selection'),
black: v('--term-black'),
red: v('--term-red'),
green: v('--term-green'),
yellow: v('--term-yellow'),
blue: v('--term-blue'),
magenta: v('--term-magenta'),
cyan: v('--term-cyan'),
white: v('--term-white'),
brightBlack: v('--term-bright-black'),
brightRed: v('--term-red'),
brightGreen: v('--term-green'),
brightYellow: v('--term-yellow'),
brightBlue: v('--term-blue'),
brightMagenta: v('--term-magenta'),
brightCyan: v('--term-cyan'),
brightWhite: v('--term-bright-white'),
};
}
// ── Terminal Init ──────────────────────────────────────────────────────
function initTerminal() {
term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, 'Courier New', monospace",
fontSize: 14,
lineHeight: 1.2,
theme: getTheme(),
allowProposedApi: true,
scrollback: 10000,
convertEol: true,
});
// Addons
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
searchAddon = new SearchAddon.SearchAddon();
term.loadAddon(searchAddon);
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
term.loadAddon(unicode11Addon);
term.unicode.activeVersion = '11';
// Open terminal
term.open(terminalContainer);
// Try WebGL renderer, fall back to canvas
try {
webglAddon = new WebglAddon.WebglAddon();
webglAddon.onContextLoss(() => {
webglAddon.dispose();
webglAddon = null;
});
term.loadAddon(webglAddon);
} catch (e) {
console.warn('WebGL renderer unavailable, using default canvas renderer');
}
fitAddon.fit();
// Resize handling
const resizeObserver = new ResizeObserver(() => {
if (fitAddon) {
fitAddon.fit();
}
});
resizeObserver.observe(terminalContainer);
term.onResize(({ cols, rows }) => {
sendJSON({ type: 'resize', cols, rows });
});
// Forward input to WebSocket
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Binary data
term.onBinary((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
const buf = new Uint8Array(data.length);
for (let i = 0; i < data.length; i++) {
buf[i] = data.charCodeAt(i) & 0xff;
}
ws.send(buf.buffer);
}
});
// Keyboard intercepts
term.attachCustomKeyEventHandler((ev) => {
// Ctrl+Shift+F → search
if (ev.ctrlKey && ev.shiftKey && ev.key === 'F') {
if (ev.type === 'keydown') {
const query = prompt('Search terminal:');
if (query) searchAddon.findNext(query);
}
return false;
}
// Ctrl+Shift+C → copy (Linux)
if (ev.ctrlKey && ev.shiftKey && ev.key === 'C') {
if (ev.type === 'keydown') {
const sel = term.getSelection();
if (sel) navigator.clipboard.writeText(sel);
}
return false;
}
// Ctrl+Shift+V → paste (Linux)
if (ev.ctrlKey && ev.shiftKey && ev.key === 'V') {
if (ev.type === 'keydown') {
navigator.clipboard.readText().then((text) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
});
}
return false;
}
return true;
});
// Theme change listener
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
term.options.theme = getTheme();
});
}
// ── WebSocket ──────────────────────────────────────────────────────────
function getWSUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = new URL(`${proto}//${location.host}/ws`);
// Auth token from URL param or localStorage
const params = new URLSearchParams(location.search);
const token = params.get('token') || localStorage.getItem('claude-terminal-token');
if (token) {
url.searchParams.set('token', token);
// Persist for reconnection
localStorage.setItem('claude-terminal-token', token);
}
return url.toString();
}
function sendJSON(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}
function connect() {
setStatus('connecting');
ws = new WebSocket(getWSUrl());
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
connected = true;
reconnectDelay = RECONNECT_BASE_MS;
setStatus('connected');
hideLoading();
hideReconnect();
// Send initial size
if (fitAddon) {
fitAddon.fit();
sendJSON({ type: 'resize', cols: term.cols, rows: term.rows });
}
// Start ping
startPing();
});
ws.addEventListener('message', (ev) => {
if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data));
} else if (typeof ev.data === 'string') {
// Could be JSON control message or plain text
try {
const msg = JSON.parse(ev.data);
handleControlMessage(msg);
} catch {
term.write(ev.data);
}
}
});
ws.addEventListener('close', () => {
onDisconnect();
});
ws.addEventListener('error', () => {
// error fires before close, let close handle reconnect
});
}
function handleControlMessage(msg) {
if (msg.type === 'pong') {
const latency = Date.now() - lastPingSent;
latencyEl.textContent = `${latency}ms`;
}
}
function onDisconnect() {
connected = false;
setStatus('disconnected');
stopPing();
if (ws) {
ws = null;
}
showReconnect();
scheduleReconnect();
}
function scheduleReconnect() {
clearTimeout(reconnectTimer);
reconnectSub.textContent = `Retrying in ${Math.round(reconnectDelay / 1000)}s...`;
reconnectTimer = setTimeout(() => {
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
}
function manualReconnect() {
clearTimeout(reconnectTimer);
reconnectDelay = RECONNECT_BASE_MS;
if (ws) {
ws.close();
ws = null;
}
// Clear terminal for fresh session
if (term) term.clear();
connect();
}
// ── Ping ───────────────────────────────────────────────────────────────
function startPing() {
stopPing();
pingTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
lastPingSent = Date.now();
sendJSON({ type: 'ping' });
}
}, PING_INTERVAL_MS);
}
function stopPing() {
clearInterval(pingTimer);
latencyEl.textContent = '--';
}
// ── UI Helpers ─────────────────────────────────────────────────────────
function setStatus(state) {
statusDot.className = 'status-dot';
if (state === 'disconnected') statusDot.classList.add('disconnected');
if (state === 'connecting') statusDot.classList.add('connecting');
barBtn.textContent = connected ? 'Disconnect' : 'Reconnect';
}
function hideLoading() {
loadingOverlay.classList.add('hidden');
}
function showReconnect() {
reconnectOverlay.classList.add('visible');
}
function hideReconnect() {
reconnectOverlay.classList.remove('visible');
}
// ── Top Bar Toggle ─────────────────────────────────────────────────────
function setupBarToggle() {
const isCollapsed = localStorage.getItem('claude-bar-collapsed') === 'true';
if (isCollapsed) topBar.classList.add('collapsed');
toggleBar.addEventListener('click', () => {
topBar.classList.remove('collapsed');
localStorage.setItem('claude-bar-collapsed', 'false');
if (fitAddon) setTimeout(() => fitAddon.fit(), 200);
});
// Double-click top bar to collapse
topBar.addEventListener('dblclick', () => {
topBar.classList.add('collapsed');
localStorage.setItem('claude-bar-collapsed', 'true');
if (fitAddon) setTimeout(() => fitAddon.fit(), 200);
});
barBtn.addEventListener('click', () => {
if (connected) {
if (ws) ws.close();
} else {
manualReconnect();
}
});
}
// ── Boot ───────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initTerminal();
setupBarToggle();
connect();
// Focus terminal on click anywhere
document.addEventListener('click', () => term.focus());
term.focus();
});

View File

@@ -0,0 +1,66 @@
/**
* Circular byte buffer for PTY scrollback replay.
* Stores the last `capacity` bytes of PTY output; oldest bytes are silently
* discarded when the buffer is full.
*/
export class ScrollbackBuffer {
private buf: Buffer;
private writePos = 0; // next write position in the ring
private stored = 0; // bytes currently stored (≤ capacity)
readonly capacity: number;
constructor(capacityBytes = 100 * 1024) {
this.capacity = capacityBytes;
this.buf = Buffer.allocUnsafe(capacityBytes);
}
/**
* Append PTY output to the buffer.
* Uses 'binary' (latin1) encoding to preserve raw byte values from node-pty.
*/
write(data: string): void {
const src = Buffer.from(data, "binary");
const len = src.length;
if (len === 0) return;
if (len >= this.capacity) {
// Incoming chunk larger than the whole buffer — keep only the tail
src.copy(this.buf, 0, len - this.capacity);
this.writePos = 0;
this.stored = this.capacity;
return;
}
const end = this.writePos + len;
if (end <= this.capacity) {
src.copy(this.buf, this.writePos);
} else {
// Wrap around the ring boundary
const tailLen = this.capacity - this.writePos;
src.copy(this.buf, this.writePos, 0, tailLen);
src.copy(this.buf, 0, tailLen);
}
this.writePos = end % this.capacity;
this.stored = Math.min(this.stored + len, this.capacity);
}
/**
* Returns all buffered bytes in chronological order (oldest first).
* The returned Buffer is a copy and safe to send over a WebSocket.
*/
read(): Buffer {
if (this.stored === 0) return Buffer.alloc(0);
if (this.stored < this.capacity) {
// Buffer hasn't wrapped yet — contiguous region starting at index 0
return Buffer.from(this.buf.subarray(0, this.stored));
}
// Buffer is full and has wrapped — oldest data starts at writePos
const out = Buffer.allocUnsafe(this.capacity);
const tailLen = this.capacity - this.writePos;
this.buf.copy(out, 0, this.writePos); // tail (oldest)
this.buf.copy(out, tailLen, 0, this.writePos); // head (newest)
return out;
}
}

View File

@@ -0,0 +1,176 @@
import type { IPty } from "node-pty";
import type { WebSocket } from "ws";
export type Session = {
id: string;
ws: WebSocket;
pty: IPty;
createdAt: number;
};
export class SessionManager {
private sessions = new Map<string, Session>();
private maxSessions: number;
private spawnPty: (cols: number, rows: number) => IPty;
constructor(
maxSessions: number,
spawnPty: (cols: number, rows: number) => IPty,
) {
this.maxSessions = maxSessions;
this.spawnPty = spawnPty;
}
get activeCount(): number {
return this.sessions.size;
}
get isFull(): boolean {
return this.sessions.size >= this.maxSessions;
}
getSession(id: string): Session | undefined {
return this.sessions.get(id);
}
/**
* Creates a new PTY session bound to the given WebSocket.
* Returns the session or null if at capacity.
*/
create(ws: WebSocket, cols = 80, rows = 24): Session | null {
if (this.isFull) {
return null;
}
const id = crypto.randomUUID();
let pty: IPty;
try {
pty = this.spawnPty(cols, rows);
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown PTY spawn error";
ws.send(
JSON.stringify({ type: "error", message: `PTY spawn failed: ${message}` }),
);
ws.close(1011, "PTY spawn failure");
return null;
}
const session: Session = { id, ws, pty, createdAt: Date.now() };
this.sessions.set(id, session);
// PTY output -> WebSocket
pty.onData((data: string) => {
if (ws.readyState === ws.OPEN) {
ws.send(data);
}
});
// PTY exit -> clean up
pty.onExit(({ exitCode, signal }) => {
console.log(
`[session ${id}] PTY exited: code=${exitCode}, signal=${signal}`,
);
this.sessions.delete(id);
if (ws.readyState === ws.OPEN) {
ws.send(
JSON.stringify({
type: "exit",
exitCode,
signal,
}),
);
ws.close(1000, "PTY exited");
}
});
// WebSocket messages -> PTY stdin (or resize)
ws.on("message", (data: Buffer | string) => {
const str = data.toString();
// Try to parse as JSON for control messages
if (str.startsWith("{")) {
try {
const msg = JSON.parse(str) as Record<string, unknown>;
if (
msg.type === "resize" &&
typeof msg.cols === "number" &&
typeof msg.rows === "number"
) {
pty.resize(msg.cols as number, msg.rows as number);
return;
}
if (msg.type === "ping") {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
return;
}
} catch {
// Not JSON, treat as terminal input
}
}
pty.write(str);
});
// WebSocket close -> kill PTY
ws.on("close", () => {
console.log(`[session ${id}] WebSocket closed`);
this.destroySession(id);
});
ws.on("error", (err) => {
console.error(`[session ${id}] WebSocket error:`, err.message);
this.destroySession(id);
});
console.log(
`[session ${id}] Created (active: ${this.sessions.size}/${this.maxSessions})`,
);
return session;
}
/**
* Gracefully destroys a session: SIGHUP, then SIGKILL after timeout.
*/
destroySession(id: string): void {
const session = this.sessions.get(id);
if (!session) return;
this.sessions.delete(id);
const { pty, ws } = session;
try {
pty.kill("SIGHUP");
} catch {
// PTY may already be dead
}
// Force kill after 5 seconds if still alive
const killTimer = setTimeout(() => {
try {
pty.kill("SIGKILL");
} catch {
// Already dead
}
}, 5000);
// If PTY exits before the timer, clear it
pty.onExit(() => clearTimeout(killTimer));
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
ws.close(1000, "Session destroyed");
}
}
/**
* Destroys all sessions. Used during server shutdown.
*/
destroyAll(): void {
for (const id of [...this.sessions.keys()]) {
this.destroySession(id);
}
}
}

View File

@@ -0,0 +1,171 @@
import type { IPty } from "node-pty";
import type { WebSocket } from "ws";
import { ScrollbackBuffer } from "./scrollback-buffer.js";
const DEFAULT_GRACE_MS = 5 * 60_000; // 5 minutes
const DEFAULT_SCROLLBACK_BYTES = 100 * 1024; // 100 KB
export type StoredSession = {
token: string;
pty: IPty;
scrollback: ScrollbackBuffer;
ws: WebSocket | null;
createdAt: Date;
lastActive: Date;
graceTimer: ReturnType<typeof setTimeout> | null;
};
export type SessionInfo = {
token: string;
created: string;
lastActive: string;
alive: boolean;
};
/**
* In-memory session store with TTL-based cleanup.
*
* Sessions survive WebSocket disconnects for `gracePeriodMs` before being
* permanently destroyed. This lets clients reconnect and resume their PTY
* without losing conversation state.
*/
export class SessionStore {
private sessions = new Map<string, StoredSession>();
private readonly gracePeriodMs: number;
private readonly scrollbackBytes: number;
constructor(
gracePeriodMs = DEFAULT_GRACE_MS,
scrollbackBytes = DEFAULT_SCROLLBACK_BYTES,
) {
this.gracePeriodMs = gracePeriodMs;
this.scrollbackBytes = scrollbackBytes;
}
/**
* Register a newly spawned PTY under a fresh session token.
*/
register(pty: IPty): StoredSession {
const token = crypto.randomUUID();
const session: StoredSession = {
token,
pty,
scrollback: new ScrollbackBuffer(this.scrollbackBytes),
ws: null,
createdAt: new Date(),
lastActive: new Date(),
graceTimer: null,
};
this.sessions.set(token, session);
return session;
}
get(token: string): StoredSession | undefined {
return this.sessions.get(token);
}
/**
* Attach a new WebSocket to an existing session.
* Cancels any running grace timer.
* Returns null if the session does not exist.
*/
reattach(token: string, ws: WebSocket): StoredSession | null {
const session = this.sessions.get(token);
if (!session) return null;
if (session.graceTimer) {
clearTimeout(session.graceTimer);
session.graceTimer = null;
}
session.ws = ws;
session.lastActive = new Date();
return session;
}
/**
* Detach the WebSocket from a session and start the grace period timer.
* After `gracePeriodMs` with no reconnect, `onExpire` is called and the
* session is destroyed.
*/
startGrace(token: string, onExpire: () => void): void {
const session = this.sessions.get(token);
if (!session) return;
session.ws = null;
session.lastActive = new Date();
if (session.graceTimer) {
clearTimeout(session.graceTimer);
}
const remainingSec = Math.round(this.gracePeriodMs / 1000);
console.log(
`[session ${token.slice(0, 8)}] Disconnected — grace period: ${remainingSec}s`,
);
session.graceTimer = setTimeout(() => {
session.graceTimer = null;
console.log(
`[session ${token.slice(0, 8)}] Grace period expired — cleaning up`,
);
this.destroy(token);
onExpire();
}, this.gracePeriodMs);
}
/**
* Immediately kill the PTY and remove the session.
*/
destroy(token: string): void {
const session = this.sessions.get(token);
if (!session) return;
this.sessions.delete(token);
if (session.graceTimer) {
clearTimeout(session.graceTimer);
session.graceTimer = null;
}
if (
session.ws &&
session.ws.readyState !== 2 /* CLOSING */ &&
session.ws.readyState !== 3 /* CLOSED */
) {
session.ws.close(1000, "Session destroyed");
}
try {
session.pty.kill("SIGHUP");
} catch {
// PTY may already be dead
}
setTimeout(() => {
try {
session.pty.kill("SIGKILL");
} catch {
// Already dead
}
}, 5000);
}
/** Returns summary info for all sessions (used by the REST API). */
list(): SessionInfo[] {
return [...this.sessions.values()].map((s) => ({
token: s.token,
created: s.createdAt.toISOString(),
lastActive: s.lastActive.toISOString(),
alive: s.ws !== null && s.ws.readyState === 1 /* OPEN */,
}));
}
get size(): number {
return this.sessions.size;
}
destroyAll(): void {
for (const token of [...this.sessions.keys()]) {
this.destroy(token);
}
}
}

372
src/server/web/terminal.ts Normal file
View File

@@ -0,0 +1,372 @@
/**
* Claude Code — terminal-in-browser
*
* WebSocket protocol (matches src/server/web/session-manager.ts):
*
* Server → client (text, JSON):
* { type: "connected", sessionId: string }
* { type: "pong" }
* { type: "error", message: string }
* { type: "exit", exitCode: number, signal: number | undefined }
*
* Server → client (text, raw):
* PTY output — plain string, written directly to xterm
*
* Client → server (text):
* { type: "resize", cols: number, rows: number }
* { type: "ping" }
* raw terminal input string
*/
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebglAddon } from '@xterm/addon-webgl'
import '@xterm/xterm/css/xterm.css'
import './styles.css'
// ── Types ────────────────────────────────────────────────────────────────────
type ServerMessage =
| { type: 'connected'; sessionId: string }
| { type: 'pong' }
| { type: 'error'; message: string }
| { type: 'exit'; exitCode: number; signal?: number }
// ── Config ───────────────────────────────────────────────────────────────────
const RECONNECT_BASE_MS = 1_000
const RECONNECT_MAX_MS = 30_000
const PING_INTERVAL_MS = 5_000
// ── State ────────────────────────────────────────────────────────────────────
let ws: WebSocket | null = null
let term: Terminal
let fitAddon: FitAddon
let searchAddon: SearchAddon
let reconnectDelay = RECONNECT_BASE_MS
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null
let lastPingSent = 0
let connected = false
// ── DOM refs ─────────────────────────────────────────────────────────────────
const loadingOverlay = document.getElementById('loading-overlay')!
const reconnectOverlay = document.getElementById('reconnect-overlay')!
const reconnectSub = document.getElementById('reconnect-sub')!
const statusDot = document.getElementById('status-dot')!
const latencyEl = document.getElementById('latency')!
const barBtn = document.getElementById('bar-btn') as HTMLButtonElement
const topBar = document.getElementById('top-bar')!
const toggleBarBtn = document.getElementById('toggle-bar') as HTMLButtonElement
const terminalContainer = document.getElementById('terminal-container')!
// ── Theme ────────────────────────────────────────────────────────────────────
function getTheme(): Terminal['options']['theme'] {
const s = getComputedStyle(document.documentElement)
const v = (prop: string) => s.getPropertyValue(prop).trim()
return {
background: v('--term-bg'),
foreground: v('--term-fg'),
cursor: v('--term-cursor'),
selectionBackground: v('--term-selection'),
black: v('--term-black'),
red: v('--term-red'),
green: v('--term-green'),
yellow: v('--term-yellow'),
blue: v('--term-blue'),
magenta: v('--term-magenta'),
cyan: v('--term-cyan'),
white: v('--term-white'),
brightBlack: v('--term-bright-black'),
brightRed: v('--term-red'),
brightGreen: v('--term-green'),
brightYellow: v('--term-yellow'),
brightBlue: v('--term-blue'),
brightMagenta: v('--term-magenta'),
brightCyan: v('--term-cyan'),
brightWhite: v('--term-bright-white'),
}
}
// ── Terminal initialisation ──────────────────────────────────────────────────
function initTerminal(): void {
term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontFamily:
"'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, 'Courier New', monospace",
fontSize: 14,
lineHeight: 1.2,
theme: getTheme(),
allowProposedApi: true,
scrollback: 10_000,
convertEol: true,
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.loadAddon(new WebLinksAddon())
searchAddon = new SearchAddon()
term.loadAddon(searchAddon)
const unicode11 = new Unicode11Addon()
term.loadAddon(unicode11)
term.unicode.activeVersion = '11'
term.open(terminalContainer)
// WebGL renderer with canvas fallback
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch {
// Canvas renderer is already active — no action needed
}
fitAddon.fit()
// Keep terminal fitted to container
const resizeObserver = new ResizeObserver(() => fitAddon.fit())
resizeObserver.observe(terminalContainer)
// Propagate resize to server
term.onResize(({ cols, rows }) => sendJSON({ type: 'resize', cols, rows }))
// Forward all terminal input to PTY
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
// Keyboard intercepts (return false = swallow; return true = pass through)
term.attachCustomKeyEventHandler((ev) => {
// Ctrl+Shift+F → in-terminal search
if (ev.ctrlKey && ev.shiftKey && ev.key === 'F') {
if (ev.type === 'keydown') {
const query = window.prompt('Search terminal:')
if (query) searchAddon.findNext(query, { caseSensitive: false, regex: false })
}
return false
}
// Ctrl+Shift+C → copy selection (Linux convention)
if (ev.ctrlKey && ev.shiftKey && ev.key === 'C') {
if (ev.type === 'keydown') {
const sel = term.getSelection()
if (sel) navigator.clipboard.writeText(sel)
}
return false
}
// Ctrl+Shift+V → paste (Linux convention)
if (ev.ctrlKey && ev.shiftKey && ev.key === 'V') {
if (ev.type === 'keydown') {
navigator.clipboard.readText().then((text) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(text)
})
}
return false
}
return true
})
// Update theme when OS preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
term.options.theme = getTheme()
})
}
// ── WebSocket ────────────────────────────────────────────────────────────────
function getWsUrl(): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = new URL(`${proto}//${location.host}/ws`)
// Auth token: URL param wins, then localStorage
const params = new URLSearchParams(location.search)
const token = params.get('token') ?? localStorage.getItem('claude-terminal-token')
if (token) {
url.searchParams.set('token', token)
localStorage.setItem('claude-terminal-token', token)
}
// Pass current terminal dimensions so the PTY is spawned at the right size
url.searchParams.set('cols', String(term.cols))
url.searchParams.set('rows', String(term.rows))
return url.toString()
}
function sendJSON(msg: Record<string, unknown>): void {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
}
function connect(): void {
setStatus('connecting')
ws = new WebSocket(getWsUrl())
ws.addEventListener('open', () => {
connected = true
reconnectDelay = RECONNECT_BASE_MS
setStatus('connected')
hideOverlay(loadingOverlay)
hideOverlay(reconnectOverlay)
// Re-sync size in case the window changed while connecting
fitAddon.fit()
sendJSON({ type: 'resize', cols: term.cols, rows: term.rows })
startPing()
})
ws.addEventListener('message', ({ data }: MessageEvent<string>) => {
// All messages from the server are strings.
// Try JSON control message first; fall back to raw PTY output.
if (data.startsWith('{')) {
try {
handleControlMessage(JSON.parse(data) as ServerMessage)
return
} catch {
// Not JSON — fall through to write as PTY output
}
}
term.write(data)
})
ws.addEventListener('close', onDisconnect)
ws.addEventListener('error', () => {
// 'error' always fires before 'close'; let onDisconnect handle reconnect
})
}
function handleControlMessage(msg: ServerMessage): void {
switch (msg.type) {
case 'connected':
// Session established — nothing extra needed
break
case 'pong':
latencyEl.textContent = `${Date.now() - lastPingSent}ms`
break
case 'error':
term.writeln(`\r\n\x1b[31m[error] ${msg.message}\x1b[0m`)
break
case 'exit':
term.writeln(
`\r\n\x1b[33m[session ended — exit code ${msg.exitCode ?? 0}]\x1b[0m`,
)
break
}
}
function onDisconnect(): void {
connected = false
ws = null
setStatus('disconnected')
stopPing()
showOverlay(reconnectOverlay)
scheduleReconnect()
}
function scheduleReconnect(): void {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectSub.textContent = `Retrying in ${Math.round(reconnectDelay / 1_000)}s…`
reconnectTimer = setTimeout(() => connect(), reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS)
}
function manualReconnect(): void {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectDelay = RECONNECT_BASE_MS
ws?.close()
ws = null
term.clear()
connect()
}
// ── Ping / latency ───────────────────────────────────────────────────────────
function startPing(): void {
stopPing()
pingTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
lastPingSent = Date.now()
sendJSON({ type: 'ping' })
}
}, PING_INTERVAL_MS)
}
function stopPing(): void {
if (pingTimer) clearInterval(pingTimer)
pingTimer = null
latencyEl.textContent = '--'
}
// ── UI helpers ────────────────────────────────────────────────────────────────
function setStatus(state: 'connected' | 'connecting' | 'disconnected'): void {
statusDot.className = 'status-dot'
if (state !== 'connected') statusDot.classList.add(state)
barBtn.textContent = connected ? 'Disconnect' : 'Reconnect'
}
function showOverlay(el: HTMLElement): void {
el.classList.remove('hidden')
el.classList.add('visible')
}
function hideOverlay(el: HTMLElement): void {
el.classList.remove('visible')
el.classList.add('hidden')
}
// ── Top bar collapse ──────────────────────────────────────────────────────────
function setupBarToggle(): void {
const STORAGE_KEY = 'claude-bar-collapsed'
if (localStorage.getItem(STORAGE_KEY) === 'true') {
topBar.classList.add('collapsed')
}
// Show bar button re-expands it
toggleBarBtn.addEventListener('click', () => {
topBar.classList.remove('collapsed')
localStorage.setItem(STORAGE_KEY, 'false')
setTimeout(() => fitAddon.fit(), 200)
})
// Double-click bar to collapse
topBar.addEventListener('dblclick', () => {
topBar.classList.add('collapsed')
localStorage.setItem(STORAGE_KEY, 'true')
setTimeout(() => fitAddon.fit(), 200)
})
barBtn.addEventListener('click', () => {
if (connected) {
ws?.close()
} else {
manualReconnect()
}
})
}
// ── Boot ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initTerminal()
setupBarToggle()
connect()
// Keep terminal focused
document.addEventListener('click', () => term.focus())
term.focus()
})

3
web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_WS_URL=ws://localhost:3001
ANTHROPIC_API_KEY=

3
web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

36
web/app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
const response = await fetch(`${apiUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.ANTHROPIC_API_KEY
? { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` }
: {}),
},
body: JSON.stringify(body),
});
if (!response.ok) {
return NextResponse.json(
{ error: "Backend request failed" },
{ status: response.status }
);
}
// Stream the response through
return new NextResponse(response.body, {
headers: {
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
},
});
} catch (error) {
console.error("Chat API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

78
web/app/globals.css Normal file
View File

@@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 20% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-surface-300 dark:bg-surface-700 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-surface-400 dark:bg-surface-600;
}

56
web/app/layout.tsx Normal file
View File

@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { ToastProvider } from "@/components/ui/ToastProvider";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const jetbrainsMono = localFont({
src: [
{
path: "../public/fonts/JetBrainsMono-Regular.woff2",
weight: "400",
style: "normal",
},
{
path: "../public/fonts/JetBrainsMono-Medium.woff2",
weight: "500",
style: "normal",
},
],
variable: "--font-jetbrains-mono",
display: "swap",
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "monospace"],
});
export const metadata: Metadata = {
title: "Claude Code",
description: "Claude Code — AI-powered development assistant",
icons: {
icon: "/favicon.ico",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
<ThemeProvider>
<ToastProvider>
{children}
</ToastProvider>
</ThemeProvider>
</body>
</html>
);
}

5
web/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { ChatLayout } from "@/components/chat/ChatLayout";
export default function Home() {
return <ChatLayout />;
}

View File

@@ -0,0 +1,174 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Send, Square, Paperclip } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { streamChat } from "@/lib/api";
import { cn } from "@/lib/utils";
import { MAX_MESSAGE_LENGTH } from "@/lib/constants";
interface ChatInputProps {
conversationId: string;
}
export function ChatInput({ conversationId }: ChatInputProps) {
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const { conversations, settings, addMessage, updateMessage } = useChatStore();
const conversation = conversations.find((c) => c.id === conversationId);
const handleSubmit = useCallback(async () => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
setIsStreaming(true);
// Add user message
addMessage(conversationId, {
role: "user",
content: text,
status: "complete",
});
// Add placeholder assistant message
const assistantId = addMessage(conversationId, {
role: "assistant",
content: "",
status: "streaming",
});
const controller = new AbortController();
abortRef.current = controller;
const messages = [
...(conversation?.messages ?? []).map((m) => ({
role: m.role,
content: m.content,
})),
{ role: "user" as const, content: text },
];
let fullText = "";
try {
for await (const chunk of streamChat(messages, settings.model, controller.signal)) {
if (chunk.type === "text" && chunk.content) {
fullText += chunk.content;
updateMessage(conversationId, assistantId, {
content: fullText,
status: "streaming",
});
} else if (chunk.type === "done") {
break;
} else if (chunk.type === "error") {
updateMessage(conversationId, assistantId, {
content: chunk.error ?? "An error occurred",
status: "error",
});
return;
}
}
updateMessage(conversationId, assistantId, { status: "complete" });
} catch (err) {
if ((err as Error).name !== "AbortError") {
updateMessage(conversationId, assistantId, {
content: "Request failed. Please try again.",
status: "error",
});
} else {
updateMessage(conversationId, assistantId, { status: "complete" });
}
} finally {
setIsStreaming(false);
abortRef.current = null;
}
}, [input, isStreaming, conversationId, conversation, settings.model, addMessage, updateMessage]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleStop = () => {
abortRef.current?.abort();
};
const adjustHeight = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
};
return (
<div className="border-t border-surface-800 bg-surface-900/50 backdrop-blur-sm px-4 py-3">
<div className="max-w-3xl mx-auto">
<div
className={cn(
"flex items-end gap-2 rounded-xl border bg-surface-800 px-3 py-2",
"border-surface-700 focus-within:border-brand-500 transition-colors"
)}
>
<button
className="p-1 text-surface-500 hover:text-surface-300 transition-colors flex-shrink-0 mb-0.5"
title="Attach file"
>
<Paperclip className="w-4 h-4" />
</button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value.slice(0, MAX_MESSAGE_LENGTH));
adjustHeight();
}}
onKeyDown={handleKeyDown}
placeholder="Message Claude Code..."
rows={1}
className={cn(
"flex-1 resize-none bg-transparent text-sm text-surface-100",
"placeholder:text-surface-500 focus:outline-none",
"min-h-[24px] max-h-[200px] py-0.5"
)}
/>
{isStreaming ? (
<button
onClick={handleStop}
className="p-1.5 rounded-lg bg-surface-700 text-surface-300 hover:bg-surface-600 transition-colors flex-shrink-0"
title="Stop generation"
>
<Square className="w-4 h-4" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!input.trim()}
className={cn(
"p-1.5 rounded-lg transition-colors flex-shrink-0",
input.trim()
? "bg-brand-600 text-white hover:bg-brand-700"
: "bg-surface-700 text-surface-500 cursor-not-allowed"
)}
title="Send message"
>
<Send className="w-4 h-4" />
</button>
)}
</div>
<p className="text-xs text-surface-600 text-center mt-2">
Claude can make mistakes. Verify important information.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useEffect } from "react";
import { useChatStore } from "@/lib/store";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
import { ChatWindow } from "./ChatWindow";
import { ChatInput } from "./ChatInput";
export function ChatLayout() {
const { conversations, createConversation, activeConversationId } = useChatStore();
useEffect(() => {
if (conversations.length === 0) {
createConversation();
}
}, []);
return (
<div className="flex h-screen bg-surface-950 text-surface-100">
<Sidebar />
<div className="flex flex-col flex-1 min-w-0">
<Header />
<main className="flex flex-col flex-1 min-h-0">
{activeConversationId ? (
<>
<ChatWindow conversationId={activeConversationId} />
<ChatInput conversationId={activeConversationId} />
</>
) : (
<div className="flex-1 flex items-center justify-center text-surface-500">
Select or create a conversation
</div>
)}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect, useRef } from "react";
import { useChatStore } from "@/lib/store";
import { MessageBubble } from "./MessageBubble";
import { Bot } from "lucide-react";
interface ChatWindowProps {
conversationId: string;
}
export function ChatWindow({ conversationId }: ChatWindowProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const { conversations } = useChatStore();
const conversation = conversations.find((c) => c.id === conversationId);
const messages = conversation?.messages ?? [];
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-6">
<div className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center">
<Bot className="w-6 h-6 text-brand-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-surface-100">How can I help?</h2>
<p className="text-sm text-surface-400 mt-1">
Start a conversation with Claude Code
</p>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto py-6 px-4 space-y-6">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
<div ref={bottomRef} />
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
interface MarkdownContentProps {
content: string;
className?: string;
}
export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div
className={cn(
"prose prose-sm prose-invert max-w-none",
"prose-p:leading-relaxed prose-p:my-1",
"prose-pre:bg-surface-900 prose-pre:border prose-pre:border-surface-700",
"prose-code:text-brand-300 prose-code:bg-surface-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs",
"prose-pre:code:bg-transparent prose-pre:code:text-surface-100 prose-pre:code:p-0",
"prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5",
"prose-headings:text-surface-100 prose-headings:font-semibold",
"prose-a:text-brand-400 prose-a:no-underline hover:prose-a:underline",
"prose-blockquote:border-brand-500 prose-blockquote:text-surface-300",
"prose-hr:border-surface-700",
className
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { User, Bot, AlertCircle } from "lucide-react";
import { cn, extractTextContent } from "@/lib/utils";
import type { Message } from "@/lib/types";
import { MarkdownContent } from "./MarkdownContent";
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === "user";
const isError = message.status === "error";
const text = extractTextContent(message.content);
return (
<div
className={cn(
"flex gap-3 animate-fade-in",
isUser && "flex-row-reverse"
)}
>
{/* Avatar */}
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
isUser
? "bg-brand-600 text-white"
: isError
? "bg-red-900 text-red-300"
: "bg-surface-700 text-surface-300"
)}
>
{isUser ? (
<User className="w-4 h-4" />
) : isError ? (
<AlertCircle className="w-4 h-4" />
) : (
<Bot className="w-4 h-4" />
)}
</div>
{/* Content */}
<div
className={cn(
"flex-1 min-w-0 max-w-2xl",
isUser && "flex justify-end"
)}
>
<div
className={cn(
"rounded-2xl px-4 py-3 text-sm",
isUser
? "bg-brand-600 text-white rounded-tr-sm"
: isError
? "bg-red-950 border border-red-800 text-red-200 rounded-tl-sm"
: "bg-surface-800 text-surface-100 rounded-tl-sm"
)}
>
{isUser ? (
<p className="whitespace-pre-wrap break-words">{text}</p>
) : (
<MarkdownContent content={text} />
)}
{message.status === "streaming" && (
<span className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft" />
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTheme } from "./ThemeProvider";
import { useChatStore } from "@/lib/store";
import { MODELS } from "@/lib/constants";
import { cn } from "@/lib/utils";
export function Header() {
const { theme, setTheme } = useTheme();
const { settings, updateSettings } = useChatStore();
const themeIcons = {
light: Sun,
dark: Moon,
system: Monitor,
} as const;
const ThemeIcon = themeIcons[theme];
const nextTheme = theme === "dark" ? "light" : theme === "light" ? "system" : "dark";
return (
<header className="flex items-center justify-between px-4 py-2.5 border-b border-surface-800 bg-surface-900/50 backdrop-blur-sm">
<div className="flex items-center gap-3">
<h1 className="text-sm font-medium text-surface-100">Chat</h1>
</div>
<div className="flex items-center gap-2">
{/* Model selector */}
<select
value={settings.model}
onChange={(e) => updateSettings({ model: e.target.value })}
className={cn(
"text-xs bg-surface-800 border border-surface-700 rounded-md px-2 py-1",
"text-surface-300 focus:outline-none focus:ring-1 focus:ring-brand-500"
)}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
{/* Theme toggle */}
<button
onClick={() => setTheme(nextTheme)}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors"
title={`Switch to ${nextTheme} theme`}
>
<ThemeIcon className="w-4 h-4" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Plus, MessageSquare, Trash2, Settings } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { cn, formatDate, truncate } from "@/lib/utils";
interface SidebarProps {
className?: string;
}
export function Sidebar({ className }: SidebarProps) {
const {
conversations,
activeConversationId,
createConversation,
setActiveConversation,
deleteConversation,
} = useChatStore();
const [hoveredId, setHoveredId] = useState<string | null>(null);
return (
<aside
className={cn(
"flex flex-col h-full bg-surface-900 border-r border-surface-800 w-64",
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-800">
<span className="text-sm font-semibold text-surface-100">Claude Code</span>
<button
onClick={createConversation}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors"
title="New conversation"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Conversation list */}
<nav className="flex-1 overflow-y-auto py-2">
{conversations.length === 0 ? (
<div className="px-4 py-8 text-center text-surface-500 text-sm">
No conversations yet
</div>
) : (
conversations.map((conv) => (
<div
key={conv.id}
className={cn(
"group relative flex items-center px-3 py-2 mx-2 rounded-md cursor-pointer",
"hover:bg-surface-800 transition-colors",
activeConversationId === conv.id && "bg-surface-800"
)}
onClick={() => setActiveConversation(conv.id)}
onMouseEnter={() => setHoveredId(conv.id)}
onMouseLeave={() => setHoveredId(null)}
>
<MessageSquare className="w-3.5 h-3.5 text-surface-500 flex-shrink-0 mr-2" />
<div className="flex-1 min-w-0">
<p className="text-sm text-surface-200 truncate">
{truncate(conv.title, 30)}
</p>
<p className="text-xs text-surface-500">{formatDate(conv.updatedAt)}</p>
</div>
{hoveredId === conv.id && (
<button
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="p-1 rounded text-surface-500 hover:text-red-400 hover:bg-surface-700 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))
)}
</nav>
{/* Footer */}
<div className="px-4 py-3 border-t border-surface-800">
<button className="flex items-center gap-2 text-sm text-surface-400 hover:text-surface-100 transition-colors w-full">
<Settings className="w-4 h-4" />
<span>Settings</span>
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { useChatStore } from "@/lib/store";
type Theme = "light" | "dark" | "system";
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: "light" | "dark";
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "dark",
setTheme: () => {},
resolvedTheme: "dark",
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { settings, updateSettings } = useChatStore();
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const resolve = () => {
if (settings.theme === "system") {
return mediaQuery.matches ? "dark" : "light";
}
return settings.theme;
};
const apply = () => {
const resolved = resolve();
setResolvedTheme(resolved);
document.documentElement.classList.toggle("dark", resolved === "dark");
};
apply();
mediaQuery.addEventListener("change", apply);
return () => mediaQuery.removeEventListener("change", apply);
}, [settings.theme]);
const setTheme = (theme: Theme) => {
updateSettings({ theme });
};
return (
<ThemeContext.Provider value={{ theme: settings.theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-brand-600 text-white hover:bg-brand-700",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-surface-700 bg-transparent hover:bg-surface-800 text-surface-200",
secondary: "bg-surface-800 text-surface-100 hover:bg-surface-700",
ghost: "hover:bg-surface-800 hover:text-surface-100 text-surface-400",
link: "text-brand-400 underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,66 @@
"use client";
import * as Toast from "@radix-ui/react-toast";
import { createContext, useContext, useState, useCallback } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface ToastMessage {
id: string;
title: string;
description?: string;
variant?: "default" | "destructive";
}
interface ToastContextValue {
toast: (message: Omit<ToastMessage, "id">) => void;
}
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const toast = useCallback((message: Omit<ToastMessage, "id">) => {
const id = Math.random().toString(36).slice(2);
setToasts((prev) => [...prev, { ...message, id }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
return (
<ToastContext.Provider value={{ toast }}>
<Toast.Provider swipeDirection="right">
{children}
{toasts.map((t) => (
<Toast.Root
key={t.id}
className={cn(
"flex items-start gap-3 p-4 rounded-lg shadow-lg border",
"bg-surface-800 border-surface-700 text-surface-100",
"data-[state=open]:animate-slide-up",
t.variant === "destructive" && "border-red-800 bg-red-950"
)}
open
>
<div className="flex-1">
<Toast.Title className="text-sm font-medium">{t.title}</Toast.Title>
{t.description && (
<Toast.Description className="text-xs text-surface-400 mt-0.5">
{t.description}
</Toast.Description>
)}
</div>
<Toast.Close className="text-surface-500 hover:text-surface-100">
<X className="w-4 h-4" />
</Toast.Close>
</Toast.Root>
))}
<Toast.Viewport className="fixed bottom-4 right-4 flex flex-col gap-2 w-80 z-50" />
</Toast.Provider>
</ToastContext.Provider>
);
}
export const useToast = () => useContext(ToastContext);

View File

@@ -0,0 +1,15 @@
import { useChatStore } from "@/lib/store";
export function useConversation(id: string) {
const { conversations, addMessage, updateMessage, deleteConversation } = useChatStore();
const conversation = conversations.find((c) => c.id === id) ?? null;
return {
conversation,
messages: conversation?.messages ?? [],
addMessage: (msg: Parameters<typeof addMessage>[1]) => addMessage(id, msg),
updateMessage: (msgId: string, updates: Parameters<typeof updateMessage>[2]) =>
updateMessage(id, msgId, updates),
deleteConversation: () => deleteConversation(id),
};
}

1
web/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1 @@
export { useTheme } from "@/components/layout/ThemeProvider";

85
web/lib/api.ts Normal file
View File

@@ -0,0 +1,85 @@
import type { Message } from "./types";
const getApiUrl = () =>
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
export interface StreamChunk {
type: "text" | "tool_use" | "tool_result" | "done" | "error";
content?: string;
tool?: {
id: string;
name: string;
input?: Record<string, unknown>;
result?: string;
is_error?: boolean;
};
error?: string;
}
export async function* streamChat(
messages: Pick<Message, "role" | "content">[],
model: string,
signal?: AbortSignal
): AsyncGenerator<StreamChunk> {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, model, stream: true }),
signal,
});
if (!response.ok) {
const err = await response.text();
yield { type: "error", error: err };
return;
}
const reader = response.body?.getReader();
if (!reader) {
yield { type: "error", error: "No response body" };
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6).trim();
if (data === "[DONE]") {
yield { type: "done" };
return;
}
try {
const chunk = JSON.parse(data) as StreamChunk;
yield chunk;
} catch {
// skip malformed chunks
}
}
}
}
} finally {
reader.releaseLock();
}
yield { type: "done" };
}
export async function fetchHealth(): Promise<boolean> {
try {
const res = await fetch(`${getApiUrl()}/health`, { cache: "no-store" });
return res.ok;
} catch {
return false;
}
}

16
web/lib/constants.ts Normal file
View File

@@ -0,0 +1,16 @@
export const MODELS = [
{ id: "claude-opus-4-6", label: "Claude Opus 4.6", description: "Most capable" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", description: "Balanced" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", description: "Fastest" },
] as const;
export const DEFAULT_MODEL = "claude-sonnet-4-6";
export const API_ROUTES = {
chat: "/api/chat",
stream: "/api/stream",
} as const;
export const MAX_MESSAGE_LENGTH = 100_000;
export const STREAMING_CHUNK_SIZE = 64;

123
web/lib/store.ts Normal file
View File

@@ -0,0 +1,123 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { nanoid } from "nanoid";
import type { Conversation, Message, AppSettings } from "./types";
import { DEFAULT_MODEL } from "./constants";
interface ChatState {
conversations: Conversation[];
activeConversationId: string | null;
settings: AppSettings;
// Actions
createConversation: () => string;
setActiveConversation: (id: string) => void;
deleteConversation: (id: string) => void;
addMessage: (conversationId: string, message: Omit<Message, "id" | "createdAt">) => string;
updateMessage: (conversationId: string, messageId: string, updates: Partial<Message>) => void;
updateSettings: (settings: Partial<AppSettings>) => void;
getActiveConversation: () => Conversation | null;
}
export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
conversations: [],
activeConversationId: null,
settings: {
theme: "dark",
model: DEFAULT_MODEL,
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001",
streamingEnabled: true,
},
createConversation: () => {
const id = nanoid();
const now = Date.now();
const conversation: Conversation = {
id,
title: "New conversation",
messages: [],
createdAt: now,
updatedAt: now,
model: get().settings.model,
};
set((state) => ({
conversations: [conversation, ...state.conversations],
activeConversationId: id,
}));
return id;
},
setActiveConversation: (id) => {
set({ activeConversationId: id });
},
deleteConversation: (id) => {
set((state) => {
const remaining = state.conversations.filter((c) => c.id !== id);
const nextActive =
state.activeConversationId === id
? (remaining[0]?.id ?? null)
: state.activeConversationId;
return { conversations: remaining, activeConversationId: nextActive };
});
},
addMessage: (conversationId, message) => {
const id = nanoid();
const now = Date.now();
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? {
...c,
messages: [...c.messages, { ...message, id, createdAt: now }],
updatedAt: now,
}
: c
),
}));
return id;
},
updateMessage: (conversationId, messageId, updates) => {
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? {
...c,
messages: c.messages.map((m) =>
m.id === messageId ? { ...m, ...updates } : m
),
updatedAt: Date.now(),
}
: c
),
}));
},
updateSettings: (settings) => {
set((state) => ({
settings: { ...state.settings, ...settings },
}));
},
getActiveConversation: () => {
const state = get();
return (
state.conversations.find((c) => c.id === state.activeConversationId) ??
null
);
},
}),
{
name: "claude-code-chat",
partialize: (state) => ({
conversations: state.conversations,
activeConversationId: state.activeConversationId,
settings: state.settings,
}),
}
)
);

59
web/lib/types.ts Normal file
View File

@@ -0,0 +1,59 @@
export type MessageRole = "user" | "assistant" | "system" | "tool";
export type MessageStatus = "pending" | "streaming" | "complete" | "error";
export interface TextContent {
type: "text";
text: string;
}
export interface ToolUseContent {
type: "tool_use";
id: string;
name: string;
input: Record<string, unknown>;
}
export interface ToolResultContent {
type: "tool_result";
tool_use_id: string;
content: string | ContentBlock[];
is_error?: boolean;
}
export type ContentBlock = TextContent | ToolUseContent | ToolResultContent;
export interface Message {
id: string;
role: MessageRole;
content: ContentBlock[] | string;
status: MessageStatus;
createdAt: number;
model?: string;
usage?: {
input_tokens: number;
output_tokens: number;
};
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: number;
updatedAt: number;
model?: string;
}
export interface ToolDefinition {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
export interface AppSettings {
theme: "light" | "dark" | "system";
model: string;
apiUrl: string;
streamingEnabled: boolean;
}

42
web/lib/utils.ts Normal file
View File

@@ -0,0 +1,42 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + "...";
}
export function extractTextContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((b): b is { type: "text"; text: string } => b?.type === "text")
.map((b) => b.text)
.join("");
}
return "";
}

10
web/next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
experimental: {
typedRoutes: true,
},
};
export default nextConfig;

46
web/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "claude-code-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^4.5.0",
"swr": "^2.2.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.0",
"framer-motion": "^11.0.0",
"lucide-react": "^0.400.0",
"shiki": "^1.10.0",
"react-markdown": "^9.0.0",
"remark-gfm": "^4.0.0",
"nanoid": "^5.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "^14.2.0",
"postcss": "^8",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10",
"typescript": "^5"
}
}

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

0
web/public/.gitkeep Normal file
View File

69
web/tailwind.config.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
50: "#f5f3ff",
100: "#ede9fe",
200: "#ddd6fe",
300: "#c4b5fd",
400: "#a78bfa",
500: "#8b5cf6",
600: "#7c3aed",
700: "#6d28d9",
800: "#5b21b6",
900: "#4c1d95",
950: "#2e1065",
},
surface: {
50: "#fafafa",
100: "#f4f4f5",
200: "#e4e4e7",
300: "#d4d4d8",
400: "#a1a1aa",
500: "#71717a",
600: "#52525b",
700: "#3f3f46",
800: "#27272a",
850: "#1f1f23",
900: "#18181b",
950: "#09090b",
},
},
fontFamily: {
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
mono: ["var(--font-jetbrains-mono)", "ui-monospace", "monospace"],
},
animation: {
"fade-in": "fadeIn 0.2s ease-in-out",
"slide-up": "slideUp 0.3s ease-out",
"pulse-soft": "pulseSoft 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { transform: "translateY(8px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
pulseSoft: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
},
},
},
plugins: [],
};
export default config;

27
web/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}