add resizing

This commit is contained in:
2026-01-20 15:31:41 -05:00
parent 66b4acaf42
commit b6f772f901
22 changed files with 1727 additions and 420 deletions
+98 -128
View File
@@ -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
+63 -149
View File
@@ -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
+103
View File
@@ -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` |
+25
View File
@@ -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",
+1
View File
@@ -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"
} }
} }
+115
View File
@@ -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
View File
@@ -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();
+13 -4
View File
@@ -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 = {
+309 -75
View File
@@ -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>
+49
View File
@@ -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
View File
@@ -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 |
+33
View File
@@ -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
View File
@@ -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
+87
View File
@@ -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.
+50
View File
@@ -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"}}))
```
+71
View File
@@ -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?`
+76 -5
View File
@@ -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)))))
+68
View File
@@ -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.
+67 -14
View File
@@ -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
+92
View File
@@ -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"])
```
+75
View File
@@ -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})
```
+172
View File
@@ -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))})