feat(terminal): add Cmd/Ctrl+F search for terminal panes#1432
feat(terminal): add Cmd/Ctrl+F search for terminal panes#1432arnestrickmann merged 1 commit intomainfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds in-terminal text search (Cmd/Ctrl+F) to both the agent
Minor style issues: Confidence Score: 3/5
|
| Filename | Overview |
|---|---|
| src/renderer/terminal/terminalSearch.ts | New pure search logic for scanning xterm.js buffer rows; overall solid approach with wrapped-line handling, but uses query.length instead of normalizedQuery.length for the selection length which can mismatch in Unicode edge cases. |
| src/renderer/hooks/useTerminalSearch.ts | React hook wiring search state and keyboard shortcut; contains a double-execution bug where handleSearchQueryChange calls runTerminalSearch directly AND a useEffect re-runs it whenever searchQuery state changes, doubling buffer scans on every keystroke. |
| src/renderer/terminal/TerminalSessionManager.ts | Adds search() and clearSearch() methods; logic is correct but normalizedQuery = query is a misleading no-op alias — the actual case-folding is delegated to collectTerminalSearchMatches. |
| src/renderer/components/TerminalSearchOverlay.tsx | New floating search bar UI component; well-structured with proper accessibility labels, keyboard handling, and conditional rendering. No issues found. |
| src/renderer/components/ChatInterface.tsx | Wires useTerminalSearch and TerminalSearchOverlay into the agent terminal panel correctly; adds relative to the container div for overlay positioning. No issues found. |
| src/renderer/components/TaskTerminalPanel.tsx | Integrates search into the worktree/global terminal panel; uses hasActiveTerminal guard correctly to disable search during lifecycle transitions. No issues found. |
| src/test/renderer/terminalSearch.test.ts | Unit tests cover case-insensitive matching, wrapped-line resolution, and forward/backward cycling. Coverage is reasonable for the core logic, though edge cases like empty buffer and multi-segment spanning matches aren't tested. |
Sequence Diagram
sequenceDiagram
participant User
participant TerminalSearchOverlay
participant useTerminalSearch
participant TerminalSessionManager
participant terminalSearch
User->>useTerminalSearch: Cmd/Ctrl+F (keydown capture)
useTerminalSearch->>useTerminalSearch: openSearch() → setIsSearchOpen(true)
useTerminalSearch->>TerminalSearchOverlay: renders overlay, focuses input
User->>TerminalSearchOverlay: types query
TerminalSearchOverlay->>useTerminalSearch: onQueryChange(query)
useTerminalSearch->>useTerminalSearch: setSearchQuery(query)
useTerminalSearch->>TerminalSessionManager: session.search(query, {direction:'next', reset:true})
TerminalSessionManager->>terminalSearch: collectTerminalSearchMatches(buffer, query)
terminalSearch-->>TerminalSessionManager: TerminalSearchMatch[]
TerminalSessionManager->>TerminalSessionManager: getNextTerminalSearchIndex(...)
TerminalSessionManager->>TerminalSessionManager: terminal.select(col, row, length)
TerminalSessionManager->>TerminalSessionManager: terminal.scrollToLine(...)
TerminalSessionManager-->>useTerminalSearch: {found, currentIndex, total}
useTerminalSearch-->>TerminalSearchOverlay: searchStatus (match count)
User->>TerminalSearchOverlay: Enter / ChevronDown (next)
TerminalSearchOverlay->>useTerminalSearch: onStep('next')
useTerminalSearch->>TerminalSessionManager: session.search(query, {direction:'next', reset:false})
TerminalSessionManager-->>useTerminalSearch: updated match
useTerminalSearch-->>TerminalSearchOverlay: updated searchStatus
User->>TerminalSearchOverlay: Escape
TerminalSearchOverlay->>useTerminalSearch: onClose()
useTerminalSearch->>TerminalSessionManager: session.clearSearch()
useTerminalSearch->>useTerminalSearch: reset state, setIsSearchOpen(false)
useTerminalSearch->>useTerminalSearch: onCloseFocus() → terminal.focus()
Last reviewed commit: 2fefc0c
| const handleSearchQueryChange = useCallback( | ||
| (nextQuery: string) => { | ||
| setSearchQuery(nextQuery); | ||
| if (!nextQuery) { | ||
| clearSearchSelection(); | ||
| setSearchStatus(EMPTY_SEARCH_STATUS); | ||
| return; | ||
| } | ||
| runTerminalSearch(nextQuery, { direction: 'next', reset: true }); | ||
| }, | ||
| [clearSearchSelection, runTerminalSearch] | ||
| ); |
There was a problem hiding this comment.
Double buffer scan on every keystroke
handleSearchQueryChange calls runTerminalSearch directly on line 98, and then also calls setSearchQuery(nextQuery) on line 92. The useEffect at lines 133–138 has searchQuery in its dependency array, so it fires again after every state update — executing runTerminalSearch a second time with the same arguments and reset: true. For large terminal buffers this doubles every scan needlessly.
The simplest fix is to remove the direct call on line 98 and let the effect own all re-execution of the search:
| const handleSearchQueryChange = useCallback( | |
| (nextQuery: string) => { | |
| setSearchQuery(nextQuery); | |
| if (!nextQuery) { | |
| clearSearchSelection(); | |
| setSearchStatus(EMPTY_SEARCH_STATUS); | |
| return; | |
| } | |
| runTerminalSearch(nextQuery, { direction: 'next', reset: true }); | |
| }, | |
| [clearSearchSelection, runTerminalSearch] | |
| ); | |
| const handleSearchQueryChange = useCallback( | |
| (nextQuery: string) => { | |
| setSearchQuery(nextQuery); | |
| if (!nextQuery) { | |
| clearSearchSelection(); | |
| setSearchStatus(EMPTY_SEARCH_STATUS); | |
| } | |
| }, | |
| [clearSearchSelection] | |
| ); |
The useEffect (lines 133–138) will then handle running the search whenever searchQuery changes, keeping a single code path for all triggering conditions.
| const start = resolveMatchStart(logicalLine.segments, matchIndex); | ||
| matches.push({ | ||
| row: start.row, | ||
| col: start.col, | ||
| length: query.length, | ||
| }); |
There was a problem hiding this comment.
Match length should use normalizedQuery.length, not query.length
The string search is performed against normalizedQuery (the lowercased form of query). In rare but valid Unicode scenarios, toLocaleLowerCase() can produce a string whose length differs from the original (e.g., "ı" → "i" in tr-TR locale, or multi-char sequences). When the lengths differ, passing query.length to terminal.select() will over- or under-select characters relative to the actual match.
| const start = resolveMatchStart(logicalLine.segments, matchIndex); | |
| matches.push({ | |
| row: start.row, | |
| col: start.col, | |
| length: query.length, | |
| }); | |
| matches.push({ | |
| row: start.row, | |
| col: start.col, | |
| length: normalizedQuery.length, | |
| }); |
| const IS_MAC_PLATFORM = | ||
| typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform); |
There was a problem hiding this comment.
navigator.platform is deprecated
navigator.platform has been deprecated in favour of navigator.userAgentData (available in Chromium-based Electron builds). The current value is already unreliable in some Electron versions and will emit console warnings in newer runtimes.
| const IS_MAC_PLATFORM = | |
| typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform); | |
| const IS_MAC_PLATFORM = | |
| typeof navigator !== 'undefined' && | |
| (navigator.userAgentData | |
| ? navigator.userAgentData.platform === 'macOS' | |
| : /Mac|iPod|iPhone|iPad/.test(navigator.platform)); |
| } { | ||
| const normalizedQuery = query; |
There was a problem hiding this comment.
normalizedQuery is a no-op alias
const normalizedQuery = query; assigns the raw query directly without any transformation. The variable name implies normalization (e.g., lowercasing), which is actually done inside collectTerminalSearchMatches. This is misleading — either apply the normalization here and pass normalizedQuery to collectTerminalSearchMatches, or just use query throughout to avoid confusion.
| } { | |
| const normalizedQuery = query; | |
| if (!query) { | |
| this.clearSearch(); | |
| return { found: false, currentIndex: 0, total: 0 }; | |
| } |
(and replace all subsequent uses of normalizedQuery in the method with query)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
Cmd+F(macOS) /Ctrl+F(Linux/Windows)ChatInterface(agent terminal) andTaskTerminalPanel(worktree/global terminals)Details
terminalSearch.ts— Pure search logic that scans xterm.js buffer rows (handling wrapped lines) for case-insensitive matches and provides directional cyclingTerminalSessionManager.ts— Addssearch()andclearSearch()methods that useterminal.select()andscrollToLine()to highlight and scroll to matchesuseTerminalSearch.ts— React hook managing search state, keyboard shortcut binding, and session lifecycle cleanupTerminalSearchOverlay.tsx— Floating search bar component with match count display and nav buttonsterminalSearch.test.ts— Unit tests for match collection and directional index cyclingTest plan
pnpm exec vitest run src/test/renderer/terminalSearch.test.tsCmd+F/Ctrl+F— search overlay appearsFixes #1428