add resizing
This commit is contained in:
@@ -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",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"marked": "^17.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2830,6 +2831,21 @@
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -3596,6 +3612,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.24.1",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"workbox-window": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"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;
|
||||
'permission-request'?: 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 {
|
||||
@@ -152,8 +176,12 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Terminal (tmux)
|
||||
async getTerminalContent(sessionId: string): Promise<{ content: string; alive: boolean; 'session-name': string }> {
|
||||
return this.request<{ content: string; alive: boolean; 'session-name': string }>(`/sessions/${sessionId}/terminal`);
|
||||
async getTerminalContent(sessionId: string, fresh: boolean = false): Promise<TerminalContent> {
|
||||
// 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 }> {
|
||||
@@ -162,6 +190,13 @@ class ApiClient {
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 FileDiff from './FileDiff.svelte';
|
||||
|
||||
@@ -59,12 +59,21 @@
|
||||
collapsedMessages = collapsedMessages; // trigger reactivity
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
scrollToBottom();
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
// Use double requestAnimationFrame to ensure DOM is fully laid out before scrolling
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
scrollToBottom();
|
||||
// Use requestAnimationFrame for consistent scroll behavior
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
});
|
||||
|
||||
const roleStyles = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 autoScroll: boolean = true;
|
||||
@@ -8,8 +9,16 @@
|
||||
|
||||
const dispatch = createEventDispatcher<{ aliveChange: boolean }>();
|
||||
|
||||
let terminalContent = '';
|
||||
let prevContentLength = 0;
|
||||
// ANSI to HTML converter for terminal colors
|
||||
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 loading = true;
|
||||
let error = '';
|
||||
@@ -18,19 +27,158 @@
|
||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
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 {
|
||||
const result = await api.getTerminalContent(sessionId);
|
||||
const contentGrew = result.content.length > prevContentLength;
|
||||
prevContentLength = result.content.length;
|
||||
terminalContent = result.content;
|
||||
const result = await api.getTerminalContent(sessionId, fresh);
|
||||
|
||||
let changed = false;
|
||||
|
||||
// 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) {
|
||||
isAlive = result.alive;
|
||||
dispatch('aliveChange', isAlive);
|
||||
}
|
||||
error = '';
|
||||
if (contentGrew) {
|
||||
|
||||
if (changed) {
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
}
|
||||
@@ -96,11 +244,38 @@
|
||||
}
|
||||
|
||||
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 {
|
||||
await api.sendTerminalInput(sessionId, input);
|
||||
setTimeout(fetchTerminalContent, 200);
|
||||
await api.sendTerminalInput(sessionId, toSend);
|
||||
// Fetch update shortly after
|
||||
setTimeout(() => fetchTerminalContent(false), 100);
|
||||
} catch (e) {
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (event.event === 'terminal-update' && event.content !== undefined) {
|
||||
const newContent = event.content as string;
|
||||
const contentGrew = newContent.length > prevContentLength;
|
||||
prevContentLength = newContent.length;
|
||||
terminalContent = newContent;
|
||||
if (contentGrew) {
|
||||
tick().then(scrollToBottom);
|
||||
if (event.event === 'terminal-update') {
|
||||
// Apply diff if provided (includes frame ID ordering check)
|
||||
if (event.diff) {
|
||||
const changed = applyDiff(event.diff);
|
||||
if (changed) {
|
||||
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 () => {
|
||||
// Initial fetch
|
||||
await fetchTerminalContent();
|
||||
// Initial fetch with fresh=true to ensure we get full current content
|
||||
await fetchTerminalContent(true);
|
||||
|
||||
// Subscribe to WebSocket updates
|
||||
await wsClient.connect();
|
||||
unsubscribe = wsClient.subscribe(sessionId, handleWebSocketEvent);
|
||||
|
||||
// Periodic refresh every 1 second
|
||||
refreshInterval = setInterval(fetchTerminalContent, 1000);
|
||||
// Periodic refresh every 1 second (no fresh flag for incremental diffs)
|
||||
refreshInterval = setInterval(() => fetchTerminalContent(false), 1000);
|
||||
|
||||
// Auto-focus input after content loads
|
||||
if (autoFocus) {
|
||||
@@ -148,6 +347,9 @@
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
if (batchTimeout) {
|
||||
clearTimeout(batchTimeout);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -165,98 +367,130 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Terminal output area -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<pre
|
||||
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"
|
||||
>{terminalContent || 'Terminal ready. Type a command below.'}</pre>
|
||||
on:click={() => terminalInput?.focus()}
|
||||
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 -->
|
||||
<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
|
||||
on:click={() => ctrlMode = !ctrlMode}
|
||||
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'}"
|
||||
>
|
||||
Ctrl
|
||||
</button>
|
||||
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>
|
||||
<button
|
||||
on:click={sendCtrlC}
|
||||
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"
|
||||
>
|
||||
Ctrl+C
|
||||
</button>
|
||||
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>
|
||||
<button
|
||||
on:click={() => sendInput('\x04')}
|
||||
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"
|
||||
>
|
||||
Ctrl+D
|
||||
</button>
|
||||
<span class="w-px h-4 bg-zinc-700"></span>
|
||||
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>
|
||||
<span class="w-px h-3 bg-zinc-700"></span>
|
||||
<button
|
||||
on:click={() => sendKey('y')}
|
||||
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"
|
||||
>
|
||||
y
|
||||
</button>
|
||||
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>
|
||||
<button
|
||||
on:click={() => sendKey('n')}
|
||||
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"
|
||||
>
|
||||
n
|
||||
</button>
|
||||
<span class="w-px h-4 bg-zinc-700"></span>
|
||||
{#each ['1', '2', '3', '4', '5'] as num}
|
||||
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>
|
||||
<span class="w-px h-3 bg-zinc-700"></span>
|
||||
{#each ['1', '2', '3', '4'] as num}
|
||||
<button
|
||||
on:click={() => sendKey(num)}
|
||||
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"
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
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>
|
||||
{/each}
|
||||
<span class="w-px h-4 bg-zinc-700"></span>
|
||||
<span class="w-px h-3 bg-zinc-700"></span>
|
||||
<button
|
||||
on:click={() => sendInput('\t')}
|
||||
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"
|
||||
>
|
||||
Tab
|
||||
</button>
|
||||
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>
|
||||
<button
|
||||
on:click={() => sendInput('\x1b[Z')}
|
||||
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
|
||||
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
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<div class="flex-shrink-0 border-t border-zinc-800 bg-zinc-900 p-2 safe-bottom">
|
||||
<div class="flex items-center gap-2 bg-black rounded border border-zinc-700 px-3 py-2">
|
||||
<span class="text-cyan-400 font-mono text-sm">$</span>
|
||||
<input
|
||||
bind:this={terminalInput}
|
||||
type="text"
|
||||
on:keydown={handleKeydown}
|
||||
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>
|
||||
<!-- Hidden input for keyboard capture (invisible but functional for mobile) -->
|
||||
<input
|
||||
bind:this={terminalInput}
|
||||
type="text"
|
||||
on:keydown={handleKeydown}
|
||||
class="sr-only"
|
||||
disabled={!isAlive}
|
||||
aria-label="Terminal input"
|
||||
/>
|
||||
{/if}
|
||||
</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}`);
|
||||
```
|
||||
Reference in New Issue
Block a user