diff --git a/.gitignore b/.gitignore index 8d9e1a2ad5..b5799a6e94 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,4 @@ worktrees/ .env .cursor +.codex/config.toml diff --git a/src/renderer/components/MultiAgentTask.tsx b/src/renderer/components/MultiAgentTask.tsx index a4d9372fb9..b65f4bd8cd 100644 --- a/src/renderer/components/MultiAgentTask.tsx +++ b/src/renderer/components/MultiAgentTask.tsx @@ -18,6 +18,7 @@ import { CornerDownLeft } from 'lucide-react'; import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'; import { useAutoScrollOnTaskSwitch } from '@/hooks/useAutoScrollOnTaskSwitch'; import { useTerminalViewportWheelForwarding } from '@/hooks/useTerminalViewportWheelForwarding'; +import { useTerminalSearch } from '@/hooks/useTerminalSearch'; import { getTaskEnvVars } from '@shared/task/envVars'; import { rpc } from '@/lib/rpc'; import { useWorkspaceConnection } from '../hooks/useWorkspaceConnection'; @@ -28,6 +29,7 @@ import { formatCommentsForAgent } from '@/lib/formatCommentsForAgent'; import { buildPromptInjectionPayload } from '@/lib/terminalInjection'; import { TaskScopeProvider } from './TaskScopeContext'; import TaskContextBadges from './TaskContextBadges'; +import { TerminalSearchOverlay } from './TerminalSearchOverlay'; interface Props { task: Task; @@ -473,8 +475,26 @@ const MultiAgentTask: React.FC = ({ // Ref to control terminal focus and viewport scrolling imperatively. const activeTerminalRef = useRef(null); + const activeTerminalContainerRef = useRef(null); const handleTerminalViewportWheelForwarding = useTerminalViewportWheelForwarding(activeTerminalRef); + const activeTerminalId = variants[activeTabIndex]?.worktreeId + ? `${variants[activeTabIndex].worktreeId}-main` + : null; + const { + isSearchOpen, + searchQuery, + searchStatus, + searchInputRef, + closeSearch, + handleSearchQueryChange, + stepSearch, + } = useTerminalSearch({ + terminalId: activeTerminalId, + containerRef: activeTerminalContainerRef, + enabled: Boolean(activeTerminalId), + onCloseFocus: () => activeTerminalRef.current?.focus(), + }); // Auto-scroll and focus when task or active tab changes useEffect(() => { @@ -637,7 +657,8 @@ const MultiAgentTask: React.FC = ({ onWheelCapture={handleTerminalViewportWheelForwarding} >
= ({ : 'bg-white' }`} > + ({ + mockGetSession: vi.fn(), +})); + +vi.mock('../../renderer/terminal/SessionRegistry', () => ({ + terminalSessionRegistry: { + getSession: mockGetSession, + }, +})); + +function TestHarness({ + enabled = true, + terminalId = 'terminal-1', +}: { + enabled?: boolean; + terminalId?: string | null; +}) { + const containerRef = useRef(null); + const { + isSearchOpen, + searchQuery, + searchStatus, + searchInputRef, + closeSearch, + handleSearchQueryChange, + } = useTerminalSearch({ + terminalId, + containerRef, + enabled, + }); + + return ( +
+ +
+ + {isSearchOpen ? ( + handleSearchQueryChange(event.target.value)} + /> + ) : null} + +
+ {searchStatus.currentIndex}/{searchStatus.total} +
+
+
+ ); +} + +describe('useTerminalSearch', () => { + beforeEach(() => { + mockGetSession.mockReset(); + }); + + it('opens search when Ctrl+F is pressed while focus is inside the terminal container', () => { + mockGetSession.mockReturnValue({ + search: vi.fn(() => ({ found: false, currentIndex: 0, total: 0 })), + clearSearch: vi.fn(), + }); + + render(); + + screen.getByTestId('terminal-focus').focus(); + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }); + + expect(screen.getByTestId('search-input')).toBeInTheDocument(); + }); + + it('ignores Ctrl+F when focus is outside the terminal container', () => { + mockGetSession.mockReturnValue({ + search: vi.fn(() => ({ found: false, currentIndex: 0, total: 0 })), + clearSearch: vi.fn(), + }); + + render(); + + screen.getByTestId('outside-focus').focus(); + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }); + + expect(screen.queryByTestId('search-input')).not.toBeInTheDocument(); + }); + + it('runs a search against the active terminal session when the query changes', () => { + const search = vi.fn(() => ({ found: true, currentIndex: 1, total: 3 })); + mockGetSession.mockReturnValue({ + search, + clearSearch: vi.fn(), + }); + + render(); + + screen.getByTestId('terminal-focus').focus(); + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }); + fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'error' } }); + + expect(search).toHaveBeenCalledWith('error', { direction: 'next', reset: true }); + expect(screen.getByTestId('search-status')).toHaveTextContent('1/3'); + }); +});