♻️ 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:
32
.dockerignore
Normal file
32
.dockerignore
Normal 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
3
.gitignore
vendored
@@ -12,6 +12,9 @@ build/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Prompts
|
||||||
|
prompts/
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
35
docker/.dockerignore
Normal file
35
docker/.dockerignore
Normal 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
83
docker/Dockerfile
Normal 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
28
docker/docker-compose.yml
Normal 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
28
docker/entrypoint.sh
Normal 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
17
package-lock.json
generated
@@ -33,6 +33,7 @@
|
|||||||
"ignore": "^6.0.0",
|
"ignore": "^6.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"p-map": "^7.0.0",
|
"p-map": "^7.0.0",
|
||||||
"picomatch": "^4.0.0",
|
"picomatch": "^4.0.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
@@ -2411,6 +2412,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
"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": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -13,6 +13,9 @@
|
|||||||
"build": "bun scripts/build-bundle.ts",
|
"build": "bun scripts/build-bundle.ts",
|
||||||
"build:watch": "bun scripts/build-bundle.ts --watch",
|
"build:watch": "bun scripts/build-bundle.ts --watch",
|
||||||
"build:prod": "bun scripts/build-bundle.ts --minify",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "biome check src/",
|
"lint": "biome check src/",
|
||||||
"lint:fix": "biome check --write src/",
|
"lint:fix": "biome check --write src/",
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
"@opentelemetry/sdk-logs": "^0.57.0",
|
"@opentelemetry/sdk-logs": "^0.57.0",
|
||||||
"@opentelemetry/sdk-metrics": "^1.30.0",
|
"@opentelemetry/sdk-metrics": "^1.30.0",
|
||||||
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
||||||
|
"auto-bind": "^5.0.1",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"chalk": "^5.4.0",
|
"chalk": "^5.4.0",
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
@@ -43,13 +47,13 @@
|
|||||||
"ignore": "^6.0.0",
|
"ignore": "^6.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"p-map": "^7.0.0",
|
"p-map": "^7.0.0",
|
||||||
"picomatch": "^4.0.0",
|
"picomatch": "^4.0.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"qrcode": "^1.5.0",
|
"qrcode": "^1.5.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-reconciler": "^0.31.0",
|
"react-reconciler": "^0.31.0",
|
||||||
"auto-bind": "^5.0.1",
|
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"stack-utils": "^2.0.6",
|
"stack-utils": "^2.0.6",
|
||||||
"strip-ansi": "^7.1.0",
|
"strip-ansi": "^7.1.0",
|
||||||
@@ -61,7 +65,13 @@
|
|||||||
"wrap-ansi": "^9.0.0",
|
"wrap-ansi": "^9.0.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"yaml": "^2.6.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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
@@ -82,5 +92,3 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "bun@1.1.0"
|
"packageManager": "bun@1.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
58
scripts/build-web.ts
Normal file
58
scripts/build-web.ts
Normal 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)
|
||||||
|
})
|
||||||
76
src/server/web/__tests__/auth.test.ts
Normal file
76
src/server/web/__tests__/auth.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
159
src/server/web/__tests__/session-manager.test.ts
Normal file
159
src/server/web/__tests__/session-manager.test.ts
Normal 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
65
src/server/web/auth.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/server/web/auth/adapter.ts
Normal file
220
src/server/web/auth/adapter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/server/web/auth/oauth-auth.ts
Normal file
276
src/server/web/auth/oauth-auth.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/server/web/auth/token-auth.ts
Normal file
81
src/server/web/auth/token-auth.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/server/web/pty-server.ts
Normal file
159
src/server/web/pty-server.ts
Normal 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 };
|
||||||
5
src/server/web/public/favicon.svg
Normal file
5
src/server/web/public/favicon.svg
Normal 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 |
54
src/server/web/public/index.html
Normal file
54
src/server/web/public/index.html
Normal 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">▾</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>
|
||||||
275
src/server/web/public/terminal.css
Normal file
275
src/server/web/public/terminal.css
Normal 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);
|
||||||
|
}
|
||||||
361
src/server/web/public/terminal.js
Normal file
361
src/server/web/public/terminal.js
Normal 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();
|
||||||
|
});
|
||||||
66
src/server/web/scrollback-buffer.ts
Normal file
66
src/server/web/scrollback-buffer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
176
src/server/web/session-manager.ts
Normal file
176
src/server/web/session-manager.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/server/web/session-store.ts
Normal file
171
src/server/web/session-store.ts
Normal 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
372
src/server/web/terminal.ts
Normal 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
3
web/.env.example
Normal 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
3
web/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"]
|
||||||
|
}
|
||||||
36
web/app/api/chat/route.ts
Normal file
36
web/app/api/chat/route.ts
Normal 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
78
web/app/globals.css
Normal 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
56
web/app/layout.tsx
Normal 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
5
web/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChatLayout } from "@/components/chat/ChatLayout";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <ChatLayout />;
|
||||||
|
}
|
||||||
174
web/components/chat/ChatInput.tsx
Normal file
174
web/components/chat/ChatInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
web/components/chat/ChatLayout.tsx
Normal file
39
web/components/chat/ChatLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
web/components/chat/ChatWindow.tsx
Normal file
48
web/components/chat/ChatWindow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
web/components/chat/MarkdownContent.tsx
Normal file
32
web/components/chat/MarkdownContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
web/components/chat/MessageBubble.tsx
Normal file
73
web/components/chat/MessageBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/components/layout/Header.tsx
Normal file
56
web/components/layout/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
web/components/layout/Sidebar.tsx
Normal file
93
web/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/components/layout/ThemeProvider.tsx
Normal file
56
web/components/layout/ThemeProvider.tsx
Normal 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);
|
||||||
48
web/components/ui/Button.tsx
Normal file
48
web/components/ui/Button.tsx
Normal 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 };
|
||||||
66
web/components/ui/ToastProvider.tsx
Normal file
66
web/components/ui/ToastProvider.tsx
Normal 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);
|
||||||
15
web/hooks/useConversation.ts
Normal file
15
web/hooks/useConversation.ts
Normal 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
1
web/hooks/useTheme.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useTheme } from "@/components/layout/ThemeProvider";
|
||||||
85
web/lib/api.ts
Normal file
85
web/lib/api.ts
Normal 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
16
web/lib/constants.ts
Normal 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
123
web/lib/store.ts
Normal 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
59
web/lib/types.ts
Normal 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
42
web/lib/utils.ts
Normal 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
10
web/next.config.ts
Normal 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
46
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
0
web/public/.gitkeep
Normal file
0
web/public/.gitkeep
Normal file
69
web/tailwind.config.ts
Normal file
69
web/tailwind.config.ts
Normal 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
27
web/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user