## CHANGES

- OpenCode CLI now uses `run` batch subcommand with positional prompts 🚀
- Dropped `-p` prompt flag for OpenCode JSON mode compatibility 🧩
- Added `noPromptSeparator` for OpenCode positional prompt handling 🧠
- Implemented “Close All Tabs” action for faster session cleanup 🧹
- Implemented “Close Other Tabs” to focus instantly on one tab 🎯
- Implemented “Close Tabs to Left” for browser-style tab management ⬅️
- Implemented “Close Tabs to Right” to prune clutter in one click ➡️
- Wired bulk tab-close handlers through App, Modals, MainPanel, TabBar 🔌
- Enhanced tab context menu with bulk close options and smart disabling 📋
- Updated tests to reflect OpenCode batch mode and UI copy tooltip 🧪
This commit is contained in:
Pedram Amini
2026-01-06 19:49:01 -06:00
parent 40f83cf772
commit 31da3fa8bd
8 changed files with 283 additions and 37 deletions

View File

@@ -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) => {

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -120,17 +120,17 @@ const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'
name: 'OpenCode',
binaryName: 'opencode',
command: 'opencode',
args: [], // Base args (none for OpenCode)
args: [], // Base args (none for OpenCode - batch mode uses 'run' subcommand)
// OpenCode CLI argument builders
// Batch mode uses -p flag: opencode -p "prompt" --format json [--model provider/model] [--session <id>] [--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 <id>] [--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: [
{

View File

@@ -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);

View File

@@ -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<void>;
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<void>;
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}

View File

@@ -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<MainPanelHandle, MainPanelProps>(
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<MainPanelHandle, MainPanelProps>(
showUnreadOnly={showUnreadOnly}
onToggleUnreadFilter={onToggleUnreadFilter}
onOpenTabSearch={onOpenTabSearch}
onCloseAllTabs={onCloseAllTabs}
onCloseOtherTabs={onCloseOtherTabs}
onCloseTabsLeft={onCloseTabsLeft}
onCloseTabsRight={onCloseTabsRight}
/>
)}

View File

@@ -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
</button>
)}
{/* Tab Close Actions Section - divider and close options */}
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
{/* Close Tab */}
<button
onClick={handleCloseTabClick}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
totalTabs === 1 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
}`}
style={{ color: theme.colors.textMain }}
disabled={totalTabs === 1}
>
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Close Tab
</button>
{/* Close Other Tabs */}
{onCloseOtherTabs && (
<button
onClick={handleCloseOtherTabsClick}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
totalTabs === 1 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
}`}
style={{ color: theme.colors.textMain }}
disabled={totalTabs === 1}
>
<X className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Close Other Tabs
</button>
)}
{/* Close Tabs to Left */}
{onCloseTabsLeft && (
<button
onClick={handleCloseTabsLeftClick}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
tabIndex === 0 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
}`}
style={{ color: theme.colors.textMain }}
disabled={tabIndex === 0}
>
<ChevronsLeft className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Close Tabs to Left
</button>
)}
{/* Close Tabs to Right */}
{onCloseTabsRight && (
<button
onClick={handleCloseTabsRightClick}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
tabIndex === (totalTabs ?? 1) - 1 ? 'opacity-40 cursor-default' : 'hover:bg-white/10'
}`}
style={{ color: theme.colors.textMain }}
disabled={tabIndex === (totalTabs ?? 1) - 1}
>
<ChevronsRight className="w-3.5 h-3.5" style={{ color: theme.colors.textDim }} />
Close Tabs to Right
</button>
)}
</div>
</div>
</div>,
@@ -660,7 +771,11 @@ function TabBarInner({
ghCliAvailable,
showUnreadOnly: showUnreadOnlyProp,
onToggleUnreadFilter,
onOpenTabSearch
onOpenTabSearch,
onCloseAllTabs,
onCloseOtherTabs,
onCloseTabsLeft,
onCloseTabsRight
}: TabBarProps) {
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [dragOverTabId, setDragOverTabId] = useState<string | null>(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}
/>
</React.Fragment>
);