Skip to content

feat(terminal): add Cmd/Ctrl+F search for terminal panes#1432

Merged
arnestrickmann merged 1 commit intomainfrom
emdash/feat-cmdf-search-agent-ojq
Mar 12, 2026
Merged

feat(terminal): add Cmd/Ctrl+F search for terminal panes#1432
arnestrickmann merged 1 commit intomainfrom
emdash/feat-cmdf-search-agent-ojq

Conversation

@arnestrickmann
Copy link
Contributor

Summary

  • Adds in-terminal text search triggered by Cmd+F (macOS) / Ctrl+F (Linux/Windows)
  • Implements a search overlay UI with match counter, prev/next navigation, and keyboard support (Enter/Shift+Enter to step, Escape to close)
  • Wires search into both ChatInterface (agent terminal) and TaskTerminalPanel (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 cycling
  • TerminalSessionManager.ts — Adds search() and clearSearch() methods that use terminal.select() and scrollToLine() to highlight and scroll to matches
  • useTerminalSearch.ts — React hook managing search state, keyboard shortcut binding, and session lifecycle cleanup
  • TerminalSearchOverlay.tsx — Floating search bar component with match count display and nav buttons
  • terminalSearch.test.ts — Unit tests for match collection and directional index cycling

Test plan

  • Unit tests pass: pnpm exec vitest run src/test/renderer/terminalSearch.test.ts
  • Open a task with terminal output, press Cmd+F/Ctrl+F — search overlay appears
  • Type a query — matches are highlighted and count is shown
  • Press Enter/Shift+Enter or arrow buttons to cycle through matches
  • Press Escape — overlay closes, selection is cleared, terminal re-focused
  • Verify search works in both ChatInterface and TaskTerminalPanel views

Fixes #1428

@vercel
Copy link

vercel bot commented Mar 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Mar 12, 2026 1:01am

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR adds in-terminal text search (Cmd/Ctrl+F) to both the agent ChatInterface and TaskTerminalPanel, with a clean separation between pure search logic (terminalSearch.ts), session-level highlight/scroll (TerminalSessionManager), state/shortcut management (useTerminalSearch), and the floating overlay UI (TerminalSearchOverlay). The architecture is well-thought-out and the wrapped-line handling is correct. Two logic-level concerns are worth addressing before merging:

  • Double buffer scan per keystrokehandleSearchQueryChange calls runTerminalSearch directly, and the useEffect at lines 133–138 of useTerminalSearch.ts also fires on every searchQuery state change, scanning the buffer twice per character typed.
  • Match selection length mismatchcollectTerminalSearchMatches stores query.length (raw input) as the match length, but the search itself operates on normalizedQuery (lowercased). In Unicode locales where case-folding changes string length, terminal.select() will over/under-select.

Minor style issues: navigator.platform is deprecated and should fall back to navigator.userAgentData, and the normalizedQuery = query alias in TerminalSessionManager.search() is a misleading no-op.

Confidence Score: 3/5

  • The PR is functional but has a confirmed double-execution bug on every keystroke that should be fixed before merging to avoid unnecessary buffer churn on large terminals.
  • Core logic and UI integration are solid, but the double-scan per keystroke in useTerminalSearch is a real performance regression for large terminal scrollback buffers, and the query.length vs normalizedQuery.length mismatch is a latent correctness bug in Unicode locales. Neither is blocking for most users day-to-day, but both are straightforward to fix before the feature ships.
  • Focus attention on src/renderer/hooks/useTerminalSearch.ts (double-execution) and src/renderer/terminal/terminalSearch.ts (match length).

Important Files Changed

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()
Loading

Last reviewed commit: 2fefc0c

Comment on lines +90 to +101
const handleSearchQueryChange = useCallback(
(nextQuery: string) => {
setSearchQuery(nextQuery);
if (!nextQuery) {
clearSearchSelection();
setSearchStatus(EMPTY_SEARCH_STATUS);
return;
}
runTerminalSearch(nextQuery, { direction: 'next', reset: true });
},
[clearSearchSelection, runTerminalSearch]
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment on lines +100 to +105
const start = resolveMatchStart(logicalLine.segments, matchIndex);
matches.push({
row: start.row,
col: start.col,
length: query.length,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
});

Comment on lines +15 to +16
const IS_MAC_PLATFORM =
typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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));

Comment on lines +623 to +624
} {
const normalizedQuery = query;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
} {
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!

@arnestrickmann arnestrickmann merged commit 2dff86a into main Mar 12, 2026
5 checks passed
@arnestrickmann arnestrickmann deleted the emdash/feat-cmdf-search-agent-ojq branch March 12, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Cmd+F search in agent terminal pane

1 participant