Skip to content

fix: implement dual-mode terminal with Claude/Bash toggle #22

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
dist
*.log
.idea/
21 changes: 6 additions & 15 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import Configuration from './Configuration.js';
import {SessionManager} from '../services/sessionManager.js';
import {WorktreeService} from '../services/worktreeService.js';
import {Worktree, Session as SessionType} from '../types/index.js';
import {shortcutManager} from '../services/shortcutManager.js';

type View =
| 'menu'
Expand Down Expand Up @@ -241,20 +240,12 @@ const App: React.FC = () => {

if (view === 'session' && activeSession) {
return (
<Box flexDirection="column">
<Session
key={activeSession.id}
session={activeSession}
sessionManager={sessionManager}
onReturnToMenu={handleReturnToMenu}
/>
<Box marginTop={1}>
<Text dimColor>
Press {shortcutManager.getShortcutDisplay('returnToMenu')} to return
to menu
</Text>
</Box>
</Box>
<Session
key={activeSession.id}
session={activeSession}
sessionManager={sessionManager}
onReturnToMenu={handleReturnToMenu}
/>
);
}

Expand Down
4 changes: 4 additions & 0 deletions src/components/ConfigureShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const ConfigureShortcuts: React.FC<ConfigureShortcutsProps> = ({
label: `Return to Menu: ${getShortcutDisplayFromState('returnToMenu')}`,
value: 'returnToMenu',
},
{
label: `Toggle Mode: ${getShortcutDisplayFromState('toggleMode')}`,
value: 'toggleMode',
},
{
label: '---',
value: 'separator',
Expand Down
171 changes: 154 additions & 17 deletions src/components/Session.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, {useEffect, useState} from 'react';
import React, {useEffect, useState, useCallback} from 'react';
import {useStdout} from 'ink';
import {Session as SessionType} from '../types/index.js';
import {Session as SessionType, TerminalMode} from '../types/index.js';
import {SessionManager} from '../services/sessionManager.js';
import {shortcutManager} from '../services/shortcutManager.js';

Expand All @@ -17,6 +17,53 @@ const Session: React.FC<SessionProps> = ({
}) => {
const {stdout} = useStdout();
const [isExiting, setIsExiting] = useState(false);
const [currentMode, setCurrentMode] = useState<TerminalMode>(
session.currentMode,
);

// Display mode indicator
const displayModeIndicator = useCallback(
(mode: TerminalMode) => {
const toggleShortcut = shortcutManager.getShortcutDisplay('toggleMode');
const menuShortcut = shortcutManager.getShortcutDisplay('returnToMenu');
const indicator =
mode === 'claude'
? `\x1b[44m Claude \x1b[0m \x1b[90m(${toggleShortcut}: Bash | ${menuShortcut}: Menu)\x1b[0m`
: `\x1b[42m Bash \x1b[0m \x1b[90m(${toggleShortcut}: Claude | ${menuShortcut}: Menu)\x1b[0m`;

// Display in status line at top of terminal
stdout.write(`\x1b[s\x1b[1;1H${indicator}\x1b[u`);
},
[stdout],
);

// Mode switching function
const toggleMode = useCallback(() => {
const newMode = currentMode === 'claude' ? 'bash' : 'claude';

// Update mode state
setCurrentMode(newMode);
session.currentMode = newMode;

// Clear screen for clean switch
stdout.write('\x1B[2J\x1B[H');

// Show current terminal content based on mode
if (newMode === 'bash') {
// Display bash history
for (const buffer of session.bashHistory) {
stdout.write(buffer);
}
} else {
// Display claude history
for (const buffer of session.outputHistory) {
stdout.write(buffer);
}
}

// Display mode indicator
displayModeIndicator(newMode);
}, [currentMode, session, stdout, displayModeIndicator]);

useEffect(() => {
if (!stdout) return;
Expand Down Expand Up @@ -50,12 +97,44 @@ const Session: React.FC<SessionProps> = ({
}
};

// Listen for restore event first
// Handle bash session restoration
const handleBashSessionRestore = (restoredSession: SessionType) => {
if (restoredSession.id === session.id) {
// Replay all bash buffered output, using the same robust logic as Claude
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'))) {
// Skip this buffer or remove the clear sequence
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);
}
}
}
};

// Listen for restore events first
sessionManager.on('sessionRestore', handleSessionRestore);
sessionManager.on('bashSessionRestore', handleBashSessionRestore);

// Mark session as active (this will trigger the restore event)
sessionManager.setSessionActive(session.worktreePath, true);

// Display initial mode indicator
setTimeout(() => {
displayModeIndicator(currentMode);
}, 100);

// Immediately resize the PTY and terminal to current dimensions
// This fixes rendering issues when terminal width changed while in menu
// https://github.com/kbwo/ccmanager/issues/2
Expand All @@ -75,8 +154,26 @@ const Session: React.FC<SessionProps> = ({

// Listen for session data events
const handleSessionData = (activeSession: SessionType, data: string) => {
// Only handle data for our session
if (activeSession.id === session.id && !isExiting) {
// Only handle data for our session and if in Claude mode
if (
activeSession.id === session.id &&
!isExiting &&
session.currentMode === 'claude'
) {
stdout.write(data);
}
};

const handleBashSessionData = (
activeSession: SessionType,
data: string,
) => {
// Only handle data for our session and if in bash mode
if (
activeSession.id === session.id &&
!isExiting &&
session.currentMode === 'bash'
) {
stdout.write(data);
}
};
Expand All @@ -89,16 +186,30 @@ const Session: React.FC<SessionProps> = ({
};

sessionManager.on('sessionData', handleSessionData);
sessionManager.on('bashSessionData', handleBashSessionData);
sessionManager.on('sessionExit', handleSessionExit);

// Handle terminal resize
const handleResize = () => {
const cols = process.stdout.columns || 80;
const rows = process.stdout.rows || 24;
session.process.resize(cols, rows);
// Also resize the virtual terminal
if (session.terminal) {
session.terminal.resize(cols, rows);

// Resize Claude PTY and virtual terminal
try {
session.process.resize(cols, rows);
if (session.terminal) {
session.terminal.resize(cols, rows);
}
} catch {
// Process might have exited
}

// Resize bash PTY (always exists)
try {
session.bashProcess.resize(cols, rows);
session.bashTerminal.resize(cols, rows);
} catch {
// Bash process might have exited
}
};

Expand All @@ -119,12 +230,13 @@ const Session: React.FC<SessionProps> = ({
const handleStdinData = (data: string) => {
if (isExiting) return;

// Check for return to menu shortcut
const returnToMenuShortcut = shortcutManager.getShortcuts().returnToMenu;
const shortcutCode =
shortcutManager.getShortcutCode(returnToMenuShortcut);
const shortcuts = shortcutManager.getShortcuts();

if (shortcutCode && data === shortcutCode) {
// Check for return to menu shortcut
const returnToMenuCode = shortcutManager.getShortcutCode(
shortcuts.returnToMenu,
);
if (returnToMenuCode && data === returnToMenuCode) {
// Disable focus reporting mode before returning to menu
if (stdout) {
stdout.write('\x1b[?1004l');
Expand All @@ -137,8 +249,22 @@ const Session: React.FC<SessionProps> = ({
return;
}

// Pass all other input directly to the PTY
session.process.write(data);
// Check for mode toggle shortcut
const toggleModeCode = shortcutManager.getShortcutCode(
shortcuts.toggleMode,
);
if (toggleModeCode && data === toggleModeCode) {
toggleMode();
return;
}

// Route input to appropriate PTY based on current mode
if (currentMode === 'claude') {
session.process.write(data);
} else {
// Bash mode - write to bash PTY
session.bashProcess.write(data);
}
};

stdin.on('data', handleStdinData);
Expand Down Expand Up @@ -167,11 +293,22 @@ const Session: React.FC<SessionProps> = ({

// Remove event listeners
sessionManager.off('sessionRestore', handleSessionRestore);
sessionManager.off('bashSessionRestore', handleBashSessionRestore);
sessionManager.off('sessionData', handleSessionData);
sessionManager.off('bashSessionData', handleBashSessionData);
sessionManager.off('sessionExit', handleSessionExit);
stdout.off('resize', handleResize);
};
}, [session, sessionManager, stdout, onReturnToMenu, isExiting]);
}, [
session,
sessionManager,
stdout,
onReturnToMenu,
isExiting,
displayModeIndicator,
toggleMode,
currentMode,
]);

// Return null to render nothing (PTY output goes directly to stdout)
return null;
Expand Down
7 changes: 6 additions & 1 deletion src/services/configurationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ export class ConfigurationManager {
}

getShortcuts(): ShortcutConfig {
return this.config.shortcuts || DEFAULT_SHORTCUTS;
const config = this.config.shortcuts || ({} as Partial<ShortcutConfig>);
return {
returnToMenu: config.returnToMenu || DEFAULT_SHORTCUTS.returnToMenu,
cancel: config.cancel || DEFAULT_SHORTCUTS.cancel,
toggleMode: config.toggleMode || DEFAULT_SHORTCUTS.toggleMode,
};
}

setShortcuts(shortcuts: ShortcutConfig): void {
Expand Down
Loading