Skip to content

Commit 47da4c0

Browse files
committed
feat(terminal): add browser-style text search (highlight + focus)
Add a per-terminal find bar (Cmd/Ctrl+F) backed by xterm's official @xterm/addon-search: live highlight of all matches, active-match focus, Enter/Shift+Enter to step, match counter, and seeding from the current selection. Esc closes and returns focus to the terminal.
1 parent 4e7fb77 commit 47da4c0

6 files changed

Lines changed: 266 additions & 1 deletion

File tree

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@fontsource/space-grotesk": "^5.2.10",
4545
"@modelcontextprotocol/sdk": "^1.27.1",
4646
"@xterm/addon-fit": "^0.12.0-beta.195",
47+
"@xterm/addon-search": "^0.17.0-beta.195",
4748
"@xterm/addon-web-links": "^0.13.0-beta.195",
4849
"@xterm/addon-webgl": "^0.20.0-beta.194",
4950
"@xterm/xterm": "^6.1.0-beta.195",
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { onMount, type JSX } from 'solid-js';
2+
3+
export interface TerminalSearchOverlayProps {
4+
query: string;
5+
/** 0-based index of the active match, or -1 when none / not computed. */
6+
resultIndex: number;
7+
resultCount: number;
8+
onInput: (value: string) => void;
9+
onNext: () => void;
10+
onPrev: () => void;
11+
onClose: () => void;
12+
/** Hands the input element back to the owner so it can refocus on reopen. */
13+
setInputRef: (el: HTMLInputElement) => void;
14+
}
15+
16+
const ICON_BUTTON_STYLE: JSX.CSSProperties = {
17+
background: 'transparent',
18+
border: 'none',
19+
color: 'var(--fg-subtle)',
20+
cursor: 'pointer',
21+
'font-size': '13px',
22+
'line-height': '1',
23+
padding: '2px 5px',
24+
'border-radius': '3px',
25+
};
26+
27+
/** Browser-style find bar for a single terminal pane. Purely presentational —
28+
* the owning TerminalView drives the xterm search addon and feeds results back
29+
* in through props. */
30+
export function TerminalSearchOverlay(props: TerminalSearchOverlayProps): JSX.Element {
31+
let inputRef!: HTMLInputElement;
32+
33+
onMount(() => {
34+
inputRef.focus();
35+
inputRef.select();
36+
});
37+
38+
function handleKeyDown(e: KeyboardEvent) {
39+
if (e.key === 'Enter') {
40+
e.preventDefault();
41+
if (e.shiftKey) props.onPrev();
42+
else props.onNext();
43+
} else if (e.key === 'Escape') {
44+
e.preventDefault();
45+
// Stop the app-level Esc handler from also reacting (e.g. closing panels).
46+
e.stopPropagation();
47+
props.onClose();
48+
}
49+
}
50+
51+
const countLabel = () => {
52+
if (props.query === '') return '';
53+
if (props.resultCount === 0) return 'No results';
54+
// Some buffers report a count without a resolved active index — show the
55+
// total rather than a misleading "0/N".
56+
if (props.resultIndex < 0) return `${props.resultCount}`;
57+
return `${props.resultIndex + 1}/${props.resultCount}`;
58+
};
59+
60+
const hasNoMatches = () => props.query !== '' && props.resultCount === 0;
61+
62+
// Clicking a button must not steal focus from the input, so the user can keep
63+
// typing and pressing Enter after stepping through matches.
64+
const keepInputFocus = (e: MouseEvent) => e.preventDefault();
65+
66+
return (
67+
<div
68+
style={{
69+
position: 'absolute',
70+
top: '6px',
71+
right: '10px',
72+
'z-index': '6',
73+
display: 'flex',
74+
'align-items': 'center',
75+
gap: '2px',
76+
padding: '4px 6px',
77+
background: 'var(--bg-elevated)',
78+
border: '1px solid var(--border)',
79+
'border-radius': '6px',
80+
'box-shadow': '0 2px 8px rgba(0, 0, 0, 0.35)',
81+
'font-family': 'var(--font-ui)',
82+
}}
83+
>
84+
<input
85+
ref={(el) => {
86+
inputRef = el;
87+
props.setInputRef(el);
88+
}}
89+
type="text"
90+
placeholder="Find"
91+
spellcheck={false}
92+
autocomplete="off"
93+
value={props.query}
94+
onInput={(e) => props.onInput(e.currentTarget.value)}
95+
onKeyDown={handleKeyDown}
96+
style={{
97+
width: '160px',
98+
padding: '2px 6px',
99+
background: 'var(--bg-input)',
100+
border: '1px solid var(--border)',
101+
'border-radius': '4px',
102+
color: hasNoMatches() ? '#ff6b6b' : 'var(--fg)',
103+
'font-family': 'var(--font-ui)',
104+
'font-size': '12px',
105+
outline: 'none',
106+
}}
107+
/>
108+
<span
109+
style={{
110+
'min-width': '52px',
111+
'text-align': 'right',
112+
padding: '0 4px',
113+
'font-size': '11px',
114+
color: 'var(--fg-subtle)',
115+
'white-space': 'nowrap',
116+
}}
117+
>
118+
{countLabel()}
119+
</span>
120+
<button
121+
type="button"
122+
title="Previous match (Shift+Enter)"
123+
aria-label="Previous match"
124+
onMouseDown={keepInputFocus}
125+
onClick={() => props.onPrev()}
126+
style={ICON_BUTTON_STYLE}
127+
>
128+
129+
</button>
130+
<button
131+
type="button"
132+
title="Next match (Enter)"
133+
aria-label="Next match"
134+
onMouseDown={keepInputFocus}
135+
onClick={() => props.onNext()}
136+
style={ICON_BUTTON_STYLE}
137+
>
138+
139+
</button>
140+
<button
141+
type="button"
142+
title="Close (Esc)"
143+
aria-label="Close search"
144+
onMouseDown={keepInputFocus}
145+
onClick={() => props.onClose()}
146+
style={ICON_BUTTON_STYLE}
147+
>
148+
149+
</button>
150+
</div>
151+
);
152+
}

src/components/TerminalView.tsx

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { onMount, onCleanup, createEffect, Show } from 'solid-js';
1+
import { onMount, onCleanup, createEffect, createSignal, Show } from 'solid-js';
22
import { Terminal, type IMarker } from '@xterm/xterm';
33
import { FitAddon } from '@xterm/addon-fit';
44
import { WebglAddon } from '@xterm/addon-webgl';
55
import { WebLinksAddon } from '@xterm/addon-web-links';
6+
import { SearchAddon } from '@xterm/addon-search';
7+
import { TerminalSearchOverlay } from './TerminalSearchOverlay';
68
import { invoke, fireAndForget, Channel } from '../lib/ipc';
79
import { IPC } from '../../electron/ipc/channels';
810
import { getTerminalFontFamily } from '../lib/fonts';
@@ -110,6 +112,65 @@ export function TerminalView(props: TerminalViewProps) {
110112
let term: Terminal | undefined;
111113
let fitAddon: FitAddon | undefined;
112114
let webglAddon: WebglAddon | undefined;
115+
let searchAddon: SearchAddon | undefined;
116+
let searchInputRef: HTMLInputElement | undefined;
117+
const [searchOpen, setSearchOpen] = createSignal(false);
118+
const [searchQuery, setSearchQuery] = createSignal('');
119+
const [searchResultIndex, setSearchResultIndex] = createSignal(-1);
120+
const [searchResultCount, setSearchResultCount] = createSignal(0);
121+
122+
// Browser-style find: amber highlight for all matches, orange for the active
123+
// one. Overview-ruler colors must be solid; match backgrounds carry alpha so
124+
// the underlying glyphs stay legible regardless of theme.
125+
const SEARCH_DECORATIONS = {
126+
matchBackground: 'rgba(255, 213, 79, 0.4)',
127+
matchOverviewRuler: '#ffd54f',
128+
activeMatchBackground: 'rgba(255, 138, 0, 0.85)',
129+
activeMatchColorOverviewRuler: '#ff8a00',
130+
} as const;
131+
132+
function runSearch(direction: 'next' | 'prev', incremental = false) {
133+
if (!searchAddon) return;
134+
const q = searchQuery();
135+
if (!q) {
136+
searchAddon.clearDecorations();
137+
setSearchResultIndex(-1);
138+
setSearchResultCount(0);
139+
return;
140+
}
141+
const opts = { incremental, decorations: SEARCH_DECORATIONS };
142+
if (direction === 'prev') searchAddon.findPrevious(q, opts);
143+
else searchAddon.findNext(q, opts);
144+
}
145+
146+
function onSearchInput(value: string) {
147+
setSearchQuery(value);
148+
// incremental keeps the current match if it still matches, so the viewport
149+
// doesn't jump ahead on every keystroke (browser find-as-you-type feel).
150+
runSearch('next', true);
151+
}
152+
153+
function openSearch() {
154+
if (!term) return;
155+
if (searchOpen()) {
156+
searchInputRef?.focus();
157+
searchInputRef?.select();
158+
return;
159+
}
160+
// Seed from a single-line selection, like a browser's Find does.
161+
const sel = term.getSelection();
162+
if (sel && !sel.includes('\n') && sel.length <= 200) setSearchQuery(sel);
163+
setSearchOpen(true);
164+
if (searchQuery()) runSearch('next');
165+
}
166+
167+
function closeSearch() {
168+
setSearchOpen(false);
169+
searchAddon?.clearDecorations();
170+
setSearchResultIndex(-1);
171+
setSearchResultCount(0);
172+
term?.focus();
173+
}
113174

114175
function activeTerminalTheme() {
115176
const id = store.activeCustomThemeId;
@@ -155,6 +216,14 @@ export function TerminalView(props: TerminalViewProps) {
155216
term.loadAddon(fitAddon);
156217
term.loadAddon(new WebLinksAddon(openTerminalHttpLinkWithModifier));
157218

219+
searchAddon = new SearchAddon();
220+
term.loadAddon(searchAddon);
221+
// Keep the find-bar counter in sync with xterm's match results.
222+
searchAddon.onDidChangeResults(({ resultIndex, resultCount }) => {
223+
setSearchResultIndex(resultIndex);
224+
setSearchResultCount(resultCount);
225+
});
226+
158227
term.open(containerRef);
159228

160229
// Block direct PTY keyboard input only after self-landing has removed the
@@ -258,6 +327,7 @@ export function TerminalView(props: TerminalViewProps) {
258327
return lines.join('\n');
259328
});
260329

330+
// eslint-disable-next-line solid/reactivity -- key handler reads current signal/binding values intentionally on each keypress
261331
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
262332
if (e.type !== 'keydown') {
263333
// Suppress Shift+Enter keyup so xterm doesn't echo a bare Enter
@@ -312,6 +382,11 @@ export function TerminalView(props: TerminalViewProps) {
312382
return false;
313383
}
314384

385+
if (binding.action === 'find') {
386+
openSearch();
387+
return false;
388+
}
389+
315390
if (binding.action?.startsWith('scrollback:')) {
316391
switch (binding.action) {
317392
case 'scrollback:line-up':
@@ -802,6 +877,18 @@ export function TerminalView(props: TerminalViewProps) {
802877
contain: 'strict',
803878
}}
804879
/>
880+
<Show when={searchOpen()}>
881+
<TerminalSearchOverlay
882+
query={searchQuery()}
883+
resultIndex={searchResultIndex()}
884+
resultCount={searchResultCount()}
885+
onInput={onSearchInput}
886+
onNext={() => runSearch('next')}
887+
onPrev={() => runSearch('prev')}
888+
onClose={closeSearch}
889+
setInputRef={(el) => (searchInputRef = el)}
890+
/>
891+
</Show>
805892
<Show when={mcpStatus() === 'error'}>
806893
<div
807894
style={{

src/lib/keybindings/__tests__/defaults.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const TERMINAL_LAYER_IDS = [
3434
'term.copy-linux',
3535
'term.paste',
3636
'term.paste-linux',
37+
'term.find',
3738
'term.shift-enter',
3839
'term.home',
3940
'term.end',

src/lib/keybindings/defaults.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,20 @@ export const DEFAULT_BINDINGS: KeyBinding[] = [
418418
action: 'paste',
419419
},
420420

421+
// -------------------------------------------------------------------------
422+
// Terminal layer — Search
423+
// -------------------------------------------------------------------------
424+
{
425+
id: 'term.find',
426+
layer: 'terminal',
427+
category: 'Search',
428+
description: 'Find in terminal',
429+
platform: 'both',
430+
key: 'f',
431+
modifiers: { cmdOrCtrl: true },
432+
action: 'find',
433+
},
434+
421435
// -------------------------------------------------------------------------
422436
// Terminal layer — Special keys
423437
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)