diff --git a/src/__tests__/integration/provider-integration.test.ts b/src/__tests__/integration/provider-integration.test.ts index 25f55f30..b285fb0f 100644 --- a/src/__tests__/integration/provider-integration.test.ts +++ b/src/__tests__/integration/provider-integration.test.ts @@ -474,12 +474,14 @@ const PROVIDERS: ProviderConfig[] = [ */ buildInitialArgs: (prompt: string, options?: { images?: string[] }) => { // OpenCode arg order from process.ts IPC handler: - // 1. base args: [] (empty for OpenCode) - // 2. jsonOutputArgs: ['--format', 'json'] - // 3. Optional: --model provider/model (from OPENCODE_MODEL env var) - // 4. promptArgs: ['-p', prompt] (YOLO mode with auto-approve) + // 1. batchModePrefix: ['run'] (subcommand for batch mode) + // 2. base args: [] (empty for OpenCode) + // 3. jsonOutputArgs: ['--format', 'json'] + // 4. Optional: --model provider/model (from OPENCODE_MODEL env var) + // 5. prompt as positional argument (noPromptSeparator: true) const args = [ + 'run', // batchModePrefix '--format', 'json', ]; @@ -501,11 +503,12 @@ const PROVIDERS: ProviderConfig[] = [ throw new Error('OpenCode should not support stream-json input - capability misconfigured'); } - // OpenCode uses -p flag for prompt (enables YOLO mode with auto-approve) - return [...args, '-p', prompt]; + // OpenCode uses 'run' subcommand for batch mode with prompt as positional arg + return [...args, prompt]; }, buildResumeArgs: (sessionId: string, prompt: string) => { const args = [ + 'run', // batchModePrefix '--format', 'json', ]; @@ -515,8 +518,8 @@ const PROVIDERS: ProviderConfig[] = [ args.push('--model', model); } - // -p flag for YOLO mode, --session for resume - args.push('--session', sessionId, '-p', prompt); + // --session for resume, prompt as positional arg + args.push('--session', sessionId, prompt); return args; }, parseSessionId: (output: string) => { diff --git a/src/__tests__/main/agent-detector.test.ts b/src/__tests__/main/agent-detector.test.ts index b79e923f..0f09e259 100644 --- a/src/__tests__/main/agent-detector.test.ts +++ b/src/__tests__/main/agent-detector.test.ts @@ -1115,10 +1115,10 @@ describe('agent-detector', () => { }); }); - describe('OpenCode YOLO mode configuration', () => { - it('should use promptArgs with -p flag for YOLO mode (not batchModePrefix with run)', async () => { - // This test ensures we never regress to using 'run' subcommand which does NOT auto-approve permissions - // The -p flag is required for YOLO mode (auto-approve all permissions) + describe('OpenCode batch mode configuration', () => { + it('should use batchModePrefix with run subcommand for batch mode (YOLO mode)', async () => { + // OpenCode uses 'run' subcommand for batch mode which auto-approves all permissions + // The -p flag is for TUI mode only and doesn't work with --format json mockExecFileNoThrow.mockImplementation(async (cmd, args) => { if (args[0] === 'opencode') { return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 }; @@ -1131,19 +1131,14 @@ describe('agent-detector', () => { expect(opencode).toBeDefined(); - // CRITICAL: OpenCode must NOT use batchModePrefix with 'run' - it doesn't auto-approve permissions - expect(opencode?.batchModePrefix).toBeUndefined(); + // OpenCode uses batchModePrefix: ['run'] for batch mode + expect(opencode?.batchModePrefix).toEqual(['run']); - // CRITICAL: OpenCode MUST use promptArgs with -p flag for YOLO mode - expect(opencode?.promptArgs).toBeDefined(); - expect(typeof opencode?.promptArgs).toBe('function'); - - // Verify promptArgs generates correct -p flag - const promptArgsResult = opencode?.promptArgs?.('test prompt'); - expect(promptArgsResult).toEqual(['-p', 'test prompt']); + // promptArgs should NOT be defined - prompt is passed as positional arg + expect(opencode?.promptArgs).toBeUndefined(); }); - it('should NOT have noPromptSeparator since promptArgs handles prompt formatting', async () => { + it('should have noPromptSeparator true since prompt is positional arg', async () => { mockExecFileNoThrow.mockImplementation(async (cmd, args) => { if (args[0] === 'opencode') { return { stdout: '/usr/bin/opencode\n', stderr: '', exitCode: 0 }; @@ -1154,9 +1149,9 @@ describe('agent-detector', () => { const agents = await detector.detectAgents(); const opencode = agents.find(a => a.id === 'opencode'); - // When using promptArgs, noPromptSeparator should be undefined or not needed - // since the prompt is passed via the -p flag, not as a positional argument - expect(opencode?.noPromptSeparator).toBeUndefined(); + // OpenCode uses noPromptSeparator: true since prompt is positional + // (yargs handles positional args without needing '--' separator) + expect(opencode?.noPromptSeparator).toBe(true); }); it('should have correct jsonOutputArgs for JSON streaming', async () => { diff --git a/src/__tests__/renderer/components/AutoRun.test.tsx b/src/__tests__/renderer/components/AutoRun.test.tsx index 7f961263..801d2499 100644 --- a/src/__tests__/renderer/components/AutoRun.test.tsx +++ b/src/__tests__/renderer/components/AutoRun.test.tsx @@ -1638,14 +1638,14 @@ describe('Lightbox Functionality', () => { }); // Verify copy button is present - const copyButton = screen.getByTitle('Copy image to clipboard'); + const copyButton = screen.getByTitle('Copy image to clipboard (⌘C)'); expect(copyButton).toBeInTheDocument(); // Click it - the actual clipboard copy may fail but we're testing the button renders/clicks fireEvent.click(copyButton); // The button should still be there - expect(screen.getByTitle('Copy image to clipboard')).toBeInTheDocument(); + expect(screen.getByTitle('Copy image to clipboard (⌘C)')).toBeInTheDocument(); }); it('closes lightbox when clicking overlay background', async () => { diff --git a/src/main/agent-detector.ts b/src/main/agent-detector.ts index cb5b65f1..fa2cdd4e 100644 --- a/src/main/agent-detector.ts +++ b/src/main/agent-detector.ts @@ -120,17 +120,17 @@ const AGENT_DEFINITIONS: Omit] [--agent plan] - // The -p flag runs in non-interactive mode and auto-approves all permissions (YOLO mode). - // Note: The 'run' subcommand does NOT auto-approve - only -p does. + // Batch mode: opencode run --format json [--model provider/model] [--session ] [--agent plan] -- "prompt" + // The 'run' subcommand auto-approves all permissions (YOLO mode is implicit). + batchModePrefix: ['run'], // OpenCode uses 'run' subcommand for batch mode jsonOutputArgs: ['--format', 'json'], // JSON output format resumeArgs: (sessionId: string) => ['--session', sessionId], // Resume with session ID readOnlyArgs: ['--agent', 'plan'], // Read-only/plan mode modelArgs: (modelId: string) => ['--model', modelId], // Model selection (e.g., 'ollama/qwen3:8b') - promptArgs: (prompt: string) => ['-p', prompt], // -p flag enables non-interactive mode with auto-approve (YOLO mode) - imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode -p "prompt" -f /path/to/image.png + imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode run -f /path/to/image.png -- "prompt" + noPromptSeparator: true, // OpenCode doesn't need '--' before prompt - yargs handles positional args // Agent-specific configuration options shown in UI configOptions: [ { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 329853c1..8d5ee08c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4070,6 +4070,85 @@ You are taking over this conversation. Based on the context above, provide a bri })); }, [defaultSaveToHistory, defaultShowThinking]); + /** + * Close all tabs in the active session. + * Creates a fresh new tab after closing all existing ones. + */ + const handleCloseAllTabs = useCallback(() => { + setSessions(prev => prev.map(s => { + if (s.id !== activeSessionIdRef.current) return s; + // Close all tabs by iterating through them + let updatedSession = s; + const tabIds = s.aiTabs.map(t => t.id); + for (const tabId of tabIds) { + const tab = updatedSession.aiTabs.find(t => t.id === tabId); + const result = closeTab(updatedSession, tabId, false, { skipHistory: tab ? hasActiveWizard(tab) : false }); + if (result) { + updatedSession = result.session; + } + } + return updatedSession; + })); + }, []); + + /** + * Close all tabs except the active tab. + */ + const handleCloseOtherTabs = useCallback(() => { + setSessions(prev => prev.map(s => { + if (s.id !== activeSessionIdRef.current) return s; + let updatedSession = s; + const tabsToClose = s.aiTabs.filter(t => t.id !== s.activeTabId); + for (const tab of tabsToClose) { + const result = closeTab(updatedSession, tab.id, false, { skipHistory: hasActiveWizard(tab) }); + if (result) { + updatedSession = result.session; + } + } + return updatedSession; + })); + }, []); + + /** + * Close all tabs to the left of the active tab. + */ + const handleCloseTabsLeft = useCallback(() => { + setSessions(prev => prev.map(s => { + if (s.id !== activeSessionIdRef.current) return s; + const activeIndex = s.aiTabs.findIndex(t => t.id === s.activeTabId); + if (activeIndex <= 0) return s; // Nothing to close + let updatedSession = s; + const tabsToClose = s.aiTabs.slice(0, activeIndex); + for (const tab of tabsToClose) { + const result = closeTab(updatedSession, tab.id, false, { skipHistory: hasActiveWizard(tab) }); + if (result) { + updatedSession = result.session; + } + } + return updatedSession; + })); + }, []); + + /** + * Close all tabs to the right of the active tab. + */ + const handleCloseTabsRight = useCallback(() => { + setSessions(prev => prev.map(s => { + if (s.id !== activeSessionIdRef.current) return s; + const activeIndex = s.aiTabs.findIndex(t => t.id === s.activeTabId); + if (activeIndex < 0 || activeIndex >= s.aiTabs.length - 1) return s; // Nothing to close + let updatedSession = s; + const tabsToClose = s.aiTabs.slice(activeIndex + 1); + for (const tab of tabsToClose) { + const result = closeTab(updatedSession, tab.id, false, { skipHistory: hasActiveWizard(tab) }); + if (result) { + updatedSession = result.session; + } + } + return updatedSession; + })); + }, []); + const handleRemoveQueuedItem = useCallback((itemId: string) => { setSessions(prev => prev.map(s => { if (s.id !== activeSessionIdRef.current) return s; @@ -9068,7 +9147,10 @@ You are taking over this conversation. Based on the context above, provide a bri setEditAgentSession, setEditAgentModalOpen, // Auto Run state for keyboard handler - activeBatchRunState + activeBatchRunState, + + // Bulk tab close handlers + handleCloseAllTabs, handleCloseOtherTabs, handleCloseTabsLeft, handleCloseTabsRight }; @@ -9461,6 +9543,10 @@ You are taking over this conversation. Based on the context above, provide a bri onQuickActionsToggleReadOnlyMode={handleQuickActionsToggleReadOnlyMode} onQuickActionsToggleTabShowThinking={handleQuickActionsToggleTabShowThinking} onQuickActionsOpenTabSwitcher={handleQuickActionsOpenTabSwitcher} + onCloseAllTabs={handleCloseAllTabs} + onCloseOtherTabs={handleCloseOtherTabs} + onCloseTabsLeft={handleCloseTabsLeft} + onCloseTabsRight={handleCloseTabsRight} setPlaygroundOpen={setPlaygroundOpen} onQuickActionsRefreshGitFileState={handleQuickActionsRefreshGitFileState} onQuickActionsDebugReleaseQueuedItem={handleQuickActionsDebugReleaseQueuedItem} @@ -10279,6 +10365,10 @@ You are taking over this conversation. Based on the context above, provide a bri showUnreadOnly={showUnreadOnly} onToggleUnreadFilter={toggleUnreadFilter} onOpenTabSearch={() => setTabSwitcherOpen(true)} + onCloseAllTabs={handleCloseAllTabs} + onCloseOtherTabs={handleCloseOtherTabs} + onCloseTabsLeft={handleCloseTabsLeft} + onCloseTabsRight={handleCloseTabsRight} onToggleTabSaveToHistory={() => { if (!activeSession) return; const activeTab = getActiveTab(activeSession); diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index fd86cee9..485005d3 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -757,6 +757,11 @@ export interface AppUtilityModalsProps { onToggleReadOnlyMode: () => void; onToggleTabShowThinking: () => void; onOpenTabSwitcher: () => void; + // Bulk tab close operations + onCloseAllTabs?: () => void; + onCloseOtherTabs?: () => void; + onCloseTabsLeft?: () => void; + onCloseTabsRight?: () => void; setPlaygroundOpen?: (open: boolean) => void; onRefreshGitFileState: () => Promise; onDebugReleaseQueuedItem: () => void; @@ -932,6 +937,11 @@ export function AppUtilityModals({ onToggleReadOnlyMode, onToggleTabShowThinking, onOpenTabSwitcher, + // Bulk tab close operations + onCloseAllTabs, + onCloseOtherTabs, + onCloseTabsLeft, + onCloseTabsRight, setPlaygroundOpen, onRefreshGitFileState, onDebugReleaseQueuedItem, @@ -1081,6 +1091,10 @@ export function AppUtilityModals({ onToggleReadOnlyMode={onToggleReadOnlyMode} onToggleTabShowThinking={onToggleTabShowThinking} onOpenTabSwitcher={onOpenTabSwitcher} + onCloseAllTabs={onCloseAllTabs} + onCloseOtherTabs={onCloseOtherTabs} + onCloseTabsLeft={onCloseTabsLeft} + onCloseTabsRight={onCloseTabsRight} setPlaygroundOpen={setPlaygroundOpen} onRefreshGitFileState={onRefreshGitFileState} onDebugReleaseQueuedItem={onDebugReleaseQueuedItem} @@ -1788,6 +1802,11 @@ export interface AppModalsProps { onQuickActionsToggleReadOnlyMode: () => void; onQuickActionsToggleTabShowThinking: () => void; onQuickActionsOpenTabSwitcher: () => void; + // Bulk tab close operations (for QuickActionsModal) + onCloseAllTabs?: () => void; + onCloseOtherTabs?: () => void; + onCloseTabsLeft?: () => void; + onCloseTabsRight?: () => void; setPlaygroundOpen?: (open: boolean) => void; onQuickActionsRefreshGitFileState: () => Promise; onQuickActionsDebugReleaseQueuedItem: () => void; @@ -2075,6 +2094,11 @@ export function AppModals(props: AppModalsProps) { onQuickActionsToggleReadOnlyMode, onQuickActionsToggleTabShowThinking, onQuickActionsOpenTabSwitcher, + // Bulk tab close operations + onCloseAllTabs, + onCloseOtherTabs, + onCloseTabsLeft, + onCloseTabsRight, setPlaygroundOpen, onQuickActionsRefreshGitFileState, onQuickActionsDebugReleaseQueuedItem, @@ -2373,6 +2397,10 @@ export function AppModals(props: AppModalsProps) { onToggleReadOnlyMode={onQuickActionsToggleReadOnlyMode} onToggleTabShowThinking={onQuickActionsToggleTabShowThinking} onOpenTabSwitcher={onQuickActionsOpenTabSwitcher} + onCloseAllTabs={onCloseAllTabs} + onCloseOtherTabs={onCloseOtherTabs} + onCloseTabsLeft={onCloseTabsLeft} + onCloseTabsRight={onCloseTabsRight} setPlaygroundOpen={setPlaygroundOpen} onRefreshGitFileState={onQuickActionsRefreshGitFileState} onDebugReleaseQueuedItem={onQuickActionsDebugReleaseQueuedItem} diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index fa0c2e9d..c393f0ed 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -155,6 +155,11 @@ interface MainPanelProps { showUnreadOnly?: boolean; onToggleUnreadFilter?: () => void; onOpenTabSearch?: () => void; + // Bulk tab close operations + onCloseAllTabs?: () => void; + onCloseOtherTabs?: () => void; + onCloseTabsLeft?: () => void; + onCloseTabsRight?: () => void; // Scroll position persistence onScrollPositionChange?: (scrollTop: number) => void; // Scroll bottom state change handler (for hasUnread logic) @@ -321,7 +326,7 @@ export const MainPanel = React.memo(forwardRef( const [configuredContextWindow, setConfiguredContextWindow] = useState(0); // Extract tab handlers from props - const { onTabSelect, onTabClose, onNewTab, onRequestTabRename, onTabReorder, onTabStar, onTabMarkUnread, showUnreadOnly, onToggleUnreadFilter, onOpenTabSearch } = props; + const { onTabSelect, onTabClose, onNewTab, onRequestTabRename, onTabReorder, onTabStar, onTabMarkUnread, showUnreadOnly, onToggleUnreadFilter, onOpenTabSearch, onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight } = props; // Get the active tab for header display // The header should show the active tab's data (UUID, name, cost, context), not session-level data @@ -1008,6 +1013,10 @@ export const MainPanel = React.memo(forwardRef( showUnreadOnly={showUnreadOnly} onToggleUnreadFilter={onToggleUnreadFilter} onOpenTabSearch={onOpenTabSearch} + onCloseAllTabs={onCloseAllTabs} + onCloseOtherTabs={onCloseOtherTabs} + onCloseTabsLeft={onCloseTabsLeft} + onCloseTabsRight={onCloseTabsRight} /> )} diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index f2a2cc73..d1e7bfbd 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -32,6 +32,14 @@ interface TabBarProps { showUnreadOnly?: boolean; onToggleUnreadFilter?: () => void; onOpenTabSearch?: () => void; + /** Handler to close all tabs */ + onCloseAllTabs?: () => void; + /** Handler to close all tabs except active */ + onCloseOtherTabs?: () => void; + /** Handler to close tabs to the left of active tab */ + onCloseTabsLeft?: () => void; + /** Handler to close tabs to the right of active tab */ + onCloseTabsRight?: () => void; } interface TabProps { @@ -74,6 +82,18 @@ interface TabProps { shortcutHint?: number | null; registerRef?: (el: HTMLDivElement | null) => void; hasDraft?: boolean; + /** Handler to close all tabs */ + onCloseAllTabs?: () => void; + /** Handler to close other tabs (all except this one) */ + onCloseOtherTabs?: () => void; + /** Handler to close tabs to the left of this tab */ + onCloseTabsLeft?: () => void; + /** Handler to close tabs to the right of this tab */ + onCloseTabsRight?: () => void; + /** Total number of tabs */ + totalTabs?: number; + /** Tab index in the full list (0-based) */ + tabIndex?: number; } /** @@ -149,7 +169,13 @@ function Tab({ isLastTab, shortcutHint, registerRef, - hasDraft + hasDraft, + onCloseAllTabs, + onCloseOtherTabs, + onCloseTabsLeft, + onCloseTabsRight, + totalTabs, + tabIndex }: TabProps) { const [isHovered, setIsHovered] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); @@ -290,6 +316,30 @@ function Tab({ setOverlayOpen(false); }; + const handleCloseTabClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClose(); + setOverlayOpen(false); + }; + + const handleCloseOtherTabsClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onCloseOtherTabs?.(); + setOverlayOpen(false); + }; + + const handleCloseTabsLeftClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onCloseTabsLeft?.(); + setOverlayOpen(false); + }; + + const handleCloseTabsRightClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onCloseTabsRight?.(); + setOverlayOpen(false); + }; + const displayName = getTabDisplayName(tab); // Browser-style tab: all tabs have borders, active tab "connects" to content @@ -626,6 +676,67 @@ function Tab({ Move to Last Position )} + + {/* Tab Close Actions Section - divider and close options */} +
+ + {/* Close Tab */} + + + {/* Close Other Tabs */} + {onCloseOtherTabs && ( + + )} + + {/* Close Tabs to Left */} + {onCloseTabsLeft && ( + + )} + + {/* Close Tabs to Right */} + {onCloseTabsRight && ( + + )}
, @@ -660,7 +771,11 @@ function TabBarInner({ ghCliAvailable, showUnreadOnly: showUnreadOnlyProp, onToggleUnreadFilter, - onOpenTabSearch + onOpenTabSearch, + onCloseAllTabs, + onCloseOtherTabs, + onCloseTabsLeft, + onCloseTabsRight }: TabBarProps) { const [draggingTabId, setDraggingTabId] = useState(null); const [dragOverTabId, setDragOverTabId] = useState(null); @@ -891,6 +1006,12 @@ function TabBarInner({ tabRefs.current.delete(tab.id); } }} + onCloseAllTabs={onCloseAllTabs} + onCloseOtherTabs={onCloseOtherTabs} + onCloseTabsLeft={onCloseTabsLeft} + onCloseTabsRight={onCloseTabsRight} + totalTabs={tabs.length} + tabIndex={originalIndex} /> );