From ce4b9b6a140af971c1b6c74aba9ca3bdc58c2ba1 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sat, 17 Jan 2026 20:22:54 +0100 Subject: [PATCH] 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. --- .../hooks/use-worktree-actions.ts | 10 +-- .../terminal/terminal-section.tsx | 22 ++++++ .../ui/src/components/views/terminal-view.tsx | 76 ++++++++++++------- apps/ui/src/store/app-store.ts | 26 +++++-- 4 files changed, 94 insertions(+), 40 deletions(-) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index b666dea9..24f3407e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -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 { diff --git a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx index f1cebb10..67e4cad1 100644 --- a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx +++ b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx @@ -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() { /> + {/* Open in Terminal Mode */} +
+ +

+ How to open terminals from the "Open in Terminal" action in the worktree menu +

+ +
+ {/* Screen Reader Mode */}
diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 26db2a62..0f7d65b0 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -244,7 +244,7 @@ export function TerminalView() { setTerminalScrollbackLines, setTerminalScreenReaderMode, updateTerminalPanelSizes, - setPendingTerminalCwd, + setPendingTerminal, } = useAppStore(); const [status, setStatus] = useState(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(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(null); const pendingTerminalCreatedRef = useRef(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 = {}; 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, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index bf1b2295..c67a0d6f 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -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()((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()((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 }, }); },