add resizing
This commit is contained in:
@@ -1,167 +1,137 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Spiceflow is an AI Session Orchestration PWA for monitoring and interacting with Claude Code and OpenCode CLI sessions from mobile devices or web browsers. It's a monorepo with three main components: a Clojure backend server, a SvelteKit frontend, and Playwright E2E tests.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Backend (Clojure)
|
```bash
|
||||||
|
./script/dev # Start backend + frontend
|
||||||
|
./script/test # Run E2E tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
clj -M:run # Start server (port 3000)
|
clj -M:dev # Start REPL with dev tools
|
||||||
clj -M:test # Run tests with Kaocha
|
clj -M:run # Start server (production mode)
|
||||||
clj -M:repl # Start REPL with nREPL for interactive development
|
clj -M:test # Run unit tests
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (SvelteKit)
|
**REPL commands:**
|
||||||
|
```clojure
|
||||||
|
(go) ; Start server + file watcher
|
||||||
|
(reset) ; Reload code
|
||||||
|
(stop) ; Stop server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd client
|
||||||
npm install # Install dependencies
|
npm install
|
||||||
npm run dev # Start dev server (port 5173, proxies /api to 3000)
|
npm run dev # Dev server
|
||||||
npm run build # Production build
|
npm run build # Production build
|
||||||
npm run check # Type checking with svelte-check
|
npm run check # TypeScript check
|
||||||
npm run check:watch # Watch mode for type checking
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### E2E Tests (Playwright)
|
### E2E Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd e2e
|
cd e2e
|
||||||
npm test # Run all tests (starts both servers automatically)
|
npm test
|
||||||
npm run test:headed # Run tests with visible browser
|
npm run test:headed # Visible browser
|
||||||
npm run test:ui # Interactive Playwright UI mode
|
npm run test:ui # Interactive explorer
|
||||||
```
|
```
|
||||||
|
|
||||||
E2E tests use a separate database (`server/test-e2e.db`).
|
|
||||||
|
|
||||||
### Development Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./script/dev # Start backend + frontend concurrently
|
|
||||||
./script/test # Start servers and run E2E tests
|
|
||||||
```
|
|
||||||
|
|
||||||
The `dev` script starts both servers and waits for each to be ready before proceeding. The `test` script uses a separate test database and cleans up after tests complete.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```
|
```
|
||||||
Claude Code/OpenCode CLI ↔ Spiceflow Server (Clojure) ↔ PWA Client (SvelteKit)
|
server/src/spiceflow/
|
||||||
↓
|
├── core.clj # Entry point
|
||||||
SQLite DB
|
├── config.clj # Settings from config.edn
|
||||||
|
├── db/
|
||||||
|
│ ├── protocol.clj # DataStore interface
|
||||||
|
│ ├── sqlite.clj # SQLite implementation
|
||||||
|
│ └── memory.clj # In-memory (tests)
|
||||||
|
├── adapters/
|
||||||
|
│ ├── protocol.clj # AgentAdapter interface
|
||||||
|
│ ├── claude.clj # Claude Code CLI
|
||||||
|
│ ├── opencode.clj # OpenCode CLI
|
||||||
|
│ └── tmux.clj # Terminal multiplexer
|
||||||
|
├── api/
|
||||||
|
│ ├── routes.clj # HTTP endpoints
|
||||||
|
│ └── websocket.clj # Real-time streaming
|
||||||
|
└── session/
|
||||||
|
└── manager.clj # Session lifecycle
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (`/server`)
|
### Frontend
|
||||||
|
|
||||||
- **Entry point**: `src/spiceflow/core.clj` - Ring/Jetty server with mount lifecycle
|
```
|
||||||
- **Routing**: `src/spiceflow/api/routes.clj` - Reitit-based REST API
|
client/src/
|
||||||
- **Database**: Protocol-based abstraction (`db/protocol.clj`) with SQLite (`db/sqlite.clj`) and in-memory (`db/memory.clj`) implementations
|
├── routes/ # SvelteKit file-based routing
|
||||||
- **Adapters**: Pluggable CLI integrations (`adapters/protocol.clj`) - Claude Code (`adapters/claude.clj`) and OpenCode (`adapters/opencode.clj`)
|
│ ├── +layout.svelte
|
||||||
- **WebSocket**: `api/websocket.clj` - Real-time message streaming
|
│ ├── +page.svelte # Home (session list)
|
||||||
- **Session management**: `session/manager.clj` - Session lifecycle
|
│ └── session/[id]/+page.svelte
|
||||||
|
├── lib/
|
||||||
|
│ ├── api.ts # HTTP + WebSocket client
|
||||||
|
│ ├── stores/sessions.ts
|
||||||
|
│ └── components/
|
||||||
|
└── app.css # Tailwind
|
||||||
|
```
|
||||||
|
|
||||||
### Frontend (`/client`)
|
### Protocols
|
||||||
|
|
||||||
- **Routes**: SvelteKit file-based routing in `src/routes/`
|
Database and CLI adapters use protocols for swappability:
|
||||||
- **State**: Svelte stores in `src/lib/stores/` (sessions, runtime selection)
|
|
||||||
- **API client**: `src/lib/api.ts` - HTTP and WebSocket clients
|
|
||||||
- **Components**: `src/lib/components/` - UI components
|
|
||||||
- `MessageList.svelte` - Displays messages with collapsible long content
|
|
||||||
- `PermissionRequest.svelte` - Permission prompts with accept/deny/steer actions
|
|
||||||
- `FileDiff.svelte` - Expandable file diffs for Write/Edit operations
|
|
||||||
- `SessionSettings.svelte` - Session settings dropdown (auto-accept edits)
|
|
||||||
- `InputBar.svelte` - Message input with steer mode support
|
|
||||||
- **PWA**: vite-plugin-pwa with Workbox service worker
|
|
||||||
- **Responsive**: Landscape mobile mode collapses header to hamburger menu
|
|
||||||
|
|
||||||
### Key Protocols
|
```clojure
|
||||||
|
(defprotocol DataStore
|
||||||
|
(get-sessions [this])
|
||||||
|
(save-message [this msg]))
|
||||||
|
|
||||||
**DataStore** (`db/protocol.clj`):
|
(defprotocol AgentAdapter
|
||||||
- `get-sessions`, `get-session`, `save-session`, `update-session`, `delete-session`
|
(spawn-session [this session])
|
||||||
- `get-messages`, `save-message`
|
(send-message [this session msg]))
|
||||||
|
```
|
||||||
**AgentAdapter** (`adapters/protocol.clj`):
|
|
||||||
- `discover` - Find existing CLI sessions
|
|
||||||
- `spawn` - Start CLI process with session
|
|
||||||
- `send` - Pipe message to stdin
|
|
||||||
- `read-stream` - Parse JSONL output
|
|
||||||
- Adding new runtimes requires implementing this protocol
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Server configuration via `server/resources/config.edn` or environment variables:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `SPICEFLOW_PORT` | 3000 | Server port |
|
|
||||||
| `SPICEFLOW_HOST` | 0.0.0.0 | Server host |
|
|
||||||
| `SPICEFLOW_DB` | spiceflow.db | SQLite database path |
|
|
||||||
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory |
|
|
||||||
| `OPENCODE_CMD` | opencode | OpenCode command |
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Permission Handling
|
|
||||||
When Claude Code requests permission for file operations (Write/Edit) or shell commands (Bash), Spiceflow intercepts these and presents them to the user:
|
|
||||||
- **Accept**: Grant permission and continue
|
|
||||||
- **Deny**: Reject the request
|
|
||||||
- **Steer ("No, and...")**: Redirect Claude with alternative instructions
|
|
||||||
|
|
||||||
File operations show expandable diffs displaying the exact changes being made.
|
|
||||||
|
|
||||||
### Auto-Accept Edits
|
|
||||||
Claude sessions can enable "Auto-accept edits" in session settings (gear icon) to automatically grant file operation permissions. When enabled:
|
|
||||||
|
|
||||||
- **Applies to**: `Write` and `Edit` tools only (file create/modify operations)
|
|
||||||
- **Does NOT apply to**: `Bash`, `WebFetch`, `WebSearch`, `NotebookEdit`, or other tools
|
|
||||||
- **Behavior**: Permission is still recorded in message history (green "accepted" status) but no user interaction required
|
|
||||||
- **Use case**: Reduces interruptions during coding sessions when you trust Claude to make file changes
|
|
||||||
|
|
||||||
Other permission types (shell commands, web access, etc.) will still prompt for manual approval.
|
|
||||||
|
|
||||||
### Session Management
|
|
||||||
- **Rename**: Click session title to rename
|
|
||||||
- **Delete**: Remove sessions from the session list
|
|
||||||
- **Condense**: Collapse long messages for easier scrolling
|
|
||||||
|
|
||||||
### Mobile Optimization
|
|
||||||
- Landscape mode collapses the header to a hamburger menu
|
|
||||||
- Compact file diffs with minimal padding
|
|
||||||
- Touch-friendly permission buttons
|
|
||||||
|
|
||||||
## Session Flow
|
|
||||||
|
|
||||||
1. User opens PWA → sees list of tracked sessions
|
|
||||||
2. User selects session → loads message history
|
|
||||||
3. User types message → POST to `/api/sessions/:id/send`
|
|
||||||
4. Server spawns CLI process with `--resume` flag
|
|
||||||
5. Server pipes user message to stdin
|
|
||||||
6. CLI streams response via stdout (JSONL format)
|
|
||||||
7. Server broadcasts to client via WebSocket
|
|
||||||
8. **If permission required** → WebSocket sends permission request → User accepts/denies/steers
|
|
||||||
9. Process completes → response saved to database
|
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/api/health` | GET | Health check |
|
| `/api/health` | GET | Health check |
|
||||||
| `/api/sessions` | GET | List all sessions |
|
| `/api/sessions` | GET | List sessions |
|
||||||
| `/api/sessions` | POST | Create new session |
|
| `/api/sessions` | POST | Create session |
|
||||||
| `/api/sessions/:id` | GET | Get session with messages |
|
| `/api/sessions/:id` | GET | Get session with messages |
|
||||||
| `/api/sessions/:id` | PATCH | Update session (title, auto-accept-edits) |
|
| `/api/sessions/:id` | PATCH | Update session |
|
||||||
| `/api/sessions/:id` | DELETE | Delete session |
|
| `/api/sessions/:id` | DELETE | Delete session |
|
||||||
| `/api/sessions/:id/send` | POST | Send message to session |
|
| `/api/sessions/:id/send` | POST | Send message |
|
||||||
| `/api/sessions/:id/permission` | POST | Respond to permission request |
|
| `/api/sessions/:id/permission` | POST | Handle permission |
|
||||||
| `/ws` | WebSocket | Real-time message streaming |
|
| `/api/sessions/:id/terminal` | GET | Get tmux content |
|
||||||
|
| `/api/sessions/:id/terminal/input` | POST | Send tmux input |
|
||||||
|
| `/api/ws` | WebSocket | Event streaming |
|
||||||
|
|
||||||
## Tech Stack
|
## Configuration
|
||||||
|
|
||||||
- **Backend**: Clojure 1.11, Ring/Jetty, Reitit, next.jdbc, SQLite, mount, Kaocha
|
Environment variables or `server/resources/config.edn`:
|
||||||
- **Frontend**: SvelteKit 2.5, Svelte 4, TypeScript, Tailwind CSS, Vite, vite-plugin-pwa
|
|
||||||
- **E2E**: Playwright
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SPICEFLOW_PORT` | 3000 | Server port |
|
||||||
|
| `SPICEFLOW_HOST` | 0.0.0.0 | Server host |
|
||||||
|
| `SPICEFLOW_DB` | spiceflow.db | SQLite path |
|
||||||
|
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions |
|
||||||
|
| `OPENCODE_CMD` | opencode | OpenCode binary |
|
||||||
|
|
||||||
|
## Subdirectory CLAUDE.md Files
|
||||||
|
|
||||||
|
Each directory has specific details:
|
||||||
|
- `server/` - REPL workflow, Mount lifecycle
|
||||||
|
- `server/src/spiceflow/db/` - Schema, queries
|
||||||
|
- `server/src/spiceflow/adapters/` - Adding adapters
|
||||||
|
- `server/src/spiceflow/api/` - HTTP handlers, WebSocket
|
||||||
|
- `server/src/spiceflow/session/` - Session state
|
||||||
|
- `client/` - SvelteKit, PWA
|
||||||
|
- `client/src/lib/` - API client, stores
|
||||||
|
- `client/src/routes/` - Pages, routing
|
||||||
|
- `e2e/` - E2E tests
|
||||||
|
|||||||
@@ -1,190 +1,104 @@
|
|||||||
# Spiceflow
|
# Spiceflow
|
||||||
|
|
||||||
AI Session Orchestration PWA for monitoring and interacting with Claude Code and OpenCode sessions.
|
A mobile-friendly web app for controlling AI coding assistants (Claude Code, OpenCode) remotely.
|
||||||
|
|
||||||
> "The spice must flow."
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐ ┌─────────────────────────┐ ┌─────────────────┐
|
Your Phone/Browser <-> Spiceflow Server <-> Claude Code CLI (on your computer)
|
||||||
│ Claude Code │<--->│ Spiceflow Server │<--->│ PWA Client │
|
|
||||||
│ (CLI) │ │ (Clojure) │ │ (Svelte) │
|
|
||||||
└─────────────────┘ │ │ └─────────────────┘
|
|
||||||
│ ┌─────────────────┐ │
|
|
||||||
┌─────────────────┐ │ │ SQLite + DB │ │
|
|
||||||
│ OpenCode │<--->│ │ Abstraction │ │
|
|
||||||
│ (CLI) │ │ │ WebSocket/SSE │ │
|
|
||||||
└─────────────────┘ │ └─────────────────┘ │
|
|
||||||
└─────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Clojure CLI (deps.edn)
|
|
||||||
- Node.js 18+ and pnpm
|
|
||||||
- SQLite
|
|
||||||
|
|
||||||
### Server
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
./script/dev # Start everything
|
||||||
clj -M:run
|
# Backend: http://localhost:3000
|
||||||
|
# Frontend: http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
The server will start on http://localhost:3000.
|
## Architecture
|
||||||
|
|
||||||
### Client
|
```
|
||||||
|
┌─────────────────┐ HTTP/WS ┌──────────────────┐ stdin/stdout ┌─────────────────┐
|
||||||
```bash
|
│ Your Browser │ <──────────────> │ Spiceflow │ <─────────────────> │ Claude Code │
|
||||||
cd client
|
│ (SvelteKit) │ │ Server (Clojure)│ │ CLI │
|
||||||
pnpm install
|
└─────────────────┘ └────────┬─────────┘ └─────────────────┘
|
||||||
pnpm dev
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────┐
|
||||||
|
│ SQLite DB │
|
||||||
|
└─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
The client dev server will start on http://localhost:5173 with proxy to the API.
|
## Project Structure
|
||||||
|
|
||||||
### Production Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build client
|
|
||||||
cd client
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# Run server (serves static files from client/build)
|
|
||||||
cd ../server
|
|
||||||
clj -M:run
|
|
||||||
```
|
```
|
||||||
|
spiceflow/
|
||||||
|
├── server/ # Clojure backend (API, database, CLI integration)
|
||||||
|
├── client/ # SvelteKit frontend (PWA, UI components)
|
||||||
|
└── e2e/ # Playwright tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Session** | A conversation with Claude Code. Has messages, settings, and working directory. |
|
||||||
|
| **Adapter** | Code that talks to a CLI (Claude, OpenCode, tmux). |
|
||||||
|
| **Permission** | When Claude wants to edit files or run commands, you must approve. |
|
||||||
|
|
||||||
|
## How a Message Flows
|
||||||
|
|
||||||
|
1. Browser sends POST to `/api/sessions/:id/send`
|
||||||
|
2. Server saves user message, starts CLI with `--resume`
|
||||||
|
3. Server sends message to CLI stdin
|
||||||
|
4. CLI outputs JSONL (one JSON per line)
|
||||||
|
5. Server parses JSONL, broadcasts via WebSocket
|
||||||
|
6. Browser shows streaming response
|
||||||
|
7. Server saves assistant message when done
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Permission Handling
|
### Permission Handling
|
||||||
|
|
||||||
When Claude Code requests permission for tools, Spiceflow intercepts and presents them for approval:
|
When Claude wants to edit files or run commands:
|
||||||
|
- **Accept**: Allow the action
|
||||||
- **Accept**: Grant permission and continue
|
- **Deny**: Block it
|
||||||
- **Deny**: Reject the request
|
- **Steer**: Say "No, but do this instead..."
|
||||||
- **Steer ("No, and...")**: Redirect Claude with alternative instructions
|
|
||||||
|
|
||||||
Supported tool types with human-readable descriptions:
|
|
||||||
- `Bash` - Shell commands (shows the command)
|
|
||||||
- `Write` - File creation (shows file path + diff preview)
|
|
||||||
- `Edit` - File modification (shows file path + diff preview)
|
|
||||||
- `WebFetch` - URL fetching (shows URL)
|
|
||||||
- `WebSearch` - Web searches (shows query)
|
|
||||||
- `NotebookEdit` - Jupyter notebook edits
|
|
||||||
- `Skill` - Slash command execution
|
|
||||||
|
|
||||||
### Auto-Accept Edits
|
### Auto-Accept Edits
|
||||||
|
|
||||||
Claude sessions can enable "Auto-accept edits" via the settings gear icon to automatically grant file operation permissions:
|
Enable in session settings to auto-approve `Write` and `Edit` tools only. Does not auto-approve `Bash`, `WebFetch`, etc.
|
||||||
|
|
||||||
- **Applies to**: `Write` and `Edit` tools only (file create/modify)
|
## Tech Stack
|
||||||
- **Does NOT apply to**: `Bash`, `WebFetch`, `WebSearch`, or other tools
|
|
||||||
- **Behavior**: Permission is recorded in message history (green "accepted" status) without user interaction
|
|
||||||
- **Use case**: Reduces interruptions during coding sessions when you trust Claude to make file changes
|
|
||||||
|
|
||||||
### Real-time Streaming
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
Messages stream in real-time via WebSocket with:
|
| Backend | Clojure 1.11, Ring/Jetty, Reitit, SQLite |
|
||||||
- Content deltas as Claude types
|
| Frontend | SvelteKit 2.5, Svelte 4, TypeScript, Tailwind |
|
||||||
- Permission request notifications
|
| Testing | Playwright, Kaocha |
|
||||||
- Working directory updates
|
|
||||||
- Session status changes
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/health` | Health check |
|
|
||||||
| GET | `/api/sessions` | List all tracked sessions |
|
|
||||||
| POST | `/api/sessions` | Create/import a session |
|
|
||||||
| GET | `/api/sessions/:id` | Get session with messages |
|
|
||||||
| DELETE | `/api/sessions/:id` | Delete a session |
|
|
||||||
| POST | `/api/sessions/:id/send` | Send message to session |
|
|
||||||
| GET | `/api/discover/claude` | Discover Claude Code sessions |
|
|
||||||
| GET | `/api/discover/opencode` | Discover OpenCode sessions |
|
|
||||||
| POST | `/api/import` | Import a discovered session |
|
|
||||||
| WS | `/api/ws` | WebSocket for real-time updates |
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server tests
|
# Backend
|
||||||
cd server
|
cd server && clj -M:dev # REPL: (go), (reset), (stop)
|
||||||
clj -M:test
|
clj -M:test # Unit tests
|
||||||
|
|
||||||
# Client type checking
|
# Frontend
|
||||||
cd client
|
cd client && npm run dev # Dev server
|
||||||
pnpm check
|
npm run check # TypeScript
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure
|
# E2E
|
||||||
|
cd e2e && npm test # Run tests
|
||||||
```
|
npm run test:ui # Interactive
|
||||||
spiceflow/
|
|
||||||
├── server/ # Clojure backend
|
|
||||||
│ ├── deps.edn
|
|
||||||
│ ├── src/spiceflow/
|
|
||||||
│ │ ├── core.clj # Entry point
|
|
||||||
│ │ ├── config.clj # Configuration
|
|
||||||
│ │ ├── db/
|
|
||||||
│ │ │ ├── protocol.clj # DataStore protocol
|
|
||||||
│ │ │ ├── sqlite.clj # SQLite implementation
|
|
||||||
│ │ │ └── memory.clj # In-memory impl for tests
|
|
||||||
│ │ ├── adapters/
|
|
||||||
│ │ │ ├── protocol.clj # AgentAdapter protocol
|
|
||||||
│ │ │ ├── claude.clj # Claude Code adapter
|
|
||||||
│ │ │ └── opencode.clj # OpenCode adapter
|
|
||||||
│ │ ├── api/
|
|
||||||
│ │ │ ├── routes.clj # REST endpoints
|
|
||||||
│ │ │ └── websocket.clj # WebSocket handlers
|
|
||||||
│ │ └── session/
|
|
||||||
│ │ └── manager.clj # Session lifecycle
|
|
||||||
│ └── test/spiceflow/
|
|
||||||
│
|
|
||||||
├── client/ # SvelteKit PWA
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── routes/ # SvelteKit routes
|
|
||||||
│ │ └── lib/
|
|
||||||
│ │ ├── api.ts # API client
|
|
||||||
│ │ ├── stores/ # Svelte stores
|
|
||||||
│ │ └── components/ # UI components
|
|
||||||
│ └── static/ # PWA assets
|
|
||||||
│
|
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Server
|
Environment variables or `server/resources/config.edn`:
|
||||||
|
|
||||||
Configuration via `resources/config.edn` or environment variables:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `SPICEFLOW_PORT` | 3000 | Server port |
|
| `SPICEFLOW_PORT` | 3000 | Server port |
|
||||||
| `SPICEFLOW_HOST` | 0.0.0.0 | Server host |
|
| `SPICEFLOW_HOST` | 0.0.0.0 | Server host |
|
||||||
| `SPICEFLOW_DB` | spiceflow.db | SQLite database path |
|
| `SPICEFLOW_DB` | spiceflow.db | SQLite path |
|
||||||
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory |
|
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions |
|
||||||
| `OPENCODE_CMD` | opencode | OpenCode command |
|
|
||||||
|
|
||||||
### PWA Icons
|
|
||||||
|
|
||||||
Generate PWA icons from the SVG favicon:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd client/static
|
|
||||||
# Use a tool like svg2png or imagemagick to generate:
|
|
||||||
# - pwa-192x192.png
|
|
||||||
# - pwa-512x512.png
|
|
||||||
# - apple-touch-icon.png (180x180)
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Client CLAUDE.md
|
||||||
|
|
||||||
|
SvelteKit frontend PWA.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # First time
|
||||||
|
npm run dev # Dev server (localhost:5173)
|
||||||
|
npm run build # Production build
|
||||||
|
npm run check # TypeScript check
|
||||||
|
npm run check:watch # Watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
client/
|
||||||
|
├── src/
|
||||||
|
│ ├── routes/ # Pages (file-based routing)
|
||||||
|
│ │ ├── +layout.svelte
|
||||||
|
│ │ ├── +page.svelte # Home (session list)
|
||||||
|
│ │ └── session/[id]/+page.svelte
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── api.ts # HTTP + WebSocket
|
||||||
|
│ │ ├── push.ts # Push notifications
|
||||||
|
│ │ ├── stores/sessions.ts # State management
|
||||||
|
│ │ └── components/ # UI components
|
||||||
|
│ ├── app.css # Tailwind
|
||||||
|
│ └── sw.ts # Service worker
|
||||||
|
├── static/ # Icons, manifest
|
||||||
|
├── vite.config.ts
|
||||||
|
└── tailwind.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
| File | URL |
|
||||||
|
|------|-----|
|
||||||
|
| `+page.svelte` | `/` |
|
||||||
|
| `session/[id]/+page.svelte` | `/session/:id` |
|
||||||
|
| `+layout.svelte` | Wraps all pages |
|
||||||
|
|
||||||
|
Access URL params: `$page.params.id`
|
||||||
|
|
||||||
|
## Stores
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { sessions, activeSession } from '$lib/stores/sessions';
|
||||||
|
|
||||||
|
// Sessions list
|
||||||
|
$sessions.sessions // Session[]
|
||||||
|
$sessions.loading // boolean
|
||||||
|
await sessions.load()
|
||||||
|
await sessions.create({ provider: 'claude' })
|
||||||
|
await sessions.delete(id)
|
||||||
|
|
||||||
|
// Active session
|
||||||
|
$activeSession.session // Session | null
|
||||||
|
$activeSession.messages // Message[]
|
||||||
|
$activeSession.streamingContent // string
|
||||||
|
$activeSession.pendingPermission // PermissionRequest | null
|
||||||
|
await activeSession.load(id)
|
||||||
|
await activeSession.sendMessage('text')
|
||||||
|
await activeSession.respondToPermission('accept')
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { api, wsClient } from '$lib/api';
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
const sessions = await api.getSessions();
|
||||||
|
await api.sendMessage(id, 'text');
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
await wsClient.connect();
|
||||||
|
wsClient.subscribe(id, (event) => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `MessageList` | Conversation display |
|
||||||
|
| `InputBar` | Text input + send |
|
||||||
|
| `PermissionRequest` | Accept/deny/steer UI |
|
||||||
|
| `FileDiff` | File changes preview |
|
||||||
|
| `SessionCard` | Session list item |
|
||||||
|
| `SessionSettings` | Gear menu |
|
||||||
|
| `TerminalView` | Tmux display |
|
||||||
|
| `PushToggle` | Push notification toggle |
|
||||||
|
|
||||||
|
## Theme
|
||||||
|
|
||||||
|
| Element | Class |
|
||||||
|
|---------|-------|
|
||||||
|
| Background | `bg-zinc-900` |
|
||||||
|
| Cards | `bg-zinc-800` |
|
||||||
|
| Text | `text-zinc-100` |
|
||||||
|
| Accent | `bg-orange-500` |
|
||||||
|
| Muted | `text-zinc-400` |
|
||||||
Generated
+25
@@ -8,6 +8,7 @@
|
|||||||
"name": "spiceflow-client",
|
"name": "spiceflow-client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ansi-to-html": "^0.7.2",
|
||||||
"marked": "^17.0.1"
|
"marked": "^17.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2830,6 +2831,21 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-to-html": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^2.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ansi-to-html": "bin/ansi-to-html"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/any-promise": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
@@ -3596,6 +3612,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.1",
|
"version": "1.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"workbox-window": "^7.0.0"
|
"workbox-window": "^7.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ansi-to-html": "^0.7.2",
|
||||||
"marked": "^17.0.1"
|
"marked": "^17.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Lib CLAUDE.md
|
||||||
|
|
||||||
|
Core library: API clients, stores, components.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `api.ts` | HTTP client, WebSocket, types |
|
||||||
|
| `push.ts` | Push notification utilities |
|
||||||
|
| `stores/sessions.ts` | Session state management |
|
||||||
|
| `stores/push.ts` | Push notification state |
|
||||||
|
| `components/` | Svelte components |
|
||||||
|
|
||||||
|
## Types (api.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
provider: 'claude' | 'opencode' | 'tmux';
|
||||||
|
title?: string;
|
||||||
|
status: 'idle' | 'processing' | 'awaiting-permission';
|
||||||
|
'working-dir'?: string;
|
||||||
|
'auto-accept-edits'?: boolean;
|
||||||
|
'pending-permission'?: PermissionRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
'session-id': string;
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionRequest {
|
||||||
|
tools: string[];
|
||||||
|
denials: PermissionDenial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamEvent {
|
||||||
|
event?: string;
|
||||||
|
text?: string;
|
||||||
|
'permission-request'?: PermissionRequest;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Sessions
|
||||||
|
api.getSessions()
|
||||||
|
api.getSession(id)
|
||||||
|
api.createSession({ provider: 'claude' })
|
||||||
|
api.updateSession(id, { title: 'New' })
|
||||||
|
api.deleteSession(id)
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
api.sendMessage(sessionId, 'text')
|
||||||
|
api.respondToPermission(sessionId, 'accept')
|
||||||
|
|
||||||
|
// Terminal
|
||||||
|
api.getTerminalContent(sessionId)
|
||||||
|
api.sendTerminalInput(sessionId, 'ls\n')
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await wsClient.connect();
|
||||||
|
const unsub = wsClient.subscribe(id, (event) => {
|
||||||
|
// content-delta, message-stop, permission-request
|
||||||
|
});
|
||||||
|
unsub();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stores
|
||||||
|
|
||||||
|
**Sessions store:**
|
||||||
|
```typescript
|
||||||
|
sessions.load() // Fetch all
|
||||||
|
sessions.create(opts) // Create new
|
||||||
|
sessions.delete(id) // Delete
|
||||||
|
sessions.rename(id, title)
|
||||||
|
sessions.updateSession(id, data) // Local update
|
||||||
|
```
|
||||||
|
|
||||||
|
**Active session store:**
|
||||||
|
```typescript
|
||||||
|
activeSession.load(id) // Fetch + subscribe WS
|
||||||
|
activeSession.sendMessage(text)
|
||||||
|
activeSession.respondToPermission(response, message?)
|
||||||
|
activeSession.setAutoAcceptEdits(bool)
|
||||||
|
activeSession.clear() // Unsubscribe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Events
|
||||||
|
|
||||||
|
**InputBar:**
|
||||||
|
```svelte
|
||||||
|
<InputBar on:submit={(e) => e.detail.message} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**PermissionRequest:**
|
||||||
|
```svelte
|
||||||
|
<PermissionRequest on:respond={(e) => {
|
||||||
|
e.detail.response // 'accept' | 'deny' | 'steer'
|
||||||
|
e.detail.message // optional steer text
|
||||||
|
}} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**SessionCard:**
|
||||||
|
```svelte
|
||||||
|
<SessionCard on:select on:delete />
|
||||||
|
```
|
||||||
+37
-2
@@ -71,6 +71,30 @@ export interface StreamEvent {
|
|||||||
'working-dir'?: string;
|
'working-dir'?: string;
|
||||||
'permission-request'?: PermissionRequest;
|
'permission-request'?: PermissionRequest;
|
||||||
permissionRequest?: PermissionRequest;
|
permissionRequest?: PermissionRequest;
|
||||||
|
diff?: TerminalDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal diff types for efficient TUI updates
|
||||||
|
export type TerminalDiffType = 'full' | 'diff' | 'unchanged';
|
||||||
|
|
||||||
|
export interface TerminalDiff {
|
||||||
|
type: TerminalDiffType;
|
||||||
|
lines?: string[]; // Full content as lines (for type: 'full')
|
||||||
|
changes?: Record<number, string | null>; // Line number -> new content, null means line removed (for type: 'diff')
|
||||||
|
'total-lines'?: number;
|
||||||
|
totalLines?: number;
|
||||||
|
hash?: number;
|
||||||
|
'frame-id'?: number; // Auto-incrementing ID for ordering frames (prevents out-of-order issues)
|
||||||
|
frameId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalContent {
|
||||||
|
content: string;
|
||||||
|
alive: boolean;
|
||||||
|
'session-name': string;
|
||||||
|
sessionName?: string;
|
||||||
|
diff?: TerminalDiff;
|
||||||
|
layout?: 'desktop' | 'landscape' | 'portrait';
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -152,8 +176,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Terminal (tmux)
|
// Terminal (tmux)
|
||||||
async getTerminalContent(sessionId: string): Promise<{ content: string; alive: boolean; 'session-name': string }> {
|
async getTerminalContent(sessionId: string, fresh: boolean = false): Promise<TerminalContent> {
|
||||||
return this.request<{ content: string; alive: boolean; 'session-name': string }>(`/sessions/${sessionId}/terminal`);
|
// Add timestamp to bust browser cache
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (fresh) params.set('fresh', 'true');
|
||||||
|
params.set('_t', Date.now().toString());
|
||||||
|
return this.request<TerminalContent>(`/sessions/${sessionId}/terminal?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendTerminalInput(sessionId: string, input: string): Promise<{ status: string }> {
|
async sendTerminalInput(sessionId: string, input: string): Promise<{ status: string }> {
|
||||||
@@ -162,6 +190,13 @@ class ApiClient {
|
|||||||
body: JSON.stringify({ input })
|
body: JSON.stringify({ input })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resizeTerminal(sessionId: string, mode: 'desktop' | 'landscape' | 'portrait'): Promise<{ status: string; mode: string }> {
|
||||||
|
return this.request<{ status: string; mode: string }>(`/sessions/${sessionId}/terminal/resize`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ mode })
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Message, PermissionDenial, ToolInput, Session } from '$lib/api';
|
import type { Message, PermissionDenial, ToolInput, Session } from '$lib/api';
|
||||||
import { onMount, afterUpdate } from 'svelte';
|
import { onMount, afterUpdate, tick } from 'svelte';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import FileDiff from './FileDiff.svelte';
|
import FileDiff from './FileDiff.svelte';
|
||||||
|
|
||||||
@@ -59,12 +59,21 @@
|
|||||||
collapsedMessages = collapsedMessages; // trigger reactivity
|
collapsedMessages = collapsedMessages; // trigger reactivity
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
scrollToBottom();
|
await tick();
|
||||||
|
// Use double requestAnimationFrame to ensure DOM is fully laid out before scrolling
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterUpdate(() => {
|
afterUpdate(() => {
|
||||||
scrollToBottom();
|
// Use requestAnimationFrame for consistent scroll behavior
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const roleStyles = {
|
const roleStyles = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
|
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
|
||||||
import { api, wsClient, type StreamEvent } from '$lib/api';
|
import { api, wsClient, type StreamEvent, type TerminalDiff } from '$lib/api';
|
||||||
|
import AnsiToHtml from 'ansi-to-html';
|
||||||
|
|
||||||
export let sessionId: string;
|
export let sessionId: string;
|
||||||
export let autoScroll: boolean = true;
|
export let autoScroll: boolean = true;
|
||||||
@@ -8,8 +9,16 @@
|
|||||||
|
|
||||||
const dispatch = createEventDispatcher<{ aliveChange: boolean }>();
|
const dispatch = createEventDispatcher<{ aliveChange: boolean }>();
|
||||||
|
|
||||||
let terminalContent = '';
|
// ANSI to HTML converter for terminal colors
|
||||||
let prevContentLength = 0;
|
const ansiConverter = new AnsiToHtml({
|
||||||
|
fg: '#22c55e', // green-500 default foreground
|
||||||
|
bg: 'transparent',
|
||||||
|
newline: false,
|
||||||
|
escapeXML: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Internal state: store content as array of lines for efficient diffing
|
||||||
|
let terminalLines: string[] = [];
|
||||||
let isAlive = false;
|
let isAlive = false;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error = '';
|
let error = '';
|
||||||
@@ -18,19 +27,158 @@
|
|||||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
let ctrlMode = false;
|
let ctrlMode = false;
|
||||||
|
let lastHash: number | null = null;
|
||||||
|
let lastFrameId: number | null = null; // Track frame ordering to prevent out-of-order updates
|
||||||
|
let initialLoadComplete = false; // Track whether initial load has happened
|
||||||
|
let screenMode: 'desktop' | 'landscape' | 'portrait' = 'landscape';
|
||||||
|
let resizing = false;
|
||||||
|
let inputBuffer = '';
|
||||||
|
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let isSending = false;
|
||||||
|
let fontScale = 1; // 0.75, 0.875, 1, 1.125, 1.25, 1.5
|
||||||
|
const fontScales = [0.75, 0.875, 1, 1.125, 1.25, 1.5];
|
||||||
|
|
||||||
async function fetchTerminalContent() {
|
function zoomIn() {
|
||||||
|
const idx = fontScales.indexOf(fontScale);
|
||||||
|
if (idx < fontScales.length - 1) {
|
||||||
|
fontScale = fontScales[idx + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
const idx = fontScales.indexOf(fontScale);
|
||||||
|
if (idx > 0) {
|
||||||
|
fontScale = fontScales[idx - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed content from lines
|
||||||
|
$: terminalContent = terminalLines.join('\n');
|
||||||
|
|
||||||
|
// Convert ANSI codes to HTML for rendering
|
||||||
|
$: terminalHtml = terminalContent ? ansiConverter.toHtml(terminalContent) : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a frame should be applied based on frame ID ordering.
|
||||||
|
* Handles wrap-around: if new ID is much smaller than current, it's likely a wrap.
|
||||||
|
* Threshold for wrap detection: if difference > MAX_SAFE_INTEGER / 2
|
||||||
|
*/
|
||||||
|
function shouldApplyFrame(newFrameId: number | undefined): boolean {
|
||||||
|
if (newFrameId === undefined || newFrameId === null) return true; // No frame ID, always apply
|
||||||
|
if (lastFrameId === null) return true; // First frame, always apply
|
||||||
|
|
||||||
|
// Handle wrap-around: if new ID is much smaller, it probably wrapped
|
||||||
|
const wrapThreshold = Number.MAX_SAFE_INTEGER / 2;
|
||||||
|
if (lastFrameId > wrapThreshold && newFrameId < wrapThreshold / 2) {
|
||||||
|
// Likely a wrap-around, accept the new frame
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal case: only apply if frame ID is newer (greater)
|
||||||
|
return newFrameId > lastFrameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a terminal diff to the current lines array.
|
||||||
|
* Handles full refresh, partial updates, and unchanged states.
|
||||||
|
* Checks frame ID to prevent out-of-order updates.
|
||||||
|
*/
|
||||||
|
function applyDiff(diff: TerminalDiff): boolean {
|
||||||
|
if (!diff) return false;
|
||||||
|
|
||||||
|
const frameId = diff['frame-id'] ?? diff.frameId;
|
||||||
|
const totalLines = diff['total-lines'] ?? diff.totalLines ?? 0;
|
||||||
|
|
||||||
|
// Check frame ordering (skip out-of-order frames)
|
||||||
|
if (!shouldApplyFrame(frameId)) {
|
||||||
|
console.debug('[Terminal] Skipping out-of-order frame:', frameId, 'last:', lastFrameId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (diff.type) {
|
||||||
|
case 'unchanged':
|
||||||
|
// Content hasn't changed
|
||||||
|
lastHash = diff.hash ?? null;
|
||||||
|
if (frameId !== undefined) lastFrameId = frameId;
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case 'full':
|
||||||
|
// Full refresh - replace all lines
|
||||||
|
terminalLines = diff.lines ?? [];
|
||||||
|
lastHash = diff.hash ?? null;
|
||||||
|
if (frameId !== undefined) lastFrameId = frameId;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case 'diff':
|
||||||
|
// Partial update - apply changes to specific lines
|
||||||
|
if (diff.changes) {
|
||||||
|
// Ensure array is long enough
|
||||||
|
while (terminalLines.length < totalLines) {
|
||||||
|
terminalLines.push('');
|
||||||
|
}
|
||||||
|
// Truncate if needed
|
||||||
|
if (terminalLines.length > totalLines) {
|
||||||
|
terminalLines = terminalLines.slice(0, totalLines);
|
||||||
|
}
|
||||||
|
// Apply changes
|
||||||
|
for (const [lineNumStr, content] of Object.entries(diff.changes)) {
|
||||||
|
const lineNum = parseInt(lineNumStr, 10);
|
||||||
|
if (lineNum >= 0 && lineNum < totalLines) {
|
||||||
|
terminalLines[lineNum] = content ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trigger reactivity
|
||||||
|
terminalLines = terminalLines;
|
||||||
|
}
|
||||||
|
lastHash = diff.hash ?? null;
|
||||||
|
if (frameId !== undefined) lastFrameId = frameId;
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTerminalContent(fresh: boolean = false) {
|
||||||
try {
|
try {
|
||||||
const result = await api.getTerminalContent(sessionId);
|
const result = await api.getTerminalContent(sessionId, fresh);
|
||||||
const contentGrew = result.content.length > prevContentLength;
|
|
||||||
prevContentLength = result.content.length;
|
let changed = false;
|
||||||
terminalContent = result.content;
|
|
||||||
|
// On initial load, always use full content directly for reliability
|
||||||
|
// Use diffs only for incremental updates after initial load
|
||||||
|
if (!initialLoadComplete) {
|
||||||
|
// First load: use raw content, ignore diff
|
||||||
|
const newLines = result.content ? result.content.split('\n') : [];
|
||||||
|
terminalLines = newLines;
|
||||||
|
lastHash = result.diff?.hash ?? null;
|
||||||
|
// Initialize frame ID tracking from first response
|
||||||
|
lastFrameId = result.diff?.['frame-id'] ?? result.diff?.frameId ?? null;
|
||||||
|
changed = newLines.length > 0;
|
||||||
|
initialLoadComplete = true;
|
||||||
|
// Set screen mode from server-detected layout
|
||||||
|
if (result.layout) {
|
||||||
|
screenMode = result.layout;
|
||||||
|
}
|
||||||
|
} else if (result.diff) {
|
||||||
|
// Subsequent loads: apply diff for efficiency
|
||||||
|
changed = applyDiff(result.diff);
|
||||||
|
} else if (result.content !== undefined) {
|
||||||
|
// Fallback: full content replacement
|
||||||
|
const newLines = result.content ? result.content.split('\n') : [];
|
||||||
|
if (newLines.join('\n') !== terminalLines.join('\n')) {
|
||||||
|
terminalLines = newLines;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isAlive !== result.alive) {
|
if (isAlive !== result.alive) {
|
||||||
isAlive = result.alive;
|
isAlive = result.alive;
|
||||||
dispatch('aliveChange', isAlive);
|
dispatch('aliveChange', isAlive);
|
||||||
}
|
}
|
||||||
error = '';
|
error = '';
|
||||||
if (contentGrew) {
|
|
||||||
|
if (changed) {
|
||||||
await tick();
|
await tick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
@@ -96,11 +244,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendInput(input: string) {
|
async function sendInput(input: string) {
|
||||||
|
// Buffer the input for batching
|
||||||
|
inputBuffer += input;
|
||||||
|
|
||||||
|
// If already scheduled to send, let it pick up the new input
|
||||||
|
if (batchTimeout) return;
|
||||||
|
|
||||||
|
// Schedule a batch send after a short delay to collect rapid keystrokes
|
||||||
|
batchTimeout = setTimeout(flushInputBuffer, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushInputBuffer() {
|
||||||
|
batchTimeout = null;
|
||||||
|
|
||||||
|
// Skip if nothing to send or already sending
|
||||||
|
if (!inputBuffer || isSending) return;
|
||||||
|
|
||||||
|
const toSend = inputBuffer;
|
||||||
|
inputBuffer = '';
|
||||||
|
isSending = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.sendTerminalInput(sessionId, input);
|
await api.sendTerminalInput(sessionId, toSend);
|
||||||
setTimeout(fetchTerminalContent, 200);
|
// Fetch update shortly after
|
||||||
|
setTimeout(() => fetchTerminalContent(false), 100);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to send input';
|
error = e instanceof Error ? e.message : 'Failed to send input';
|
||||||
|
} finally {
|
||||||
|
isSending = false;
|
||||||
|
// If more input accumulated while sending, flush it
|
||||||
|
if (inputBuffer) {
|
||||||
|
batchTimeout = setTimeout(flushInputBuffer, 30);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,28 +287,52 @@
|
|||||||
await sendInput(key);
|
await sendInput(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resizeScreen(mode: 'desktop' | 'landscape' | 'portrait') {
|
||||||
|
if (resizing) return;
|
||||||
|
resizing = true;
|
||||||
|
try {
|
||||||
|
await api.resizeTerminal(sessionId, mode);
|
||||||
|
screenMode = mode;
|
||||||
|
// Invalidate terminal cache and fetch fresh content after resize
|
||||||
|
setTimeout(() => fetchTerminalContent(true), 150);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Resize failed:', e);
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to resize terminal';
|
||||||
|
} finally {
|
||||||
|
resizing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleWebSocketEvent(event: StreamEvent) {
|
function handleWebSocketEvent(event: StreamEvent) {
|
||||||
if (event.event === 'terminal-update' && event.content !== undefined) {
|
if (event.event === 'terminal-update') {
|
||||||
const newContent = event.content as string;
|
// Apply diff if provided (includes frame ID ordering check)
|
||||||
const contentGrew = newContent.length > prevContentLength;
|
if (event.diff) {
|
||||||
prevContentLength = newContent.length;
|
const changed = applyDiff(event.diff);
|
||||||
terminalContent = newContent;
|
if (changed) {
|
||||||
if (contentGrew) {
|
tick().then(scrollToBottom);
|
||||||
tick().then(scrollToBottom);
|
}
|
||||||
|
} else if (event.content !== undefined) {
|
||||||
|
// Fallback: full content replacement (no frame ID available)
|
||||||
|
const newContent = event.content as string;
|
||||||
|
const newLines = newContent ? newContent.split('\n') : [];
|
||||||
|
if (newLines.join('\n') !== terminalLines.join('\n')) {
|
||||||
|
terminalLines = newLines;
|
||||||
|
tick().then(scrollToBottom);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Initial fetch
|
// Initial fetch with fresh=true to ensure we get full current content
|
||||||
await fetchTerminalContent();
|
await fetchTerminalContent(true);
|
||||||
|
|
||||||
// Subscribe to WebSocket updates
|
// Subscribe to WebSocket updates
|
||||||
await wsClient.connect();
|
await wsClient.connect();
|
||||||
unsubscribe = wsClient.subscribe(sessionId, handleWebSocketEvent);
|
unsubscribe = wsClient.subscribe(sessionId, handleWebSocketEvent);
|
||||||
|
|
||||||
// Periodic refresh every 1 second
|
// Periodic refresh every 1 second (no fresh flag for incremental diffs)
|
||||||
refreshInterval = setInterval(fetchTerminalContent, 1000);
|
refreshInterval = setInterval(() => fetchTerminalContent(false), 1000);
|
||||||
|
|
||||||
// Auto-focus input after content loads
|
// Auto-focus input after content loads
|
||||||
if (autoFocus) {
|
if (autoFocus) {
|
||||||
@@ -148,6 +347,9 @@
|
|||||||
if (unsubscribe) {
|
if (unsubscribe) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
|
if (batchTimeout) {
|
||||||
|
clearTimeout(batchTimeout);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -165,98 +367,130 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Terminal output area -->
|
<!-- Terminal output area -->
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<pre
|
<pre
|
||||||
bind:this={terminalElement}
|
bind:this={terminalElement}
|
||||||
class="flex-1 min-h-0 overflow-auto p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-words leading-relaxed"
|
on:click={() => terminalInput?.focus()}
|
||||||
>{terminalContent || 'Terminal ready. Type a command below.'}</pre>
|
class="flex-1 min-h-0 overflow-auto p-3 font-mono text-green-400 whitespace-pre-wrap break-words leading-relaxed terminal-content cursor-text"
|
||||||
|
style="font-size: {fontScale * 0.875}rem;"
|
||||||
|
>{@html terminalHtml}</pre>
|
||||||
|
|
||||||
<!-- Quick action buttons -->
|
<!-- Quick action buttons -->
|
||||||
<div class="flex-shrink-0 px-2 py-1.5 bg-zinc-900 border-t border-zinc-800 flex items-center gap-1.5 overflow-x-auto">
|
<div class="flex-shrink-0 px-1 py-0.5 bg-zinc-900 border-t border-zinc-800 flex items-center gap-0.5 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
on:click={() => ctrlMode = !ctrlMode}
|
on:click={() => ctrlMode = !ctrlMode}
|
||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-2.5 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs font-mono text-zinc-200 transition-colors {ctrlMode ? 'font-bold ring-2 ring-cyan-400 rounded-lg' : 'rounded'}"
|
class="px-1.5 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-[10px] font-mono text-zinc-200 transition-colors {ctrlMode ? 'font-bold ring-1 ring-cyan-400 rounded' : 'rounded-sm'}"
|
||||||
>
|
>^</button>
|
||||||
Ctrl
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
on:click={sendCtrlC}
|
on:click={sendCtrlC}
|
||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-2.5 py-1 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
class="px-1.5 py-0.5 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
||||||
>
|
>^C</button>
|
||||||
Ctrl+C
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
on:click={() => sendInput('\x04')}
|
on:click={() => sendInput('\x04')}
|
||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-2.5 py-1 bg-amber-600/80 hover:bg-amber-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
class="px-1.5 py-0.5 bg-amber-600/80 hover:bg-amber-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
||||||
>
|
>^D</button>
|
||||||
Ctrl+D
|
<span class="w-px h-3 bg-zinc-700"></span>
|
||||||
</button>
|
|
||||||
<span class="w-px h-4 bg-zinc-700"></span>
|
|
||||||
<button
|
<button
|
||||||
on:click={() => sendKey('y')}
|
on:click={() => sendKey('y')}
|
||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-2.5 py-1 bg-green-600/80 hover:bg-green-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
class="px-1.5 py-0.5 bg-green-600/80 hover:bg-green-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
||||||
>
|
>y</button>
|
||||||
y
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
on:click={() => sendKey('n')}
|
on:click={() => sendKey('n')}
|
||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-2.5 py-1 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
class="px-1.5 py-0.5 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
||||||
>
|
>n</button>
|
||||||
n
|
<span class="w-px h-3 bg-zinc-700"></span>
|
||||||
</button>
|
{#each ['1', '2', '3', '4'] as num}
|
||||||
<span class="w-px h-4 bg-zinc-700"></span>
|
|
||||||
{#each ['1', '2', '3', '4', '5'] as num}
|
|
||||||
<button
|
<button
|
||||||
on:click={() => sendKey(num)}
|
on:click={() => sendKey(num)}
|
||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-2.5 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded text-xs font-mono text-zinc-200 transition-colors"
|
class="px-1.5 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
|
||||||
>
|
>{num}</button>
|
||||||
{num}
|
|
||||||
</button>
|
|
||||||
{/each}
|
{/each}
|
||||||
<span class="w-px h-4 bg-zinc-700"></span>
|
<span class="w-px h-3 bg-zinc-700"></span>
|
||||||
<button
|
<button
|
||||||
on:click={() => sendInput('\t')}
|
on:click={() => sendInput('\t')}
|
||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-2.5 py-1 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
||||||
>
|
>⇥</button>
|
||||||
Tab
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
on:click={() => sendInput('\x1b[Z')}
|
on:click={() => sendInput('\x1b[Z')}
|
||||||
disabled={!isAlive}
|
disabled={!isAlive}
|
||||||
class="px-2.5 py-1 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
|
class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
|
||||||
|
>⇤</button>
|
||||||
|
<!-- Text zoom -->
|
||||||
|
<span class="w-px h-3 bg-zinc-700 ml-auto"></span>
|
||||||
|
<button
|
||||||
|
on:click={zoomOut}
|
||||||
|
disabled={fontScale <= fontScales[0]}
|
||||||
|
class="px-1 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
|
||||||
|
title="Zoom out"
|
||||||
|
>-</button>
|
||||||
|
<span class="text-[9px] text-zinc-400 font-mono w-6 text-center">{Math.round(fontScale * 100)}%</span>
|
||||||
|
<button
|
||||||
|
on:click={zoomIn}
|
||||||
|
disabled={fontScale >= fontScales[fontScales.length - 1]}
|
||||||
|
class="px-1 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
|
||||||
|
title="Zoom in"
|
||||||
|
>+</button>
|
||||||
|
<!-- Screen size selector -->
|
||||||
|
<span class="w-px h-3 bg-zinc-700"></span>
|
||||||
|
<button
|
||||||
|
on:click={() => resizeScreen('portrait')}
|
||||||
|
disabled={resizing}
|
||||||
|
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'portrait' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
||||||
|
title="Portrait (50x60)"
|
||||||
>
|
>
|
||||||
S-Tab
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-2.5 inline-block" fill="none" viewBox="0 0 10 16" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="1" y="1" width="8" height="14" rx="1" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => resizeScreen('landscape')}
|
||||||
|
disabled={resizing}
|
||||||
|
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
||||||
|
title="Landscape (100x30)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-3 inline-block" fill="none" viewBox="0 0 16 10" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="1" y="1" width="14" height="8" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => resizeScreen('desktop')}
|
||||||
|
disabled={resizing}
|
||||||
|
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
|
||||||
|
title="Desktop (180x50)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3.5 inline-block" fill="none" viewBox="0 0 20 16" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="1" y="1" width="18" height="11" rx="1" />
|
||||||
|
<path d="M6 14h8M10 12v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="w-px h-3 bg-zinc-700"></span>
|
||||||
<button
|
<button
|
||||||
on:click={scrollToBottom}
|
on:click={scrollToBottom}
|
||||||
class="ml-auto p-1 bg-zinc-700 hover:bg-zinc-600 rounded text-zinc-200 transition-colors"
|
class="p-0.5 bg-zinc-700 hover:bg-zinc-600 rounded-sm text-zinc-200 transition-colors"
|
||||||
aria-label="Scroll to bottom"
|
aria-label="Scroll to bottom"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input area -->
|
<!-- Hidden input for keyboard capture (invisible but functional for mobile) -->
|
||||||
<div class="flex-shrink-0 border-t border-zinc-800 bg-zinc-900 p-2 safe-bottom">
|
<input
|
||||||
<div class="flex items-center gap-2 bg-black rounded border border-zinc-700 px-3 py-2">
|
bind:this={terminalInput}
|
||||||
<span class="text-cyan-400 font-mono text-sm">$</span>
|
type="text"
|
||||||
<input
|
on:keydown={handleKeydown}
|
||||||
bind:this={terminalInput}
|
class="sr-only"
|
||||||
type="text"
|
disabled={!isAlive}
|
||||||
on:keydown={handleKeydown}
|
aria-label="Terminal input"
|
||||||
class="flex-1 bg-transparent border-none outline-none font-mono text-sm text-green-400 placeholder-zinc-600"
|
/>
|
||||||
placeholder="Keys sent immediately..."
|
|
||||||
disabled={!isAlive}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Routes CLAUDE.md
|
||||||
|
|
||||||
|
SvelteKit pages.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | URL | Purpose |
|
||||||
|
|------|-----|---------|
|
||||||
|
| `+layout.svelte` | All | Root layout, initializes stores |
|
||||||
|
| `+page.svelte` | `/` | Session list |
|
||||||
|
| `session/[id]/+page.svelte` | `/session/:id` | Session detail |
|
||||||
|
|
||||||
|
## +layout.svelte
|
||||||
|
|
||||||
|
- Loads sessions on mount
|
||||||
|
- Registers service worker
|
||||||
|
- Contains `<slot />` for pages
|
||||||
|
|
||||||
|
## +page.svelte (Home)
|
||||||
|
|
||||||
|
- Shows `$sortedSessions` list
|
||||||
|
- Create session button -> `sessions.create()` -> `goto('/session/id')`
|
||||||
|
- Delete via `sessions.delete(id)`
|
||||||
|
|
||||||
|
## session/[id]/+page.svelte
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
$: sessionId = $page.params.id;
|
||||||
|
|
||||||
|
onMount(() => activeSession.load(sessionId));
|
||||||
|
onDestroy(() => activeSession.clear());
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permission flow:**
|
||||||
|
1. `$activeSession.pendingPermission` becomes non-null
|
||||||
|
2. Show `<PermissionRequest>`
|
||||||
|
3. User responds -> `activeSession.respondToPermission()`
|
||||||
|
4. Permission clears, streaming continues
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
goto('/');
|
||||||
|
goto(`/session/${id}`);
|
||||||
|
```
|
||||||
+64
-36
@@ -1,58 +1,86 @@
|
|||||||
# CLAUDE.md
|
# E2E CLAUDE.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
Playwright end-to-end tests.
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Spiceflow E2E tests - Playwright-based end-to-end tests for the Spiceflow AI Session Orchestration PWA. Tests run against both the Clojure backend and SvelteKit frontend.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests (starts both servers automatically)
|
npm test # Headless
|
||||||
npm test
|
npm run test:headed # Visible browser
|
||||||
|
npm run test:ui # Interactive UI
|
||||||
# Run with visible browser
|
npx playwright test tests/basic.spec.ts # Specific file
|
||||||
npm run test:headed
|
npx playwright test -g "test name" # By name
|
||||||
|
|
||||||
# Run with Playwright UI mode
|
|
||||||
npm run test:ui
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Test Ports
|
||||||
|
|
||||||
The e2e setup automatically manages both servers:
|
| Service | Dev Port | E2E Port |
|
||||||
|
|---------|----------|----------|
|
||||||
|
| Backend | 3000 | 3001 |
|
||||||
|
| Frontend | 5173 | 5174 |
|
||||||
|
| Database | spiceflow.db | test-e2e.db |
|
||||||
|
|
||||||
1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3001) and frontend (port 5174)
|
## Directory Structure
|
||||||
2. **Global Teardown** (`global-teardown.ts`) - Stops both servers
|
|
||||||
3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks
|
|
||||||
|
|
||||||
E2E tests use different ports (3001/5174) than dev servers (3000/5173) to allow running tests without interfering with development.
|
```
|
||||||
|
e2e/
|
||||||
|
├── tests/ # Test files
|
||||||
|
├── global-setup.ts # Starts servers
|
||||||
|
├── global-teardown.ts # Stops servers
|
||||||
|
├── server-utils.ts # Server utilities
|
||||||
|
└── playwright.config.ts # Configuration
|
||||||
|
```
|
||||||
|
|
||||||
Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls.
|
## Test Pattern
|
||||||
|
|
||||||
## Test Database
|
|
||||||
|
|
||||||
E2E tests use a separate database (`server/test-e2e.db`) to avoid polluting the main database.
|
|
||||||
|
|
||||||
## Writing Tests
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { E2E_BACKEND_URL } from '../playwright.config';
|
import { E2E_BACKEND_URL } from '../playwright.config';
|
||||||
|
|
||||||
test('example', async ({ page, request }) => {
|
test('example', async ({ page, request }) => {
|
||||||
// Direct API call - use E2E_BACKEND_URL for backend requests
|
// API setup
|
||||||
const response = await request.get(`${E2E_BACKEND_URL}/api/sessions`);
|
const res = await request.post(`${E2E_BACKEND_URL}/api/sessions`, {
|
||||||
|
data: { provider: 'claude' }
|
||||||
|
});
|
||||||
|
const session = await res.json();
|
||||||
|
|
||||||
// Browser interaction - baseURL is configured in playwright.config.ts
|
// Browser interaction
|
||||||
await page.goto('/');
|
await page.goto(`/session/${session.id}`);
|
||||||
await expect(page.locator('h1')).toBeVisible();
|
await page.fill('textarea', 'Hello');
|
||||||
|
await page.click('button:has-text("Send")');
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
await expect(page.locator('.assistant-message')).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await request.delete(`${E2E_BACKEND_URL}/api/sessions/${session.id}`);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Parent Project
|
## Selectors
|
||||||
|
|
||||||
This is part of the Spiceflow monorepo. See `../CLAUDE.md` for full project documentation including:
|
```typescript
|
||||||
- `../server/` - Clojure backend (Ring/Reitit, SQLite)
|
page.locator('[data-testid="x"]') // By test ID
|
||||||
- `../client/` - SvelteKit PWA frontend
|
page.getByRole('button', { name: 'Send' })
|
||||||
|
page.locator('text=Hello')
|
||||||
|
page.locator('.class')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Waits
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await expect(locator).toBeVisible();
|
||||||
|
await expect(locator).toBeVisible({ timeout: 30000 });
|
||||||
|
await expect(locator).toHaveText('Expected');
|
||||||
|
await expect(locator).not.toBeVisible();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
| File | Tests |
|
||||||
|
|------|-------|
|
||||||
|
| `basic.spec.ts` | Health, loading |
|
||||||
|
| `workflow-claude.spec.ts` | Claude session flow |
|
||||||
|
| `permissions-claude.spec.ts` | Permission handling |
|
||||||
|
| `autoaccept-claude.spec.ts` | Auto-accept edits |
|
||||||
|
| `tmux-terminal.spec.ts` | Tmux sessions |
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Scripts CLAUDE.md
|
||||||
|
|
||||||
|
Development scripts.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `./script/dev` | Start backend + frontend |
|
||||||
|
| `./script/test` | Run E2E tests |
|
||||||
|
|
||||||
|
## dev
|
||||||
|
|
||||||
|
Starts:
|
||||||
|
1. Backend REPL on port 3000 with nREPL on 7888
|
||||||
|
2. Auto-reload enabled
|
||||||
|
3. Frontend dev server on port 5173
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Ctrl+C stops everything
|
||||||
|
- Shows local IP for phone testing
|
||||||
|
- File changes auto-reload backend
|
||||||
|
|
||||||
|
## test
|
||||||
|
|
||||||
|
Runs Playwright tests with test database and ports.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./script/test # All tests
|
||||||
|
./script/test -- --headed # Visible browser
|
||||||
|
./script/test -- --ui # Interactive
|
||||||
|
./script/test -- tests/file.ts # Specific file
|
||||||
|
```
|
||||||
+59
-7
@@ -13,11 +13,18 @@ NC='\033[0m' # No Color
|
|||||||
|
|
||||||
BACKEND_PID=""
|
BACKEND_PID=""
|
||||||
FRONTEND_PID=""
|
FRONTEND_PID=""
|
||||||
|
WATCHER_PID=""
|
||||||
NREPL_PORT=7888
|
NREPL_PORT=7888
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo -e "\n${YELLOW}Shutting down...${NC}"
|
echo -e "\n${YELLOW}Shutting down...${NC}"
|
||||||
|
|
||||||
|
# Kill file watcher
|
||||||
|
if [ -n "$WATCHER_PID" ]; then
|
||||||
|
pkill -P $WATCHER_PID 2>/dev/null || true
|
||||||
|
kill $WATCHER_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# Kill backend and all its children
|
# Kill backend and all its children
|
||||||
if [ -n "$BACKEND_PID" ]; then
|
if [ -n "$BACKEND_PID" ]; then
|
||||||
pkill -P $BACKEND_PID 2>/dev/null || true
|
pkill -P $BACKEND_PID 2>/dev/null || true
|
||||||
@@ -45,20 +52,32 @@ cleanup() {
|
|||||||
|
|
||||||
trap cleanup SIGINT SIGTERM SIGHUP EXIT
|
trap cleanup SIGINT SIGTERM SIGHUP EXIT
|
||||||
|
|
||||||
|
# Check for clj-nrepl-eval (required for auto-reload)
|
||||||
|
HAS_NREPL_EVAL=true
|
||||||
|
if ! command -v clj-nrepl-eval &> /dev/null; then
|
||||||
|
echo -e "${YELLOW}Warning: clj-nrepl-eval not found. Auto-reload will be disabled.${NC}"
|
||||||
|
HAS_NREPL_EVAL=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for inotifywait (preferred) or fall back to polling
|
||||||
|
HAS_INOTIFY=false
|
||||||
|
if command -v inotifywait &> /dev/null; then
|
||||||
|
HAS_INOTIFY=true
|
||||||
|
fi
|
||||||
|
|
||||||
echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n"
|
echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n"
|
||||||
|
|
||||||
# Start backend REPL with auto-reload
|
# Start backend REPL
|
||||||
echo -e "${GREEN}Starting backend REPL with auto-reload...${NC}"
|
echo -e "${GREEN}Starting backend REPL...${NC}"
|
||||||
cd "$ROOT_DIR/server"
|
cd "$ROOT_DIR/server"
|
||||||
|
|
||||||
# Start nREPL server and run (go) to start app with file watcher
|
# Start nREPL server and run (start) - no hawk watcher, we use inotifywait instead
|
||||||
clj -M:dev -e "
|
clj -M:dev -e "
|
||||||
(require 'nrepl.server)
|
(require 'nrepl.server)
|
||||||
(def server (nrepl.server/start-server :port $NREPL_PORT))
|
(def server (nrepl.server/start-server :port $NREPL_PORT))
|
||||||
(println \"nREPL server started on port $NREPL_PORT\")
|
(println \"nREPL server started on port $NREPL_PORT\")
|
||||||
(require 'user)
|
(require 'user)
|
||||||
(user/go)
|
(user/start)
|
||||||
;; Block forever to keep the process running
|
|
||||||
@(promise)
|
@(promise)
|
||||||
" &
|
" &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
@@ -70,7 +89,40 @@ until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do
|
|||||||
done
|
done
|
||||||
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
|
echo -e "${GREEN}Backend ready on http://localhost:3000${NC}"
|
||||||
echo -e "${GREEN}nREPL available on port $NREPL_PORT${NC}"
|
echo -e "${GREEN}nREPL available on port $NREPL_PORT${NC}"
|
||||||
echo -e "${GREEN}Auto-reload enabled - editing .clj files will trigger reload${NC}"
|
|
||||||
|
# Start file watcher for auto-reload
|
||||||
|
if [ "$HAS_NREPL_EVAL" = true ]; then
|
||||||
|
echo -e "${GREEN}Starting file watcher for auto-reload...${NC}"
|
||||||
|
if [ "$HAS_INOTIFY" = true ]; then
|
||||||
|
# Use inotifywait (efficient, event-based)
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR/server"
|
||||||
|
while inotifywait -r -e modify,create,delete --include '.*\.clj$' src/ 2>/dev/null; do
|
||||||
|
echo -e "${YELLOW}File change detected, reloading...${NC}"
|
||||||
|
clj-nrepl-eval -p $NREPL_PORT "(user/reset)" > /dev/null 2>&1 &
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
WATCHER_PID=$!
|
||||||
|
else
|
||||||
|
# Fallback: polling with find (works everywhere)
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR/server"
|
||||||
|
LAST_HASH=""
|
||||||
|
while true; do
|
||||||
|
CURRENT_HASH=$(find src -name '*.clj' -exec stat -c '%Y %n' {} \; 2>/dev/null | md5sum)
|
||||||
|
if [ -n "$LAST_HASH" ] && [ "$CURRENT_HASH" != "$LAST_HASH" ]; then
|
||||||
|
echo -e "${YELLOW}File change detected, reloading...${NC}"
|
||||||
|
clj-nrepl-eval -p $NREPL_PORT "(user/reset)" > /dev/null 2>&1 &
|
||||||
|
fi
|
||||||
|
LAST_HASH="$CURRENT_HASH"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
WATCHER_PID=$!
|
||||||
|
echo -e "${YELLOW}Using polling for file watching (install inotify-tools for better performance)${NC}"
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}Auto-reload enabled - editing .clj files will trigger reload${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Start frontend
|
# Start frontend
|
||||||
echo -e "${GREEN}Starting frontend server...${NC}"
|
echo -e "${GREEN}Starting frontend server...${NC}"
|
||||||
@@ -93,7 +145,7 @@ echo -e "${GREEN}Backend:${NC} http://localhost:3000"
|
|||||||
echo -e "${GREEN}nREPL:${NC} localhost:$NREPL_PORT"
|
echo -e "${GREEN}nREPL:${NC} localhost:$NREPL_PORT"
|
||||||
echo -e "${GREEN}Frontend:${NC} https://localhost:5173"
|
echo -e "${GREEN}Frontend:${NC} https://localhost:5173"
|
||||||
echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}:5173"
|
echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}:5173"
|
||||||
echo -e "\n${YELLOW}Auto-reload is active. Edit any .clj file to trigger reload.${NC}"
|
echo -e "\n${YELLOW}Edit any .clj file to trigger auto-reload.${NC}"
|
||||||
echo -e "Press Ctrl+C to stop\n"
|
echo -e "Press Ctrl+C to stop\n"
|
||||||
|
|
||||||
# Wait for processes
|
# Wait for processes
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Server CLAUDE.md
|
||||||
|
|
||||||
|
Clojure backend for Spiceflow.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clj -M:dev # REPL with dev tools
|
||||||
|
clj -M:run # Production mode
|
||||||
|
clj -M:test # Unit tests
|
||||||
|
clj -M:test --focus ns # Specific namespace
|
||||||
|
```
|
||||||
|
|
||||||
|
**REPL commands** (in `dev/user.clj`):
|
||||||
|
```clojure
|
||||||
|
(go) ; Start server + auto-reload
|
||||||
|
(reset) ; Reload code + restart
|
||||||
|
(stop) ; Stop server
|
||||||
|
(reload) ; Reload code only
|
||||||
|
(reload-all) ; Force reload all namespaces
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
├── src/spiceflow/
|
||||||
|
│ ├── core.clj # Entry point, Mount states
|
||||||
|
│ ├── config.clj # Configuration (aero)
|
||||||
|
│ ├── db/ # Database layer
|
||||||
|
│ ├── adapters/ # CLI integrations
|
||||||
|
│ ├── api/ # HTTP & WebSocket
|
||||||
|
│ ├── session/ # Session lifecycle
|
||||||
|
│ ├── push/ # Push notifications
|
||||||
|
│ └── terminal/ # Terminal diff caching
|
||||||
|
├── dev/user.clj # REPL helpers
|
||||||
|
├── test/ # Unit tests
|
||||||
|
├── resources/config.edn # Configuration
|
||||||
|
└── deps.edn # Dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mount States (start order)
|
||||||
|
|
||||||
|
1. `store` - SQLite database
|
||||||
|
2. `push` - Push notification store
|
||||||
|
3. `server` - Jetty HTTP server
|
||||||
|
|
||||||
|
## Namespaces
|
||||||
|
|
||||||
|
| Namespace | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `spiceflow.core` | Entry point, Mount states |
|
||||||
|
| `spiceflow.config` | Configuration |
|
||||||
|
| `spiceflow.db.protocol` | DataStore protocol |
|
||||||
|
| `spiceflow.db.sqlite` | SQLite implementation |
|
||||||
|
| `spiceflow.db.memory` | In-memory (tests) |
|
||||||
|
| `spiceflow.adapters.protocol` | AgentAdapter protocol |
|
||||||
|
| `spiceflow.adapters.claude` | Claude Code CLI |
|
||||||
|
| `spiceflow.adapters.opencode` | OpenCode CLI |
|
||||||
|
| `spiceflow.adapters.tmux` | Tmux terminal |
|
||||||
|
| `spiceflow.api.routes` | HTTP handlers |
|
||||||
|
| `spiceflow.api.websocket` | WebSocket management |
|
||||||
|
| `spiceflow.session.manager` | Session lifecycle |
|
||||||
|
|
||||||
|
## Configuration (aero)
|
||||||
|
|
||||||
|
```edn
|
||||||
|
{:server {:port #long #or [#env SPICEFLOW_PORT 3000]
|
||||||
|
:host #or [#env SPICEFLOW_HOST "0.0.0.0"]}
|
||||||
|
:database {:type :sqlite
|
||||||
|
:dbname #or [#env SPICEFLOW_DB "spiceflow.db"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Access: `(get-in config/config [:server :port])`
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
| Thing | Convention |
|
||||||
|
|-------|------------|
|
||||||
|
| Files | kebab-case |
|
||||||
|
| Namespaces | kebab-case |
|
||||||
|
| Functions | kebab-case |
|
||||||
|
| Protocols | PascalCase |
|
||||||
|
| Records | PascalCase |
|
||||||
|
| Private vars | `^:private` |
|
||||||
|
|
||||||
|
Thread safety: `ConcurrentHashMap` for processes, `atom` for simpler state.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Spiceflow Core CLAUDE.md
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `core.clj` | Entry point, Mount states, server wiring |
|
||||||
|
| `config.clj` | Configuration loading |
|
||||||
|
|
||||||
|
## core.clj
|
||||||
|
|
||||||
|
Mount states (start order):
|
||||||
|
1. `store` - SQLite via `sqlite/create-store`
|
||||||
|
2. `push` - Push store using same DB connection
|
||||||
|
3. `server` - Jetty with WebSocket support
|
||||||
|
|
||||||
|
Server wiring:
|
||||||
|
```clojure
|
||||||
|
(ws/set-pending-permission-fn! (partial manager/get-pending-permission store))
|
||||||
|
(manager/set-push-store! push)
|
||||||
|
(routes/create-app store ws/broadcast-to-session push)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `db/` | DataStore protocol + implementations |
|
||||||
|
| `adapters/` | AgentAdapter protocol + CLI integrations |
|
||||||
|
| `api/` | HTTP routes + WebSocket |
|
||||||
|
| `session/` | Session state machine |
|
||||||
|
| `push/` | Push notifications |
|
||||||
|
| `terminal/` | Terminal diff caching |
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
**Dependency injection via currying:**
|
||||||
|
```clojure
|
||||||
|
(defn create-app [store broadcast-fn push-store]
|
||||||
|
(let [handlers (make-handlers store broadcast-fn)]
|
||||||
|
(ring/ring-handler (router handlers))))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Async processing:**
|
||||||
|
```clojure
|
||||||
|
(defn handler [store broadcast-fn]
|
||||||
|
(fn [request]
|
||||||
|
(future (manager/stream-session-response ...))
|
||||||
|
{:status 200 :body {:status "sent"}}))
|
||||||
|
```
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Adapters CLAUDE.md
|
||||||
|
|
||||||
|
CLI integrations for Claude Code, OpenCode, and tmux.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `protocol.clj` | AgentAdapter protocol |
|
||||||
|
| `claude.clj` | Claude Code CLI |
|
||||||
|
| `opencode.clj` | OpenCode CLI |
|
||||||
|
| `tmux.clj` | Tmux terminal |
|
||||||
|
|
||||||
|
## AgentAdapter Protocol
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defprotocol AgentAdapter
|
||||||
|
(provider-name [this]) ; :claude, :opencode, :tmux
|
||||||
|
(discover-sessions [this]) ; Find existing sessions
|
||||||
|
(spawn-session [this session-id opts]) ; Start CLI process
|
||||||
|
(send-message [this handle message]) ; Write to stdin
|
||||||
|
(read-stream [this handle callback]) ; Read JSONL, call callback
|
||||||
|
(kill-process [this handle]) ; Terminate process
|
||||||
|
(parse-output [this line])) ; Parse JSONL line to event
|
||||||
|
```
|
||||||
|
|
||||||
|
## Process Handle
|
||||||
|
|
||||||
|
All adapters return:
|
||||||
|
```clojure
|
||||||
|
{:process java.lang.Process
|
||||||
|
:stdin java.io.Writer
|
||||||
|
:stdout java.io.BufferedReader}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Types
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `:init` | Process started, includes `:cwd` |
|
||||||
|
| `:content-delta` | Streaming text chunk |
|
||||||
|
| `:message-stop` | Message complete |
|
||||||
|
| `:permission-request` | Permission needed |
|
||||||
|
| `:working-dir-update` | CWD changed |
|
||||||
|
| `:error` | Error occurred |
|
||||||
|
|
||||||
|
## Claude Adapter
|
||||||
|
|
||||||
|
**Spawn command:**
|
||||||
|
```bash
|
||||||
|
claude --print --output-format stream-json \
|
||||||
|
--resume <external-id> \
|
||||||
|
--allowedTools '["Write","Edit"]' \
|
||||||
|
-- <working-dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session discovery:** Reads `~/.claude/projects/*/sessions/*.json`
|
||||||
|
|
||||||
|
**Permission detection:** Looks for `tool_denied` in result event.
|
||||||
|
|
||||||
|
## Tmux Adapter
|
||||||
|
|
||||||
|
- No JSONL (raw terminal)
|
||||||
|
- Session names: `spiceflow-{adjective}-{noun}-{4digits}`
|
||||||
|
- Diff-based terminal updates
|
||||||
|
|
||||||
|
## Adding an Adapter
|
||||||
|
|
||||||
|
1. Implement `AgentAdapter` protocol in new file
|
||||||
|
2. Register in `api/routes.clj` `get-adapter` function
|
||||||
|
3. Add provider to `db/protocol.clj` `valid-session?`
|
||||||
@@ -252,16 +252,35 @@
|
|||||||
[]
|
[]
|
||||||
(->TmuxAdapter))
|
(->TmuxAdapter))
|
||||||
|
|
||||||
|
;; Pattern to match clear screen escape sequences
|
||||||
|
;; \x1b[H moves cursor home, \x1b[2J clears screen, \x1b[3J clears scrollback
|
||||||
|
(def ^:private clear-screen-pattern #"\u001b\[H\u001b\[2J|\u001b\[2J\u001b\[H|\u001b\[2J|\u001b\[3J")
|
||||||
|
|
||||||
|
(defn- content-after-last-clear
|
||||||
|
"Return content after the last clear screen sequence, or full content if no clear found."
|
||||||
|
[content]
|
||||||
|
(if (str/blank? content)
|
||||||
|
content
|
||||||
|
(let [matcher (re-matcher clear-screen-pattern content)]
|
||||||
|
(loop [last-end nil]
|
||||||
|
(if (.find matcher)
|
||||||
|
(recur (.end matcher))
|
||||||
|
(if last-end
|
||||||
|
(subs content last-end)
|
||||||
|
content))))))
|
||||||
|
|
||||||
(defn capture-pane
|
(defn capture-pane
|
||||||
"Capture the current content of a tmux pane.
|
"Capture the current content of a tmux pane.
|
||||||
Returns the visible terminal content as a string, or nil if session doesn't exist."
|
Returns the visible terminal content as a string, or nil if session doesn't exist.
|
||||||
|
Preserves ANSI escape sequences for color rendering in the client.
|
||||||
|
Content before the last clear screen sequence is stripped."
|
||||||
[session-name]
|
[session-name]
|
||||||
(when session-name
|
(when session-name
|
||||||
;; Use capture-pane with -p to print to stdout, -e to include escape sequences (then strip them)
|
;; Use capture-pane with -p to print to stdout, -e to include escape sequences
|
||||||
;; -S - and -E - captures the entire scrollback history
|
;; -S -1000 captures scrollback history
|
||||||
(let [result (shell/sh "tmux" "capture-pane" "-t" session-name "-p" "-S" "-1000")]
|
(let [result (shell/sh "tmux" "capture-pane" "-t" session-name "-p" "-e" "-S" "-1000")]
|
||||||
(when (zero? (:exit result))
|
(when (zero? (:exit result))
|
||||||
(strip-ansi (:out result))))))
|
(content-after-last-clear (:out result))))))
|
||||||
|
|
||||||
(defn get-session-name
|
(defn get-session-name
|
||||||
"Get the tmux session name for a spiceflow session.
|
"Get the tmux session name for a spiceflow session.
|
||||||
@@ -317,3 +336,55 @@
|
|||||||
(let [result (shell/sh "tmux" "rename-session" "-t" old-name new-name)]
|
(let [result (shell/sh "tmux" "rename-session" "-t" old-name new-name)]
|
||||||
(when (zero? (:exit result))
|
(when (zero? (:exit result))
|
||||||
new-name))))
|
new-name))))
|
||||||
|
|
||||||
|
;; Screen size presets for different device orientations
|
||||||
|
(def ^:private screen-sizes
|
||||||
|
{:desktop {:width 180 :height 50}
|
||||||
|
:landscape {:width 100 :height 30}
|
||||||
|
:portrait {:width 40 :height 35}})
|
||||||
|
|
||||||
|
(defn resize-session
|
||||||
|
"Resize a tmux session window to a preset size.
|
||||||
|
mode should be :desktop, :landscape, or :portrait.
|
||||||
|
Returns true on success, nil on failure."
|
||||||
|
[session-name mode]
|
||||||
|
(when (and session-name mode)
|
||||||
|
(let [{:keys [width height]} (get screen-sizes mode)]
|
||||||
|
(when (and width height)
|
||||||
|
(log/debug "[Tmux] Resizing session" session-name "to" mode "(" width "x" height ")")
|
||||||
|
;; Resize the window - need to use resize-window on the session's window
|
||||||
|
(let [result (shell/sh "tmux" "resize-window" "-t" session-name "-x" (str width) "-y" (str height))]
|
||||||
|
(when (zero? (:exit result))
|
||||||
|
true))))))
|
||||||
|
|
||||||
|
(defn get-window-size
|
||||||
|
"Get the current window size of a tmux session.
|
||||||
|
Returns {:width N :height M} or nil if session doesn't exist."
|
||||||
|
[session-name]
|
||||||
|
(when session-name
|
||||||
|
(let [result (shell/sh "tmux" "display-message" "-t" session-name "-p" "#{window_width}x#{window_height}")]
|
||||||
|
(when (zero? (:exit result))
|
||||||
|
(let [output (str/trim (:out result))
|
||||||
|
[width height] (str/split output #"x")]
|
||||||
|
(when (and width height)
|
||||||
|
{:width (parse-long width)
|
||||||
|
:height (parse-long height)}))))))
|
||||||
|
|
||||||
|
(defn detect-layout-mode
|
||||||
|
"Detect the current layout mode based on tmux window size.
|
||||||
|
Returns :desktop, :landscape, :portrait, or nil if unknown."
|
||||||
|
[session-name]
|
||||||
|
(when-let [{:keys [width height]} (get-window-size session-name)]
|
||||||
|
;; Find the closest matching preset
|
||||||
|
(let [presets (vec screen-sizes)
|
||||||
|
distances (map (fn [[mode {:keys [width w height h] :as preset-size}]]
|
||||||
|
(let [pw (:width preset-size)
|
||||||
|
ph (:height preset-size)]
|
||||||
|
{:mode mode
|
||||||
|
:distance (+ (Math/abs (- width pw))
|
||||||
|
(Math/abs (- height ph)))}))
|
||||||
|
presets)
|
||||||
|
closest (first (sort-by :distance distances))]
|
||||||
|
;; Only return the mode if it's an exact match or very close
|
||||||
|
(when (and closest (<= (:distance closest) 5))
|
||||||
|
(:mode closest)))))
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# API CLAUDE.md
|
||||||
|
|
||||||
|
HTTP and WebSocket handlers.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `routes.clj` | HTTP endpoints |
|
||||||
|
| `websocket.clj` | WebSocket management |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Handler |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/health` | `health-handler` |
|
||||||
|
| GET | `/api/sessions` | `get-sessions-handler` |
|
||||||
|
| POST | `/api/sessions` | `create-session-handler` |
|
||||||
|
| GET | `/api/sessions/:id` | `get-session-handler` |
|
||||||
|
| PATCH | `/api/sessions/:id` | `update-session-handler` |
|
||||||
|
| DELETE | `/api/sessions/:id` | `delete-session-handler` |
|
||||||
|
| POST | `/api/sessions/:id/send` | `send-message-handler` |
|
||||||
|
| POST | `/api/sessions/:id/permission` | `permission-handler` |
|
||||||
|
| GET | `/api/sessions/:id/terminal` | `get-terminal-handler` |
|
||||||
|
| POST | `/api/sessions/:id/terminal/input` | `send-terminal-input` |
|
||||||
|
|
||||||
|
## Handler Pattern
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn my-handler [store broadcast-fn]
|
||||||
|
(fn [request]
|
||||||
|
(let [id (get-in request [:path-params :id])
|
||||||
|
body (:body request)]
|
||||||
|
{:status 200 :body {:result "ok"}})))
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
**State:**
|
||||||
|
```clojure
|
||||||
|
(defonce ^:private all-connections (atom #{}))
|
||||||
|
(defonce ^:private connections (ConcurrentHashMap.))
|
||||||
|
;; {session-id -> #{socket1 socket2}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client sends:**
|
||||||
|
```json
|
||||||
|
{"type": "subscribe", "session-id": "uuid"}
|
||||||
|
{"type": "unsubscribe", "session-id": "uuid"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server sends:**
|
||||||
|
```json
|
||||||
|
{"event": "subscribed", "session-id": "uuid"}
|
||||||
|
{"event": "content-delta", "text": "..."}
|
||||||
|
{"event": "message-stop"}
|
||||||
|
{"event": "permission-request", "permission-request": {...}}
|
||||||
|
{"event": "terminal-update", "content": "...", "diff": {...}}
|
||||||
|
{"event": "error", "message": "..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key functions:**
|
||||||
|
```clojure
|
||||||
|
(subscribe-to-session socket session-id)
|
||||||
|
(broadcast-to-session session-id message)
|
||||||
|
```
|
||||||
|
|
||||||
|
On subscribe, checks for pending permission and sends if exists.
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
[spiceflow.session.manager :as manager]
|
[spiceflow.session.manager :as manager]
|
||||||
[spiceflow.adapters.protocol :as adapter]
|
[spiceflow.adapters.protocol :as adapter]
|
||||||
[spiceflow.adapters.tmux :as tmux]
|
[spiceflow.adapters.tmux :as tmux]
|
||||||
|
[spiceflow.terminal.diff :as terminal-diff]
|
||||||
[spiceflow.push.protocol :as push-proto]
|
[spiceflow.push.protocol :as push-proto]
|
||||||
[clojure.tools.logging :as log]))
|
[clojure.tools.logging :as log]))
|
||||||
|
|
||||||
@@ -18,6 +19,15 @@
|
|||||||
(-> (response/response body)
|
(-> (response/response body)
|
||||||
(response/content-type "application/json")))
|
(response/content-type "application/json")))
|
||||||
|
|
||||||
|
(defn- json-response-no-cache
|
||||||
|
"Create a JSON response with no-cache headers (for polling endpoints)"
|
||||||
|
[body]
|
||||||
|
(-> (response/response body)
|
||||||
|
(response/content-type "application/json")
|
||||||
|
(response/header "Cache-Control" "no-store, no-cache, must-revalidate")
|
||||||
|
(response/header "Pragma" "no-cache")
|
||||||
|
(response/header "Expires" "0")))
|
||||||
|
|
||||||
(defn- error-response
|
(defn- error-response
|
||||||
"Create an error response"
|
"Create an error response"
|
||||||
[status message]
|
[status message]
|
||||||
@@ -104,13 +114,15 @@
|
|||||||
(let [id (get-in request [:path-params :id])]
|
(let [id (get-in request [:path-params :id])]
|
||||||
(log/debug "API request: delete-session" {:session-id id})
|
(log/debug "API request: delete-session" {:session-id id})
|
||||||
(if (tmux-session-id? id)
|
(if (tmux-session-id? id)
|
||||||
;; Tmux session - just kill the tmux session
|
;; Tmux session - kill the session and clean up cache
|
||||||
(if (tmux/session-alive? id)
|
(if (tmux/session-alive? id)
|
||||||
(do
|
(do
|
||||||
(log/debug "Killing tmux session:" id)
|
(log/debug "Killing tmux session:" id)
|
||||||
(let [tmux-adapter (manager/get-adapter :tmux)]
|
(let [tmux-adapter (manager/get-adapter :tmux)]
|
||||||
(adapter/kill-process tmux-adapter {:session-name id
|
(adapter/kill-process tmux-adapter {:session-name id
|
||||||
:output-file (str "/tmp/spiceflow-tmux-" id ".log")}))
|
:output-file (str "/tmp/spiceflow-tmux-" id ".log")}))
|
||||||
|
;; Clean up terminal diff cache
|
||||||
|
(terminal-diff/invalidate-cache id)
|
||||||
(response/status (response/response nil) 204))
|
(response/status (response/response nil) 204))
|
||||||
(error-response 404 "Session not found"))
|
(error-response 404 "Session not found"))
|
||||||
;; Regular DB session
|
;; Regular DB session
|
||||||
@@ -213,22 +225,36 @@
|
|||||||
;; Tmux terminal handlers
|
;; Tmux terminal handlers
|
||||||
(defn terminal-capture-handler
|
(defn terminal-capture-handler
|
||||||
"Get the current terminal content for a tmux session.
|
"Get the current terminal content for a tmux session.
|
||||||
For ephemeral tmux sessions, the session ID IS the tmux session name."
|
For ephemeral tmux sessions, the session ID IS the tmux session name.
|
||||||
|
Returns diff information for efficient updates.
|
||||||
|
Pass ?fresh=true to force a fresh capture (invalidates cache first)."
|
||||||
[_store]
|
[_store]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])]
|
(let [id (get-in request [:path-params :id])
|
||||||
|
fresh? (= "true" (get-in request [:query-params "fresh"]))]
|
||||||
(if (tmux-session-id? id)
|
(if (tmux-session-id? id)
|
||||||
;; Ephemeral tmux session - ID is the session name
|
;; Ephemeral tmux session - ID is the session name
|
||||||
(if (tmux/session-alive? id)
|
(if (tmux/session-alive? id)
|
||||||
(let [content (tmux/capture-pane id)]
|
(do
|
||||||
(json-response {:content (or content "")
|
;; If fresh=true, invalidate cache to ensure full content is returned
|
||||||
:alive true
|
(when fresh?
|
||||||
:session-name id}))
|
(terminal-diff/invalidate-cache id))
|
||||||
(error-response 404 "Session not found"))
|
(let [{:keys [content diff]} (terminal-diff/capture-with-diff id tmux/capture-pane)
|
||||||
|
layout (tmux/detect-layout-mode id)]
|
||||||
|
(json-response-no-cache {:content (or content "")
|
||||||
|
:alive true
|
||||||
|
:session-name id
|
||||||
|
:diff diff
|
||||||
|
:layout (when layout (name layout))})))
|
||||||
|
(do
|
||||||
|
;; Session died - invalidate cache
|
||||||
|
(terminal-diff/invalidate-cache id)
|
||||||
|
(error-response 404 "Session not found")))
|
||||||
(error-response 400 "Not a tmux session")))))
|
(error-response 400 "Not a tmux session")))))
|
||||||
|
|
||||||
(defn terminal-input-handler
|
(defn terminal-input-handler
|
||||||
"Send raw input to a tmux session (stdin-style)"
|
"Send raw input to a tmux session (stdin-style).
|
||||||
|
Broadcasts diff-based terminal updates after input."
|
||||||
[_store broadcast-fn]
|
[_store broadcast-fn]
|
||||||
(fn [request]
|
(fn [request]
|
||||||
(let [id (get-in request [:path-params :id])
|
(let [id (get-in request [:path-params :id])
|
||||||
@@ -238,21 +264,45 @@
|
|||||||
(if (tmux/session-alive? id)
|
(if (tmux/session-alive? id)
|
||||||
(do
|
(do
|
||||||
(tmux/send-keys-raw id input)
|
(tmux/send-keys-raw id input)
|
||||||
;; Broadcast terminal update after input
|
;; Broadcast terminal update with diff after input
|
||||||
(future
|
(future
|
||||||
(Thread/sleep 100) ;; Small delay to let terminal update
|
(Thread/sleep 100) ;; Small delay to let terminal update
|
||||||
(let [content (tmux/capture-pane id)]
|
(let [{:keys [content diff changed]} (terminal-diff/capture-with-diff id tmux/capture-pane)]
|
||||||
(broadcast-fn id {:event :terminal-update
|
(when changed
|
||||||
:content (or content "")})))
|
(broadcast-fn id {:event :terminal-update
|
||||||
|
:content (or content "")
|
||||||
|
:diff diff}))))
|
||||||
(json-response {:status "sent"}))
|
(json-response {:status "sent"}))
|
||||||
(error-response 400 "Tmux session not alive"))
|
(error-response 400 "Tmux session not alive"))
|
||||||
(error-response 400 "Not a tmux session")))))
|
(error-response 400 "Not a tmux session")))))
|
||||||
|
|
||||||
|
(defn terminal-resize-handler
|
||||||
|
"Resize a tmux session to a preset screen size.
|
||||||
|
Mode can be: desktop, landscape, or portrait."
|
||||||
|
[_store]
|
||||||
|
(fn [request]
|
||||||
|
(let [id (get-in request [:path-params :id])
|
||||||
|
mode (keyword (get-in request [:body :mode]))]
|
||||||
|
(if (tmux-session-id? id)
|
||||||
|
(if (tmux/session-alive? id)
|
||||||
|
(if (#{:desktop :landscape :portrait} mode)
|
||||||
|
(if (tmux/resize-session id mode)
|
||||||
|
(json-response {:status "resized" :mode (name mode)})
|
||||||
|
(error-response 500 "Failed to resize tmux session"))
|
||||||
|
(error-response 400 "Invalid mode. Must be: desktop, landscape, or portrait"))
|
||||||
|
(error-response 400 "Tmux session not alive"))
|
||||||
|
(error-response 400 "Not a tmux session")))))
|
||||||
|
|
||||||
;; Health check
|
;; Health check
|
||||||
(defn health-handler
|
(defn health-handler
|
||||||
[_request]
|
[_request]
|
||||||
(json-response {:status "ok" :service "spiceflow"}))
|
(json-response {:status "ok" :service "spiceflow"}))
|
||||||
|
|
||||||
|
;; Test endpoint for verifying hot reload
|
||||||
|
(defn ping-handler
|
||||||
|
[_request]
|
||||||
|
(json-response {:pong true :time (str (java.time.Instant/now))}))
|
||||||
|
|
||||||
;; Push notification handlers
|
;; Push notification handlers
|
||||||
(defn vapid-key-handler
|
(defn vapid-key-handler
|
||||||
"Return the public VAPID key for push subscriptions"
|
"Return the public VAPID key for push subscriptions"
|
||||||
@@ -295,6 +345,7 @@
|
|||||||
[store broadcast-fn push-store]
|
[store broadcast-fn push-store]
|
||||||
[["/api"
|
[["/api"
|
||||||
["/health" {:get health-handler}]
|
["/health" {:get health-handler}]
|
||||||
|
["/ping" {:get ping-handler}]
|
||||||
["/sessions" {:get (list-sessions-handler store)
|
["/sessions" {:get (list-sessions-handler store)
|
||||||
:post (create-session-handler store)}]
|
:post (create-session-handler store)}]
|
||||||
["/sessions/:id" {:get (get-session-handler store)
|
["/sessions/:id" {:get (get-session-handler store)
|
||||||
@@ -304,6 +355,7 @@
|
|||||||
["/sessions/:id/permission" {:post (permission-response-handler store broadcast-fn)}]
|
["/sessions/:id/permission" {:post (permission-response-handler store broadcast-fn)}]
|
||||||
["/sessions/:id/terminal" {:get (terminal-capture-handler store)}]
|
["/sessions/:id/terminal" {:get (terminal-capture-handler store)}]
|
||||||
["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}]
|
["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}]
|
||||||
|
["/sessions/:id/terminal/resize" {:post (terminal-resize-handler store)}]
|
||||||
["/push/vapid-key" {:get (vapid-key-handler push-store)}]
|
["/push/vapid-key" {:get (vapid-key-handler push-store)}]
|
||||||
["/push/subscribe" {:post (subscribe-handler push-store)}]
|
["/push/subscribe" {:post (subscribe-handler push-store)}]
|
||||||
["/push/unsubscribe" {:post (unsubscribe-handler push-store)}]]])
|
["/push/unsubscribe" {:post (unsubscribe-handler push-store)}]]])
|
||||||
@@ -312,7 +364,8 @@
|
|||||||
"Create the Ring application"
|
"Create the Ring application"
|
||||||
[store broadcast-fn push-store]
|
[store broadcast-fn push-store]
|
||||||
(-> (ring/ring-handler
|
(-> (ring/ring-handler
|
||||||
(ring/router (create-routes store broadcast-fn push-store))
|
(ring/router (create-routes store broadcast-fn push-store)
|
||||||
|
{:data {:middleware [parameters/parameters-middleware]}})
|
||||||
(ring/create-default-handler))
|
(ring/create-default-handler))
|
||||||
(wrap-json-body {:keywords? true})
|
(wrap-json-body {:keywords? true})
|
||||||
wrap-json-response
|
wrap-json-response
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Database CLAUDE.md
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `protocol.clj` | DataStore protocol definition |
|
||||||
|
| `sqlite.clj` | SQLite implementation |
|
||||||
|
| `memory.clj` | In-memory (tests) |
|
||||||
|
|
||||||
|
## DataStore Protocol
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defprotocol DataStore
|
||||||
|
(get-sessions [this])
|
||||||
|
(get-session [this id])
|
||||||
|
(save-session [this session])
|
||||||
|
(update-session [this id data])
|
||||||
|
(delete-session [this id])
|
||||||
|
(get-messages [this session-id])
|
||||||
|
(save-message [this message])
|
||||||
|
(get-message [this id])
|
||||||
|
(update-message [this id data]))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
provider TEXT NOT NULL, -- 'claude', 'opencode', 'tmux'
|
||||||
|
external_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
working_dir TEXT,
|
||||||
|
spawn_dir TEXT,
|
||||||
|
status_id INTEGER DEFAULT 1, -- FK to session_statuses
|
||||||
|
pending_permission TEXT, -- JSON
|
||||||
|
auto_accept_edits INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT REFERENCES sessions(id),
|
||||||
|
role TEXT NOT NULL, -- 'user', 'assistant', 'system'
|
||||||
|
content TEXT,
|
||||||
|
metadata TEXT, -- JSON
|
||||||
|
created_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session_statuses (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE -- 'idle', 'processing', 'awaiting-permission'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Shapes
|
||||||
|
|
||||||
|
**Session:**
|
||||||
|
```clojure
|
||||||
|
{:id "uuid"
|
||||||
|
:provider :claude ; :opencode, :tmux
|
||||||
|
:external-id "cli-session-id"
|
||||||
|
:title "Fixing bug"
|
||||||
|
:working-dir "/path"
|
||||||
|
:spawn-dir "/path"
|
||||||
|
:status :idle ; :processing, :awaiting-permission
|
||||||
|
:pending-permission {...}
|
||||||
|
:auto-accept-edits true}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message:**
|
||||||
|
```clojure
|
||||||
|
{:id "uuid"
|
||||||
|
:session-id "session-uuid"
|
||||||
|
:role :user ; :assistant, :system
|
||||||
|
:content "text"
|
||||||
|
:metadata {:tools ["Write"] :status "accept"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming Conversion
|
||||||
|
|
||||||
|
Clojure kebab-case <-> DB snake_case handled by `row->session` and `session->row`.
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Add to `migrations` vector in `sqlite.clj`:
|
||||||
|
```clojure
|
||||||
|
(def migrations
|
||||||
|
["ALTER TABLE sessions ADD COLUMN new_field TEXT"])
|
||||||
|
```
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Session CLAUDE.md
|
||||||
|
|
||||||
|
Session lifecycle and state machine.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `manager.clj` | Session state machine, streaming, permissions |
|
||||||
|
|
||||||
|
## Session States
|
||||||
|
|
||||||
|
```
|
||||||
|
idle -> processing -> awaiting-permission -> idle
|
||||||
|
^ |
|
||||||
|
+--------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
| State | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `idle` | No active process |
|
||||||
|
| `processing` | CLI running, streaming |
|
||||||
|
| `awaiting-permission` | Paused for permission |
|
||||||
|
|
||||||
|
## Active Processes
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defonce ^:private active-processes (ConcurrentHashMap.))
|
||||||
|
;; {session-id -> {:process ... :stdin ... :stdout ...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Functions
|
||||||
|
|
||||||
|
**`start-session [store session-id opts]`** - Spawn CLI process, add to active-processes.
|
||||||
|
|
||||||
|
**`send-message-to-session [store session-id message]`** - Save message, send to stdin.
|
||||||
|
|
||||||
|
**`stream-session-response [store session-id broadcast-fn]`** - Read JSONL, broadcast events, handle permissions.
|
||||||
|
|
||||||
|
**`respond-to-permission [store session-id response message broadcast-fn]`** - Handle accept/deny/steer.
|
||||||
|
|
||||||
|
## Permission Flow
|
||||||
|
|
||||||
|
1. CLI outputs permission request in result event
|
||||||
|
2. Save permission message with status "pending"
|
||||||
|
3. Set `pending-permission` on session
|
||||||
|
4. Status -> `:awaiting-permission`
|
||||||
|
5. Broadcast `:permission-request` event
|
||||||
|
6. User responds via `/api/sessions/:id/permission`
|
||||||
|
7. Spawn new process with `--resume --allowedTools`
|
||||||
|
8. Send response message ("continue" or steer text)
|
||||||
|
9. Continue streaming
|
||||||
|
|
||||||
|
## Auto-Accept
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
(defn should-auto-accept? [session permission]
|
||||||
|
(and (:auto-accept-edits session)
|
||||||
|
(every? #{"Write" "Edit"} (:tools permission))))
|
||||||
|
```
|
||||||
|
|
||||||
|
## REPL Debugging
|
||||||
|
|
||||||
|
```clojure
|
||||||
|
@#'m/active-processes ; See running sessions
|
||||||
|
(.get @#'m/active-processes "id") ; Specific session
|
||||||
|
(db/get-session store "id") ; Check status/pending
|
||||||
|
```
|
||||||
|
|
||||||
|
Force cleanup:
|
||||||
|
```clojure
|
||||||
|
(.destroyForcibly (:process (.get @#'m/active-processes "id")))
|
||||||
|
(.remove @#'m/active-processes "id")
|
||||||
|
(db/update-session store "id" {:status :idle :pending-permission :clear})
|
||||||
|
```
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
(ns spiceflow.terminal.diff
|
||||||
|
"Terminal content diffing with caching.
|
||||||
|
|
||||||
|
Maintains a ConcurrentHashMap of terminal state per session and computes
|
||||||
|
line-based diffs to minimize data transfer. Supports TUI applications
|
||||||
|
where any line can change on screen refresh.
|
||||||
|
|
||||||
|
Includes auto-incrementing frame IDs to prevent out-of-order frame issues
|
||||||
|
when reducing batch timers."
|
||||||
|
(:require [clojure.string :as str]
|
||||||
|
[clojure.tools.logging :as log])
|
||||||
|
(:import [java.util.concurrent ConcurrentHashMap]
|
||||||
|
[java.util.concurrent.atomic AtomicLong]))
|
||||||
|
|
||||||
|
;; Cache storing terminal state per session
|
||||||
|
;; Key: session-name (string)
|
||||||
|
;; Value: {:lines [vector of strings] :hash int :frame-id long}
|
||||||
|
(defonce ^:private terminal-cache (ConcurrentHashMap.))
|
||||||
|
|
||||||
|
;; Global frame counter - auto-incrementing sequence for ordering frames
|
||||||
|
;; Uses AtomicLong for thread safety. Max value is 2^53-1 (JS MAX_SAFE_INTEGER)
|
||||||
|
;; to ensure safe handling on the client side
|
||||||
|
(def ^:private max-frame-id 9007199254740991) ;; 2^53-1 (JS MAX_SAFE_INTEGER)
|
||||||
|
(defonce ^:private frame-counter (AtomicLong. 0))
|
||||||
|
|
||||||
|
(defn- next-frame-id
|
||||||
|
"Get the next frame ID, handling overflow by wrapping to 0.
|
||||||
|
Returns a monotonically increasing value (except on wrap)."
|
||||||
|
[]
|
||||||
|
(loop []
|
||||||
|
(let [current (.get frame-counter)
|
||||||
|
next-val (if (>= current max-frame-id) 0 (inc current))]
|
||||||
|
(if (.compareAndSet frame-counter current next-val)
|
||||||
|
next-val
|
||||||
|
(recur)))))
|
||||||
|
|
||||||
|
(defn- content->lines
|
||||||
|
"Split terminal content into lines, preserving empty lines."
|
||||||
|
[content]
|
||||||
|
(if (str/blank? content)
|
||||||
|
[]
|
||||||
|
(str/split-lines content)))
|
||||||
|
|
||||||
|
(defn- lines->content
|
||||||
|
"Join lines back into content string."
|
||||||
|
[lines]
|
||||||
|
(str/join "\n" lines))
|
||||||
|
|
||||||
|
(defn- compute-line-diff
|
||||||
|
"Compute line-by-line diff between old and new line vectors.
|
||||||
|
Returns a map of {:line-num content} for changed lines.
|
||||||
|
|
||||||
|
For efficiency, if more than 50% of lines changed, returns nil
|
||||||
|
to signal that a full refresh is more efficient."
|
||||||
|
[old-lines new-lines]
|
||||||
|
(let [old-count (count old-lines)
|
||||||
|
new-count (count new-lines)
|
||||||
|
max-count (max old-count new-count)
|
||||||
|
changes (volatile! (transient {}))]
|
||||||
|
(when (pos? max-count)
|
||||||
|
;; Compare existing lines
|
||||||
|
(dotimes [i max-count]
|
||||||
|
(let [old-line (get old-lines i)
|
||||||
|
new-line (get new-lines i)]
|
||||||
|
(when (not= old-line new-line)
|
||||||
|
(vswap! changes assoc! i new-line))))
|
||||||
|
(let [diff (persistent! @changes)
|
||||||
|
change-count (count diff)]
|
||||||
|
;; If more than 50% changed, full refresh is more efficient
|
||||||
|
(when (and (pos? change-count)
|
||||||
|
(< (/ change-count max-count) 0.5))
|
||||||
|
diff)))))
|
||||||
|
|
||||||
|
(defn- compute-diff
|
||||||
|
"Compute diff between cached state and new content.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
- {:type :unchanged :frame-id n} - no changes
|
||||||
|
- {:type :diff :changes {line-num content} :total-lines n :frame-id n} - partial update
|
||||||
|
- {:type :full :lines [lines] :total-lines n :frame-id n} - full refresh needed
|
||||||
|
|
||||||
|
All responses include :frame-id for ordering. Unchanged responses use
|
||||||
|
the cached frame-id since content hasn't changed."
|
||||||
|
[cached new-content]
|
||||||
|
(let [new-lines (content->lines new-content)
|
||||||
|
new-count (count new-lines)
|
||||||
|
new-hash (hash new-content)]
|
||||||
|
(cond
|
||||||
|
;; No cached state - full refresh
|
||||||
|
(nil? cached)
|
||||||
|
(let [frame-id (next-frame-id)]
|
||||||
|
{:type :full
|
||||||
|
:lines new-lines
|
||||||
|
:total-lines new-count
|
||||||
|
:hash new-hash
|
||||||
|
:frame-id frame-id})
|
||||||
|
|
||||||
|
;; Content unchanged (fast path via hash)
|
||||||
|
(= (:hash cached) new-hash)
|
||||||
|
{:type :unchanged
|
||||||
|
:total-lines new-count
|
||||||
|
:hash new-hash
|
||||||
|
:frame-id (:frame-id cached)} ;; Reuse cached frame-id for unchanged
|
||||||
|
|
||||||
|
;; Compute line diff
|
||||||
|
:else
|
||||||
|
(let [old-lines (:lines cached)
|
||||||
|
line-diff (compute-line-diff old-lines new-lines)
|
||||||
|
frame-id (next-frame-id)]
|
||||||
|
(if line-diff
|
||||||
|
;; Partial diff is efficient
|
||||||
|
{:type :diff
|
||||||
|
:changes line-diff
|
||||||
|
:total-lines new-count
|
||||||
|
:hash new-hash
|
||||||
|
:frame-id frame-id}
|
||||||
|
;; Too many changes - send full
|
||||||
|
{:type :full
|
||||||
|
:lines new-lines
|
||||||
|
:total-lines new-count
|
||||||
|
:hash new-hash
|
||||||
|
:frame-id frame-id})))))
|
||||||
|
|
||||||
|
(defn capture-with-diff
|
||||||
|
"Capture terminal content and compute diff from cached state.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
- session-name: tmux session identifier
|
||||||
|
- capture-fn: function that takes session-name and returns content string
|
||||||
|
|
||||||
|
Returns map with:
|
||||||
|
- :content - full content string (always included for GET requests)
|
||||||
|
- :diff - diff payload for WebSocket updates (includes :frame-id)
|
||||||
|
- :changed - boolean indicating if content changed"
|
||||||
|
[session-name capture-fn]
|
||||||
|
(let [new-content (capture-fn session-name)
|
||||||
|
cached (.get terminal-cache session-name)
|
||||||
|
diff-result (compute-diff cached new-content)]
|
||||||
|
;; Update cache if changed (store frame-id for unchanged responses)
|
||||||
|
(when (not= :unchanged (:type diff-result))
|
||||||
|
(.put terminal-cache session-name
|
||||||
|
{:lines (content->lines new-content)
|
||||||
|
:hash (:hash diff-result)
|
||||||
|
:frame-id (:frame-id diff-result)}))
|
||||||
|
{:content new-content
|
||||||
|
:diff diff-result
|
||||||
|
:changed (not= :unchanged (:type diff-result))}))
|
||||||
|
|
||||||
|
(defn get-cached-content
|
||||||
|
"Get cached content for a session without capturing.
|
||||||
|
Returns nil if not cached."
|
||||||
|
[session-name]
|
||||||
|
(when-let [cached (.get terminal-cache session-name)]
|
||||||
|
(lines->content (:lines cached))))
|
||||||
|
|
||||||
|
(defn invalidate-cache
|
||||||
|
"Remove a session from the cache."
|
||||||
|
[session-name]
|
||||||
|
(.remove terminal-cache session-name)
|
||||||
|
(log/debug "[TerminalDiff] Invalidated cache for" session-name))
|
||||||
|
|
||||||
|
(defn clear-cache
|
||||||
|
"Clear all cached terminal state."
|
||||||
|
[]
|
||||||
|
(.clear terminal-cache)
|
||||||
|
(log/debug "[TerminalDiff] Cleared all terminal cache"))
|
||||||
|
|
||||||
|
(defn cache-stats
|
||||||
|
"Get cache statistics for debugging."
|
||||||
|
[]
|
||||||
|
{:session-count (.size terminal-cache)
|
||||||
|
:sessions (vec (.keySet terminal-cache))})
|
||||||
Reference in New Issue
Block a user