Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,4 @@ worktrees/

.env
.cursor
.codex/config.toml
33 changes: 32 additions & 1 deletion src/renderer/components/MultiAgentTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -473,8 +475,26 @@ const MultiAgentTask: React.FC<Props> = ({

// Ref to control terminal focus and viewport scrolling imperatively.
const activeTerminalRef = useRef<TerminalPaneHandle>(null);
const activeTerminalContainerRef = useRef<HTMLDivElement | null>(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(() => {
Expand Down Expand Up @@ -637,7 +657,8 @@ const MultiAgentTask: React.FC<Props> = ({
onWheelCapture={handleTerminalViewportWheelForwarding}
>
<div
className={`mx-auto h-full max-w-4xl overflow-hidden rounded-md ${
ref={isActive ? activeTerminalContainerRef : undefined}
className={`relative mx-auto h-full max-w-4xl overflow-hidden rounded-md ${
v.agent === 'mistral'
? isDark
? 'bg-[#202938]'
Expand All @@ -647,6 +668,16 @@ const MultiAgentTask: React.FC<Props> = ({
: 'bg-white'
}`}
>
<TerminalSearchOverlay
isOpen={isActive && isSearchOpen}
fullWidth
searchQuery={searchQuery}
searchStatus={searchStatus}
searchInputRef={searchInputRef}
onQueryChange={handleSearchQueryChange}
onStep={stepSearch}
onClose={closeSearch}
/>
<TerminalPane
ref={isActive ? activeTerminalRef : undefined}
id={`${v.worktreeId}-main`}
Expand Down
114 changes: 114 additions & 0 deletions src/test/renderer/useTerminalSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { useTerminalSearch } from '../../renderer/hooks/useTerminalSearch';

const { mockGetSession } = vi.hoisted(() => ({
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<HTMLDivElement | null>(null);
const {
isSearchOpen,
searchQuery,
searchStatus,
searchInputRef,
closeSearch,
handleSearchQueryChange,
} = useTerminalSearch({
terminalId,
containerRef,
enabled,
});

return (
<div>
<button type="button" data-testid="outside-focus">
Outside
</button>
<div ref={containerRef} data-testid="terminal-container">
<button type="button" data-testid="terminal-focus">
Terminal
</button>
{isSearchOpen ? (
<input
ref={searchInputRef}
data-testid="search-input"
value={searchQuery}
onChange={(event) => handleSearchQueryChange(event.target.value)}
/>
) : null}
<button type="button" data-testid="close-search" onClick={closeSearch}>
Close
</button>
<div data-testid="search-status">
{searchStatus.currentIndex}/{searchStatus.total}
</div>
</div>
</div>
);
}

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(<TestHarness />);

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(<TestHarness />);

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(<TestHarness />);

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');
});
});
Loading