From 1800c1b9c68654f47b53468d2ab01a7ed32b363d Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 11:25:51 -0600 Subject: [PATCH 1/6] feat(web): find text in the terminal with Cmd/Ctrl+F Adds a find bar to web and desktop terminals. With a terminal focused, mod+f (Cmd+F on macOS, Ctrl+F on Windows and Linux) or Ctrl+Shift+F opens it; it searches the full scrollback, including matches that span soft wraps, highlights every visible match on the canvas, and Enter/Shift+Enter move between matches and scroll them into view. Case-sensitive and regex modes are available, and Escape closes the bar and refocuses the terminal. Previously the shortcut fell through to the shell and typed a literal "f". The command is terminal.find and can be rebound in Settings > Keybindings. --- apps/web/src/components/TerminalSearchBar.tsx | 111 ++++++++++ .../src/components/ThreadTerminalDrawer.tsx | 115 +++++++++- apps/web/src/keybindings.ts | 8 + apps/web/src/terminal/ghostty/core.test.ts | 59 +++++ apps/web/src/terminal/ghostty/core.ts | 76 +++++++ .../web/src/terminal/ghostty/renderer.test.ts | 59 +++++ apps/web/src/terminal/ghostty/renderer.ts | 37 ++++ apps/web/src/terminal/ghostty/search.test.ts | 166 ++++++++++++++ apps/web/src/terminal/ghostty/search.ts | 207 ++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 159 +++++++++++++- docs/user/terminal.md | 8 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 2 + 13 files changed, 996 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/TerminalSearchBar.tsx create mode 100644 apps/web/src/terminal/ghostty/search.test.ts create mode 100644 apps/web/src/terminal/ghostty/search.ts diff --git a/apps/web/src/components/TerminalSearchBar.tsx b/apps/web/src/components/TerminalSearchBar.tsx new file mode 100644 index 000000000000..740aa7bfabf6 --- /dev/null +++ b/apps/web/src/components/TerminalSearchBar.tsx @@ -0,0 +1,111 @@ +import { ChevronDown, ChevronUp, X } from "lucide-react"; +import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef } from "react"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +const STATUS_CLASS = "inline-block min-w-12 tabular-nums text-xs text-muted-foreground"; + +export interface TerminalSearchBarProps { + readonly query: string; + readonly caseSensitive: boolean; + readonly regex: boolean; + readonly matchCount: number; + readonly activeIndex: number; + readonly truncated: boolean; + readonly error: string | null; + readonly focusRequestId: number; + readonly isFindShortcut: (event: KeyboardEvent) => boolean; + readonly onQueryChange: (query: string) => void; + readonly onCaseSensitiveChange: (caseSensitive: boolean) => void; + readonly onRegexChange: (regex: boolean) => void; + readonly onNext: () => void; + readonly onPrevious: () => void; + readonly onClose: () => void; +} + +export function TerminalSearchBar(props: TerminalSearchBarProps) { + const inputRef = useRef(null); + const lastFocusRequestIdRef = useRef(null); + + useEffect(() => { + if (lastFocusRequestIdRef.current === props.focusRequestId) return; + lastFocusRequestIdRef.current = props.focusRequestId; + inputRef.current?.focus({ preventScroll: true }); + inputRef.current?.select(); + }, [props.focusRequestId]); + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === "Enter") (event.shiftKey ? props.onPrevious : props.onNext)(); + else if (event.key === "Escape") props.onClose(); + else if (props.isFindShortcut(event.nativeEvent)) inputRef.current?.select(); + else return; + event.preventDefault(); + event.stopPropagation(); + }; + const toggles = [ + [".*", "Use regular expression", props.regex, props.onRegexChange], + ["Aa", "Match case", props.caseSensitive, props.onCaseSensitiveChange], + ] as const; + const actions = [ + [ChevronUp, "Previous match", props.onPrevious, props.matchCount === 0], + [ChevronDown, "Next match", props.onNext, props.matchCount === 0], + [X, "Close find", props.onClose, false], + ] as const; + + return ( +
+ props.onQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + aria-label="Find in terminal" + aria-invalid={props.error !== null} + size="compact" + className="w-44" + nativeInput + /> + + {toggles.map(([label, ariaLabel, value, setValue]) => ( + + ))} + + + {props.error + ? "Invalid" + : props.query.length > 0 && + (props.matchCount === 0 + ? "No results" + : `${props.activeIndex + 1}/${props.matchCount}${props.truncated ? "+" : ""}`)} + + + {actions.map(([Icon, label, onClick, disabled]) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 91c7cc855596..93d70f6fbcf8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -42,6 +42,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { TerminalSearchBar } from "~/components/TerminalSearchBar"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; @@ -54,6 +55,7 @@ import { import { GhosttyTerminalSurface, type GhosttyTerminalSurfaceOptions, + type GhosttyTerminalSearchState, } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; import { useOpenInPreferredEditor } from "../editorPreferences"; @@ -61,6 +63,7 @@ import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, + isTerminalFindShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, @@ -92,6 +95,20 @@ import { const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; +const TERMINAL_SHORTCUT_OPTIONS = { + context: { + terminalFocus: true, + terminalOpen: true, + previewFocus: false, + previewOpen: false, + }, +}; +const INITIAL_TERMINAL_SEARCH = { + open: false, + query: "", + caseSensitive: false, + regex: false, +}; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -354,6 +371,15 @@ export function TerminalViewport({ const containerRef = useRef(null); const terminalRef = useRef(null); const visibleRef = useRef(visible); + const [search, setSearch] = useState(INITIAL_TERMINAL_SEARCH); + const [searchState, setSearchState] = useState({ + matchCount: 0, + activeIndex: -1, + truncated: false, + error: null, + }); + const [searchFocusRequestId, setSearchFocusRequestId] = useState(0); + const searchRef = useRef(search); const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( @@ -377,6 +403,39 @@ export function TerminalViewport({ // cannot be mistaken for the active flow. const openSelectionMenuRequestIdRef = useRef(null); const keybindingsRef = useRef(keybindings); + + const handleSearchChange = useEffectEvent((state: GhosttyTerminalSearchState) => { + if (searchRef.current.open) setSearchState(state); + }); + + const updateSearch = (next: Partial): void => { + const updated = { ...searchRef.current, ...next }; + searchRef.current = updated; + setSearch(updated); + const term = terminalRef.current; + if (!updated.open) { + term?.clearSearch(); + return; + } + const state = term?.setSearch(updated.query, updated); + if (state) setSearchState(state); + }; + + const openSearch = useEffectEvent(() => { + updateSearch({ open: true }); + setSearchFocusRequestId((prev) => prev + 1); + }); + + const stepSearch = (direction: 1 | -1) => { + const term = terminalRef.current; + if (term) setSearchState(direction === 1 ? term.searchNext() : term.searchPrevious()); + }; + + const handleSearchClose = () => { + updateSearch({ open: false }); + terminalRef.current?.focus(); + }; + const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); const handleSessionExited = useEffectEvent(() => { onSessionExited(); @@ -501,6 +560,7 @@ export function TerminalViewport({ onSelectionChange: () => handleSelectionChange(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), + onSearchChange: (state) => handleSearchChange(state), // The surface listens from construction, so a right-click can land // while `create` is still awaiting WASM — before the handler below it // exists. The ref is only assigned once that setup has run. @@ -519,6 +579,11 @@ export function TerminalViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + const currentSearch = searchRef.current; + if (currentSearch.open && currentSearch.query.length > 0) { + const state = terminal.setSearch(currentSearch.query, currentSearch); + setSearchState(state); + } // Client settings hydrate asynchronously; a font preference that landed // while the surface was loading found terminalRef null, so its setFont // was dropped. Re-apply whatever is current once the terminal exists. @@ -744,16 +809,21 @@ export function TerminalViewport({ function handleBeforeKey(event: KeyboardEvent): boolean { const currentKeybindings = keybindingsRef.current; - const options = { context: { terminalFocus: true, terminalOpen: true } }; if (preventTerminalCloseShortcut(event, currentKeybindings)) { return false; } + if (isTerminalFindShortcut(event, currentKeybindings, TERMINAL_SHORTCUT_OPTIONS)) { + event.preventDefault(); + event.stopPropagation(); + openSearch(); + return false; + } if ( - isTerminalToggleShortcut(event, currentKeybindings, options) || - isTerminalSplitShortcut(event, currentKeybindings, options) || - isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || - isTerminalNewShortcut(event, currentKeybindings, options) || - isDiffToggleShortcut(event, currentKeybindings, options) + isTerminalToggleShortcut(event, currentKeybindings, TERMINAL_SHORTCUT_OPTIONS) || + isTerminalSplitShortcut(event, currentKeybindings, TERMINAL_SHORTCUT_OPTIONS) || + isTerminalSplitVerticalShortcut(event, currentKeybindings, TERMINAL_SHORTCUT_OPTIONS) || + isTerminalNewShortcut(event, currentKeybindings, TERMINAL_SHORTCUT_OPTIONS) || + isDiffToggleShortcut(event, currentKeybindings, TERMINAL_SHORTCUT_OPTIONS) ) { return false; } @@ -978,11 +1048,34 @@ export function TerminalViewport({ }; }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]); return ( -
+
+
+ {visible && search.open && ( + + isTerminalFindShortcut(event, keybindings, TERMINAL_SHORTCUT_OPTIONS) + } + onQueryChange={(query) => updateSearch({ query })} + onCaseSensitiveChange={(caseSensitive) => updateSearch({ caseSensitive })} + onRegexChange={(regex) => updateSearch({ regex })} + onNext={() => stepSearch(1)} + onPrevious={() => stepSearch(-1)} + onClose={handleSearchClose} + /> + )} +
); } diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 8683ad3c68a5..3aa69fbbad29 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -390,6 +390,14 @@ export function isTerminalCloseShortcut( return matchesCommandShortcut(event, keybindings, "terminal.close", options); } +export function isTerminalFindShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.find", options); +} + export function isDiffToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 48cc4256de61..819d05d394ef 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -12,6 +12,7 @@ import { import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer"; import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core"; import { loadGhosttyRuntime } from "./runtime"; +import { findTerminalSearchMatches } from "./search"; vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ default: (await import("./vendor/ghostty-vt.wasm?inline")).default, @@ -378,3 +379,61 @@ describe("GhosttyTerminalCore snapshots", () => { expect(core.snapshot()).toEqual(reference.snapshot()); }); }); + +const searchTheme = { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, +}; +const searchOptions = { caseSensitive: false, regex: false }; + +describe("GhosttyTerminalCore.searchRows", () => { + const cores = new Set(); + const noWraps = [false, false, false, false]; + async function createCore() { + const core = await GhosttyTerminalCore.create(10, 4, 8, 16, searchTheme, () => {}); + cores.add(core); + return core; + } + afterEach(() => { + for (const core of cores) core.dispose(); + cores.clear(); + }); + it.each([ + ["plain text", "Hello", { texts: ["Hello"], wraps: noWraps }], + ["wide text", "漢字x", { texts: ["漢字x"], wraps: noWraps }], + ["an empty screen", "", { texts: [], wraps: noWraps }], + ["hard breaks", "One\r\nTwo", { texts: ["One", "Two"], wraps: noWraps }], + ["leading spaces", " padded", { texts: [" padded"], wraps: noWraps }], + ["styled text", "\x1b[31mRed\x1b[0m", { texts: ["Red"], wraps: noWraps }], + [ + "a soft wrap", + "0123456789WRAP", + { texts: ["0123456789", "WRAP"], wraps: [true, false, false, false] }, + ], + [ + "scrollback", + "Line1\r\nLine2\r\nLine3\r\nLine4\r\nLine5\r\n", + { texts: ["Line1", "Line2", "Line3", "Line4", "Line5"], wraps: [...noWraps, false, false] }, + ], + ])("formats %s exactly", async (_description, input, expected) => { + const core = await createCore(); + core.write(input); + expect(core.searchRows()).toEqual(expected); + }); + it("supports matches across formatted rows", async () => { + const core = await createCore(); + core.write("0123456789WRAP"); + expect(findTerminalSearchMatches(core.searchRows(), "89WRAP", searchOptions).matches).toEqual([ + { start: { row: 0, offset: 8 }, end: { row: 1, offset: 4 } }, + ]); + }); + it("preserves selection text", async () => { + const core = await createCore(); + core.write("Hello World"); + core.setSelection({ x: 0, y: 0 }, { x: 4, y: 0 }); + expect(core.selectionText()).toBe("Hello"); + core.searchRows(); + expect(core.selectionText()).toBe("Hello"); + }); +}); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index d01e20529d45..70207eecd97b 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -835,6 +835,82 @@ export class GhosttyTerminalCore { return text; } + searchRows(): { texts: string[]; wraps: boolean[] } { + this.ensureActive(); + const output = this.formatScreenPlainText(); + const texts = output === "" ? [] : output.split("\n"); + const totalRows = this.scrollbarState()?.total ?? texts.length; + return { texts, wraps: this.readRowWrapFlags(totalRows) }; + } + + private formatScreenPlainText(): string { + const runtime = this.runtime; + const optionsLayout = runtime.layout("GhosttyFormatterTerminalOptions"); + const extraLayout = runtime.layout("GhosttyFormatterTerminalExtra"); + const screenLayout = runtime.layout("GhosttyFormatterScreenExtra"); + const options = runtime.alloc(optionsLayout.size); + const formatterSlot = runtime.allocOpaque(); + const written = runtime.call("ghostty_wasm_alloc_usize"); + let formatter = 0; + try { + runtime.setField(options, "GhosttyFormatterTerminalOptions", "size", optionsLayout.size); + runtime.setField(options, "GhosttyFormatterTerminalOptions", "emit", 0); + runtime.setField(options, "GhosttyFormatterTerminalOptions", "unwrap", 0); + runtime.setField(options, "GhosttyFormatterTerminalOptions", "trim", 0); + const extraOffset = optionsLayout.fields.extra!.offset; + runtime.view(options + extraOffset, extraLayout.size).setUint32(0, extraLayout.size, true); + runtime + .view(options + extraOffset + extraLayout.fields.screen!.offset, screenLayout.size) + .setUint32(0, screenLayout.size, true); + this.assertSuccess( + "ghostty_formatter_terminal_new", + runtime.call("ghostty_formatter_terminal_new", 0, formatterSlot, this.terminal, options), + ); + formatter = runtime.readPointer(formatterSlot); + return this.encodeOutput(written, (output, outputSize) => + runtime.call("ghostty_formatter_format_buf", formatter, output, outputSize, written), + ); + } finally { + if (formatter !== 0) runtime.call("ghostty_formatter_free", formatter); + runtime.call("ghostty_wasm_free_usize", written); + runtime.freeOpaque(formatterSlot); + runtime.free(options, optionsLayout.size); + } + } + private readRowWrapFlags(totalRows: number): boolean[] { + const runtime = this.runtime; + const pointLayout = runtime.layout("GhosttyPoint"); + const gridReferenceLayout = runtime.layout("GhosttyGridRef"); + const point = runtime.alloc(pointLayout.size); + const gridReference = runtime.alloc(gridReferenceLayout.size); + const scratch = runtime.alloc(16); + const wraps: boolean[] = []; + try { + runtime.setField(point, "GhosttyPoint", "tag", 2); + const pointValue = pointLayout.fields.value!; + for (let index = 0; index < totalRows; index += 1) { + runtime.view(point + pointValue.offset, pointValue.size).setUint16(0, 0, true); + runtime.view(point + pointValue.offset, pointValue.size).setUint32(4, index, true); + runtime.setField(gridReference, "GhosttyGridRef", "size", gridReferenceLayout.size); + const rowAvailable = + runtime.call("ghostty_terminal_grid_ref", this.terminal, point, gridReference) === + GHOSTTY_SUCCESS && + runtime.call("ghostty_grid_ref_row", gridReference, scratch) === GHOSTTY_SUCCESS; + const rawRow = runtime.view(scratch, 8).getBigUint64(0, true); + runtime.bytes(scratch + 8, 1)[0] = 0; + const wrapAvailable = + rowAvailable && + runtime.call("ghostty_row_get", rawRow, 1, scratch + 8) === GHOSTTY_SUCCESS; + wraps.push(wrapAvailable && runtime.bytes(scratch + 8, 1)[0] !== 0); + } + return wraps; + } finally { + runtime.free(point, pointLayout.size); + runtime.free(gridReference, gridReferenceLayout.size); + runtime.free(scratch, 16); + } + } + viewportPointToScreen(col: number, row: number): { x: number; y: number } | null { return this.convertPoint(col, row, 1, 2); } diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts index 5f5c41c8fecb..5d83aca559e8 100644 --- a/apps/web/src/terminal/ghostty/renderer.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -282,4 +282,63 @@ describe("renderGhosttySnapshot", () => { expect(clearedRows).toEqual([4, 36, 36]); }); + + const snapshot = { + cols: 3, + rows: 1, + background: { r: 0, g: 0, b: 0 }, + cursorY: -1, + cursorVisible: false, + dirtyRows: new Set([0]), + rowData: [{ cells: [cell("a"), cell("b"), cell("c")], text: "abc" }], + } as unknown as GhosttySnapshot; + const renderSearch = ( + context: CanvasRenderingContext2D, + searchHighlights: { row: number; startColumn: number; endColumn: number; active: boolean }[], + dirtyRows = snapshot.dirtyRows, + ) => + renderGhosttySnapshot({ + context, + snapshot: { ...snapshot, dirtyRows }, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: false, + searchHighlights, + }); + const context = (events: string[], styles: string[] = []) => + new Proxy({ canvas: { width: 200, height: 40 } } as unknown as CanvasRenderingContext2D, { + get: (target, key) => + key === "canvas" + ? target.canvas + : key === "fillRect" + ? () => events.push("fill") + : key === "fillText" + ? () => events.push("text") + : () => {}, + set: (_target, key, value) => (key === "fillStyle" && styles.push(String(value)), true), + }); + const highlight = { row: 0, startColumn: 0, endColumn: 0, active: false }; + it("highlight fill painted before text for a drawn row", () => { + const events: string[] = []; + renderSearch(context(events), [{ ...highlight, endColumn: 1 }]); + expect(events).toEqual(["fill", "fill", "text"]); + }); + + it("active vs inactive color", () => { + const styles: string[] = []; + renderSearch(context([], styles), [ + highlight, + { ...highlight, startColumn: 1, endColumn: 1, active: true }, + ]); + expect(styles).toContain("rgba(249, 115, 22, 0.62)"); + }); + + it("rows not in the redraw set are not painted", () => { + const events: string[] = []; + renderSearch(context(events), [highlight], new Set()); + expect(events).toEqual([]); + }); }); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 9d47718464ea..ac720ee56460 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -17,12 +17,32 @@ export interface GhosttyCellRange { readonly end: { readonly x: number; readonly y: number }; } +/** A find match on one viewport row; columns are inclusive. */ +export interface GhosttySearchHighlight { + readonly row: number; + readonly startColumn: number; + readonly endColumn: number; + readonly active: boolean; +} + const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; +const DEFAULT_SEARCH_MATCH_BACKGROUND = "rgba(234, 179, 8, 0.32)"; +const DEFAULT_SEARCH_ACTIVE_MATCH_BACKGROUND = "rgba(249, 115, 22, 0.62)"; function cssColor(color: GhosttyColor): string { return `rgb(${color.r}, ${color.g}, ${color.b})`; } +function groupHighlightsByRow(highlights: readonly GhosttySearchHighlight[]) { + const highlightsByRow = new Map(); + for (const highlight of highlights) { + const rowHighlights = highlightsByRow.get(highlight.row); + if (rowHighlights) rowHighlights.push(highlight); + else highlightsByRow.set(highlight.row, [highlight]); + } + return highlightsByRow; +} + function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { // Selection deliberately does not participate: it only tints the background // overlay, and splitting a text run at a selection boundary visibly shifts @@ -106,6 +126,7 @@ export function renderGhosttySnapshot(options: { readonly hoveredLinkRange?: GhosttyCellRange | null; /** Vertical origin of row 0; defaults to the horizontal padding. */ readonly originY?: number; + readonly searchHighlights?: readonly GhosttySearchHighlight[]; }): void { const { context, @@ -137,6 +158,10 @@ export function renderGhosttySnapshot(options: { rowsToDraw.push(snapshot.cursorY); } + const highlightsByRow = options.searchHighlights?.length + ? groupHighlightsByRow(options.searchHighlights) + : null; + if (forceFull) { context.save(); context.resetTransform(); @@ -185,6 +210,18 @@ export function renderGhosttySnapshot(options: { backgroundStart = backgroundEnd; } + for (const highlight of highlightsByRow?.get(rowIndex) ?? []) { + const left = padding + highlight.startColumn * metrics.width; + const width = Math.min( + (highlight.endColumn - highlight.startColumn + 1) * metrics.width, + snapshot.cols * metrics.width - (left - padding), + ); + context.fillStyle = highlight.active + ? DEFAULT_SEARCH_ACTIVE_MATCH_BACKGROUND + : DEFAULT_SEARCH_MATCH_BACKGROUND; + context.fillRect(left, top, width, metrics.height); + } + let runStart = 0; while (runStart < row.cells.length) { const first = row.cells[runStart]; diff --git a/apps/web/src/terminal/ghostty/search.test.ts b/apps/web/src/terminal/ghostty/search.test.ts new file mode 100644 index 000000000000..52cc0f13c85d --- /dev/null +++ b/apps/web/src/terminal/ghostty/search.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + closestTerminalSearchIndex, + findTerminalSearchMatches, + initialTerminalSearchIndex, + MAX_TERMINAL_SEARCH_MATCHES, + stepTerminalSearchIndex, + terminalSearchHighlights, + terminalSearchScrollDelta, + type TerminalSearchCellRow, + type TerminalSearchMatch, + type TerminalSearchRows, +} from "./search"; +const insensitiveLiteral = { caseSensitive: false, regex: false }; +function match(row: number, start: number, end: number): TerminalSearchMatch { + return { start: { row, offset: start }, end: { row, offset: end } }; +} +describe("findTerminalSearchMatches", () => { + it("finds case-insensitive matches", () => { + const rows = { texts: ["Hello", "HELLO"], wraps: [false, false] }; + expect(findTerminalSearchMatches(rows, "hello", insensitiveLiteral)).toEqual({ + matches: [match(0, 0, 5), match(1, 0, 5)], + truncated: false, + error: null, + }); + }); + it("respects case-sensitive option", () => { + const rows = { texts: ["Hello HELLO hello"], wraps: [false] }; + expect(findTerminalSearchMatches(rows, "hello", { caseSensitive: true, regex: false })).toEqual( + { matches: [match(0, 12, 17)], truncated: false, error: null }, + ); + }); + it("handles regex patterns", () => { + const rows = { texts: ["foo123bar456"], wraps: [false] }; + expect( + findTerminalSearchMatches(rows, "(\\d+)", { caseSensitive: false, regex: true }), + ).toEqual({ matches: [match(0, 3, 6), match(0, 9, 12)], truncated: false, error: null }); + }); + it("returns error for invalid regex", () => { + const rows = { texts: ["test"], wraps: [false] }; + expect( + findTerminalSearchMatches(rows, "[invalid(", { caseSensitive: false, regex: true }), + ).toEqual({ matches: [], truncated: false, error: "Invalid regular expression" }); + }); + it("skips zero-length matches", () => { + const rows = { texts: ["aaa"], wraps: [false] }; + expect(findTerminalSearchMatches(rows, "a*", { caseSensitive: false, regex: true })).toEqual({ + matches: [match(0, 0, 3)], + truncated: false, + error: null, + }); + }); + it("handles matches spanning wrapped rows, including an empty row", () => { + const rows = { texts: ["0123456789", "", "WRAP"], wraps: [true, true, false] }; + expect(findTerminalSearchMatches(rows, "89WRAP", insensitiveLiteral)).toEqual({ + matches: [{ start: { row: 0, offset: 8 }, end: { row: 2, offset: 4 } }], + truncated: false, + error: null, + }); + }); + it("skips trimmed trailing whitespace", () => { + const rows = { texts: ["hello ", "world"], wraps: [false, false] }; + expect(findTerminalSearchMatches(rows, " ", insensitiveLiteral)).toEqual({ + matches: [], + truncated: false, + error: null, + }); + }); + it("truncates only when matches exceed the maximum", () => { + const texts = Array.from({ length: MAX_TERMINAL_SEARCH_MATCHES + 1 }, () => "match"); + const exactRows: TerminalSearchRows = { + texts: texts.slice(0, MAX_TERMINAL_SEARCH_MATCHES), + wraps: texts.map(() => false), + }; + const exactResult = findTerminalSearchMatches(exactRows, "match", insensitiveLiteral); + expect([exactResult.matches.length, exactResult.truncated, exactResult.error]).toEqual([ + MAX_TERMINAL_SEARCH_MATCHES, + false, + null, + ]); + const overflowResult = findTerminalSearchMatches( + { texts, wraps: exactRows.wraps }, + "match", + insensitiveLiteral, + ); + expect([overflowResult.matches.length, overflowResult.truncated, overflowResult.error]).toEqual( + [MAX_TERMINAL_SEARCH_MATCHES, true, null], + ); + }); +}); +describe("terminalSearchHighlights", () => { + it("maps wide-character offsets", () => { + const rows: TerminalSearchCellRow[] = [ + { + cells: [ + { text: "漢", wide: 1 }, + { text: "", wide: 2 }, + { text: "x", wide: 0 }, + ], + }, + ]; + expect(terminalSearchHighlights([match(0, 0, 1)], 0, 0, rows)).toEqual([ + { row: 0, startColumn: 0, endColumn: 1, active: true }, + ]); + }); + it("clips multi-row highlights", () => { + const rows = [{ cells: [{ text: "a", wide: 0 }] }, { cells: [{ text: "b", wide: 0 }] }]; + expect( + terminalSearchHighlights( + [{ start: { row: 0, offset: 0 }, end: { row: 3, offset: 5 } }], + 0, + 1, + rows, + ), + ).toEqual([ + { row: 0, startColumn: 0, endColumn: 0, active: true }, + { row: 1, startColumn: 0, endColumn: 0, active: true }, + ]); + }); + it("sets the active flag", () => { + const rows = [ + { cells: [{ text: "first", wide: 0 }] }, + { cells: [{ text: "second", wide: 0 }] }, + ]; + expect(terminalSearchHighlights([match(0, 0, 5), match(1, 0, 6)], 1, 0, rows)).toEqual([ + { row: 0, startColumn: 0, endColumn: 0, active: false }, + { row: 1, startColumn: 0, endColumn: 0, active: true }, + ]); + }); +}); +describe("initialTerminalSearchIndex", () => { + it("returns -1 when no matches", () => { + expect(initialTerminalSearchIndex([], 0, 10)).toBe(-1); + }); + it("returns last match below viewport bottom", () => { + expect( + initialTerminalSearchIndex([match(0, 0, 5), match(5, 0, 5), match(15, 0, 5)], 10, 10), + ).toBe(2); + }); + it("returns 0 if no matches precede the viewport bottom", () => { + expect(initialTerminalSearchIndex([match(20, 0, 5)], 0, 10)).toBe(0); + }); +}); +describe("stepTerminalSearchIndex", () => { + it("returns -1 when no matches", () => expect(stepTerminalSearchIndex(0, 0, 1)).toBe(-1)); + it("wraps forward", () => expect(stepTerminalSearchIndex(4, 5, 1)).toBe(0)); + it("wraps backward", () => expect(stepTerminalSearchIndex(0, 5, -1)).toBe(4)); + it("starts forward", () => expect(stepTerminalSearchIndex(-1, 5, 1)).toBe(0)); + it("starts backward", () => expect(stepTerminalSearchIndex(-1, 5, -1)).toBe(4)); +}); +describe("closestTerminalSearchIndex", () => { + it("returns -1 when empty or no previous match", () => { + expect(closestTerminalSearchIndex([], null)).toBe(-1); + }); + it("finds the next match at the previous position", () => { + expect(closestTerminalSearchIndex([match(0, 0, 5), match(5, 0, 5)], match(5, 0, 5))).toBe(1); + }); +}); +describe("terminalSearchScrollDelta", () => { + const scrollbar = { total: 100, offset: 0, len: 20 }; + it("returns 0 when visible", () => + expect(terminalSearchScrollDelta(match(5, 0, 5), scrollbar)).toBe(0)); + it("centers row 50", () => + expect(terminalSearchScrollDelta(match(50, 0, 5), scrollbar)).toBe(41)); + it("clamps row 95", () => expect(terminalSearchScrollDelta(match(95, 0, 5), scrollbar)).toBe(80)); +}); diff --git a/apps/web/src/terminal/ghostty/search.ts b/apps/web/src/terminal/ghostty/search.ts new file mode 100644 index 000000000000..c6f5307d934e --- /dev/null +++ b/apps/web/src/terminal/ghostty/search.ts @@ -0,0 +1,207 @@ +export const MAX_TERMINAL_SEARCH_MATCHES = 2000; + +export interface TerminalSearchOptions { + readonly caseSensitive: boolean; + readonly regex: boolean; +} + +export interface TerminalSearchRows { + readonly texts: readonly string[]; + readonly wraps: readonly boolean[]; +} + +export interface TerminalSearchPosition { + readonly row: number; + readonly offset: number; +} + +export interface TerminalSearchMatch { + readonly start: TerminalSearchPosition; + readonly end: TerminalSearchPosition; +} + +export interface TerminalSearchResult { + readonly matches: readonly TerminalSearchMatch[]; + readonly truncated: boolean; + readonly error: string | null; +} + +export interface TerminalSearchHighlight { + readonly row: number; + readonly startColumn: number; + readonly endColumn: number; + readonly active: boolean; +} + +export interface TerminalSearchCellRow { + readonly cells: readonly { readonly text: string; readonly wide: number }[]; +} + +function logicalLines(rows: TerminalSearchRows): { text: string; startRow: number }[] { + const lines: { text: string; startRow: number }[] = []; + const rowCount = Math.max(rows.texts.length, rows.wraps.length); + let text = ""; + let startRow = 0; + for (let row = 0; row < rowCount; row += 1) { + text += rows.texts[row] ?? ""; + if (rows.wraps[row] === true && row < rowCount - 1) continue; + const trimmedText = text.trimEnd(); + if (trimmedText.length > 0) lines.push({ text: trimmedText, startRow }); + text = ""; + startRow = row + 1; + } + return lines; +} + +function searchPattern(query: string, options: TerminalSearchOptions): RegExp { + const source = options.regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(source, options.caseSensitive ? "g" : "gi"); +} + +function positionForOffset( + texts: readonly string[], + startRow: number, + offset: number, +): TerminalSearchPosition | null { + let consumedCharacters = 0; + for (let row = startRow; row < texts.length; row += 1) { + const rowLength = (texts[row] ?? "").length; + if (offset < consumedCharacters + rowLength) + return { row, offset: offset - consumedCharacters }; + consumedCharacters += rowLength; + } + return null; +} + +/** Finds literal or regular-expression matches across terminal soft wraps. */ +export function findTerminalSearchMatches( + rows: TerminalSearchRows, + query: string, + options: TerminalSearchOptions, +): TerminalSearchResult { + if (query.length === 0) return { matches: [], truncated: false, error: null }; + let pattern: RegExp; + try { + pattern = searchPattern(query, options); + } catch { + return { matches: [], truncated: false, error: "Invalid regular expression" }; + } + const matches: TerminalSearchMatch[] = []; + for (const line of logicalLines(rows)) { + pattern.lastIndex = 0; + for (let match = pattern.exec(line.text); match !== null; match = pattern.exec(line.text)) { + if (match[0].length === 0) { + pattern.lastIndex = match.index + 1; + continue; + } + const start = positionForOffset(rows.texts, line.startRow, match.index); + const inclusiveEnd = positionForOffset( + rows.texts, + line.startRow, + match.index + match[0].length - 1, + ); + if (start === null || inclusiveEnd === null) continue; + if (matches.length === MAX_TERMINAL_SEARCH_MATCHES) + return { matches, truncated: true, error: null }; + matches.push({ start, end: { row: inclusiveEnd.row, offset: inclusiveEnd.offset + 1 } }); + } + } + return { matches, truncated: false, error: null }; +} + +function columnForOffset( + cells: readonly { readonly text: string; readonly wide: number }[], + offset: number, +): number { + let consumedCharacters = 0; + for (let index = 0; index < cells.length; index += 1) { + const cell = cells[index]!; + const cellLength = cell.wide === 2 ? 0 : (cell.text || " ").length; + if (consumedCharacters + cellLength > offset) return index; + consumedCharacters += cellLength; + } + return Math.max(0, cells.length - 1); +} + +/** Maps search matches to visible terminal-cell highlight ranges. */ +export function terminalSearchHighlights( + matches: readonly TerminalSearchMatch[], + activeIndex: number, + viewportTop: number, + viewportRows: readonly TerminalSearchCellRow[], +): TerminalSearchHighlight[] { + const highlights: TerminalSearchHighlight[] = []; + const viewportBottom = viewportTop + viewportRows.length; + let firstVisibleIndex = 0; + let searchEnd = matches.length; + while (firstVisibleIndex < searchEnd) { + const middleIndex = Math.floor((firstVisibleIndex + searchEnd) / 2); + if (matches[middleIndex]!.end.row < viewportTop) firstVisibleIndex = middleIndex + 1; + else searchEnd = middleIndex; + } + for (let index = firstVisibleIndex; index < matches.length; index += 1) { + const match = matches[index]!; + if (match.start.row >= viewportBottom) break; + const firstRow = Math.max(match.start.row, viewportTop); + const lastRow = Math.min(match.end.row, viewportBottom - 1); + for (let row = firstRow; row <= lastRow; row += 1) { + const viewportRow = row - viewportTop; + const cells = viewportRows[viewportRow]?.cells; + if (cells === undefined) continue; + const startColumn = row === match.start.row ? columnForOffset(cells, match.start.offset) : 0; + let endColumn = + row === match.end.row + ? Math.max(0, columnForOffset(cells, match.end.offset - 1)) + : Math.max(0, cells.length - 1); + if (endColumn + 1 < cells.length && cells[endColumn + 1]!.wide === 2) endColumn += 1; + highlights.push({ row: viewportRow, startColumn, endColumn, active: index === activeIndex }); + } + } + return highlights; +} + +/** Chooses the last match above the viewport bottom, or the first match. */ +export function initialTerminalSearchIndex( + matches: readonly TerminalSearchMatch[], + viewportTop: number, + viewportRowCount: number, +): number { + if (matches.length === 0) return -1; + const viewportBottom = viewportTop + viewportRowCount; + const lastIndex = matches.findLastIndex((match) => match.start.row < viewportBottom); + return lastIndex === -1 ? 0 : lastIndex; +} + +/** Moves the active match index with wraparound in either direction. */ +export function stepTerminalSearchIndex(current: number, count: number, direction: 1 | -1): number { + if (count === 0) return -1; + if (current === -1) return direction === 1 ? 0 : count - 1; + const next = current + direction; + return next < 0 ? count - 1 : next >= count ? 0 : next; +} + +/** Finds the closest match at or after a previously active match. */ +export function closestTerminalSearchIndex( + matches: readonly TerminalSearchMatch[], + previous: TerminalSearchMatch | null, +): number { + if (matches.length === 0 || previous === null) return -1; + const previousStart = previous.start; + const index = matches.findIndex( + (match) => + match.start.row > previousStart.row || + (match.start.row === previousStart.row && match.start.offset >= previousStart.offset), + ); + return index === -1 ? matches.length - 1 : index; +} + +/** Returns the scrollbar delta needed to reveal and center a match. */ +export function terminalSearchScrollDelta( + match: TerminalSearchMatch, + scrollbar: { readonly total: number; readonly offset: number; readonly len: number }, +): number { + if (match.start.row >= scrollbar.offset && match.end.row < scrollbar.offset + scrollbar.len) + return 0; + const centeredOffset = match.start.row - Math.floor((scrollbar.len - 1) / 2); + return Math.max(0, Math.min(centeredOffset, scrollbar.total - scrollbar.len)) - scrollbar.offset; +} diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index be62ede4d065..cd2fb7e860fb 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -7,6 +7,17 @@ import { type GhosttySnapshot, type GhosttyTheme, } from "./core"; +import { + type TerminalSearchMatch, + type TerminalSearchOptions, + type TerminalSearchResult, + closestTerminalSearchIndex, + findTerminalSearchMatches, + initialTerminalSearchIndex, + stepTerminalSearchIndex, + terminalSearchHighlights, + terminalSearchScrollDelta, +} from "./search"; import { measureGhosttyCell, renderGhosttySnapshot, @@ -37,6 +48,7 @@ const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; +const SEARCH_REFRESH_DELAY_MS = 150; const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; const TERMINAL_FONT_LOAD_VARIANTS = [ "normal 400", @@ -537,6 +549,13 @@ export interface GhosttySelectionPosition { readonly end: { readonly x: number; readonly y: number }; } +export interface GhosttyTerminalSearchState { + readonly matchCount: number; + readonly activeIndex: number; + readonly truncated: boolean; + readonly error: string | null; +} + export interface GhosttyTerminalSurfaceOptions { readonly theme: GhosttyTheme; readonly font?: GhosttyTerminalFont; @@ -553,6 +572,7 @@ export interface GhosttyTerminalSurfaceOptions { * default — whose Paste entry can never reach a canvas terminal. */ readonly onContextMenu?: (event: MouseEvent) => void; + readonly onSearchChange?: (state: GhosttyTerminalSearchState) => void; } export class GhosttyTerminalSurface { @@ -637,6 +657,14 @@ export class GhosttyTerminalSurface { private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)"); private inputLeft = -1; private inputTop = -1; + private searchQuery = ""; + private searchOptions: TerminalSearchOptions | null = null; + private searchMatches: TerminalSearchMatch[] = []; + private searchActiveIndex = -1; + private searchRefreshTimer: number | null = null; + private searchPreviousActiveMatch: TerminalSearchMatch | null = null; + private searchTruncated = false; + private searchError: string | null = null; private constructor( mount: HTMLElement, @@ -770,6 +798,7 @@ export class GhosttyTerminalSurface { this.cursorOn = true; this.scrollbarDirty = true; this.requestRender(); + if (this.searchQuery) this.scheduleSearchRefresh(); } resetAndWrite(data: string): void { @@ -783,6 +812,7 @@ export class GhosttyTerminalSurface { this.forceFullRender = true; this.scrollbarDirty = true; this.requestRender(); + if (this.searchQuery) this.scheduleSearchRefresh(); } setTheme(theme: GhosttyTheme): void { @@ -890,7 +920,8 @@ export class GhosttyTerminalSurface { this.mountHeight = height; // onResize is the only PTY resize channel, so the first successful fit must // notify even when the measured grid equals the 1x1 construction sentinel. - if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { + const gridChanged = grid.cols !== this.cols || grid.rows !== this.rows; + if (gridChanged || !this.resizeNotified) { this.cols = grid.cols; this.rows = grid.rows; this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); @@ -898,6 +929,7 @@ export class GhosttyTerminalSurface { this.forceFullRender = true; this.scrollbarDirty = true; shouldRender = true; + if (gridChanged && this.searchQuery) this.scheduleSearchRefresh(); } // Rendering synchronously keeps the repaint inside the same frame as the // layout change: ResizeObserver fires before paint, so the browser never @@ -1035,6 +1067,7 @@ export class GhosttyTerminalSurface { // the surface unmounts inside the debounce window. this.options.onResize(this.cols, this.rows); } + this.clearSearchRefreshTimer(); this.cancelRender(); if (this.compositionSuppressionTimer !== null) { window.clearTimeout(this.compositionSuppressionTimer); @@ -1052,6 +1085,120 @@ export class GhosttyTerminalSurface { } } + /** Replace the active terminal search and select its first visible match. */ + setSearch(query: string, options: TerminalSearchOptions): GhosttyTerminalSearchState { + if (this.disposed) return this.getSearchState(); + this.searchQuery = query; + this.searchOptions = options; + this.clearSearchRefreshTimer(); + const result = query + ? this.computeSearch(query, options) + : { matches: [], truncated: false, error: null }; + return this.applySearchResult(result, this.initialSearchIndex(result.matches), true); + } + + /** Select and reveal the next search match, wrapping at the end. */ + searchNext(): GhosttyTerminalSearchState { + return this.stepSearch(1); + } + + /** Select and reveal the previous search match, wrapping at the start. */ + searchPrevious(): GhosttyTerminalSearchState { + return this.stepSearch(-1); + } + + /** Clear the active search and remove its highlights. */ + clearSearch(): void { + if (this.disposed) return; + this.searchQuery = ""; + this.searchOptions = null; + this.clearSearchRefreshTimer(); + this.applySearchResult({ matches: [], truncated: false, error: null }, -1, false); + } + + private getSearchState(): GhosttyTerminalSearchState { + return { + matchCount: this.searchMatches.length, + activeIndex: this.searchActiveIndex, + truncated: this.searchTruncated, + error: this.searchError, + }; + } + + private stepSearch(direction: 1 | -1): GhosttyTerminalSearchState { + if (this.disposed || this.searchMatches.length === 0) return this.getSearchState(); + const count = this.searchMatches.length; + this.searchActiveIndex = stepTerminalSearchIndex(this.searchActiveIndex, count, direction); + const match = this.searchMatches[this.searchActiveIndex]; + this.searchPreviousActiveMatch = match ?? null; + if (match) this.reveal(match); + this.forceFullRender = true; + this.requestRender(); + return this.getSearchState(); + } + + private applySearchResult(result: TerminalSearchResult, activeIndex: number, reveal: boolean) { + if (this.disposed) return this.getSearchState(); + this.searchMatches = [...result.matches]; + this.searchTruncated = result.truncated; + this.searchError = result.error; + const match = this.searchMatches[activeIndex]; + this.searchActiveIndex = match ? activeIndex : -1; + this.searchPreviousActiveMatch = match ?? null; + if (match && reveal) this.reveal(match); + this.forceFullRender = true; + this.requestRender(); + return this.getSearchState(); + } + + private reveal(match: TerminalSearchMatch): void { + const scrollState = this.readScrollbarState(); + if (scrollState === null) return; + const delta = terminalSearchScrollDelta(match, scrollState); + if (delta !== 0) this.scrollViewport(delta); + } + + private computeSearch(query: string, options: TerminalSearchOptions) { + const { texts, wraps } = this.core.searchRows(); + return findTerminalSearchMatches({ texts, wraps }, query, options); + } + + private initialSearchIndex(matches: readonly TerminalSearchMatch[]): number { + const scroll = this.readScrollbarState(); + return initialTerminalSearchIndex(matches, scroll?.offset ?? 0, scroll?.len ?? this.rows); + } + + private scheduleSearchRefresh(): void { + if (this.disposed || !this.searchQuery || this.searchRefreshTimer !== null) return; + this.searchRefreshTimer = window.setTimeout(() => { + this.searchRefreshTimer = null; + this.refreshSearchResults(); + }, SEARCH_REFRESH_DELAY_MS); + } + + private clearSearchRefreshTimer(): void { + if (this.searchRefreshTimer !== null) window.clearTimeout(this.searchRefreshTimer); + this.searchRefreshTimer = null; + } + + private refreshSearchResults(): void { + if (this.disposed || !this.searchQuery || this.searchOptions === null) return; + const previous = this.getSearchState(); + const result = this.computeSearch(this.searchQuery, this.searchOptions); + const closest = closestTerminalSearchIndex(result.matches, this.searchPreviousActiveMatch); + const next = this.applySearchResult( + result, + closest === -1 ? this.initialSearchIndex(result.matches) : closest, + false, + ); + const changed = + previous.matchCount !== next.matchCount || + previous.activeIndex !== next.activeIndex || + previous.truncated !== next.truncated || + previous.error !== next.error; + if (changed) this.options.onSearchChange?.(next); + } + private readonly onKeyDown = (event: KeyboardEvent) => { // Presses handled outside the terminal must also swallow their release: // beforeKey runs side effects (keybindings, navigation sends), so it cannot @@ -1849,6 +1996,16 @@ export class GhosttyTerminalSurface { previousCursorY: this.renderedCursorY, focused: this.focused, hoveredLinkRange: this.hoveredLink?.range ?? null, + ...(this.searchMatches.length > 0 + ? { + searchHighlights: terminalSearchHighlights( + this.searchMatches, + this.searchActiveIndex, + scrollState?.offset ?? 0, + this.snapshot.rowData, + ), + } + : {}), ...(this.theme.selectionBackground !== undefined ? { selectionBackground: this.theme.selectionBackground } : {}), diff --git a/docs/user/terminal.md b/docs/user/terminal.md index 2e31ddf3b177..c55010a5f457 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -6,3 +6,11 @@ line can be shortened at the start. New terminal output is not truncated. These limits apply when you reconnect and when T3 Code restores saved terminal history. A client can show less scrollback than the server keeps. + +# Find in the terminal + +With a terminal focused, press `mod+f` to search its output and scrollback. +`mod` is Command on macOS and Ctrl on Windows and Linux; `Ctrl+Shift+F` also +works. Enter moves to the next match and Shift+Enter to the previous one. Use +the toggles for case-sensitive or regular-expression search, and Escape to +close. Rebind **Terminal: Find** in Settings → Keybindings. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index a285d2fcf4ac..fed280ee07e6 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -60,6 +60,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "terminal.splitVertical", "terminal.new", "terminal.close", + "terminal.find", "rightPanel.toggle", "rightPanel.toggleMaximized", "rightPanel.close", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8d73c07f34ab..21a74e3953af 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -26,6 +26,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { key: "ctrl+shift+f", command: "terminal.find", when: "terminalFocus" }, + { key: "mod+f", command: "terminal.find", when: "terminalFocus" }, { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+shift+j", command: "preview.toggle" }, From 7eb8cf12e5b1c072cef9fbc52193f1d9281a8c0b Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 11:44:30 -0600 Subject: [PATCH 2/6] refactor(web): reuse existing search helpers in terminal find Terminal find now uses the shared pieces the app already has instead of parallel copies: - Soft-wrapped rows are joined with collectWrappedTerminalLinkLine from terminal-links, the same helper terminal link detection uses. - The regex and match-case toggles use SearchOptionButton, extracted from the project content search dialog so both surfaces share one control. --- apps/web/src/components/TerminalSearchBar.tsx | 36 ++++++------ .../search/ProjectContentSearchDialog.tsx | 32 +--------- .../components/search/SearchOptionButton.tsx | 30 ++++++++++ apps/web/src/terminal/ghostty/search.ts | 58 +++++++++---------- 4 files changed, 76 insertions(+), 80 deletions(-) create mode 100644 apps/web/src/components/search/SearchOptionButton.tsx diff --git a/apps/web/src/components/TerminalSearchBar.tsx b/apps/web/src/components/TerminalSearchBar.tsx index 740aa7bfabf6..61f53a5a4e9e 100644 --- a/apps/web/src/components/TerminalSearchBar.tsx +++ b/apps/web/src/components/TerminalSearchBar.tsx @@ -2,6 +2,8 @@ import { ChevronDown, ChevronUp, X } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef } from "react"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; +import { SearchOptionButton } from "~/components/search/SearchOptionButton"; + const STATUS_CLASS = "inline-block min-w-12 tabular-nums text-xs text-muted-foreground"; export interface TerminalSearchBarProps { @@ -41,10 +43,6 @@ export function TerminalSearchBar(props: TerminalSearchBarProps) { event.preventDefault(); event.stopPropagation(); }; - const toggles = [ - [".*", "Use regular expression", props.regex, props.onRegexChange], - ["Aa", "Match case", props.caseSensitive, props.onCaseSensitiveChange], - ] as const; const actions = [ [ChevronUp, "Previous match", props.onPrevious, props.matchCount === 0], [ChevronDown, "Next match", props.onNext, props.matchCount === 0], @@ -67,22 +65,22 @@ export function TerminalSearchBar(props: TerminalSearchBarProps) { nativeInput /> - {toggles.map(([label, ariaLabel, value, setValue]) => ( - - ))} + .* + + props.onCaseSensitiveChange(!props.caseSensitive)} + > + Aa + +
{props.error diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 26015c5b3393..a35cd23bc185 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,7 +1,7 @@ import { Spinner } from "~/components/ui/spinner"; import type { ProjectContentMatch } from "@t3tools/contracts"; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; import { useTheme } from "~/hooks/useTheme"; @@ -12,9 +12,8 @@ import { useProjectContentSearch } from "~/state/queries"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { CommandPaletteContent } from "../CommandPaletteContent"; import { ScrollArea } from "../ui/scroll-area"; -import { Toggle } from "../ui/toggle"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { HighlightedSearchLine } from "./HighlightedSearchLine"; +import { SearchOptionButton } from "./SearchOptionButton"; interface ProjectContentSearchDialogProps { readonly onOpenChange: (open: boolean) => void; @@ -54,33 +53,6 @@ function groupMatches(matches: ReadonlyArray): MatchGroup[] return [...groups].map(([path, groupedMatches]) => ({ path, matches: groupedMatches })); } -function SearchOptionButton(props: { - readonly active: boolean; - readonly label: string; - readonly onClick: () => void; - readonly children: ReactNode; -}) { - return ( - - - } - > - {props.children} - - {props.label} - - ); -} - function EmptyContentSearchDialog() { return ( void; + readonly children: ReactNode; +}) { + return ( + + + } + > + {props.children} + + {props.label} + + ); +} diff --git a/apps/web/src/terminal/ghostty/search.ts b/apps/web/src/terminal/ghostty/search.ts index c6f5307d934e..c0c2e38bf12a 100644 --- a/apps/web/src/terminal/ghostty/search.ts +++ b/apps/web/src/terminal/ghostty/search.ts @@ -1,3 +1,5 @@ +import { collectWrappedTerminalLinkLine, type WrappedTerminalLinkLine } from "../../terminal-links"; + export const MAX_TERMINAL_SEARCH_MATCHES = 2000; export interface TerminalSearchOptions { @@ -37,38 +39,19 @@ export interface TerminalSearchCellRow { readonly cells: readonly { readonly text: string; readonly wide: number }[]; } -function logicalLines(rows: TerminalSearchRows): { text: string; startRow: number }[] { - const lines: { text: string; startRow: number }[] = []; - const rowCount = Math.max(rows.texts.length, rows.wraps.length); - let text = ""; - let startRow = 0; - for (let row = 0; row < rowCount; row += 1) { - text += rows.texts[row] ?? ""; - if (rows.wraps[row] === true && row < rowCount - 1) continue; - const trimmedText = text.trimEnd(); - if (trimmedText.length > 0) lines.push({ text: trimmedText, startRow }); - text = ""; - startRow = row + 1; - } - return lines; -} - function searchPattern(query: string, options: TerminalSearchOptions): RegExp { const source = options.regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(source, options.caseSensitive ? "g" : "gi"); } function positionForOffset( - texts: readonly string[], - startRow: number, + line: WrappedTerminalLinkLine, offset: number, ): TerminalSearchPosition | null { - let consumedCharacters = 0; - for (let row = startRow; row < texts.length; row += 1) { - const rowLength = (texts[row] ?? "").length; - if (offset < consumedCharacters + rowLength) - return { row, offset: offset - consumedCharacters }; - consumedCharacters += rowLength; + for (const segment of line.segments) { + if (offset >= segment.startIndex && offset < segment.endIndex) { + return { row: segment.bufferLineNumber - 1, offset: offset - segment.startIndex }; + } } return null; } @@ -86,20 +69,33 @@ export function findTerminalSearchMatches( } catch { return { matches: [], truncated: false, error: "Invalid regular expression" }; } + + const getLine = (index: number) => + index < rows.texts.length + ? { + isWrapped: index > 0 && rows.wraps[index - 1] === true, + translateToString: (trimRight = false) => { + const text = rows.texts[index] ?? ""; + return trimRight ? text.trimEnd() : text; + }, + } + : null; + const matches: TerminalSearchMatch[] = []; - for (const line of logicalLines(rows)) { + let row = 0; + while (row < rows.texts.length) { + const line = collectWrappedTerminalLinkLine(row + 1, getLine); + if (!line) break; + row = line.segments.at(-1)?.bufferLineNumber ?? row + 1; + if (line.text.length === 0) continue; pattern.lastIndex = 0; for (let match = pattern.exec(line.text); match !== null; match = pattern.exec(line.text)) { if (match[0].length === 0) { pattern.lastIndex = match.index + 1; continue; } - const start = positionForOffset(rows.texts, line.startRow, match.index); - const inclusiveEnd = positionForOffset( - rows.texts, - line.startRow, - match.index + match[0].length - 1, - ); + const start = positionForOffset(line, match.index); + const inclusiveEnd = positionForOffset(line, match.index + match[0].length - 1); if (start === null || inclusiveEnd === null) continue; if (matches.length === MAX_TERMINAL_SEARCH_MATCHES) return { matches, truncated: true, error: null }; From 3026d251cf4aaa79ff1580d6a94ab29234a4bfd5 Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 12:51:52 -0600 Subject: [PATCH 3/6] feat(web): condense terminal find bar and let it snap between corners - Find is literal text only. Regex search is removed, so a pathological pattern can no longer block the terminal UI while scanning scrollback. - The find bar is a single compact pill (grip, input, match case, count, previous/next/close) using the same floating toolbar styling as the preview mini player. - Drag the grip to move the bar between the top-right and bottom-right of the terminal. It snaps to the nearer corner with the panel easing, a click or Enter on the grip flips it, reduced motion disables the animation, and the corner is remembered. - Escape and the find shortcut now work from anywhere in the bar, not only while the input has focus. --- apps/web/src/components/TerminalSearchBar.tsx | 201 +++++++++++++++--- .../src/components/ThreadTerminalDrawer.tsx | 5 - apps/web/src/terminal/ghostty/core.test.ts | 2 +- apps/web/src/terminal/ghostty/search.test.ts | 72 ++++--- apps/web/src/terminal/ghostty/search.ts | 26 +-- apps/web/src/terminal/ghostty/surface.ts | 13 +- docs/user/terminal.md | 6 +- 7 files changed, 226 insertions(+), 99 deletions(-) diff --git a/apps/web/src/components/TerminalSearchBar.tsx b/apps/web/src/components/TerminalSearchBar.tsx index 61f53a5a4e9e..ccf1c96327c2 100644 --- a/apps/web/src/components/TerminalSearchBar.tsx +++ b/apps/web/src/components/TerminalSearchBar.tsx @@ -1,32 +1,64 @@ -import { ChevronDown, ChevronUp, X } from "lucide-react"; -import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef } from "react"; +import { ChevronDown, ChevronUp, GripVertical, X } from "lucide-react"; +import { + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + useEffect, + useLayoutEffect, + useRef, +} from "react"; +import * as Schema from "effect/Schema"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { SearchOptionButton } from "~/components/search/SearchOptionButton"; +import { cn } from "~/lib/utils"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; -const STATUS_CLASS = "inline-block min-w-12 tabular-nums text-xs text-muted-foreground"; +const TERMINAL_FIND_POSITION_STORAGE_KEY = "t3code:terminal-find-position:v1"; +const TerminalFindPosition = Schema.Literals(["top", "bottom"]); +const DRAG_THRESHOLD_PX = 4; +const PANE_EDGE_GAP_PX = 4; + +interface GripDrag { + readonly pointerId: number; + readonly startY: number; + readonly minOffset: number; + readonly maxOffset: number; + readonly startCenter: number; + readonly paneCenter: number; +} export interface TerminalSearchBarProps { readonly query: string; readonly caseSensitive: boolean; - readonly regex: boolean; readonly matchCount: number; readonly activeIndex: number; readonly truncated: boolean; - readonly error: string | null; readonly focusRequestId: number; readonly isFindShortcut: (event: KeyboardEvent) => boolean; readonly onQueryChange: (query: string) => void; readonly onCaseSensitiveChange: (caseSensitive: boolean) => void; - readonly onRegexChange: (regex: boolean) => void; readonly onNext: () => void; readonly onPrevious: () => void; readonly onClose: () => void; } +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + export function TerminalSearchBar(props: TerminalSearchBarProps) { const inputRef = useRef(null); + const barRef = useRef(null); + const dragRef = useRef(null); + const snapFromTopRef = useRef(null); const lastFocusRequestIdRef = useRef(null); + const [position, setPosition] = useLocalStorage( + TERMINAL_FIND_POSITION_STORAGE_KEY, + "top", + TerminalFindPosition, + ); + const lastPositionRef = useRef(position); useEffect(() => { if (lastFocusRequestIdRef.current === props.focusRequestId) return; @@ -35,14 +67,105 @@ export function TerminalSearchBar(props: TerminalSearchBarProps) { inputRef.current?.select(); }, [props.focusRequestId]); - const handleKeyDown = (event: ReactKeyboardEvent) => { - if (event.key === "Enter") (event.shiftKey ? props.onPrevious : props.onNext)(); - else if (event.key === "Escape") props.onClose(); - else if (props.isFindShortcut(event.nativeEvent)) inputRef.current?.select(); - else return; + useLayoutEffect(() => { + if (lastPositionRef.current === position) return; + lastPositionRef.current = position; + const bar = barRef.current; + const fromTop = snapFromTopRef.current; + snapFromTopRef.current = null; + if (!bar || fromTop === null) return; + bar.dataset.dragging = ""; + bar.style.transform = ""; + const restingTop = bar.getBoundingClientRect().top; + bar.style.transform = `translateY(${fromTop - restingTop}px)`; + bar.getBoundingClientRect(); + delete bar.dataset.dragging; + bar.style.transform = ""; + }, [position]); + + const settle = (next: "top" | "bottom") => { + const bar = barRef.current; + if (!bar) return; + if (next === position) { + delete bar.dataset.dragging; + bar.style.transform = ""; + return; + } + snapFromTopRef.current = bar.getBoundingClientRect().top; + setPosition(next); + }; + + const handleContainerKeyDown = (event: ReactKeyboardEvent) => { + const nativeEvent = event.nativeEvent; + if (nativeEvent.key === "Escape") { + props.onClose(); + event.preventDefault(); + event.stopPropagation(); + } else if (props.isFindShortcut(nativeEvent)) { + inputRef.current?.focus({ preventScroll: true }); + inputRef.current?.select(); + event.preventDefault(); + event.stopPropagation(); + } else if (nativeEvent.key === "Enter" && event.target === inputRef.current) { + (nativeEvent.shiftKey ? props.onPrevious : props.onNext)(); + event.preventDefault(); + event.stopPropagation(); + } + }; + + const handleGripPointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0) return; event.preventDefault(); - event.stopPropagation(); + const bar = barRef.current; + const pane = bar?.offsetParent as HTMLElement | null; + if (!bar || !pane) return; + const barRect = bar.getBoundingClientRect(); + const paneRect = pane.getBoundingClientRect(); + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + pointerId: event.pointerId, + startY: event.clientY, + minOffset: paneRect.top + PANE_EDGE_GAP_PX - barRect.top, + maxOffset: paneRect.bottom - PANE_EDGE_GAP_PX - barRect.bottom, + startCenter: barRect.top + barRect.height / 2, + paneCenter: paneRect.top + paneRect.height / 2, + }; + bar.dataset.dragging = ""; }; + + const handleGripPointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const bar = barRef.current; + if (!bar) return; + const offset = clamp(event.clientY - drag.startY, drag.minOffset, drag.maxOffset); + bar.style.transform = `translateY(${offset}px)`; + }; + + const handleGripPointerEnd = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + event.currentTarget.releasePointerCapture(event.pointerId); + dragRef.current = null; + const bar = barRef.current; + if (!bar) return; + const moved = event.clientY - drag.startY; + if (event.type !== "pointercancel" && Math.abs(moved) < DRAG_THRESHOLD_PX) { + settle(position === "top" ? "bottom" : "top"); + } else { + settle( + drag.startCenter + clamp(moved, drag.minOffset, drag.maxOffset) > drag.paneCenter + ? "bottom" + : "top", + ); + } + }; + + const handleGripClick = (event: ReactMouseEvent) => { + if (event.detail === 0) settle(position === "top" ? "bottom" : "top"); + }; + + const positionClass = position === "top" ? "top-8" : "bottom-2"; const actions = [ [ChevronUp, "Previous match", props.onPrevious, props.matchCount === 0], [ChevronDown, "Next match", props.onNext, props.matchCount === 0], @@ -50,29 +173,42 @@ export function TerminalSearchBar(props: TerminalSearchBarProps) { ] as const; return ( -
+
+ + props.onQueryChange(e.target.value)} - onKeyDown={handleKeyDown} aria-label="Find in terminal" - aria-invalid={props.error !== null} - size="compact" - className="w-44" + className="h-6 w-28 bg-transparent px-1.5 text-xs outline-none placeholder:text-muted-foreground" nativeInput + unstyled /> -
e.preventDefault()} className="flex items-center gap-0.5"> - props.onRegexChange(!props.regex)} - > - .* - +
e.preventDefault()}>
- - {props.error - ? "Invalid" - : props.query.length > 0 && - (props.matchCount === 0 - ? "No results" - : `${props.activeIndex + 1}/${props.matchCount}${props.truncated ? "+" : ""}`)} + + {props.query.length === 0 + ? "" + : props.matchCount === 0 + ? "0/0" + : `${props.activeIndex + 1}/${props.matchCount}${props.truncated ? "+" : ""}`} {actions.map(([Icon, label, onClick, disabled]) => ( @@ -101,7 +236,7 @@ export function TerminalSearchBar(props: TerminalSearchBarProps) { onMouseDown={(e) => e.preventDefault()} disabled={disabled} > - + ))}
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 93d70f6fbcf8..dc150ad5753f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -107,7 +107,6 @@ const INITIAL_TERMINAL_SEARCH = { open: false, query: "", caseSensitive: false, - regex: false, }; function maxDrawerHeight(): number { @@ -376,7 +375,6 @@ export function TerminalViewport({ matchCount: 0, activeIndex: -1, truncated: false, - error: null, }); const [searchFocusRequestId, setSearchFocusRequestId] = useState(0); const searchRef = useRef(search); @@ -1058,18 +1056,15 @@ export function TerminalViewport({ isTerminalFindShortcut(event, keybindings, TERMINAL_SHORTCUT_OPTIONS) } onQueryChange={(query) => updateSearch({ query })} onCaseSensitiveChange={(caseSensitive) => updateSearch({ caseSensitive })} - onRegexChange={(regex) => updateSearch({ regex })} onNext={() => stepSearch(1)} onPrevious={() => stepSearch(-1)} onClose={handleSearchClose} diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 819d05d394ef..b2ef36689ade 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -385,7 +385,7 @@ const searchTheme = { background: { r: 0, g: 0, b: 0 }, cursor: { r: 255, g: 255, b: 255 }, }; -const searchOptions = { caseSensitive: false, regex: false }; +const searchOptions = { caseSensitive: false }; describe("GhosttyTerminalCore.searchRows", () => { const cores = new Set(); diff --git a/apps/web/src/terminal/ghostty/search.test.ts b/apps/web/src/terminal/ghostty/search.test.ts index 52cc0f13c85d..a82c1227f4ac 100644 --- a/apps/web/src/terminal/ghostty/search.test.ts +++ b/apps/web/src/terminal/ghostty/search.test.ts @@ -11,61 +11,58 @@ import { type TerminalSearchMatch, type TerminalSearchRows, } from "./search"; -const insensitiveLiteral = { caseSensitive: false, regex: false }; + +const insensitiveLiteral = { caseSensitive: false }; + function match(row: number, start: number, end: number): TerminalSearchMatch { return { start: { row, offset: start }, end: { row, offset: end } }; } + describe("findTerminalSearchMatches", () => { it("finds case-insensitive matches", () => { const rows = { texts: ["Hello", "HELLO"], wraps: [false, false] }; expect(findTerminalSearchMatches(rows, "hello", insensitiveLiteral)).toEqual({ matches: [match(0, 0, 5), match(1, 0, 5)], truncated: false, - error: null, }); }); + it("respects case-sensitive option", () => { const rows = { texts: ["Hello HELLO hello"], wraps: [false] }; - expect(findTerminalSearchMatches(rows, "hello", { caseSensitive: true, regex: false })).toEqual( - { matches: [match(0, 12, 17)], truncated: false, error: null }, - ); - }); - it("handles regex patterns", () => { - const rows = { texts: ["foo123bar456"], wraps: [false] }; - expect( - findTerminalSearchMatches(rows, "(\\d+)", { caseSensitive: false, regex: true }), - ).toEqual({ matches: [match(0, 3, 6), match(0, 9, 12)], truncated: false, error: null }); - }); - it("returns error for invalid regex", () => { - const rows = { texts: ["test"], wraps: [false] }; - expect( - findTerminalSearchMatches(rows, "[invalid(", { caseSensitive: false, regex: true }), - ).toEqual({ matches: [], truncated: false, error: "Invalid regular expression" }); + expect(findTerminalSearchMatches(rows, "hello", { caseSensitive: true })).toEqual({ + matches: [match(0, 12, 17)], + truncated: false, + }); }); - it("skips zero-length matches", () => { - const rows = { texts: ["aaa"], wraps: [false] }; - expect(findTerminalSearchMatches(rows, "a*", { caseSensitive: false, regex: true })).toEqual({ - matches: [match(0, 0, 3)], + + it("matches regex metacharacters literally", () => { + const rows = { texts: ["xa+(by", "x"], wraps: [false, false] }; + expect(findTerminalSearchMatches(rows, "a+(b", insensitiveLiteral)).toEqual({ + matches: [match(0, 1, 5)], + truncated: false, + }); + expect(findTerminalSearchMatches(rows, ".", insensitiveLiteral)).toEqual({ + matches: [], truncated: false, - error: null, }); }); + it("handles matches spanning wrapped rows, including an empty row", () => { const rows = { texts: ["0123456789", "", "WRAP"], wraps: [true, true, false] }; expect(findTerminalSearchMatches(rows, "89WRAP", insensitiveLiteral)).toEqual({ matches: [{ start: { row: 0, offset: 8 }, end: { row: 2, offset: 4 } }], truncated: false, - error: null, }); }); + it("skips trimmed trailing whitespace", () => { const rows = { texts: ["hello ", "world"], wraps: [false, false] }; expect(findTerminalSearchMatches(rows, " ", insensitiveLiteral)).toEqual({ matches: [], truncated: false, - error: null, }); }); + it("truncates only when matches exceed the maximum", () => { const texts = Array.from({ length: MAX_TERMINAL_SEARCH_MATCHES + 1 }, () => "match"); const exactRows: TerminalSearchRows = { @@ -73,21 +70,22 @@ describe("findTerminalSearchMatches", () => { wraps: texts.map(() => false), }; const exactResult = findTerminalSearchMatches(exactRows, "match", insensitiveLiteral); - expect([exactResult.matches.length, exactResult.truncated, exactResult.error]).toEqual([ + expect([exactResult.matches.length, exactResult.truncated]).toEqual([ MAX_TERMINAL_SEARCH_MATCHES, false, - null, ]); const overflowResult = findTerminalSearchMatches( { texts, wraps: exactRows.wraps }, "match", insensitiveLiteral, ); - expect([overflowResult.matches.length, overflowResult.truncated, overflowResult.error]).toEqual( - [MAX_TERMINAL_SEARCH_MATCHES, true, null], - ); + expect([overflowResult.matches.length, overflowResult.truncated]).toEqual([ + MAX_TERMINAL_SEARCH_MATCHES, + true, + ]); }); }); + describe("terminalSearchHighlights", () => { it("maps wide-character offsets", () => { const rows: TerminalSearchCellRow[] = [ @@ -103,6 +101,7 @@ describe("terminalSearchHighlights", () => { { row: 0, startColumn: 0, endColumn: 1, active: true }, ]); }); + it("clips multi-row highlights", () => { const rows = [{ cells: [{ text: "a", wide: 0 }] }, { cells: [{ text: "b", wide: 0 }] }]; expect( @@ -117,6 +116,7 @@ describe("terminalSearchHighlights", () => { { row: 1, startColumn: 0, endColumn: 0, active: true }, ]); }); + it("sets the active flag", () => { const rows = [ { cells: [{ text: "first", wide: 0 }] }, @@ -128,39 +128,53 @@ describe("terminalSearchHighlights", () => { ]); }); }); + describe("initialTerminalSearchIndex", () => { it("returns -1 when no matches", () => { expect(initialTerminalSearchIndex([], 0, 10)).toBe(-1); }); + it("returns last match below viewport bottom", () => { expect( initialTerminalSearchIndex([match(0, 0, 5), match(5, 0, 5), match(15, 0, 5)], 10, 10), ).toBe(2); }); + it("returns 0 if no matches precede the viewport bottom", () => { expect(initialTerminalSearchIndex([match(20, 0, 5)], 0, 10)).toBe(0); }); }); + describe("stepTerminalSearchIndex", () => { it("returns -1 when no matches", () => expect(stepTerminalSearchIndex(0, 0, 1)).toBe(-1)); + it("wraps forward", () => expect(stepTerminalSearchIndex(4, 5, 1)).toBe(0)); + it("wraps backward", () => expect(stepTerminalSearchIndex(0, 5, -1)).toBe(4)); + it("starts forward", () => expect(stepTerminalSearchIndex(-1, 5, 1)).toBe(0)); + it("starts backward", () => expect(stepTerminalSearchIndex(-1, 5, -1)).toBe(4)); }); + describe("closestTerminalSearchIndex", () => { it("returns -1 when empty or no previous match", () => { expect(closestTerminalSearchIndex([], null)).toBe(-1); }); + it("finds the next match at the previous position", () => { expect(closestTerminalSearchIndex([match(0, 0, 5), match(5, 0, 5)], match(5, 0, 5))).toBe(1); }); }); + describe("terminalSearchScrollDelta", () => { const scrollbar = { total: 100, offset: 0, len: 20 }; + it("returns 0 when visible", () => expect(terminalSearchScrollDelta(match(5, 0, 5), scrollbar)).toBe(0)); + it("centers row 50", () => expect(terminalSearchScrollDelta(match(50, 0, 5), scrollbar)).toBe(41)); + it("clamps row 95", () => expect(terminalSearchScrollDelta(match(95, 0, 5), scrollbar)).toBe(80)); }); diff --git a/apps/web/src/terminal/ghostty/search.ts b/apps/web/src/terminal/ghostty/search.ts index c0c2e38bf12a..1b11ef5096b8 100644 --- a/apps/web/src/terminal/ghostty/search.ts +++ b/apps/web/src/terminal/ghostty/search.ts @@ -4,7 +4,6 @@ export const MAX_TERMINAL_SEARCH_MATCHES = 2000; export interface TerminalSearchOptions { readonly caseSensitive: boolean; - readonly regex: boolean; } export interface TerminalSearchRows { @@ -25,7 +24,6 @@ export interface TerminalSearchMatch { export interface TerminalSearchResult { readonly matches: readonly TerminalSearchMatch[]; readonly truncated: boolean; - readonly error: string | null; } export interface TerminalSearchHighlight { @@ -40,8 +38,8 @@ export interface TerminalSearchCellRow { } function searchPattern(query: string, options: TerminalSearchOptions): RegExp { - const source = options.regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(source, options.caseSensitive ? "g" : "gi"); + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(escaped, options.caseSensitive ? "g" : "gi"); } function positionForOffset( @@ -56,19 +54,14 @@ function positionForOffset( return null; } -/** Finds literal or regular-expression matches across terminal soft wraps. */ +/** Finds literal matches across terminal soft wraps. */ export function findTerminalSearchMatches( rows: TerminalSearchRows, query: string, options: TerminalSearchOptions, ): TerminalSearchResult { - if (query.length === 0) return { matches: [], truncated: false, error: null }; - let pattern: RegExp; - try { - pattern = searchPattern(query, options); - } catch { - return { matches: [], truncated: false, error: "Invalid regular expression" }; - } + if (query.length === 0) return { matches: [], truncated: false }; + const pattern = searchPattern(query, options); const getLine = (index: number) => index < rows.texts.length @@ -90,19 +83,14 @@ export function findTerminalSearchMatches( if (line.text.length === 0) continue; pattern.lastIndex = 0; for (let match = pattern.exec(line.text); match !== null; match = pattern.exec(line.text)) { - if (match[0].length === 0) { - pattern.lastIndex = match.index + 1; - continue; - } const start = positionForOffset(line, match.index); const inclusiveEnd = positionForOffset(line, match.index + match[0].length - 1); if (start === null || inclusiveEnd === null) continue; - if (matches.length === MAX_TERMINAL_SEARCH_MATCHES) - return { matches, truncated: true, error: null }; + if (matches.length === MAX_TERMINAL_SEARCH_MATCHES) return { matches, truncated: true }; matches.push({ start, end: { row: inclusiveEnd.row, offset: inclusiveEnd.offset + 1 } }); } } - return { matches, truncated: false, error: null }; + return { matches, truncated: false }; } function columnForOffset( diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index cd2fb7e860fb..b37152e5f603 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -553,7 +553,6 @@ export interface GhosttyTerminalSearchState { readonly matchCount: number; readonly activeIndex: number; readonly truncated: boolean; - readonly error: string | null; } export interface GhosttyTerminalSurfaceOptions { @@ -664,7 +663,6 @@ export class GhosttyTerminalSurface { private searchRefreshTimer: number | null = null; private searchPreviousActiveMatch: TerminalSearchMatch | null = null; private searchTruncated = false; - private searchError: string | null = null; private constructor( mount: HTMLElement, @@ -1091,9 +1089,7 @@ export class GhosttyTerminalSurface { this.searchQuery = query; this.searchOptions = options; this.clearSearchRefreshTimer(); - const result = query - ? this.computeSearch(query, options) - : { matches: [], truncated: false, error: null }; + const result = query ? this.computeSearch(query, options) : { matches: [], truncated: false }; return this.applySearchResult(result, this.initialSearchIndex(result.matches), true); } @@ -1113,7 +1109,7 @@ export class GhosttyTerminalSurface { this.searchQuery = ""; this.searchOptions = null; this.clearSearchRefreshTimer(); - this.applySearchResult({ matches: [], truncated: false, error: null }, -1, false); + this.applySearchResult({ matches: [], truncated: false }, -1, false); } private getSearchState(): GhosttyTerminalSearchState { @@ -1121,7 +1117,6 @@ export class GhosttyTerminalSurface { matchCount: this.searchMatches.length, activeIndex: this.searchActiveIndex, truncated: this.searchTruncated, - error: this.searchError, }; } @@ -1141,7 +1136,6 @@ export class GhosttyTerminalSurface { if (this.disposed) return this.getSearchState(); this.searchMatches = [...result.matches]; this.searchTruncated = result.truncated; - this.searchError = result.error; const match = this.searchMatches[activeIndex]; this.searchActiveIndex = match ? activeIndex : -1; this.searchPreviousActiveMatch = match ?? null; @@ -1194,8 +1188,7 @@ export class GhosttyTerminalSurface { const changed = previous.matchCount !== next.matchCount || previous.activeIndex !== next.activeIndex || - previous.truncated !== next.truncated || - previous.error !== next.error; + previous.truncated !== next.truncated; if (changed) this.options.onSearchChange?.(next); } diff --git a/docs/user/terminal.md b/docs/user/terminal.md index c55010a5f457..411730366ff9 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -12,5 +12,7 @@ history. A client can show less scrollback than the server keeps. With a terminal focused, press `mod+f` to search its output and scrollback. `mod` is Command on macOS and Ctrl on Windows and Linux; `Ctrl+Shift+F` also works. Enter moves to the next match and Shift+Enter to the previous one. Use -the toggles for case-sensitive or regular-expression search, and Escape to -close. Rebind **Terminal: Find** in Settings → Keybindings. +the toggle for case-sensitive search, and Escape to close. The find bar can be +dragged by its grip handle between the top-right and bottom-right corners of the +terminal and remembers your preference. Rebind **Terminal: Find** in Settings → +Keybindings. From ccccbd6e54ad8f5feba0c3db65506c060aee3469 Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 13:58:13 -0600 Subject: [PATCH 4/6] feat(web): open terminal find inline with the terminal action buttons Find now lives in the terminal's floating button row instead of a separate draggable bar, so it takes no extra space: - A search icon sits at the left of the split/new/close buttons. Clicking it, or pressing the find shortcut, expands find leftward inside the same bordered group: input, match case, count, previous, next, close. Every segment uses the row's own button size and dividers, so the row stays 23px tall. Closing collapses it back to the icon. - With split panes or the terminal sidebar, where that row isn't shown, the find shortcut opens the same compact strip at the focused pane's top-right. - Drag and corner snapping are removed. - TerminalActionButton moves into its own module so find reuses the same tooltip button as the rest of the row. --- .../src/components/TerminalActionButton.tsx | 46 +++ apps/web/src/components/TerminalSearchBar.tsx | 269 ++++++------------ .../src/components/ThreadTerminalDrawer.tsx | 84 +++--- .../components/search/SearchOptionButton.tsx | 7 +- docs/user/terminal.md | 9 +- 5 files changed, 178 insertions(+), 237 deletions(-) create mode 100644 apps/web/src/components/TerminalActionButton.tsx diff --git a/apps/web/src/components/TerminalActionButton.tsx b/apps/web/src/components/TerminalActionButton.tsx new file mode 100644 index 000000000000..ddf97db60511 --- /dev/null +++ b/apps/web/src/components/TerminalActionButton.tsx @@ -0,0 +1,46 @@ +import type { ComponentType, MouseEventHandler, ReactNode } from "react"; +import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; + +interface TerminalActionButtonProps { + readonly icon?: ComponentType<{ className?: string }>; + readonly label: string; + readonly className?: string; + readonly onClick: () => void; + readonly onMouseDown?: MouseEventHandler; + readonly children?: ReactNode; +} + +export const TerminalActionButton = ({ + icon: Icon, + label, + className = "p-1 text-foreground/90 transition-colors hover:bg-accent", + onClick, + onMouseDown, + children, +}: TerminalActionButtonProps) => ( + + + } + > + {Icon ? : children} + + + {label} + + +); diff --git a/apps/web/src/components/TerminalSearchBar.tsx b/apps/web/src/components/TerminalSearchBar.tsx index ccf1c96327c2..4cdee13e5535 100644 --- a/apps/web/src/components/TerminalSearchBar.tsx +++ b/apps/web/src/components/TerminalSearchBar.tsx @@ -1,34 +1,17 @@ -import { ChevronDown, ChevronUp, GripVertical, X } from "lucide-react"; +import { ChevronDown, ChevronUp, Search, X } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, useEffect, - useLayoutEffect, useRef, } from "react"; -import * as Schema from "effect/Schema"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; import { SearchOptionButton } from "~/components/search/SearchOptionButton"; +import { TerminalActionButton } from "~/components/TerminalActionButton"; import { cn } from "~/lib/utils"; -import { useLocalStorage } from "~/hooks/useLocalStorage"; - -const TERMINAL_FIND_POSITION_STORAGE_KEY = "t3code:terminal-find-position:v1"; -const TerminalFindPosition = Schema.Literals(["top", "bottom"]); -const DRAG_THRESHOLD_PX = 4; -const PANE_EDGE_GAP_PX = 4; - -interface GripDrag { - readonly pointerId: number; - readonly startY: number; - readonly minOffset: number; - readonly maxOffset: number; - readonly startCenter: number; - readonly paneCenter: number; -} export interface TerminalSearchBarProps { + readonly open: boolean; + readonly docked: boolean; readonly query: string; readonly caseSensitive: boolean; readonly matchCount: number; @@ -36,209 +19,131 @@ export interface TerminalSearchBarProps { readonly truncated: boolean; readonly focusRequestId: number; readonly isFindShortcut: (event: KeyboardEvent) => boolean; + readonly findShortcutLabel?: string; + readonly onOpen: () => void; readonly onQueryChange: (query: string) => void; - readonly onCaseSensitiveChange: (caseSensitive: boolean) => void; + readonly onCaseSensitiveChange: (value: boolean) => void; readonly onNext: () => void; readonly onPrevious: () => void; readonly onClose: () => void; } -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - export function TerminalSearchBar(props: TerminalSearchBarProps) { const inputRef = useRef(null); - const barRef = useRef(null); - const dragRef = useRef(null); - const snapFromTopRef = useRef(null); const lastFocusRequestIdRef = useRef(null); - const [position, setPosition] = useLocalStorage( - TERMINAL_FIND_POSITION_STORAGE_KEY, - "top", - TerminalFindPosition, - ); - const lastPositionRef = useRef(position); useEffect(() => { - if (lastFocusRequestIdRef.current === props.focusRequestId) return; + if (!props.open || lastFocusRequestIdRef.current === props.focusRequestId) return; lastFocusRequestIdRef.current = props.focusRequestId; inputRef.current?.focus({ preventScroll: true }); inputRef.current?.select(); - }, [props.focusRequestId]); - - useLayoutEffect(() => { - if (lastPositionRef.current === position) return; - lastPositionRef.current = position; - const bar = barRef.current; - const fromTop = snapFromTopRef.current; - snapFromTopRef.current = null; - if (!bar || fromTop === null) return; - bar.dataset.dragging = ""; - bar.style.transform = ""; - const restingTop = bar.getBoundingClientRect().top; - bar.style.transform = `translateY(${fromTop - restingTop}px)`; - bar.getBoundingClientRect(); - delete bar.dataset.dragging; - bar.style.transform = ""; - }, [position]); - - const settle = (next: "top" | "bottom") => { - const bar = barRef.current; - if (!bar) return; - if (next === position) { - delete bar.dataset.dragging; - bar.style.transform = ""; - return; - } - snapFromTopRef.current = bar.getBoundingClientRect().top; - setPosition(next); - }; + }, [props.focusRequestId, props.open]); - const handleContainerKeyDown = (event: ReactKeyboardEvent) => { + const handleKeyDown = (event: ReactKeyboardEvent) => { const nativeEvent = event.nativeEvent; if (nativeEvent.key === "Escape") { props.onClose(); - event.preventDefault(); - event.stopPropagation(); } else if (props.isFindShortcut(nativeEvent)) { inputRef.current?.focus({ preventScroll: true }); inputRef.current?.select(); - event.preventDefault(); - event.stopPropagation(); } else if (nativeEvent.key === "Enter" && event.target === inputRef.current) { (nativeEvent.shiftKey ? props.onPrevious : props.onNext)(); - event.preventDefault(); - event.stopPropagation(); - } - }; - - const handleGripPointerDown = (event: ReactPointerEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - const bar = barRef.current; - const pane = bar?.offsetParent as HTMLElement | null; - if (!bar || !pane) return; - const barRect = bar.getBoundingClientRect(); - const paneRect = pane.getBoundingClientRect(); - event.currentTarget.setPointerCapture(event.pointerId); - dragRef.current = { - pointerId: event.pointerId, - startY: event.clientY, - minOffset: paneRect.top + PANE_EDGE_GAP_PX - barRect.top, - maxOffset: paneRect.bottom - PANE_EDGE_GAP_PX - barRect.bottom, - startCenter: barRect.top + barRect.height / 2, - paneCenter: paneRect.top + paneRect.height / 2, - }; - bar.dataset.dragging = ""; - }; - - const handleGripPointerMove = (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - const bar = barRef.current; - if (!bar) return; - const offset = clamp(event.clientY - drag.startY, drag.minOffset, drag.maxOffset); - bar.style.transform = `translateY(${offset}px)`; - }; - - const handleGripPointerEnd = (event: ReactPointerEvent) => { - const drag = dragRef.current; - if (!drag || drag.pointerId !== event.pointerId) return; - event.currentTarget.releasePointerCapture(event.pointerId); - dragRef.current = null; - const bar = barRef.current; - if (!bar) return; - const moved = event.clientY - drag.startY; - if (event.type !== "pointercancel" && Math.abs(moved) < DRAG_THRESHOLD_PX) { - settle(position === "top" ? "bottom" : "top"); } else { - settle( - drag.startCenter + clamp(moved, drag.minOffset, drag.maxOffset) > drag.paneCenter - ? "bottom" - : "top", - ); + return; } + event.preventDefault(); + event.stopPropagation(); }; - const handleGripClick = (event: ReactMouseEvent) => { - if (event.detail === 0) settle(position === "top" ? "bottom" : "top"); - }; - - const positionClass = position === "top" ? "top-8" : "bottom-2"; - const actions = [ - [ChevronUp, "Previous match", props.onPrevious, props.matchCount === 0], - [ChevronDown, "Next match", props.onNext, props.matchCount === 0], - [X, "Close find", props.onClose, false], - ] as const; - - return ( -
- - - ) => event.preventDefault(); + const count = + props.query.length === 0 + ? "" + : props.matchCount === 0 + ? "0/0" + : `${props.activeIndex + 1}/${props.matchCount}${props.truncated ? "+" : ""}`; + const actionClassName = "p-1 text-foreground/90 transition-colors hover:bg-accent"; + const navigationClassName = cn( + actionClassName, + props.matchCount === 0 && "pointer-events-none opacity-45", + ); + const row = ( + <> + props.onQueryChange(e.target.value)} + onChange={(event) => props.onQueryChange(event.target.value)} + className="h-5 w-32 bg-transparent px-1.5 text-xs leading-5 text-foreground outline-none placeholder:text-muted-foreground" + placeholder="Find" aria-label="Find in terminal" - className="h-6 w-28 bg-transparent px-1.5 text-xs outline-none placeholder:text-muted-foreground" - nativeInput - unstyled /> - -
e.preventDefault()}> +
+
event.preventDefault()}> props.onCaseSensitiveChange(!props.caseSensitive)} > Aa
- +
- {props.query.length === 0 - ? "" - : props.matchCount === 0 - ? "0/0" - : `${props.activeIndex + 1}/${props.matchCount}${props.truncated ? "+" : ""}`} + {count} +
+ + + + + ); - {actions.map(([Icon, label, onClick, disabled]) => ( - - ))} -
+ if (!props.docked) { + if (!props.open) return null; + return ( +
+ {row} +
+ ); + } + + const label = `Find in terminal${props.findShortcutLabel ? ` (${props.findShortcutLabel})` : ""}`; + return ( + <> + {!props.open && } +
+
{row}
+
+ ); } diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index dc150ad5753f..b6092a1f30d3 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -29,7 +29,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, - type ReactNode, type SetStateAction, useCallback, useEffect, @@ -39,9 +38,10 @@ import { useRef, useState, } from "react"; -import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { createPortal } from "react-dom"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { TerminalActionButton } from "~/components/TerminalActionButton"; import { TerminalSearchBar } from "~/components/TerminalSearchBar"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; @@ -68,6 +68,7 @@ import { isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + shortcutLabelForCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; @@ -340,6 +341,7 @@ interface TerminalViewportProps { resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; + findSlot?: HTMLElement | null; } interface TerminalLaunchLocation { @@ -366,6 +368,7 @@ export function TerminalViewport({ resizeEpoch, drawerHeight, keybindings, + findSlot, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -1045,6 +1048,31 @@ export function TerminalViewport({ window.cancelAnimationFrame(frame); }; }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]); + const findShortcutLabel = shortcutLabelForCommand(keybindings, "terminal.find", { + context: { terminalFocus: true, terminalOpen: true }, + }); + const searchBar = ( + + isTerminalFindShortcut(event, keybindings, TERMINAL_SHORTCUT_OPTIONS) + } + {...(findShortcutLabel ? { findShortcutLabel } : {})} + onOpen={() => openSearch()} + onQueryChange={(query) => updateSearch({ query })} + onCaseSensitiveChange={(caseSensitive) => updateSearch({ caseSensitive })} + onNext={() => stepSearch(1)} + onPrevious={() => stepSearch(-1)} + onClose={handleSearchClose} + /> + ); return (
- {visible && search.open && ( - - isTerminalFindShortcut(event, keybindings, TERMINAL_SHORTCUT_OPTIONS) - } - onQueryChange={(query) => updateSearch({ query })} - onCaseSensitiveChange={(caseSensitive) => updateSearch({ caseSensitive })} - onNext={() => stepSearch(1)} - onPrevious={() => stepSearch(-1)} - onClose={handleSearchClose} - /> - )} + {visible && (findSlot ? createPortal(searchBar, findSlot) : searchBar)}
); } @@ -1106,35 +1117,6 @@ interface ThreadTerminalDrawerProps { terminalLaunchLocationsById?: ReadonlyMap; } -interface TerminalActionButtonProps { - label: string; - className: string; - onClick: () => void; - children: ReactNode; -} - -function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { - return ( - - } - > - {children} - - - {label} - - - ); -} - export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, @@ -1194,6 +1176,7 @@ export default function ThreadTerminalDrawer({ setDrawerHeight(nextHeight); }); const [resizeEpoch, setResizeEpoch] = useState(0); + const [findSlot, setFindSlot] = useState(null); const drawerHeightRef = useRef(drawerHeight); const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); @@ -1530,6 +1513,8 @@ export default function ThreadTerminalDrawer({ {!hasTerminalSidebar && (
+
+
)} diff --git a/apps/web/src/components/search/SearchOptionButton.tsx b/apps/web/src/components/search/SearchOptionButton.tsx index 6d6c9447e789..b200c0456edc 100644 --- a/apps/web/src/components/search/SearchOptionButton.tsx +++ b/apps/web/src/components/search/SearchOptionButton.tsx @@ -1,12 +1,14 @@ import type { ReactNode } from "react"; import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; export function SearchOptionButton(props: { readonly active: boolean; readonly label: string; readonly onClick: () => void; readonly children: ReactNode; + readonly className?: string; }) { return ( @@ -15,7 +17,10 @@ export function SearchOptionButton(props: { Date: Tue, 15 Sep 2026 14:33:04 -0600 Subject: [PATCH 5/6] fix(web): tighten terminal find spacing and stop the row shifting - The match count and its divider only render once there is a query, so an empty find row no longer leaves a blank gap between Aa and the previous/next buttons. - Aa keeps its compact 11px size at the sm breakpoint instead of picking up the toggle's default text size. - The floating terminal button group sits in a flex wrapper, so it no longer rides a text baseline that moved it 2px when find opened or closed. --- apps/web/src/components/TerminalSearchBar.tsx | 14 +++++++++----- apps/web/src/components/ThreadTerminalDrawer.tsx | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/TerminalSearchBar.tsx b/apps/web/src/components/TerminalSearchBar.tsx index 4cdee13e5535..4776a6a75993 100644 --- a/apps/web/src/components/TerminalSearchBar.tsx +++ b/apps/web/src/components/TerminalSearchBar.tsx @@ -83,16 +83,20 @@ export function TerminalSearchBar(props: TerminalSearchBarProps) { props.onCaseSensitiveChange(!props.caseSensitive)} > Aa
-
- - {count} - + {count ? ( + <> +
+ + {count} + + + ) : null}
+
From b72d9c7de67d111ce49ac024e2f29f3acb6b88e2 Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 15:02:24 -0600 Subject: [PATCH 6/6] feat(web): show the terminal find count inside the input The match count now sits inside the find input as a small muted italic label instead of its own segment. The input keeps a fixed width with room reserved for the count, so typing or getting results never resizes the row. --- apps/web/src/components/TerminalSearchBar.tsx | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/TerminalSearchBar.tsx b/apps/web/src/components/TerminalSearchBar.tsx index 4776a6a75993..458f28a54b85 100644 --- a/apps/web/src/components/TerminalSearchBar.tsx +++ b/apps/web/src/components/TerminalSearchBar.tsx @@ -69,15 +69,20 @@ export function TerminalSearchBar(props: TerminalSearchBarProps) { ); const row = ( <> - props.onQueryChange(event.target.value)} - className="h-5 w-32 bg-transparent px-1.5 text-xs leading-5 text-foreground outline-none placeholder:text-muted-foreground" - placeholder="Find" - aria-label="Find in terminal" - /> + + props.onQueryChange(event.target.value)} + className="h-5 w-full bg-transparent pr-11 pl-1.5 text-xs leading-5 text-foreground outline-none placeholder:text-muted-foreground" + placeholder="Find" + aria-label="Find in terminal" + /> + + {count} + +
event.preventDefault()}>
- {count ? ( - <> -
- - {count} - - - ) : null}