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
+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",
"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",
+1
View File
@@ -28,6 +28,7 @@
"workbox-window": "^7.0.0"
},
"dependencies": {
"ansi-to-html": "^0.7.2",
"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;
'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();
+13 -4
View File
@@ -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 = {
+309 -75
View File
@@ -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>
+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}`);
```