diff --git a/CLAUDE.md b/CLAUDE.md index 18ca850..0b82f5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,14 @@ When Claude Code requests permission for file operations (Write/Edit) or shell c File operations show expandable diffs displaying the exact changes being made. ### Auto-Accept Edits -Claude sessions can enable "Auto-accept edits" in session settings to automatically grant Write/Edit permissions, reducing interruptions during coding sessions. +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 diff --git a/README.md b/README.md index 943dcb7..87f2ffc 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,42 @@ cd ../server clj -M:run ``` +## Features + +### Permission Handling + +When Claude Code requests permission for tools, Spiceflow intercepts and presents them for approval: + +- **Accept**: Grant permission and continue +- **Deny**: Reject the request +- **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 + +Claude sessions can enable "Auto-accept edits" via the settings gear icon to automatically grant file operation permissions: + +- **Applies to**: `Write` and `Edit` tools only (file create/modify) +- **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 + +Messages stream in real-time via WebSocket with: +- Content deltas as Claude types +- Permission request notifications +- Working directory updates +- Session status changes + ## API Endpoints | Method | Path | Description | diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 11fb218..5ca95ed 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -2,7 +2,7 @@ const API_BASE = '/api'; export interface Session { id: string; - provider: 'claude' | 'opencode'; + provider: 'claude' | 'opencode' | 'tmux'; 'external-id'?: string; externalId?: string; title?: string; @@ -150,6 +150,18 @@ class ApiClient { async health(): Promise<{ status: string; service: string }> { return this.request<{ status: string; service: string }>('/health'); } + + // 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 sendTerminalInput(sessionId: string, input: string): Promise<{ status: string }> { + return this.request<{ status: string }>(`/sessions/${sessionId}/terminal/input`, { + method: 'POST', + body: JSON.stringify({ input }) + }); + } } export const api = new ApiClient(); diff --git a/client/src/lib/components/InputBar.svelte b/client/src/lib/components/InputBar.svelte index 0e05b62..8135fa7 100644 --- a/client/src/lib/components/InputBar.svelte +++ b/client/src/lib/components/InputBar.svelte @@ -1,20 +1,29 @@ -
+
+ +{/if} + +{#if errorMessage || storeError} +
+ {errorMessage || storeError} +
+{/if} diff --git a/client/src/lib/components/SessionCard.svelte b/client/src/lib/components/SessionCard.svelte index 9d67ed5..aac53dd 100644 --- a/client/src/lib/components/SessionCard.svelte +++ b/client/src/lib/components/SessionCard.svelte @@ -13,7 +13,6 @@ } $: externalId = session['external-id'] || session.externalId || ''; - $: workingDir = session['working-dir'] || session.workingDir || ''; $: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || ''; $: shortId = externalId.slice(0, 8); @@ -38,9 +37,12 @@ 'awaiting-permission': 'bg-amber-500 animate-pulse' }; - const providerColors = { + $: statusColor = session.provider === 'tmux' ? 'bg-green-500' : statusColors[session.status]; + + const providerColors: Record = { claude: 'text-spice-400', - opencode: 'text-emerald-400' + opencode: 'text-emerald-400', + tmux: 'text-cyan-400' }; @@ -51,7 +53,7 @@
- + {session.provider} @@ -60,12 +62,6 @@

{session.title || `Session ${shortId}`}

- - {#if workingDir} -

- {workingDir} -

- {/if}
diff --git a/client/src/lib/components/SessionSettings.svelte b/client/src/lib/components/SessionSettings.svelte index 05c67ce..b1ed125 100644 --- a/client/src/lib/components/SessionSettings.svelte +++ b/client/src/lib/components/SessionSettings.svelte @@ -2,12 +2,19 @@ import { createEventDispatcher } from 'svelte'; export let autoAcceptEdits: boolean = false; - export let provider: 'claude' | 'opencode' = 'claude'; + export let autoScroll: boolean = true; + export let provider: 'claude' | 'opencode' | 'tmux' = 'claude'; const dispatch = createEventDispatcher<{ toggleAutoAccept: boolean; + toggleAutoScroll: boolean; + condenseAll: void; }>(); + function handleToggleAutoScroll() { + dispatch('toggleAutoScroll', !autoScroll); + } + let open = false; function handleToggle() { @@ -15,6 +22,11 @@ dispatch('toggleAutoAccept', newValue); } + function handleCondenseAll() { + dispatch('condenseAll'); + open = false; + } + function handleClickOutside(event: MouseEvent) { const target = event.target as HTMLElement; if (!target.closest('.settings-dropdown')) { @@ -56,6 +68,36 @@ Session Settings
+ + + + {#if provider !== 'tmux'} + + {/if} + {#if provider === 'claude'}
- {:else} -
- No settings available for OpenCode sessions yet. -
{/if}
{/if} diff --git a/client/src/lib/components/TerminalView.svelte b/client/src/lib/components/TerminalView.svelte new file mode 100644 index 0000000..ec5bf67 --- /dev/null +++ b/client/src/lib/components/TerminalView.svelte @@ -0,0 +1,262 @@ + + +
+ {#if loading} +
+ + + + +
+ {:else if error} +
+
{error}
+
+ {:else} + +
{terminalContent || 'Terminal ready. Type a command below.'}
+ + +
+ + + + + + + + {#each ['1', '2', '3', '4', '5'] as num} + + {/each} + + + + +
+ + +
+
+ $ + +
+
+ {/if} +
diff --git a/client/src/lib/push.ts b/client/src/lib/push.ts new file mode 100644 index 0000000..1225273 --- /dev/null +++ b/client/src/lib/push.ts @@ -0,0 +1,150 @@ +/** + * Web Push notification utilities for Spiceflow PWA + */ + +const API_BASE = '/api'; + +/** + * Check if push notifications are supported + */ +export function isPushSupported(): boolean { + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +} + +/** + * Get the current notification permission status + */ +export function getPermissionStatus(): NotificationPermission { + if (!isPushSupported()) { + return 'denied'; + } + return Notification.permission; +} + +/** + * Request notification permission from the user + */ +export async function requestPermission(): Promise { + if (!isPushSupported()) { + throw new Error('Push notifications are not supported'); + } + return await Notification.requestPermission(); +} + +/** + * Convert URL-safe base64 string to Uint8Array + * (for applicationServerKey) + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +/** + * Get the VAPID public key from the server + */ +export async function getVapidKey(): Promise { + const response = await fetch(`${API_BASE}/push/vapid-key`); + if (!response.ok) { + throw new Error('Failed to get VAPID key'); + } + const data = await response.json(); + return data.publicKey; +} + +/** + * Get the current push subscription if one exists + */ +export async function getSubscription(): Promise { + if (!isPushSupported()) { + return null; + } + + const registration = await navigator.serviceWorker.ready; + return await registration.pushManager.getSubscription(); +} + +/** + * Subscribe to push notifications + */ +export async function subscribe(): Promise { + if (!isPushSupported()) { + throw new Error('Push notifications are not supported'); + } + + // Request permission first + const permission = await requestPermission(); + if (permission !== 'granted') { + throw new Error('Notification permission denied'); + } + + // Get VAPID public key + const vapidKey = await getVapidKey(); + const applicationServerKey = urlBase64ToUint8Array(vapidKey); + + // Get service worker registration + const registration = await navigator.serviceWorker.ready; + + // Subscribe to push + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey as BufferSource + }); + + // Send subscription to server + const response = await fetch(`${API_BASE}/push/subscribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(subscription.toJSON()) + }); + + if (!response.ok) { + // Unsubscribe if server save failed + await subscription.unsubscribe(); + throw new Error('Failed to save subscription on server'); + } + + return subscription; +} + +/** + * Unsubscribe from push notifications + */ +export async function unsubscribe(): Promise { + const subscription = await getSubscription(); + if (!subscription) { + return; + } + + // Notify server first + try { + await fetch(`${API_BASE}/push/unsubscribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ endpoint: subscription.endpoint }) + }); + } catch (err) { + console.warn('Failed to notify server of unsubscribe:', err); + } + + // Unsubscribe locally + await subscription.unsubscribe(); +} + +/** + * Check if currently subscribed to push notifications + */ +export async function isSubscribed(): Promise { + const subscription = await getSubscription(); + return subscription !== null; +} diff --git a/client/src/lib/stores/push.ts b/client/src/lib/stores/push.ts new file mode 100644 index 0000000..21f5d51 --- /dev/null +++ b/client/src/lib/stores/push.ts @@ -0,0 +1,143 @@ +import { writable, derived } from 'svelte/store'; +import { + isPushSupported, + getPermissionStatus, + isSubscribed, + subscribe as pushSubscribe, + unsubscribe as pushUnsubscribe +} from '$lib/push'; + +export type PushState = 'unsupported' | 'default' | 'denied' | 'subscribed' | 'unsubscribed'; + +interface PushStore { + supported: boolean; + permission: NotificationPermission; + subscribed: boolean; + loading: boolean; + error: string | null; +} + +function createPushStore() { + const store = writable({ + supported: false, + permission: 'default', + subscribed: false, + loading: false, + error: null + }); + + const { subscribe: storeSubscribe, set, update } = store; + + return { + subscribe: storeSubscribe, + + /** + * Initialize the push store state + */ + async init() { + const supported = isPushSupported(); + if (!supported) { + set({ + supported: false, + permission: 'denied', + subscribed: false, + loading: false, + error: null + }); + return; + } + + const permission = getPermissionStatus(); + const subscribed = await isSubscribed(); + + set({ + supported, + permission, + subscribed, + loading: false, + error: null + }); + }, + + /** + * Subscribe to push notifications + */ + async enablePush() { + update((s) => ({ ...s, loading: true, error: null })); + + try { + await pushSubscribe(); + update((s) => ({ + ...s, + subscribed: true, + permission: 'granted', + loading: false + })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to subscribe'; + update((s) => ({ + ...s, + loading: false, + error: message, + permission: getPermissionStatus() + })); + throw err; + } + }, + + /** + * Unsubscribe from push notifications + */ + async disablePush() { + update((s) => ({ ...s, loading: true, error: null })); + + try { + await pushUnsubscribe(); + update((s) => ({ + ...s, + subscribed: false, + loading: false + })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to unsubscribe'; + update((s) => ({ + ...s, + loading: false, + error: message + })); + throw err; + } + }, + + /** + * Toggle subscription state + */ + async toggle() { + const state = await new Promise((resolve) => { + const unsub = storeSubscribe((s) => { + resolve(s); + unsub(); + }); + }); + + if (state.subscribed) { + await this.disablePush(); + } else { + await this.enablePush(); + } + } + }; +} + +export const pushStore = createPushStore(); + +/** + * Derived store for the overall push state + */ +export const pushState = derived(pushStore, ($push): PushState => { + if (!$push.supported) return 'unsupported'; + if ($push.permission === 'denied') return 'denied'; + if ($push.subscribed) return 'subscribed'; + if ($push.permission === 'granted') return 'unsubscribed'; + return 'default'; +}); diff --git a/client/src/lib/stores/sessions.ts b/client/src/lib/stores/sessions.ts index e811682..545d0e0 100644 --- a/client/src/lib/stores/sessions.ts +++ b/client/src/lib/stores/sessions.ts @@ -212,19 +212,29 @@ function createActiveSessionStore() { update((s) => ({ ...s, error: (e as Error).message })); } }, - async rename(title: string) { + async rename(title: string): Promise<{ updated: Session; idChanged: boolean }> { const state = get(); - if (!state.session) return; + if (!state.session) throw new Error('No active session'); try { + const oldId = state.session.id; const updated = await api.updateSession(state.session.id, { title }); + const idChanged = updated.id !== oldId; + update((s) => ({ ...s, session: s.session ? { ...s.session, ...updated } : null })); - // Also update in the sessions list - sessions.updateSession(state.session.id, { title }); - return updated; + + if (idChanged) { + // For tmux sessions, ID changes on rename - remove old, add new in sessions list + sessions.updateSession(oldId, { ...updated }); + } else { + // Regular session - just update title + sessions.updateSession(state.session.id, { title }); + } + + return { updated, idChanged }; } catch (e) { update((s) => ({ ...s, error: (e as Error).message })); throw e; @@ -329,11 +339,20 @@ function createActiveSessionStore() { const permReq = event['permission-request'] || event.permissionRequest; const permMessage = (event as StreamEvent & { message?: Message }).message; const messageId = (event as StreamEvent & { 'message-id'?: string })['message-id']; - console.log('[WS] Permission request received:', permReq, 'message:', permMessage); + const autoAccepted = (event as StreamEvent & { 'auto-accepted'?: boolean })['auto-accepted']; + console.log('[WS] Permission request received:', permReq, 'message:', permMessage, 'autoAccepted:', autoAccepted); if (permReq) { // Store the message-id in the permission request for later status update const permReqWithMsgId = messageId ? { ...permReq, 'message-id': messageId } : permReq; update((s) => { + // If auto-accepted, just add message to history (don't show permission UI) + if (autoAccepted && permMessage) { + return { + ...s, + messages: [...s.messages, permMessage] + // Note: pendingPermission stays null, so no permission UI shown + }; + } // If we received the full message, add it to messages array // Otherwise just update pendingPermission if (permMessage) { @@ -345,7 +364,7 @@ function createActiveSessionStore() { } return { ...s, pendingPermission: permReqWithMsgId }; }); - console.log('[WS] pendingPermission state updated with message-id:', messageId); + console.log('[WS] pendingPermission state updated with message-id:', messageId, 'autoAccepted:', autoAccepted); } } else if (event.event === 'error') { update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false })); @@ -366,5 +385,5 @@ export const sortedSessions: Readable = derived(sessions, ($sessions) ); export const processingSessions: Readable = derived(sessions, ($sessions) => - $sessions.sessions.filter((s) => s.status === 'processing') + $sessions.sessions.filter((s) => s.status === 'processing' && s.provider !== 'tmux') ); diff --git a/client/src/routes/+layout.svelte b/client/src/routes/+layout.svelte index 38bd38f..e5cf5a5 100644 --- a/client/src/routes/+layout.svelte +++ b/client/src/routes/+layout.svelte @@ -2,12 +2,23 @@ import '../app.css'; import { onMount } from 'svelte'; import { sessions } from '$lib/stores/sessions'; + import { pushStore } from '$lib/stores/push'; onMount(() => { sessions.load(); + pushStore.init(); + + // Listen for navigation messages from service worker + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data?.type === 'NAVIGATE' && event.data?.url) { + window.location.href = event.data.url; + } + }); + } }); -
+
diff --git a/client/src/routes/+page.svelte b/client/src/routes/+page.svelte index b215c3c..1d1c832 100644 --- a/client/src/routes/+page.svelte +++ b/client/src/routes/+page.svelte @@ -3,6 +3,7 @@ import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions'; import type { Session } from '$lib/api'; import SessionCard from '$lib/components/SessionCard.svelte'; + import PushToggle from '$lib/components/PushToggle.svelte'; let showNewSessionMenu = false; let creating = false; @@ -46,6 +47,8 @@
+ +
+
{/if}
diff --git a/client/src/routes/session/[id]/+page.svelte b/client/src/routes/session/[id]/+page.svelte index ce33434..e648dc9 100644 --- a/client/src/routes/session/[id]/+page.svelte +++ b/client/src/routes/session/[id]/+page.svelte @@ -1,4 +1,5 @@ @@ -134,7 +171,7 @@ {:else if session}
- + {#if isEditingTitle} {/if}
- {#if projectName} -

{projectName}

- {/if}
messageList?.condenseAll()} /> - - {session.provider} + + {session.provider === 'tmux' ? 'terminal' : session.provider} {/if}
@@ -194,12 +227,9 @@ {#if session}
- + {session.title || `Session ${shortId}`}
- {#if projectName} -

{projectName}

- {/if}
{/if} - + + {#if !isTmuxSession} + + {/if} {#if session?.provider === 'claude'}