diff --git a/src/components/App.tsx b/src/components/App.tsx index a63fc63..4e10b8a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2,6 +2,7 @@ import React, {useState, useEffect} from 'react'; import {useApp, Box, Text} from 'ink'; import Menu from './Menu.js'; import Session from './Session.js'; +import BashSession from './BashSession.js'; import NewWorktree from './NewWorktree.js'; import DeleteWorktree from './DeleteWorktree.js'; import MergeWorktree from './MergeWorktree.js'; @@ -9,7 +10,11 @@ import Configuration from './Configuration.js'; import PresetSelector from './PresetSelector.js'; import {SessionManager} from '../services/sessionManager.js'; import {WorktreeService} from '../services/worktreeService.js'; -import {Worktree, Session as SessionType} from '../types/index.js'; +import { + Worktree, + Session as SessionType, + TerminalMode, +} from '../types/index.js'; import {shortcutManager} from '../services/shortcutManager.js'; import {configurationManager} from '../services/configurationManager.js'; @@ -28,6 +33,7 @@ type View = const App: React.FC = () => { const {exit} = useApp(); const [view, setView] = useState('menu'); + const [sessionMode, setSessionMode] = useState('claude'); const [sessionManager] = useState(() => new SessionManager()); const [worktreeService] = useState(() => new WorktreeService()); const [activeSession, setActiveSession] = useState(null); @@ -121,7 +127,13 @@ const App: React.FC = () => { } } + // Clear screen before entering session + if (process.stdout.isTTY) { + process.stdout.write('\x1B[2J\x1B[H'); + } + setActiveSession(session); + setSessionMode('claude'); // Always start in Claude mode setView('session'); }; @@ -134,7 +146,14 @@ const App: React.FC = () => { selectedWorktree.path, presetId, ); + + // Clear screen before entering session + if (process.stdout.isTTY) { + process.stdout.write('\x1B[2J\x1B[H'); + } + setActiveSession(session); + setSessionMode('claude'); setView('session'); setSelectedWorktree(null); } catch (error) { @@ -150,6 +169,14 @@ const App: React.FC = () => { setMenuKey(prev => prev + 1); }; + const handleToggleMode = () => { + // Clear screen before mode toggle to have a clean transition + if (process.stdout.isTTY) { + process.stdout.write('\x1B[2J\x1B[H'); + } + setSessionMode(current => (current === 'claude' ? 'bash' : 'claude')); + }; + const handleReturnToMenu = () => { setActiveSession(null); setError(null); @@ -282,18 +309,35 @@ const App: React.FC = () => { } if (view === 'session' && activeSession) { + // SEPARATE COMPONENTS ARCHITECTURE: Route to Claude or Bash component + const SessionComponent = sessionMode === 'claude' ? Session : BashSession; + const currentModeDisplay = sessionMode === 'claude' ? 'Claude' : 'Bash'; + const toggleModeDisplay = sessionMode === 'claude' ? 'Bash' : 'Claude'; + return ( - - - + + + + + + + {currentModeDisplay} + - Press {shortcutManager.getShortcutDisplay('returnToMenu')} to return - to menu + {' '} + ({shortcutManager.getShortcutDisplay('toggleMode')}:{' '} + {toggleModeDisplay} |{' '} + {shortcutManager.getShortcutDisplay('returnToMenu')}: Menu) diff --git a/src/components/BashSession.tsx b/src/components/BashSession.tsx new file mode 100644 index 0000000..ea229bb --- /dev/null +++ b/src/components/BashSession.tsx @@ -0,0 +1,196 @@ +import React, {useEffect, useState} from 'react'; +import {useStdout} from 'ink'; +import {Session as SessionType} from '../types/index.js'; +import {SessionManager} from '../services/sessionManager.js'; +import {shortcutManager} from '../services/shortcutManager.js'; + +interface BashSessionProps { + session: SessionType; + sessionManager: SessionManager; + onToggleMode: () => void; + onReturnToMenu: () => void; +} + +const BashSession: React.FC = ({ + session, + sessionManager, + onToggleMode, + onReturnToMenu, +}) => { + const {stdout} = useStdout(); + const [isExiting, setIsExiting] = useState(false); + + useEffect(() => { + if (!stdout) return; + + // Only clear screen on initial load, not on mode toggles + if (session.bashHistory.length === 0) { + stdout.write('\x1B[2J\x1B[H'); + } + + // Set session to bash mode so SessionManager routes events correctly + session.currentMode = 'bash'; + + // Handle Bash session restoration + const handleBashSessionRestore = (restoredSession: SessionType) => { + if (restoredSession.id !== session.id) return; + + // Replay all Bash buffered output, using robust logic + for (let i = 0; i < restoredSession.bashHistory.length; i++) { + const buffer = restoredSession.bashHistory[i]; + if (!buffer) continue; + + const str = buffer.toString('utf8'); + + // Skip clear screen sequences at the beginning + if (i === 0 && (str.includes('\x1B[2J') || str.includes('\x1B[H'))) { + const cleaned = str.replace(/\x1B\[2J/g, '').replace(/\x1B\[H/g, ''); + if (cleaned.length > 0) { + stdout.write(Buffer.from(cleaned, 'utf8')); + } + } else { + stdout.write(buffer); + } + } + }; + + // Handle Bash data only + const handleBashSessionData = ( + activeSession: SessionType, + data: string, + ) => { + if (activeSession.id === session.id && !isExiting) { + stdout.write(data); + } + }; + + const handleSessionExit = (exitedSession: SessionType) => { + if (exitedSession.id === session.id) { + setIsExiting(true); + } + }; + + // Setup event listeners for Bash events only + sessionManager.on('bashSessionRestore', handleBashSessionRestore); + sessionManager.on('bashSessionData', handleBashSessionData); + sessionManager.on('sessionExit', handleSessionExit); + + // Mark session as active (triggers restore event) + sessionManager.setSessionActive(session.worktreePath, true); + + // If bash history is empty, send initial newline to get bash prompt + if (session.bashHistory.length === 0) { + setTimeout(() => { + session.bashProcess.write('\n'); + }, 150); + } + + // Resize PTY to current dimensions + const currentCols = process.stdout.columns || 80; + const currentRows = process.stdout.rows || 24; + + try { + session.bashProcess.resize(currentCols, currentRows); + } catch { + // Bash process might have exited + } + + // Handle terminal resize + const handleResize = () => { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + + // Resize Bash PTY only + try { + session.bashProcess.resize(cols, rows); + } catch { + // Bash process might have exited + } + }; + + stdout.on('resize', handleResize); + + // Setup stdin handling + const stdin = process.stdin; + const originalRawMode = stdin.isRaw; + const originalPaused = stdin.isPaused(); + + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + + const handleStdinData = (data: string) => { + if (isExiting) return; + + const shortcuts = shortcutManager.getShortcuts(); + + // Check for toggle mode shortcut + const toggleModeCode = shortcutManager.getShortcutCode( + shortcuts.toggleMode, + ); + if (toggleModeCode && data === toggleModeCode) { + onToggleMode(); + return; + } + + // Check for return to menu shortcut + const returnToMenuCode = shortcutManager.getShortcutCode( + shortcuts.returnToMenu, + ); + if (returnToMenuCode && data === returnToMenuCode) { + if (stdout) { + stdout.write('\x1b[?1004l'); + } + onReturnToMenu(); + return; + } + + // Send to Bash PTY only + session.bashProcess.write(data); + }; + + stdin.on('data', handleStdinData); + + return () => { + // Cleanup Bash session + stdin.removeListener('data', handleStdinData); + + try { + stdin.setRawMode(originalRawMode); + if (originalPaused) { + stdin.pause(); + } + } catch { + // Handle case where stdin is already in desired state + } + + if (stdout) { + try { + stdout.write('\x1b[?1004l'); + } catch { + // Handle case where stdout is no longer available + } + } + + // Mark session as inactive + sessionManager.setSessionActive(session.worktreePath, false); + + // Remove event listeners + sessionManager.off('bashSessionRestore', handleBashSessionRestore); + sessionManager.off('bashSessionData', handleBashSessionData); + sessionManager.off('sessionExit', handleSessionExit); + stdout.off('resize', handleResize); + }; + }, [ + session, + sessionManager, + stdout, + onToggleMode, + onReturnToMenu, + isExiting, + ]); + + return null; +}; + +export default BashSession; diff --git a/src/components/ConfigureShortcuts.tsx b/src/components/ConfigureShortcuts.tsx index 8d6f5ce..aa5a9cc 100644 --- a/src/components/ConfigureShortcuts.tsx +++ b/src/components/ConfigureShortcuts.tsx @@ -50,6 +50,10 @@ const ConfigureShortcuts: React.FC = ({ label: `Return to Menu: ${getShortcutDisplayFromState('returnToMenu')}`, value: 'returnToMenu', }, + { + label: `Toggle Mode: ${getShortcutDisplayFromState('toggleMode')}`, + value: 'toggleMode', + }, { label: '---', value: 'separator', diff --git a/src/components/Session.tsx b/src/components/Session.tsx index daa5c45..23c3251 100644 --- a/src/components/Session.tsx +++ b/src/components/Session.tsx @@ -7,12 +7,14 @@ import {shortcutManager} from '../services/shortcutManager.js'; interface SessionProps { session: SessionType; sessionManager: SessionManager; + onToggleMode: () => void; onReturnToMenu: () => void; } const Session: React.FC = ({ session, sessionManager, + onToggleMode, onReturnToMenu, }) => { const {stdout} = useStdout(); @@ -21,8 +23,13 @@ const Session: React.FC = ({ useEffect(() => { if (!stdout) return; - // Clear screen when entering session - stdout.write('\x1B[2J\x1B[H'); + // Only clear screen on initial load, not on mode toggles + if (session.outputHistory.length === 0) { + stdout.write('\x1B[2J\x1B[H'); + } + + // Set session to claude mode so SessionManager routes events correctly + session.currentMode = 'claude'; // Handle session restoration const handleSessionRestore = (restoredSession: SessionType) => { @@ -119,8 +126,19 @@ const Session: React.FC = ({ const handleStdinData = (data: string) => { if (isExiting) return; + const shortcuts = shortcutManager.getShortcuts(); + + // Check for toggle mode shortcut + const toggleModeCode = shortcutManager.getShortcutCode( + shortcuts.toggleMode, + ); + if (toggleModeCode && data === toggleModeCode) { + onToggleMode(); + return; + } + // Check for return to menu shortcut - const returnToMenuShortcut = shortcutManager.getShortcuts().returnToMenu; + const returnToMenuShortcut = shortcuts.returnToMenu; const shortcutCode = shortcutManager.getShortcutCode(returnToMenuShortcut); @@ -171,7 +189,14 @@ const Session: React.FC = ({ sessionManager.off('sessionExit', handleSessionExit); stdout.off('resize', handleResize); }; - }, [session, sessionManager, stdout, onReturnToMenu, isExiting]); + }, [ + session, + sessionManager, + stdout, + onToggleMode, + onReturnToMenu, + isExiting, + ]); // Return null to render nothing (PTY output goes directly to stdout) return null; diff --git a/src/services/configurationManager.selectPresetOnStart.test.ts b/src/services/configurationManager.selectPresetOnStart.test.ts index a4a4456..1c2a562 100644 --- a/src/services/configurationManager.selectPresetOnStart.test.ts +++ b/src/services/configurationManager.selectPresetOnStart.test.ts @@ -29,6 +29,7 @@ describe('ConfigurationManager - selectPresetOnStart', () => { shortcuts: { returnToMenu: {ctrl: true, key: 'e'}, cancel: {key: 'escape'}, + toggleMode: {ctrl: true, key: 't'}, }, commandPresets: { presets: [ diff --git a/src/services/configurationManager.test.ts b/src/services/configurationManager.test.ts index 248be1d..252a5ad 100644 --- a/src/services/configurationManager.test.ts +++ b/src/services/configurationManager.test.ts @@ -33,6 +33,7 @@ describe('ConfigurationManager - Command Presets', () => { shortcuts: { returnToMenu: {ctrl: true, key: 'e'}, cancel: {key: 'escape'}, + toggleMode: {ctrl: true, key: 't'}, }, command: { command: 'claude', diff --git a/src/services/configurationManager.ts b/src/services/configurationManager.ts index 69fba2f..75f238d 100644 --- a/src/services/configurationManager.ts +++ b/src/services/configurationManager.ts @@ -111,7 +111,12 @@ export class ConfigurationManager { } getShortcuts(): ShortcutConfig { - return this.config.shortcuts || DEFAULT_SHORTCUTS; + const config = this.config.shortcuts || ({} as Partial); + return { + returnToMenu: config.returnToMenu || DEFAULT_SHORTCUTS.returnToMenu, + cancel: config.cancel || DEFAULT_SHORTCUTS.cancel, + toggleMode: config.toggleMode || DEFAULT_SHORTCUTS.toggleMode, + }; } setShortcuts(shortcuts: ShortcutConfig): void { diff --git a/src/services/sessionManager.test.ts b/src/services/sessionManager.test.ts index 451ac19..c9a2340 100644 --- a/src/services/sessionManager.test.ts +++ b/src/services/sessionManager.test.ts @@ -1,6 +1,7 @@ import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; import {SessionManager} from './sessionManager.js'; import {configurationManager} from './configurationManager.js'; +import {shortcutManager} from './shortcutManager.js'; import {spawn, IPty} from 'node-pty'; import {EventEmitter} from 'events'; import {Session} from '../types/index.js'; @@ -122,18 +123,21 @@ describe('SessionManager', () => { // First spawn attempt - will exit with code 1 const firstMockPty = new MockPty(); - // Second spawn attempt - succeeds + // Second spawn attempt (Bash) - succeeds + const bashMockPty = new MockPty(); + // Third spawn attempt (Claude fallback) - succeeds const secondMockPty = new MockPty(); vi.mocked(spawn) .mockReturnValueOnce(firstMockPty as unknown as IPty) + .mockReturnValueOnce(bashMockPty as unknown as IPty) .mockReturnValueOnce(secondMockPty as unknown as IPty); // Create session const session = await sessionManager.createSession('/test/worktree'); // Verify initial spawn - expect(spawn).toHaveBeenCalledTimes(1); + expect(spawn).toHaveBeenCalledTimes(2); expect(spawn).toHaveBeenCalledWith( 'claude', ['--invalid-flag'], @@ -146,10 +150,10 @@ describe('SessionManager', () => { // Wait for fallback to occur await new Promise(resolve => setTimeout(resolve, 50)); - // Verify fallback spawn was called - expect(spawn).toHaveBeenCalledTimes(2); + // Verify fallback spawn was called (Claude initial + Bash + Claude fallback) + expect(spawn).toHaveBeenCalledTimes(3); expect(spawn).toHaveBeenNthCalledWith( - 2, + 3, 'claude', ['--resume'], expect.objectContaining({cwd: '/test/worktree'}), @@ -220,8 +224,8 @@ describe('SessionManager', () => { // Wait a bit to ensure no early exit await new Promise(resolve => setTimeout(resolve, 600)); - // Verify only one spawn attempt - expect(spawn).toHaveBeenCalledTimes(1); + // Verify spawn attempts (Claude + Bash PTYs) + expect(spawn).toHaveBeenCalledTimes(2); expect(spawn).toHaveBeenCalledWith( 'claude', ['--resume'], @@ -246,8 +250,8 @@ describe('SessionManager', () => { // Should return the same session expect(session1).toBe(session2); - // Spawn should only be called once - expect(spawn).toHaveBeenCalledTimes(1); + // Spawn should only be called for first session (Claude + Bash PTYs) + expect(spawn).toHaveBeenCalledTimes(2); }); it('should throw error when spawn fails with fallback args', async () => { @@ -328,12 +332,14 @@ describe('SessionManager', () => { }); // Setup spawn mock - vi.mocked(spawn).mockReturnValue(mockPty as unknown as IPty); + vi.mocked(spawn) + .mockReturnValueOnce(mockPty as unknown as IPty) + .mockReturnValueOnce(mockPty as unknown as IPty); // Create session with preset await sessionManager.createSessionWithPreset('/test/worktree'); - // Verify spawn was called with preset config + // Verify spawn was called with preset config for Claude expect(spawn).toHaveBeenCalledWith('claude', ['--preset-arg'], { name: 'xterm-color', cols: expect.any(Number), @@ -341,6 +347,12 @@ describe('SessionManager', () => { cwd: '/test/worktree', env: process.env, }); + // Verify spawn was called for bash PTY + expect(spawn).toHaveBeenCalledWith( + process.env['SHELL'] || 'bash', + [], + expect.objectContaining({cwd: '/test/worktree'}), + ); }); it('should use specific preset when ID provided', async () => { @@ -354,7 +366,9 @@ describe('SessionManager', () => { }); // Setup spawn mock - vi.mocked(spawn).mockReturnValue(mockPty as unknown as IPty); + vi.mocked(spawn) + .mockReturnValueOnce(mockPty as unknown as IPty) + .mockReturnValueOnce(mockPty as unknown as IPty); // Create session with specific preset await sessionManager.createSessionWithPreset('/test/worktree', '2'); @@ -382,7 +396,9 @@ describe('SessionManager', () => { }); // Setup spawn mock - vi.mocked(spawn).mockReturnValue(mockPty as unknown as IPty); + vi.mocked(spawn) + .mockReturnValueOnce(mockPty as unknown as IPty) + .mockReturnValueOnce(mockPty as unknown as IPty); // Create session with non-existent preset await sessionManager.createSessionWithPreset('/test/worktree', 'invalid'); @@ -402,7 +418,7 @@ describe('SessionManager', () => { fallbackArgs: ['--good-flag'], }); - // Mock spawn to fail first, succeed second + // Mock spawn to fail first Claude command, succeed on second, then succeed for bash let callCount = 0; vi.mocked(spawn).mockImplementation(() => { callCount++; @@ -415,8 +431,8 @@ describe('SessionManager', () => { // Create session await sessionManager.createSessionWithPreset('/test/worktree'); - // Verify both attempts were made - expect(spawn).toHaveBeenCalledTimes(2); + // Verify fallback attempt was made for Claude + expect(spawn).toHaveBeenCalledTimes(3); // Claude (fail) + Claude (success) + Bash expect(spawn).toHaveBeenNthCalledWith( 1, 'claude', @@ -439,7 +455,9 @@ describe('SessionManager', () => { }); // Setup spawn mock - vi.mocked(spawn).mockReturnValue(mockPty as unknown as IPty); + vi.mocked(spawn) + .mockReturnValueOnce(mockPty as unknown as IPty) + .mockReturnValueOnce(mockPty as unknown as IPty); // Create session using legacy method await sessionManager.createSession('/test/worktree'); @@ -452,4 +470,189 @@ describe('SessionManager', () => { ); }); }); + + describe('Dual Mode Bug Fixes', () => { + it('should handle undefined shortcut in getShortcutCode without crashing', () => { + // Test específico para el TypeError reportado por kbwo + // Previene regresión del bug: Cannot read properties of undefined (reading 'ctrl') + expect(() => + shortcutManager.getShortcutCode(undefined as any), + ).not.toThrow(); + expect(shortcutManager.getShortcutCode(undefined as any)).toBeNull(); + }); + + it('should emit bashSessionData events when bash mode is active', async () => { + // Setup mock configuration + vi.mocked(configurationManager.getCommandConfig).mockReturnValue({ + command: 'claude', + }); + + // Create separate mock PTYs for proper testing + const claudeMockPty = new MockPty(); + const bashMockPty = new MockPty(); + + vi.mocked(spawn) + .mockReturnValueOnce(claudeMockPty as unknown as IPty) + .mockReturnValueOnce(bashMockPty as unknown as IPty); + + // Create session + const session = await sessionManager.createSession('/test/worktree'); + session.currentMode = 'bash'; + session.isActive = true; + + // Set up event listener spy + const bashDataEventSpy = vi.fn(); + sessionManager.on('bashSessionData', bashDataEventSpy); + + // Simulate bash PTY sending data (trigger onData handler) + const bashDataHandler = bashMockPty.onData.mock.calls[0]?.[0]; + if (bashDataHandler) { + bashDataHandler('$ echo test\ntest\n$ '); + } + + // Verify: bashSessionData event is emitted + expect(bashDataEventSpy).toHaveBeenCalledWith( + session, + '$ echo test\ntest\n$ ', + ); + }); + }); + + describe('Dual Mode Integration', () => { + it('should create both Claude and Bash PTYs during session creation', async () => { + // Setup separate mock PTYs for Claude and Bash + const claudeMockPty = new MockPty(); + const bashMockPty = new MockPty(); + + vi.mocked(configurationManager.getCommandConfig).mockReturnValue({ + command: 'claude', + }); + + vi.mocked(spawn) + .mockReturnValueOnce(claudeMockPty as unknown as IPty) + .mockReturnValueOnce(bashMockPty as unknown as IPty); + + // Create session + const session = await sessionManager.createSession('/test/worktree'); + + // Verify: Both PTYs are created and assigned correctly + expect(session.process).toBe(claudeMockPty); + expect(session.bashProcess).toBe(bashMockPty); + + // Verify: spawn called for both Claude and Bash + expect(spawn).toHaveBeenCalledTimes(2); + expect(spawn).toHaveBeenNthCalledWith( + 1, + 'claude', + expect.any(Array), + expect.objectContaining({cwd: '/test/worktree'}), + ); + expect(spawn).toHaveBeenNthCalledWith( + 2, + process.env['SHELL'] || 'bash', + [], + expect.objectContaining({cwd: '/test/worktree'}), + ); + + // Verify: Both terminals are created with allowProposedApi + expect(session.terminal).toBeDefined(); + expect(session.bashProcess).toBeDefined(); + }); + + it('should route bash events correctly when in bash mode', async () => { + // Setup mock configuration + vi.mocked(configurationManager.getCommandConfig).mockReturnValue({ + command: 'claude', + }); + + // Create separate mock PTYs for proper testing + const claudeMockPty = new MockPty(); + const bashMockPty = new MockPty(); + + vi.mocked(spawn) + .mockReturnValueOnce(claudeMockPty as unknown as IPty) + .mockReturnValueOnce(bashMockPty as unknown as IPty); + + // Create session and set bash mode + const session = await sessionManager.createSession('/test/worktree'); + session.currentMode = 'bash'; + session.isActive = true; + + // Set up event listener spies + const bashDataEventSpy = vi.fn(); + const claudeDataEventSpy = vi.fn(); + sessionManager.on('bashSessionData', bashDataEventSpy); + sessionManager.on('sessionData', claudeDataEventSpy); + + // Simulate bash PTY data (should emit bashSessionData) + const bashDataHandler = bashMockPty.onData.mock.calls[0]?.[0]; + if (bashDataHandler) { + bashDataHandler('bash output'); + } + + // Simulate claude PTY data (should emit sessionData) + const claudeDataHandler = claudeMockPty.onData.mock.calls[0]?.[0]; + if (claudeDataHandler) { + claudeDataHandler('claude output'); + } + + // Verify: bash data routed to bashSessionData event + expect(bashDataEventSpy).toHaveBeenCalledWith(session, 'bash output'); + + // Verify: claude data routed to sessionData event (regardless of mode) + expect(claudeDataEventSpy).toHaveBeenCalledWith(session, 'claude output'); + }); + }); + + describe('Bash Session Restoration Events', () => { + it('should emit bashSessionRestore event when bash session becomes active with history', async () => { + // Setup mock configuration + vi.mocked(configurationManager.getCommandConfig).mockReturnValue({ + command: 'claude', + }); + vi.mocked(spawn).mockReturnValue(mockPty as unknown as IPty); + + // Create session with bash history + const session = await sessionManager.createSession('/test/worktree'); + session.currentMode = 'bash'; + session.bashHistory = [Buffer.from('$ echo test\ntest\n$ ')]; + + // Set up event listener spy + const bashRestoreEventSpy = vi.fn(); + sessionManager.on('bashSessionRestore', bashRestoreEventSpy); + + // Test: Activate session + sessionManager.setSessionActive('/test/worktree', true); + + // Verify: bashSessionRestore event is emitted + expect(bashRestoreEventSpy).toHaveBeenCalledWith(session); + }); + + it('should not emit restore events when session has no history', async () => { + // Setup mock configuration + vi.mocked(configurationManager.getCommandConfig).mockReturnValue({ + command: 'claude', + }); + vi.mocked(spawn).mockReturnValue(mockPty as unknown as IPty); + + // Create session with empty histories + const session = await sessionManager.createSession('/test/worktree'); + session.currentMode = 'bash'; + session.bashHistory = []; + session.outputHistory = []; + + // Set up event listener spies + const bashRestoreEventSpy = vi.fn(); + const claudeRestoreEventSpy = vi.fn(); + sessionManager.on('bashSessionRestore', bashRestoreEventSpy); + sessionManager.on('sessionRestore', claudeRestoreEventSpy); + + // Test: Activate session with empty histories + sessionManager.setSessionActive('/test/worktree', true); + + // Verify: No restore events are emitted + expect(bashRestoreEventSpy).not.toHaveBeenCalled(); + expect(claudeRestoreEventSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/services/sessionManager.ts b/src/services/sessionManager.ts index e76c00f..644aaac 100644 --- a/src/services/sessionManager.ts +++ b/src/services/sessionManager.ts @@ -113,10 +113,22 @@ export class SessionManager extends EventEmitter implements ISessionManager { terminal, isPrimaryCommand: true, commandConfig, + + // Dual-mode properties initialization + bashProcess: spawn(process.env['SHELL'] || 'bash', [], { + name: 'xterm-color', + cols: process.stdout.columns || 80, + rows: process.stdout.rows || 24, + cwd: worktreePath, + env: process.env, + }), + currentMode: 'claude', // Always start in Claude mode + bashHistory: [], }; // Set up persistent background data handler for state detection this.setupBackgroundHandler(session); + this.setupBashHandler(session); this.sessions.set(worktreePath, session); @@ -198,10 +210,22 @@ export class SessionManager extends EventEmitter implements ISessionManager { terminal, isPrimaryCommand, commandConfig, + + // Dual-mode properties initialization + bashProcess: spawn(process.env['SHELL'] || 'bash', [], { + name: 'xterm-color', + cols: process.stdout.columns || 80, + rows: process.stdout.rows || 24, + cwd: worktreePath, + env: process.env, + }), + currentMode: 'claude', // Always start in Claude mode + bashHistory: [], }; // Set up persistent background data handler for state detection this.setupBackgroundHandler(session); + this.setupBashHandler(session); this.sessions.set(worktreePath, session); @@ -295,6 +319,37 @@ export class SessionManager extends EventEmitter implements ISessionManager { this.setupExitHandler(session); } + private setupBashHandler(session: Session): void { + // Setup bash data handler (background only - no stdout writing) + session.bashProcess.onData((data: string) => { + // Store in bash history as Buffer (no state detection) + const buffer = Buffer.from(data, 'utf8'); + session.bashHistory.push(buffer); + + // Apply 10MB memory limit for bash history + const MAX_BASH_HISTORY = 10 * 1024 * 1024; + let totalSize = session.bashHistory.reduce( + (sum, buf) => sum + buf.length, + 0, + ); + while (totalSize > MAX_BASH_HISTORY && session.bashHistory.length > 0) { + const removed = session.bashHistory.shift(); + if (removed) totalSize -= removed.length; + } + + // Emit bash data event for active sessions + if (session.isActive && session.currentMode === 'bash') { + this.emit('bashSessionData', session, data); + } + }); + + // Setup bash exit handler + session.bashProcess.onExit(() => { + // Bash process exited - could restart or handle gracefully + console.warn(`Bash process exited for session ${session.id}`); + }); + } + private cleanupSession(session: Session): void { // Clear the state check interval if (session.stateCheckInterval) { @@ -316,9 +371,22 @@ export class SessionManager extends EventEmitter implements ISessionManager { if (session) { session.isActive = active; - // If becoming active, emit a restore event with the output history - if (active && session.outputHistory.length > 0) { - this.emit('sessionRestore', session); + // If becoming active, emit a restore event with the appropriate history + if (active) { + // Restore Claude history if in Claude mode and has history + if ( + session.outputHistory.length > 0 && + session.currentMode === 'claude' + ) { + this.emit('sessionRestore', session); + } + // Restore bash history if in bash mode and has history + else if ( + session.bashHistory.length > 0 && + session.currentMode === 'bash' + ) { + this.emit('bashSessionRestore', session); + } } } } @@ -335,6 +403,14 @@ export class SessionManager extends EventEmitter implements ISessionManager { } catch (_error) { // Process might already be dead } + + // Clean up bash PTY (always exists now) + try { + session.bashProcess.kill(); + } catch (_error) { + // Bash process might already be dead + } + // Clean up any pending timer const timer = this.busyTimers.get(worktreePath); if (timer) { diff --git a/src/services/shortcutManager.ts b/src/services/shortcutManager.ts index 4980bc3..286a449 100644 --- a/src/services/shortcutManager.ts +++ b/src/services/shortcutManager.ts @@ -66,6 +66,9 @@ export class ShortcutManager { currentShortcuts.returnToMenu, cancel: this.validateShortcut(shortcuts.cancel) || currentShortcuts.cancel, + toggleMode: + this.validateShortcut(shortcuts.toggleMode) || + currentShortcuts.toggleMode, }; configurationManager.setShortcuts(validated); @@ -121,6 +124,9 @@ export class ShortcutManager { public getShortcutCode(shortcut: ShortcutKey): string | null { // Convert shortcut to terminal code for raw stdin handling + if (!shortcut) { + return null; // Handle undefined/null shortcut + } if (!shortcut.ctrl || shortcut.alt || shortcut.shift) { return null; // Only support Ctrl+key for raw codes } diff --git a/src/types/index.ts b/src/types/index.ts index a255609..87dec81 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,8 @@ export type Terminal = InstanceType; export type SessionState = 'idle' | 'busy' | 'waiting_input'; +export type TerminalMode = 'claude' | 'bash'; + export interface Worktree { path: string; branch?: string; @@ -28,6 +30,11 @@ export interface Session { stateCheckInterval?: NodeJS.Timeout; // Interval for checking terminal state isPrimaryCommand?: boolean; // Track if process was started with main command args commandConfig?: CommandConfig; // Store command config for fallback + + // Dual-mode properties + bashProcess: IPty; // Bash PTY instance (always exists) + currentMode: TerminalMode; // Current active mode + bashHistory: Buffer[]; // Bash mode history for restoration } export interface SessionManager { @@ -48,11 +55,13 @@ export interface ShortcutKey { export interface ShortcutConfig { returnToMenu: ShortcutKey; cancel: ShortcutKey; + toggleMode: ShortcutKey; // Toggle between Claude and Bash modes } export const DEFAULT_SHORTCUTS: ShortcutConfig = { returnToMenu: {ctrl: true, key: 'e'}, cancel: {key: 'escape'}, + toggleMode: {ctrl: true, key: 't'}, }; export interface StatusHook { diff --git a/src/utils/worktreeUtils.test.ts b/src/utils/worktreeUtils.test.ts index b9c3f8c..6c4ec73 100644 --- a/src/utils/worktreeUtils.test.ts +++ b/src/utils/worktreeUtils.test.ts @@ -154,6 +154,10 @@ describe('prepareWorktreeItems', () => { lastActivity: new Date(), isActive: true, terminal: {} as Session['terminal'], + // Dual-mode properties + bashProcess: {} as Session['bashProcess'], + currentMode: 'claude', + bashHistory: [], }; it('should prepare basic worktree without git status', () => {