diff --git a/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts b/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts new file mode 100644 index 00000000..417affd2 --- /dev/null +++ b/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest'; +import { + detectClaudeExit, + isClaudeExitOutput, + isClaudeBusyOutput, + isClaudeIdleOutput, + detectClaudeBusyState, + extractClaudeSessionId, + extractRateLimitReset, + extractOAuthToken, + extractEmail, + hasRateLimitMessage, + hasOAuthToken, +} from '../output-parser'; + +describe('output-parser', () => { + describe('detectClaudeExit', () => { + describe('should detect shell prompts (Claude has exited)', () => { + const shellPrompts = [ + // user@hostname patterns + { input: 'user@hostname:~$ ', desc: 'bash: user@hostname:~$' }, + { input: 'user@host:~/projects$ ', desc: 'bash with path' }, + { input: 'root@server:/var/log# ', desc: 'root prompt' }, + { input: 'dev@localhost:~ % ', desc: 'zsh style' }, + + // Bracket prompts + { input: '[user@host directory]$ ', desc: 'bracket prompt' }, + { input: ' [user@host ~]$ ', desc: 'bracket prompt with leading space' }, + + // Virtual environment prompts + { input: '(venv) user@host:~$ ', desc: 'venv prompt' }, + { input: '(base) $ ', desc: 'conda base prompt' }, + { input: '(myenv) ~/projects $ ', desc: 'venv with path' }, + + // Starship/Oh-My-Zsh prompts + { input: '❯ ', desc: 'starship arrow' }, + { input: ' ❯ ', desc: 'starship with space' }, + { input: '➜ ', desc: 'oh-my-zsh arrow' }, + { input: 'λ ', desc: 'lambda prompt' }, + + // Fish shell prompts + { input: '~/projects> ', desc: 'fish path prompt' }, + { input: '/home/user> ', desc: 'fish absolute path' }, + + // Git branch prompts + { input: '(main) $ ', desc: 'git branch prompt' }, + { input: '[feature/test] > ', desc: 'git branch in brackets' }, + + // Simple hostname prompts + { input: 'hostname$ ', desc: 'simple hostname$' }, + { input: 'myserver% ', desc: 'hostname with %' }, + + // Explicit exit messages + { input: 'Goodbye!', desc: 'goodbye message' }, + { input: 'Session ended', desc: 'session ended' }, + { input: 'Exiting Claude', desc: 'exiting claude' }, + ]; + + it.each(shellPrompts)('detects: $desc', ({ input }) => { + expect(detectClaudeExit(input)).toBe(true); + }); + }); + + describe('should NOT detect these as exit (Claude is still active or these are Claude output)', () => { + const notExitPatterns = [ + // Claude's idle prompt + { input: '> ', desc: 'Claude idle prompt (just >)' }, + { input: '\n> ', desc: 'Claude idle prompt after newline' }, + + // Claude busy indicators + { input: '● Working on your request...', desc: 'Claude bullet point' }, + { input: 'Loading...', desc: 'Claude loading' }, + { input: 'Read(/path/to/file.ts)', desc: 'Claude tool execution' }, + + // Content that could false-positive match if not anchored + { input: 'The path is ~/config $HOME', desc: 'path in explanation (should NOT match)' }, + { input: 'Use arr[index] to access elements', desc: 'array access in explanation' }, + { input: 'See the arrow → for details', desc: 'arrow in text' }, + { input: 'File path: ~/projects/test.js', desc: 'file path mid-line' }, + { input: 'Contact user@example.com for help', desc: 'email in text (mid-line)' }, + { input: 'user@example.com: please review this', desc: 'email at line start with colon (should NOT match)' }, + { input: 'admin@company.org: check the logs', desc: 'email at line start with text after colon' }, + { input: 'The variable $HOME is set to /Users/dev', desc: 'shell var in explanation' }, + { input: 'Example: (main) branch is default', desc: 'branch name in explanation' }, + + // Progress indicators + { input: '[Opus 4] 50%', desc: 'Opus progress' }, + { input: '███████░░░ 70%', desc: 'progress bar' }, + ]; + + it.each(notExitPatterns)('ignores: $desc', ({ input }) => { + expect(detectClaudeExit(input)).toBe(false); + }); + }); + + describe('should return false when Claude is busy even if shell-like patterns appear', () => { + it('returns false when busy indicators present with shell pattern', () => { + // This tests the guard in detectClaudeExit that checks busy state first + const mixedOutput = '● Processing...\nuser@host:~$ '; + expect(detectClaudeExit(mixedOutput)).toBe(false); + }); + }); + }); + + describe('isClaudeExitOutput', () => { + it('detects user@hostname pattern at line start', () => { + expect(isClaudeExitOutput('user@hostname:~$ ')).toBe(true); + expect(isClaudeExitOutput('dev@server:/home$ ')).toBe(true); + }); + + it('does not match user@hostname in middle of line', () => { + // With the line-start anchor, this should NOT match + expect(isClaudeExitOutput('Contact user@hostname.com for help')).toBe(false); + }); + + it('detects goodbye message', () => { + expect(isClaudeExitOutput('Goodbye!')).toBe(true); + expect(isClaudeExitOutput('Goodbye')).toBe(true); + }); + + it('detects session ended', () => { + expect(isClaudeExitOutput('Session ended')).toBe(true); + }); + }); + + describe('isClaudeBusyOutput', () => { + const busyPatterns = [ + '● Here is my response', + 'Read(/path/to/file.ts)', + 'Write(/output.json)', + 'Loading...', + 'Thinking...', + 'Analyzing...', + '[Opus 4] 25%', + '[Sonnet 3.5] 50%', + '██████░░░░', + ]; + + it.each(busyPatterns)('detects busy pattern: %s', (input) => { + expect(isClaudeBusyOutput(input)).toBe(true); + }); + + it('returns false for normal text', () => { + expect(isClaudeBusyOutput('Hello, how can I help?')).toBe(false); + }); + }); + + describe('isClaudeIdleOutput', () => { + it('detects Claude idle prompt', () => { + expect(isClaudeIdleOutput('> ')).toBe(true); + expect(isClaudeIdleOutput('\n> ')).toBe(true); + expect(isClaudeIdleOutput(' > ')).toBe(true); + }); + + it('returns false for non-idle output', () => { + expect(isClaudeIdleOutput('Hello')).toBe(false); + expect(isClaudeIdleOutput('● Working')).toBe(false); + }); + }); + + describe('detectClaudeBusyState', () => { + it('returns "busy" when busy indicators present', () => { + expect(detectClaudeBusyState('● Thinking...')).toBe('busy'); + expect(detectClaudeBusyState('Loading...')).toBe('busy'); + }); + + it('returns "idle" when idle prompt present and no busy indicators', () => { + expect(detectClaudeBusyState('> ')).toBe('idle'); + }); + + it('returns null when no state change detected', () => { + expect(detectClaudeBusyState('Hello')).toBe(null); + }); + + it('prioritizes busy over idle', () => { + // If both patterns present, busy should win + expect(detectClaudeBusyState('● Working\n> ')).toBe('busy'); + }); + }); + + describe('extractClaudeSessionId', () => { + it('extracts session ID from "Session ID: xxx" format', () => { + expect(extractClaudeSessionId('Session ID: abc123')).toBe('abc123'); + expect(extractClaudeSessionId('Session: xyz789')).toBe('xyz789'); + }); + + it('extracts session ID from "Resuming session: xxx"', () => { + expect(extractClaudeSessionId('Resuming session: sess_456')).toBe('sess_456'); + }); + + it('returns null when no session ID found', () => { + expect(extractClaudeSessionId('Hello world')).toBe(null); + }); + }); + + describe('extractRateLimitReset', () => { + it('extracts rate limit reset time', () => { + expect(extractRateLimitReset('Limit reached · resets Dec 17 at 6am (Europe/Oslo)')).toBe('Dec 17 at 6am (Europe/Oslo)'); + expect(extractRateLimitReset('Limit reached • resets Jan 1 at noon')).toBe('Jan 1 at noon'); + }); + + it('returns null when no rate limit message', () => { + expect(extractRateLimitReset('Normal output')).toBe(null); + }); + }); + + describe('hasRateLimitMessage', () => { + it('returns true when rate limit message present', () => { + expect(hasRateLimitMessage('Limit reached · resets tomorrow')).toBe(true); + }); + + it('returns false when no rate limit message', () => { + expect(hasRateLimitMessage('Normal text')).toBe(false); + }); + }); + + describe('extractOAuthToken', () => { + it('extracts OAuth token', () => { + const token = 'sk-ant-oat01-abc123_XYZ'; + expect(extractOAuthToken(`Token: ${token}`)).toBe(token); + }); + + it('returns null when no token present', () => { + expect(extractOAuthToken('No token here')).toBe(null); + }); + }); + + describe('hasOAuthToken', () => { + it('returns true when OAuth token present', () => { + expect(hasOAuthToken('sk-ant-oat01-test123')).toBe(true); + }); + + it('returns false when no token', () => { + expect(hasOAuthToken('No token')).toBe(false); + }); + }); + + describe('extractEmail', () => { + it('extracts email from "email:" format', () => { + expect(extractEmail('email: user@example.com')).toBe('user@example.com'); + expect(extractEmail('email:dev@company.org')).toBe('dev@company.org'); + }); + + it('extracts email with whitespace after "email"', () => { + expect(extractEmail('email test@domain.com')).toBe('test@domain.com'); + }); + + it('returns null when no email found', () => { + expect(extractEmail('No email here')).toBe(null); + }); + + it('returns null for "Authenticated as" with space before email', () => { + // Note: The current pattern expects email immediately after "as" + // This documents the actual behavior of the implementation + expect(extractEmail('Authenticated as user@example.com')).toBe(null); + }); + }); +}); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index ae420b2d..35c6feca 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -329,6 +329,40 @@ export function handleClaudeSessionId( } } +/** + * Handle Claude exit detection (user closed Claude, returned to shell) + * + * This is called when we detect that Claude has exited and the terminal + * has returned to a shell prompt. This resets the Claude mode state + * and notifies the renderer to update the UI. + */ +export function handleClaudeExit( + terminal: TerminalProcess, + getWindow: WindowGetter +): void { + // Only handle if we're actually in Claude mode + if (!terminal.isClaudeMode) { + return; + } + + console.warn('[ClaudeIntegration] Claude exit detected, resetting mode for terminal:', terminal.id); + + // Reset Claude mode state + terminal.isClaudeMode = false; + terminal.claudeSessionId = undefined; + + // Persist the session state change + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + + // Notify renderer to update UI + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, terminal.id); + } +} + /** * Invoke Claude with optional profile override */ diff --git a/apps/frontend/src/main/terminal/output-parser.ts b/apps/frontend/src/main/terminal/output-parser.ts index e955935a..4f577c7d 100644 --- a/apps/frontend/src/main/terminal/output-parser.ts +++ b/apps/frontend/src/main/terminal/output-parser.ts @@ -159,3 +159,90 @@ export function detectClaudeBusyState(data: string): 'busy' | 'idle' | null { } return null; } + +/** + * Patterns indicating Claude Code has exited and returned to shell + * + * These patterns detect shell prompts that are distinct from Claude's simple ">" prompt. + * Shell prompts typically include: + * - Username and hostname (user@host) + * - Current directory + * - Git branch indicators + * - Shell-specific characters at the end ($, %, #, ❯) + * + * We look for these patterns to distinguish between Claude's idle prompt (">") + * and a proper shell prompt indicating Claude has exited. + */ +const CLAUDE_EXIT_PATTERNS = [ + // Standard shell prompts with path/context (bash/zsh) + // Matches: "user@hostname:~/path$", "hostname:path %", "[user@host path]$" + // Must be at line start to avoid matching user@host in Claude's output + // Requires path indicator after colon to avoid matching emails like "user@example.com:" + /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:[~/$]/m, // user@hostname:~ or user@hostname:/path + + // Path-based prompts (often in zsh, fish, etc.) + // Matches: "~/projects $", "/home/user %" + // Anchored to line start to avoid matching paths in Claude's explanations + /^[~/][^\s]*\s*[$%#❯]\s*$/m, + + // Prompts with brackets (common in bash) + // Matches: "[user@host directory]$", "(venv) user@host:~$" + // Anchored to avoid matching array access like ${arr[0]} + /^\s*\[[^\]]+\]\s*[$%#]\s*$/m, + + // Virtual environment or conda prompts followed by standard prompt + // Matches: "(venv) $", "(base) user@host:~$" + /^\([a-zA-Z0-9_-]+\)\s*.*[$%#❯]\s*$/m, + + // Starship, Oh My Zsh, Powerlevel10k common patterns + // Matches: "❯", "➜", "λ" at end of line (often colored/styled) + // Anchored to avoid matching Unicode arrows in Claude's explanations + /^\s*[❯➜λ]\s*$/m, + + // Fish shell prompt patterns + // Matches: "user@host ~/path>", "~/path>" + // Anchored to avoid matching file paths ending with > + /^~?\/[^\s]*>\s*$/m, + + // Git branch in prompt followed by prompt character + // Matches: "(main) $", "[git:main] >" + // Anchored to avoid matching code snippets with brackets + /^\s*[([a-zA-Z0-9/_-]+[)\]]\s*[$%#>❯]\s*$/m, + + // Simple but distinctive shell prompts with hostname + // Matches: "hostname$", "hostname %" + /^[a-zA-Z0-9._-]+[$%#]\s*$/m, + + // Detect Claude exit messages (optional, catches explicit exits) + /Goodbye!?\s*$/im, + /Session ended/i, + /Exiting Claude/i, +]; + +/** + * Check if output indicates Claude has exited and returned to shell + * + * This is more specific than shell prompt detection - it looks for patterns + * that indicate we've returned to a shell AFTER being in Claude mode. + */ +export function isClaudeExitOutput(data: string): boolean { + return CLAUDE_EXIT_PATTERNS.some(pattern => pattern.test(data)); +} + +/** + * Detect if Claude has exited based on terminal output + * Returns true if output indicates Claude has exited and shell is ready + * + * This function should be called when the terminal is in Claude mode + * to detect if Claude has exited (user typed /exit, Ctrl+D, etc.) + */ +export function detectClaudeExit(data: string): boolean { + // First, make sure this doesn't look like Claude activity + // If we see Claude busy indicators, Claude hasn't exited + if (isClaudeBusyOutput(data)) { + return false; + } + + // Check for Claude exit patterns (shell prompt return) + return isClaudeExitOutput(data); +} diff --git a/apps/frontend/src/main/terminal/terminal-event-handler.ts b/apps/frontend/src/main/terminal/terminal-event-handler.ts index 7f8b061d..dddb90af 100644 --- a/apps/frontend/src/main/terminal/terminal-event-handler.ts +++ b/apps/frontend/src/main/terminal/terminal-event-handler.ts @@ -16,6 +16,7 @@ export interface EventHandlerCallbacks { onRateLimit: (terminal: TerminalProcess, data: string) => void; onOAuthToken: (terminal: TerminalProcess, data: string) => void; onClaudeBusyChange: (terminal: TerminalProcess, isBusy: boolean) => void; + onClaudeExit: (terminal: TerminalProcess) => void; } // Track the last known busy state per terminal to avoid duplicate events @@ -58,6 +59,14 @@ export function handleTerminalData( callbacks.onClaudeBusyChange(terminal, isBusy); } } + + // Detect Claude exit (returned to shell prompt) + // Only check if not busy - busy output takes precedence + if (busyState !== 'busy' && OutputParser.detectClaudeExit(data)) { + callbacks.onClaudeExit(terminal); + // Clear busy state tracking since Claude has exited + lastBusyState.delete(terminal.id); + } } } @@ -97,6 +106,9 @@ export function createEventCallbacks( if (win) { win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy); } + }, + onClaudeExit: (terminal) => { + ClaudeIntegration.handleClaudeExit(terminal, getWindow); } }; } diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index c66e0c2f..54c2452e 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -79,6 +79,7 @@ export interface TerminalAPI { callback: (info: { terminalId: string; profileId: string; profileName: string }) => void ) => () => void; onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void; + onTerminalClaudeExit: (callback: (id: string) => void) => () => void; onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void; // Claude Profile Management @@ -321,6 +322,21 @@ export const createTerminalAPI = (): TerminalAPI => ({ }; }, + onTerminalClaudeExit: ( + callback: (id: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + id: string + ): void => { + callback(id); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, handler); + }; + }, + onTerminalPendingResume: ( callback: (id: string, sessionId?: string) => void ): (() => void) => { diff --git a/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts index 47dbe0c3..6fed5db1 100644 --- a/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts +++ b/apps/frontend/src/renderer/components/terminal/useTerminalEvents.ts @@ -154,6 +154,33 @@ export function useTerminalEvents({ return cleanup; }, [terminalId]); + // Handle Claude exit (user closed Claude within terminal, returned to shell) + useEffect(() => { + const cleanup = window.electronAPI.onTerminalClaudeExit((id: string) => { + if (id === terminalId) { + const store = useTerminalStore.getState(); + const terminal = store.getTerminal(terminalId); + // Guard: If terminal has already exited, don't set status back to 'running' + // This handles the race condition where terminal exit and Claude exit events + // arrive in unexpected order (e.g., user types 'exit' which closes both) + if (terminal?.status === 'exited') { + return; + } + // Reset Claude mode - Claude has exited but terminal is still running + // Use updateTerminal to set all Claude-related state at once + store.updateTerminal(terminalId, { + isClaudeMode: false, + isClaudeBusy: undefined, + claudeSessionId: undefined, + status: 'running' // Terminal is still running, just not in Claude mode + }); + console.warn('[Terminal] Claude exited, reset mode for terminal:', terminalId); + } + }); + + return cleanup; + }, [terminalId]); + // Handle pending Claude resume notification (for deferred resume on tab activation) useEffect(() => { const cleanup = window.electronAPI.onTerminalPendingResume((id, _sessionId) => { diff --git a/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts b/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts index 8888707c..6c927a6d 100644 --- a/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/terminal-mock.ts @@ -98,5 +98,6 @@ export const terminalMock = { onTerminalOAuthToken: () => () => {}, onTerminalAuthCreated: () => () => {}, onTerminalClaudeBusy: () => () => {}, + onTerminalClaudeExit: () => () => {}, onTerminalPendingResume: () => () => {} }; diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 0ea976ed..4928a513 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -95,6 +95,7 @@ export const IPC_CHANNELS = { TERMINAL_OAUTH_TOKEN: 'terminal:oauthToken', // OAuth token captured from setup-token output TERMINAL_AUTH_CREATED: 'terminal:authCreated', // Auth terminal created for OAuth flow TERMINAL_CLAUDE_BUSY: 'terminal:claudeBusy', // Claude Code busy state (for visual indicator) + TERMINAL_CLAUDE_EXIT: 'terminal:claudeExit', // Claude Code exited (returned to shell) // Claude profile management (multi-account support) CLAUDE_PROFILES_GET: 'claude:profilesGet', diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 5afa310e..fe54cc40 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -240,6 +240,8 @@ export interface ElectronAPI { }) => void) => () => void; /** Listen for Claude busy state changes (for visual indicator: red=busy, green=idle) */ onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void; + /** Listen for Claude exit (user closed Claude within terminal, returned to shell) */ + onTerminalClaudeExit: (callback: (id: string) => void) => () => void; /** Listen for pending Claude resume notifications (for deferred resume on tab activation) */ onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;