fix(terminal): detect Claude exit and reset label when user closes Claude (#990)

* fix(terminal): detect Claude exit and reset label when user closes Claude

Previously, the "Claude" label on terminals would persist even after the
user closed Claude (via /exit, Ctrl+D, etc.) because the system only
reset isClaudeMode when the entire terminal process exited.

This change adds robust Claude exit detection by:
- Adding shell prompt patterns to detect when Claude exits and returns
  to shell (output-parser.ts)
- Adding new IPC channel TERMINAL_CLAUDE_EXIT for exit notifications
- Adding handleClaudeExit() to reset terminal state in main process
- Adding onClaudeExit callback in terminal event handler
- Adding onTerminalClaudeExit listener in preload API
- Handling exit event in renderer to update terminal store

Now when a user closes Claude within a terminal, the label is removed
immediately while the terminal continues running.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): add line-start anchors to exit detection regex patterns

Address PR review findings:
- Add ^ anchors to CLAUDE_EXIT_PATTERNS to prevent false positive exit
  detection when Claude outputs paths, array access, or Unicode arrows
- Add comprehensive unit tests for detectClaudeExit and related functions
- Remove duplicate debugLog call in handleClaudeExit (keep console.warn)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): prevent false exit detection for emails and race condition

- Update user@host regex to require path indicator after colon,
  preventing emails like user@example.com: from triggering exit detection
- Add test cases for emails at line start to ensure they don't match
- Add guard in onTerminalClaudeExit to prevent setting status to 'running'
  if terminal has already exited (fixes potential race condition)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-13 10:13:21 +01:00
committed by AndyMik90
parent 4736b6b61a
commit 14982e6696
9 changed files with 438 additions and 0 deletions
@@ -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);
});
});
});
@@ -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
*/
@@ -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);
}
@@ -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);
}
};
}
@@ -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) => {
@@ -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) => {
@@ -98,5 +98,6 @@ export const terminalMock = {
onTerminalOAuthToken: () => () => {},
onTerminalAuthCreated: () => () => {},
onTerminalClaudeBusy: () => () => {},
onTerminalClaudeExit: () => () => {},
onTerminalPendingResume: () => () => {}
};
@@ -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',
+2
View File
@@ -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;