diff --git a/apps/frontend/src/main/terminal-session-store.ts b/apps/frontend/src/main/terminal-session-store.ts index cd6d290a..26afbbcd 100644 --- a/apps/frontend/src/main/terminal-session-store.ts +++ b/apps/frontend/src/main/terminal-session-store.ts @@ -2,6 +2,7 @@ import { app } from 'electron'; import { join } from 'path'; import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, promises as fsPromises } from 'fs'; import type { TerminalWorktreeConfig } from '../shared/types'; +import { debugLog } from '../shared/utils/debug-logger'; /** * Persisted terminal session data @@ -376,12 +377,24 @@ export class TerminalSessionStore { todaySessions[projectPath] = []; } + // Debug: Log incoming outputBuffer info + const incomingBufferLen = session.outputBuffer?.length ?? 0; + debugLog('[TerminalSessionStore] Updating session in memory:', session.id, + 'incoming outputBuffer:', incomingBufferLen, 'bytes', + 'isClaudeMode:', session.isClaudeMode); + // Update existing or add new const existingIndex = todaySessions[projectPath].findIndex(s => s.id === session.id); if (existingIndex >= 0) { // Preserve displayOrder from existing session if not provided in incoming session // This prevents periodic saves (which don't include displayOrder) from losing tab order const existingSession = todaySessions[projectPath][existingIndex]; + const existingBufferLen = existingSession.outputBuffer?.length ?? 0; + const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length; + debugLog('[TerminalSessionStore] Updating existing session:', session.id, + 'existing outputBuffer:', existingBufferLen, 'bytes', + 'new outputBuffer (after truncation):', truncatedLen, 'bytes'); + todaySessions[projectPath][existingIndex] = { ...session, // Limit output buffer size @@ -391,6 +404,10 @@ export class TerminalSessionStore { displayOrder: session.displayOrder ?? existingSession.displayOrder, }; } else { + const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length; + debugLog('[TerminalSessionStore] Creating new session:', session.id, + 'outputBuffer (after truncation):', truncatedLen, 'bytes'); + todaySessions[projectPath].push({ ...session, outputBuffer: session.outputBuffer.slice(-MAX_OUTPUT_BUFFER), @@ -437,9 +454,18 @@ export class TerminalSessionStore { getSessions(projectPath: string): TerminalSession[] { const today = getDateString(); + debugLog('[TerminalSessionStore] Getting sessions for project:', projectPath, 'date:', today); + // First check today const todaySessions = this.getTodaysSessions(); if (todaySessions[projectPath]?.length > 0) { + // Debug: Log outputBuffer info for each session + for (const session of todaySessions[projectPath]) { + const bufferLen = session.outputBuffer?.length ?? 0; + debugLog('[TerminalSessionStore] Session', session.id, 'outputBuffer:', bufferLen, 'bytes', + 'isClaudeMode:', session.isClaudeMode, + 'hasBuffer:', bufferLen > 0); + } // Validate worktree configs before returning return todaySessions[projectPath].map(session => ({ ...session, @@ -462,6 +488,15 @@ export class TerminalSessionStore { console.warn(`[TerminalSessionStore] No sessions today, migrating sessions from ${mostRecentDate} to today`); const sessions = this.data.sessionsByDate[mostRecentDate][projectPath] || []; + // Debug: Log outputBuffer info for sessions being migrated + for (const session of sessions) { + const bufferLen = session.outputBuffer?.length ?? 0; + debugLog('[TerminalSessionStore] Migrating session', session.id, 'from', mostRecentDate, + 'outputBuffer:', bufferLen, 'bytes', + 'isClaudeMode:', session.isClaudeMode, + 'hasBuffer:', bufferLen > 0); + } + // MIGRATE: Copy sessions to today's bucket with validated worktree configs const migratedSessions = sessions.map(session => ({ ...session, @@ -629,8 +664,16 @@ export class TerminalSessionStore { const session = sessions.find(s => s.id === sessionId); if (session) { + const prevLen = session.outputBuffer?.length ?? 0; session.outputBuffer = (session.outputBuffer + output).slice(-MAX_OUTPUT_BUFFER); + const newLen = session.outputBuffer.length; session.lastActiveAt = new Date().toISOString(); + + // Debug: Log buffer update (throttled to avoid spam - only log when significant changes) + if (newLen - prevLen > 1000 || prevLen === 0) { + debugLog('[TerminalSessionStore] updateOutputBuffer:', sessionId, + 'prev:', prevLen, 'bytes, added:', output.length, 'bytes, new total:', newLen, 'bytes'); + } // Note: We don't save immediately here to avoid excessive disk writes // Call saveAllPending() periodically or on app quit } diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index 423071a4..cdcf1c83 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -149,6 +149,11 @@ export async function restoreTerminal( 'Stored Claude mode:', storedIsClaudeMode, 'Stored session ID:', storedClaudeSessionId); + // Debug: Log outputBuffer info from both passed and stored session + const passedBufferLen = session.outputBuffer?.length ?? 0; + const storedBufferLen = storedSession?.outputBuffer?.length ?? 0; + debugLog('[TerminalLifecycle] OutputBuffer info - passed session:', passedBufferLen, 'bytes, stored session:', storedBufferLen, 'bytes'); + // Validate cwd exists - if the directory was deleted (e.g., worktree removed), // fall back to project path to prevent shell exit with code 1 let effectiveCwd = session.cwd; @@ -235,6 +240,12 @@ export async function restoreTerminal( } } + // Debug: Log the outputBuffer being returned for replay + const returnBufferLen = session.outputBuffer?.length ?? 0; + debugLog('[TerminalLifecycle] Returning outputBuffer for terminal:', session.id, + 'length:', returnBufferLen, 'bytes', + 'hasContent:', returnBufferLen > 0); + return { success: true, outputBuffer: session.outputBuffer diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx index ddbf2277..f7910749 100644 --- a/apps/frontend/src/renderer/components/Terminal.tsx +++ b/apps/frontend/src/renderer/components/Terminal.tsx @@ -439,6 +439,7 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title dragHandleListeners={dragHandleListeners} isExpanded={isExpanded} onToggleExpand={onToggleExpand} + pendingClaudeResume={terminal?.pendingClaudeResume} />
void; + /** Whether this terminal has a pending Claude resume (deferred until tab activated) */ + pendingClaudeResume?: boolean; } export function TerminalHeader({ @@ -64,6 +66,7 @@ export function TerminalHeader({ dragHandleListeners, isExpanded, onToggleExpand, + pendingClaudeResume, }: TerminalHeaderProps) { const { t } = useTranslation(['terminal', 'common']); const backlogTasks = tasks.filter((t) => t.status === 'backlog'); @@ -106,6 +109,15 @@ export function TerminalHeader({ {terminalCount < 4 && Claude} )} + {pendingClaudeResume && ( + + + {terminalCount < 4 && {t('terminal:resume.pending')}} + + )} {isClaudeMode && ( { if (result.success && result.data?.success) { + debugLog(`[usePtyProcess] Successfully restored PTY session for terminal: ${terminalId}`); handleSuccess(); const store = getStore(); store.setTerminalStatus(terminalId, terminalState.isClaudeMode ? 'claude-active' : 'running'); store.updateTerminal(terminalId, { isRestored: false }); onCreated?.(); } else { - handleError(`Error restoring session: ${result.data?.error || result.error}`); + const errorMsg = `Error restoring session: ${result.data?.error || result.error}`; + debugError(`[usePtyProcess] Failed to restore PTY session for terminal: ${terminalId}, error: ${errorMsg}`); + handleError(errorMsg); } }).catch((err) => { + debugError(`[usePtyProcess] Exception restoring PTY session for terminal: ${terminalId}, error:`, err); handleError(err.message); }); } else { // New terminal + debugLog(`[usePtyProcess] Creating new PTY for terminal: ${terminalId}, cwd: ${cwd}, projectPath: ${projectPath}`); window.electronAPI.createTerminal({ id: terminalId, cwd, @@ -199,15 +215,19 @@ export function usePtyProcess({ projectPath, }).then((result) => { if (result.success) { + debugLog(`[usePtyProcess] Successfully created PTY for terminal: ${terminalId}`); handleSuccess(); if (!alreadyRunning) { getStore().setTerminalStatus(terminalId, 'running'); } onCreated?.(); } else { - handleError(result.error || 'Unknown error'); + const errorMsg = result.error || 'Unknown error'; + debugError(`[usePtyProcess] Failed to create PTY for terminal: ${terminalId}, error: ${errorMsg}`); + handleError(errorMsg); } }).catch((err) => { + debugError(`[usePtyProcess] Exception creating PTY for terminal: ${terminalId}, error:`, err); handleError(err.message); }); } diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts index 856a32d0..391aeb32 100644 --- a/apps/frontend/src/renderer/components/terminal/useXterm.ts +++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts @@ -5,6 +5,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'; import { SerializeAddon } from '@xterm/addon-serialize'; import { terminalBufferManager } from '../../lib/terminal-buffer-manager'; import { registerOutputCallback, unregisterOutputCallback } from '../../stores/terminal-store'; +import { debugLog, debugError } from '../../../shared/utils/debug-logger'; // Type augmentation for navigator.userAgentData (modern User-Agent Client Hints API) interface NavigatorUAData { @@ -44,7 +45,12 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Initialize xterm.js UI useEffect(() => { - if (!terminalRef.current || xtermRef.current) return; + if (!terminalRef.current || xtermRef.current) { + debugLog(`[useXterm] Skipping xterm initialization for terminal: ${terminalId} - already initialized or container not ready`); + return; + } + + debugLog(`[useXterm] Initializing xterm for terminal: ${terminalId}`); const xterm = new XTerm({ cursorBlink: true, @@ -242,6 +248,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Call onDimensionsReady once when we have valid dimensions if (!dimensionsReadyCalledRef.current && cols > 0 && rows > 0) { dimensionsReadyCalledRef.current = true; + debugLog(`[useXterm] Dimensions ready for terminal: ${terminalId}, cols: ${cols}, rows: ${rows}`); onDimensionsReady?.(cols, rows); } } else { @@ -255,11 +262,14 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Replay buffered output if this is a remount or restored session // This now includes ANSI codes for proper formatting/colors/prompt - const bufferedOutput = terminalBufferManager.get(terminalId); + // Use atomic getAndClear to prevent race condition where new output could arrive between get() and clear() + const bufferedOutput = terminalBufferManager.getAndClear(terminalId); if (bufferedOutput && bufferedOutput.length > 0) { + debugLog(`[useXterm] Replaying buffered output for terminal: ${terminalId}, buffer size: ${bufferedOutput.length} chars`); xterm.write(bufferedOutput); - // Clear buffer after replay to avoid duplicate output - terminalBufferManager.clear(terminalId); + debugLog(`[useXterm] Buffer replay complete and cleared for terminal: ${terminalId}`); + } else { + debugLog(`[useXterm] No buffered output to replay for terminal: ${terminalId}`); } // Handle terminal input @@ -298,7 +308,12 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // This allows the global listener to write directly to xterm when terminal is visible useEffect(() => { // Only register if xterm is ready - if (!xtermRef.current) return; + if (!xtermRef.current) { + debugLog(`[useXterm] Skipping output callback registration for terminal: ${terminalId} - xterm not ready`); + return; + } + + debugLog(`[useXterm] Registering output callback for terminal: ${terminalId}`); // Create a write function that writes directly to this xterm instance const writeCallback = (data: string) => { @@ -312,6 +327,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Cleanup: unregister callback when component unmounts return () => { + debugLog(`[useXterm] Unregistering output callback for terminal: ${terminalId}`); unregisterOutputCallback(terminalId); }; }, [terminalId]); @@ -394,19 +410,29 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea const serializeBuffer = useCallback(() => { if (xtermRef.current && serializeAddonRef.current) { try { + debugLog(`[useXterm] Serializing buffer for terminal: ${terminalId}`); const serialized = serializeAddonRef.current.serialize(); if (serialized && serialized.length > 0) { terminalBufferManager.set(terminalId, serialized); + debugLog(`[useXterm] Buffer serialized for terminal: ${terminalId}, size: ${serialized.length} chars`); + } else { + debugLog(`[useXterm] No content to serialize for terminal: ${terminalId}`); } } catch (error) { - console.error('[useXterm] Failed to serialize terminal buffer:', error); + debugError('[useXterm] Failed to serialize terminal buffer:', error); } + } else { + debugLog(`[useXterm] Cannot serialize buffer for terminal: ${terminalId} - xterm or serializeAddon not available`); } }, [terminalId]); const dispose = useCallback(() => { // Guard against double dispose (can happen in React StrictMode or rapid unmount) - if (isDisposedRef.current) return; + if (isDisposedRef.current) { + debugLog(`[useXterm] Skipping dispose for terminal: ${terminalId} - already disposed`); + return; + } + debugLog(`[useXterm] Disposing xterm for terminal: ${terminalId}`); isDisposedRef.current = true; // Serialize buffer before disposing to preserve ANSI formatting diff --git a/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts b/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts index 13fc9953..29170614 100644 --- a/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts +++ b/apps/frontend/src/renderer/lib/terminal-buffer-manager.ts @@ -65,6 +65,16 @@ class TerminalBufferManager { this.buffers.delete(id); } + /** + * Atomically get and clear a terminal's buffer + * This prevents race conditions where data could be appended between get() and clear() + */ + getAndClear(id: string): string { + const buffer = this.buffers.get(id) || ''; + this.buffers.delete(id); + return buffer; + } + /** * Check if a terminal has a buffer */ diff --git a/apps/frontend/src/renderer/stores/terminal-store.ts b/apps/frontend/src/renderer/stores/terminal-store.ts index 8b156371..ba2314bc 100644 --- a/apps/frontend/src/renderer/stores/terminal-store.ts +++ b/apps/frontend/src/renderer/stores/terminal-store.ts @@ -192,10 +192,36 @@ export const useTerminalStore = create((set, get) => ({ addRestoredTerminal: (session: TerminalSession) => { const state = get(); + debugLog(`[TerminalStore] addRestoredTerminal called for session: ${session.id}, title: "${session.title}", projectPath: ${session.projectPath}`); + + // CRITICAL: Always restore buffer to buffer manager FIRST, even if terminal already exists. + // This ensures useXterm can replay the buffer regardless of whether this is a fresh restore + // or a re-restore (e.g., after project switch). The buffer must be available before + // the Terminal component mounts and useXterm tries to read it. + if (session.outputBuffer) { + terminalBufferManager.set(session.id, session.outputBuffer); + debugLog(`[TerminalStore] Restored buffer for terminal ${session.id}, size: ${session.outputBuffer.length} chars`); + } else { + debugLog(`[TerminalStore] No output buffer to restore for terminal ${session.id}`); + } // Check if terminal already exists const existingTerminal = state.terminals.find(t => t.id === session.id); if (existingTerminal) { + debugLog(`[TerminalStore] Terminal ${session.id} already exists in store, returning existing (buffer was still restored above)`); + + // If session was in Claude mode before shutdown, update pendingClaudeResume for re-restore scenarios + // (e.g., after project switch). This ensures the deferred resume logic can trigger even when + // the terminal already exists in the store. + if (session.isClaudeMode === true && !existingTerminal.pendingClaudeResume) { + debugLog(`[TerminalStore] Updating pendingClaudeResume for existing terminal ${session.id}`); + set((state) => ({ + terminals: state.terminals.map(t => + t.id === session.id ? { ...t, pendingClaudeResume: true } : t + ) + })); + } + return existingTerminal; } @@ -214,25 +240,26 @@ export const useTerminalStore = create((set, get) => ({ // Keep claudeSessionId so users can resume by clicking the invoke button isClaudeMode: false, claudeSessionId: session.claudeSessionId, - // outputBuffer now stored in terminalBufferManager + // outputBuffer now stored in terminalBufferManager (done above before existence check) isRestored: true, projectPath: session.projectPath, // Worktree config is validated in main process before restore worktreeConfig: session.worktreeConfig, // Restore displayOrder for tab position persistence (falls back to end if not set) displayOrder: session.displayOrder ?? state.terminals.length, + // If session was in Claude mode before shutdown, mark for deferred resume. + // This ensures the renderer knows to trigger 'claude --continue' when the terminal + // becomes active, without relying on the TERMINAL_PENDING_RESUME IPC event timing + // (which may be sent before the Terminal component mounts its listener). + pendingClaudeResume: session.isClaudeMode === true, }; - // Restore buffer to buffer manager - if (session.outputBuffer) { - terminalBufferManager.set(session.id, session.outputBuffer); - } - set((state) => ({ terminals: [...state.terminals, restoredTerminal], activeTerminalId: state.activeTerminalId || restoredTerminal.id, })); + debugLog(`[TerminalStore] Successfully added restored terminal ${session.id} to store, isRestored: true, claudeSessionId: ${session.claudeSessionId || 'none'}, pendingClaudeResume: ${session.isClaudeMode === true}`); return restoredTerminal; }, @@ -489,10 +516,13 @@ export async function restoreTerminalSessions(projectPath: string): Promise