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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user