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 new file mode 100644 index 000000000000..458f28a54b85 --- /dev/null +++ b/apps/web/src/components/TerminalSearchBar.tsx @@ -0,0 +1,150 @@ +import { ChevronDown, ChevronUp, Search, X } from "lucide-react"; +import { + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + useEffect, + useRef, +} from "react"; +import { SearchOptionButton } from "~/components/search/SearchOptionButton"; +import { TerminalActionButton } from "~/components/TerminalActionButton"; +import { cn } from "~/lib/utils"; + +export interface TerminalSearchBarProps { + readonly open: boolean; + readonly docked: boolean; + readonly query: string; + readonly caseSensitive: boolean; + readonly matchCount: number; + readonly activeIndex: number; + readonly truncated: boolean; + readonly focusRequestId: number; + readonly isFindShortcut: (event: KeyboardEvent) => boolean; + readonly findShortcutLabel?: string; + readonly onOpen: () => void; + readonly onQueryChange: (query: string) => void; + readonly onCaseSensitiveChange: (value: 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 (!props.open || lastFocusRequestIdRef.current === props.focusRequestId) return; + lastFocusRequestIdRef.current = props.focusRequestId; + inputRef.current?.focus({ preventScroll: true }); + inputRef.current?.select(); + }, [props.focusRequestId, props.open]); + + const handleKeyDown = (event: ReactKeyboardEvent) => { + const nativeEvent = event.nativeEvent; + if (nativeEvent.key === "Escape") { + props.onClose(); + } else if (props.isFindShortcut(nativeEvent)) { + inputRef.current?.focus({ preventScroll: true }); + inputRef.current?.select(); + } else if (nativeEvent.key === "Enter" && event.target === inputRef.current) { + (nativeEvent.shiftKey ? props.onPrevious : props.onNext)(); + } else { + return; + } + event.preventDefault(); + event.stopPropagation(); + }; + + const keepInputFocus = (event: ReactMouseEvent) => 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(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()}> + props.onCaseSensitiveChange(!props.caseSensitive)} + > + Aa + +
+
+ + + + + ); + + 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 91c7cc855596..6ca40282081f 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,11 @@ 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"; 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,10 +63,12 @@ import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, + isTerminalFindShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + shortcutLabelForCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; @@ -92,6 +96,19 @@ 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, +}; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -324,6 +341,7 @@ interface TerminalViewportProps { resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; + findSlot?: HTMLElement | null; } interface TerminalLaunchLocation { @@ -350,10 +368,19 @@ export function TerminalViewport({ resizeEpoch, drawerHeight, keybindings, + findSlot, }: TerminalViewportProps) { 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, + }); + const [searchFocusRequestId, setSearchFocusRequestId] = useState(0); + const searchRef = useRef(search); const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( @@ -377,6 +404,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 +561,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 +580,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 +810,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; } @@ -977,13 +1048,41 @@ export function TerminalViewport({ window.cancelAnimationFrame(frame); }; }, [drawerHeight, environmentId, resizeEpoch, terminalId, threadId]); - return ( -
+ 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 && (findSlot ? createPortal(searchBar, findSlot) : searchBar)} +
+ ); } interface ThreadTerminalDrawerProps { @@ -1018,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, @@ -1106,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); @@ -1440,8 +1511,10 @@ export default function ThreadTerminalDrawer({ ) : null} {!hasTerminalSidebar && ( -
+
+
+
)} 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; + readonly className?: string; +}) { + return ( + + + } + > + {props.children} + + {props.label} + + ); +} 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..b2ef36689ade 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 }; + +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..a82c1227f4ac --- /dev/null +++ b/apps/web/src/terminal/ghostty/search.test.ts @@ -0,0 +1,180 @@ +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 }; + +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, + }); + }); + + it("respects case-sensitive option", () => { + const rows = { texts: ["Hello HELLO hello"], wraps: [false] }; + expect(findTerminalSearchMatches(rows, "hello", { caseSensitive: true })).toEqual({ + matches: [match(0, 12, 17)], + truncated: false, + }); + }); + + 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, + }); + }); + + 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, + }); + }); + + it("skips trimmed trailing whitespace", () => { + const rows = { texts: ["hello ", "world"], wraps: [false, false] }; + expect(findTerminalSearchMatches(rows, " ", insensitiveLiteral)).toEqual({ + matches: [], + truncated: false, + }); + }); + + 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]).toEqual([ + MAX_TERMINAL_SEARCH_MATCHES, + false, + ]); + const overflowResult = findTerminalSearchMatches( + { texts, wraps: exactRows.wraps }, + "match", + insensitiveLiteral, + ); + expect([overflowResult.matches.length, overflowResult.truncated]).toEqual([ + MAX_TERMINAL_SEARCH_MATCHES, + true, + ]); + }); +}); + +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..1b11ef5096b8 --- /dev/null +++ b/apps/web/src/terminal/ghostty/search.ts @@ -0,0 +1,191 @@ +import { collectWrappedTerminalLinkLine, type WrappedTerminalLinkLine } from "../../terminal-links"; + +export const MAX_TERMINAL_SEARCH_MATCHES = 2000; + +export interface TerminalSearchOptions { + readonly caseSensitive: 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; +} + +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 searchPattern(query: string, options: TerminalSearchOptions): RegExp { + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(escaped, options.caseSensitive ? "g" : "gi"); +} + +function positionForOffset( + line: WrappedTerminalLinkLine, + offset: number, +): TerminalSearchPosition | null { + for (const segment of line.segments) { + if (offset >= segment.startIndex && offset < segment.endIndex) { + return { row: segment.bufferLineNumber - 1, offset: offset - segment.startIndex }; + } + } + return null; +} + +/** 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 }; + const pattern = searchPattern(query, options); + + 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[] = []; + 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)) { + 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 }; + matches.push({ start, end: { row: inclusiveEnd.row, offset: inclusiveEnd.offset + 1 } }); + } + } + return { matches, truncated: false }; +} + +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..b37152e5f603 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,12 @@ export interface GhosttySelectionPosition { readonly end: { readonly x: number; readonly y: number }; } +export interface GhosttyTerminalSearchState { + readonly matchCount: number; + readonly activeIndex: number; + readonly truncated: boolean; +} + export interface GhosttyTerminalSurfaceOptions { readonly theme: GhosttyTheme; readonly font?: GhosttyTerminalFont; @@ -553,6 +571,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 +656,13 @@ 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 constructor( mount: HTMLElement, @@ -770,6 +796,7 @@ export class GhosttyTerminalSurface { this.cursorOn = true; this.scrollbarDirty = true; this.requestRender(); + if (this.searchQuery) this.scheduleSearchRefresh(); } resetAndWrite(data: string): void { @@ -783,6 +810,7 @@ export class GhosttyTerminalSurface { this.forceFullRender = true; this.scrollbarDirty = true; this.requestRender(); + if (this.searchQuery) this.scheduleSearchRefresh(); } setTheme(theme: GhosttyTheme): void { @@ -890,7 +918,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 +927,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 +1065,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 +1083,115 @@ 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 }; + 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 }, -1, false); + } + + private getSearchState(): GhosttyTerminalSearchState { + return { + matchCount: this.searchMatches.length, + activeIndex: this.searchActiveIndex, + truncated: this.searchTruncated, + }; + } + + 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; + 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; + 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 +1989,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..f1615ddea956 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -6,3 +6,12 @@ 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, or click the search icon next to the terminal buttons. Enter moves to +the next match and Shift+Enter to the previous one. Use the toggle for +case-sensitive 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" },