feat(ui): add terminal open mode setting (new tab vs split)

Add a setting to choose how "Open in Terminal" behaves:
- New Tab: Creates a new tab named after the branch (default)
- Split: Adds to current tab as a split view

Changes:
- Add openTerminalMode setting to terminal state ('newTab' | 'split')
- Update terminal-view to respect the setting
- Add UI in Terminal Settings to toggle the behavior
- Rename pendingTerminalCwd to pendingTerminal with branch name

The new tab mode names tabs after the branch for easy identification.
The split mode is useful for comparing terminals side by side.
This commit is contained in:
Stefan de Vogelaere
2026-01-17 20:22:54 +01:00
parent 03103fd1bb
commit ce4b9b6a14
4 changed files with 94 additions and 40 deletions

View File

@@ -42,7 +42,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const navigate = useNavigate();
const setPendingTerminalCwd = useAppStore((state) => state.setPendingTerminalCwd);
const setPendingTerminal = useAppStore((state) => state.setPendingTerminal);
const handleSwitchBranch = useCallback(
async (worktree: WorktreeInfo, branchName: string) => {
@@ -149,13 +149,13 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
const handleOpenInTerminal = useCallback(
(worktree: WorktreeInfo) => {
// Set the pending terminal cwd to the worktree path
setPendingTerminalCwd(worktree.path);
// Set the pending terminal with cwd and branch name
setPendingTerminal({ cwd: worktree.path, branchName: worktree.branch });
// Navigate to the terminal page
navigate({ to: '/terminal' });
logger.info('Opening terminal for worktree:', worktree.path);
logger.info('Opening terminal for worktree:', worktree.path, 'branch:', worktree.branch);
},
[navigate, setPendingTerminalCwd]
[navigate, setPendingTerminal]
);
return {

View File

@@ -25,6 +25,7 @@ export function TerminalSection() {
setTerminalScrollbackLines,
setTerminalLineHeight,
setTerminalDefaultFontSize,
setOpenTerminalMode,
} = useAppStore();
const {
@@ -34,6 +35,7 @@ export function TerminalSection() {
scrollbackLines,
lineHeight,
defaultFontSize,
openTerminalMode,
} = terminalState;
return (
@@ -165,6 +167,26 @@ export function TerminalSection() {
/>
</div>
{/* Open in Terminal Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Open in Terminal Mode</Label>
<p className="text-xs text-muted-foreground">
How to open terminals from the "Open in Terminal" action in the worktree menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => setOpenTerminalMode(value)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">New Tab (named after branch)</SelectItem>
<SelectItem value="split">Split Current Tab</SelectItem>
</SelectContent>
</Select>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">

View File

@@ -244,7 +244,7 @@ export function TerminalView() {
setTerminalScrollbackLines,
setTerminalScreenReaderMode,
updateTerminalPanelSizes,
setPendingTerminalCwd,
setPendingTerminal,
} = useAppStore();
const [status, setStatus] = useState<TerminalStatus | null>(null);
@@ -538,23 +538,24 @@ export function TerminalView() {
}
}, [terminalState.isUnlocked, fetchServerSettings]);
// Handle pending terminal cwd (from "open in terminal" action on worktree menu)
// When pendingTerminalCwd is set and we're ready, create a terminal with that cwd
const pendingTerminalCwdRef = useRef<string | null>(null);
// Handle pending terminal (from "open in terminal" action on worktree menu)
// When pendingTerminal is set and we're ready, create a terminal based on openTerminalMode setting
const pendingTerminalRef = useRef<string | null>(null);
const pendingTerminalCreatedRef = useRef<boolean>(false);
useEffect(() => {
const pendingCwd = terminalState.pendingTerminalCwd;
const pending = terminalState.pendingTerminal;
const openMode = terminalState.openTerminalMode;
// Skip if no pending cwd
if (!pendingCwd) {
// Reset the created ref when there's no pending cwd
// Skip if no pending terminal
if (!pending) {
// Reset the created ref when there's no pending terminal
pendingTerminalCreatedRef.current = false;
pendingTerminalCwdRef.current = null;
pendingTerminalRef.current = null;
return;
}
// Skip if we already created a terminal for this exact cwd
if (pendingCwd === pendingTerminalCwdRef.current && pendingTerminalCreatedRef.current) {
if (pending.cwd === pendingTerminalRef.current && pendingTerminalCreatedRef.current) {
return;
}
@@ -571,13 +572,12 @@ export function TerminalView() {
}
// Track that we're processing this cwd
pendingTerminalCwdRef.current = pendingCwd;
pendingTerminalRef.current = pending.cwd;
// Create a terminal with the pending cwd
logger.info('Creating terminal from pending cwd:', pendingCwd);
logger.info('Creating terminal from pending:', pending, 'mode:', openMode);
// Create terminal with the specified cwd
const createTerminalWithCwd = async () => {
const createTerminalFromPending = async () => {
try {
const headers: Record<string, string> = {};
const authToken = useAppStore.getState().terminalState.authToken;
@@ -587,46 +587,66 @@ export function TerminalView() {
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: { cwd: pendingCwd, cols: 80, rows: 24 },
body: { cwd: pending.cwd, cols: 80, rows: 24 },
});
const data = await response.json();
if (data.success) {
// Mark as successfully created
pendingTerminalCreatedRef.current = true;
addTerminalToLayout(data.data.id);
if (openMode === 'newTab') {
// Create a new tab named after the branch
const newTabId = addTerminalTab(pending.branchName);
// Set the tab's layout to the new terminal
useAppStore
.getState()
.setTerminalTabLayout(
newTabId,
{ type: 'terminal', sessionId: data.data.id, size: 100 },
data.data.id
);
toast.success(`Opened terminal for ${pending.branchName}`);
} else {
// Split mode: add to current tab layout
addTerminalToLayout(data.data.id);
toast.success(`Opened terminal in ${pending.cwd.split('/').pop() || pending.cwd}`);
}
// Mark this session as new for running initial command
if (defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
}
fetchServerSettings();
toast.success(`Opened terminal in ${pendingCwd.split('/').pop() || pendingCwd}`);
// Clear the pending cwd after successful creation
setPendingTerminalCwd(null);
// Clear the pending terminal after successful creation
setPendingTerminal(null);
} else {
logger.error('Failed to create session from pending cwd:', data.error);
logger.error('Failed to create session from pending terminal:', data.error);
toast.error('Failed to open terminal', {
description: data.error || 'Unknown error',
});
// Clear pending cwd on failure to prevent infinite retries
setPendingTerminalCwd(null);
// Clear pending terminal on failure to prevent infinite retries
setPendingTerminal(null);
}
} catch (err) {
logger.error('Create session error from pending cwd:', err);
logger.error('Create session error from pending terminal:', err);
toast.error('Failed to open terminal');
// Clear pending cwd on error to prevent infinite retries
setPendingTerminalCwd(null);
// Clear pending terminal on error to prevent infinite retries
setPendingTerminal(null);
}
};
createTerminalWithCwd();
createTerminalFromPending();
}, [
terminalState.pendingTerminalCwd,
terminalState.pendingTerminal,
terminalState.openTerminalMode,
terminalState.isUnlocked,
loading,
status?.enabled,
status?.passwordRequired,
setPendingTerminalCwd,
setPendingTerminal,
addTerminalTab,
addTerminalToLayout,
defaultRunScript,
fetchServerSettings,

View File

@@ -531,7 +531,8 @@ export interface TerminalState {
lineHeight: number; // Line height multiplier for terminal text
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
pendingTerminalCwd: string | null; // Pending cwd to use when creating next terminal (from "open in terminal" action)
pendingTerminal: { cwd: string; branchName: string } | null; // Pending terminal to create (from "open in terminal" action)
openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
}
// Persisted terminal layout - now includes sessionIds for reconnection
@@ -1230,7 +1231,8 @@ export interface AppActions {
setTerminalLineHeight: (lineHeight: number) => void;
setTerminalMaxSessions: (maxSessions: number) => void;
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
setPendingTerminalCwd: (cwd: string | null) => void;
setPendingTerminal: (pending: { cwd: string; branchName: string } | null) => void;
setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
addTerminalTab: (name?: string) => string;
removeTerminalTab: (tabId: string) => void;
setActiveTerminalTab: (tabId: string) => void;
@@ -1447,7 +1449,8 @@ const initialState: AppState = {
lineHeight: 1.0,
maxSessions: 100,
lastActiveProjectPath: null,
pendingTerminalCwd: null,
pendingTerminal: null,
openTerminalMode: 'newTab',
},
terminalLayoutByProject: {},
specCreatingForProject: null,
@@ -2896,9 +2899,11 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
maxSessions: current.maxSessions,
// Preserve lastActiveProjectPath - it will be updated separately when needed
lastActiveProjectPath: current.lastActiveProjectPath,
// Preserve pendingTerminalCwd - this is set by "open in terminal" action and should
// Preserve pendingTerminal - this is set by "open in terminal" action and should
// survive the clearTerminalState() call that happens during project switching
pendingTerminalCwd: current.pendingTerminalCwd,
pendingTerminal: current.pendingTerminal,
// Preserve openTerminalMode - user preference
openTerminalMode: current.openTerminalMode,
},
});
},
@@ -2990,10 +2995,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
});
},
setPendingTerminalCwd: (cwd) => {
setPendingTerminal: (pending) => {
const current = get().terminalState;
set({
terminalState: { ...current, pendingTerminalCwd: cwd },
terminalState: { ...current, pendingTerminal: pending },
});
},
setOpenTerminalMode: (mode) => {
const current = get().terminalState;
set({
terminalState: { ...current, openTerminalMode: mode },
});
},