diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..c0932955b1b5 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -90,7 +90,7 @@ export const Definitions = { session_list: keybind("l", "List all sessions"), session_timeline: keybind("g", "Show session timeline"), session_fork: keybind("none", "Fork session from message"), - session_rename: keybind("ctrl+r", "Rename session"), + session_rename: keybind("none", "Rename session"), session_delete: keybind("ctrl+d", "Delete session"), session_share: keybind("none", "Share current session"), session_unshare: keybind("none", "Unshare current session"), @@ -143,6 +143,9 @@ export const Definitions = { messages_next: keybind("none", "Navigate to next message"), messages_previous: keybind("none", "Navigate to previous message"), messages_last_user: keybind("none", "Navigate to last user message"), + messages_search: keybind("ctrl+r", "Search messages"), + messages_search_next: keybind("none", "Next search match"), + messages_search_previous: keybind("none", "Previous search match"), messages_copy: keybind("y", "Copy message"), messages_undo: keybind("u", "Undo message"), messages_redo: keybind("r", "Redo message"), @@ -211,6 +214,10 @@ export const Definitions = { "dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), "dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"), + "search.previous": keybind("ctrl+r,up", "Go to previous match from the search bar"), + "search.next": keybind("ctrl+s,down", "Go to next match from the search bar"), + "search.accept": keybind("return", "Accept search and keep position"), + "search.close": keybind("escape,ctrl+c", "Close search and restore position"), "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), @@ -348,6 +355,9 @@ export const CommandMap = { messages_next: "session.message.next", messages_previous: "session.message.previous", messages_last_user: "session.messages_last_user", + messages_search: "session.search", + messages_search_next: "session.search.next", + messages_search_previous: "session.search.previous", messages_copy: "messages.copy", messages_undo: "session.undo", messages_redo: "session.redo", diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6df6b00d2d11..3c33c2aea601 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -25,7 +25,18 @@ import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner } from "../../component/spinner" import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" -import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" +import { + BoxRenderable, + CodeRenderable, + InputRenderable, + Renderable, + ScrollBoxRenderable, + addDefaultParsers, + TextAttributes, + RGBA, + type MarkdownOptions, + type OnHighlightCallback, +} from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { AssistantMessage, @@ -82,6 +93,18 @@ import { getRevertDiffFiles } from "../../util/revert-diff" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" +import { SearchBar } from "./search-bar" +import { + collectSearchUnits, + estimateRenderedLine, + findSearchHits, + highlightSegments, + initialSearchIndex, + moveSearchIndex, + searchHighlights, + type SearchDirection, + type SearchHit, +} from "../../util/session-search" addDefaultParsers(parsers.parsers) @@ -134,6 +157,9 @@ const sessionBindingCommands = [ "session.messages_last_user", "session.message.next", "session.message.previous", + "session.search", + "session.search.next", + "session.search.previous", "messages.copy", "session.copy", "session.export", @@ -167,6 +193,11 @@ const context = createContext<{ providers: () => ReadonlyMap sync: ReturnType tui: ReturnType + search: { + query: () => string + active: () => SearchHit | undefined + anchors: () => ReadonlySet + } }>() function use() { @@ -420,6 +451,123 @@ export function Session() { }, 50) } + const [searchOpen, setSearchOpen] = createSignal(false) + const [searchQuery, setSearchQuery] = createSignal("") + const [searchIndex, setSearchIndex] = createSignal(0) + let searchLast = "" + let searchOrigin: number | undefined + let searchOriginAtBottom = false + let searchInput: InputRenderable | undefined + + // Search state is session-local: switching sessions closes the bar and drops highlights. + createEffect( + on( + () => route.sessionID, + () => { + setSearchOpen(false) + setSearchQuery("") + setSearchIndex(0) + searchOrigin = undefined + searchLast = "" + }, + { defer: true }, + ), + ) + + const searchUnits = createMemo(() => + searchQuery().trim() ? collectSearchUnits(messages(), sync.data.part, session()?.revert?.messageID) : [], + ) + const searchHits = createMemo(() => findSearchHits(searchUnits(), searchQuery())) + const searchActive = createMemo(() => { + const hits = searchHits() + if (!hits.length) return undefined + return hits[Math.min(searchIndex(), hits.length - 1)] + }) + // In-place markdown highlighting only engages when matches are few enough to be meaningful; + // broad queries (single letters) would re-highlight every part on each keystroke. + const searchAnchors = createMemo(() => { + const hits = searchHits() + if (!hits.length || hits.length > 200) return new Set() + return new Set(hits.map((hit) => hit.anchorID)) + }) + + // Content-space position of a hit: child.y is in screen coordinates, so translate through scrollTop. + // The row within the anchor is estimated wrap-aware so matches deep in long messages stay on screen. + function searchAnchorY(hit: SearchHit) { + if (!scroll || scroll.isDestroyed) return undefined + const child = scroll.getChildren().find((c) => c.id === hit.anchorID) + if (!child) return undefined + const row = estimateRenderedLine(hit.text, hit.line, Math.max(20, child.width - 4)) + return scroll.scrollTop + (child.y - scroll.y) + Math.min(row, Math.max(child.height - 1, 0)) + } + + function jumpToHit(hit: SearchHit | undefined) { + if (!hit) return + const y = searchAnchorY(hit) + if (y === undefined) return + // Center the match: the row estimate is approximate, so give it slack in both directions. + scroll.scrollTo(Math.max(0, y - Math.floor(scroll.height / 2))) + } + + function runSearch(query: string) { + const previous = untrack(searchActive) + setSearchQuery(query) + const hits = searchHits() + if (!hits.length) return + // Readline behavior: when the current match still matches the refined query, stay on it + // instead of snapping back and discarding manual cycling. + if (previous) { + const kept = hits.findIndex((hit) => hit.anchorID === previous.anchorID && hit.start === previous.start) + if (kept !== -1) { + setSearchIndex(kept) + return + } + } + // Otherwise anchor from where the search started; the +2 covers the rows the bar itself + // took from the viewport after the origin was captured. + const top = searchOrigin ?? scroll?.scrollTop ?? 0 + const bottom = top + (scroll?.height ?? 0) + 2 + const index = initialSearchIndex(hits.map(searchAnchorY), top, bottom, "previous") + if (index < 0) return + setSearchIndex(index) + jumpToHit(hits[index]) + } + + function moveSearch(direction: SearchDirection) { + const hits = searchHits() + if (!hits.length) return + const index = moveSearchIndex(hits.length, Math.min(searchIndex(), hits.length - 1), direction) + setSearchIndex(index) + jumpToHit(hits[index]) + } + + function openSearch() { + if (searchOpen()) { + searchInput?.focus() + return + } + searchOrigin = scroll && !scroll.isDestroyed ? scroll.scrollTop : undefined + searchOriginAtBottom = + scroll && !scroll.isDestroyed ? scroll.scrollTop >= scroll.scrollHeight - scroll.height - 1 : false + setSearchQuery("") + setSearchIndex(0) + setSearchOpen(true) + } + + function closeSearch(accept: boolean) { + if (searchQuery().trim()) searchLast = searchQuery() + if (!accept) { + // Opened while stuck to the bottom: restore all the way down, since closing the bar + // returns rows to the viewport and a raw offset would land short of the bottom. + if (searchOriginAtBottom) toBottom() + else if (searchOrigin !== undefined && scroll && !scroll.isDestroyed) scroll.scrollTo(searchOrigin) + setSearchQuery("") + } + setSearchOpen(false) + searchOrigin = undefined + prompt?.focus() + } + const local = useLocal() function enterChild(sessionID: string) { @@ -529,6 +677,41 @@ export function Session() { )) }, }, + { + title: "Search session", + value: "session.search", + category: "Session", + slash: { + name: "search", + aliases: ["find"], + }, + run: () => { + dialog.clear() + openSearch() + }, + }, + { + title: "Next search match", + value: "session.search.next", + category: "Session", + hidden: true, + enabled: searchHits().length > 0, + run: () => { + dialog.clear() + moveSearch("next") + }, + }, + { + title: "Previous search match", + value: "session.search.previous", + category: "Session", + hidden: true, + enabled: searchHits().length > 0, + run: () => { + dialog.clear() + moveSearch("previous") + }, + }, { title: "Fork session", value: "session.fork", @@ -1160,6 +1343,11 @@ export function Session() { providers, sync, tui: tuiConfig, + search: { + query: searchQuery, + active: searchActive, + anchors: searchAnchors, + }, }} > @@ -1295,6 +1483,18 @@ export function Session() { + + searchLast || undefined} + ref={(input) => (searchInput = input)} + /> + props.parts.find((x) => x.type === "compaction")) + const searchSegments = createMemo(() => highlightSegments(text(), ctx.search.query())) + const activeSearchStart = createMemo(() => { + const active = ctx.search.active() + if (!active || active.anchorID !== props.message.id) return + return active.start + }) + return ( <> @@ -1402,7 +1609,15 @@ function UserMessage(props: { backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()} + + + {(segment) => { + if (!segment.match) return {segment.text} + const color = () => (activeSearchStart() === segment.start ? theme.warning : theme.textMuted) + return {segment.text} + }} + + @@ -1596,9 +1811,23 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass setExpanded((prev) => !prev) } + const searchHighlighter = createMemo(() => { + if (!ctx.search.anchors().has(props.part.id)) return undefined + const query = ctx.search.query() + const active = ctx.search.active() + const activeStart = active?.anchorID === props.part.id ? active.start : undefined + return (highlights, highlightContext) => { + // The rendered body is a slice of the reasoning unit text; map the active hit offset into it. + const blockOffset = activeStart === undefined ? -1 : content().indexOf(highlightContext.content) + const activeOffset = blockOffset === -1 ? undefined : activeStart! - blockOffset + return [...highlights, ...searchHighlights(highlightContext.content, query, activeOffset)] + } + }) + return ( alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} @@ -1624,6 +1853,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass content={summary().body} conceal={ctx.conceal()} fg={theme.textMuted} + onHighlight={searchHighlighter()} /> @@ -1679,9 +1909,52 @@ function ReasoningHeader(props: { function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() const { theme, syntax } = useTheme() + const activeSearchStart = createMemo(() => { + const active = ctx.search.active() + if (!active || active.anchorID !== props.part.id) return + return active.start + }) + + // In-place search highlighting: markdown renders prose and code blocks through CodeRenderables, + // whose public onHighlight hook lets us append match spans to the tree-sitter highlights. + const codes = new Set() + let searchHighlighter: OnHighlightCallback | undefined + const collectCodes = (renderable: Renderable) => { + if (renderable instanceof CodeRenderable) { + codes.add(renderable) + renderable.onHighlight = searchHighlighter + } + for (const child of renderable.getChildren()) collectCodes(child) + } + // Stable identity: reassigning renderNode would force a full markdown re-parse. + const renderNode: MarkdownOptions["renderNode"] = (_token, context) => { + const renderable = context.defaultRender() + if (renderable) collectCodes(renderable) + return renderable + } + createEffect(() => { + const query = ctx.search.query() + const activeStart = activeSearchStart() + searchHighlighter = ctx.search.anchors().has(props.part.id) + ? (highlights, context) => { + // Each block's content is a slice of the part text; map the active hit offset into it. + const blockOffset = activeStart === undefined ? -1 : props.part.text.trim().indexOf(context.content) + const activeOffset = blockOffset === -1 ? undefined : activeStart! - blockOffset + return [...highlights, ...searchHighlights(context.content, query, activeOffset)] + } + : undefined + for (const code of codes) { + if (code.isDestroyed) { + codes.delete(code) + continue + } + code.onHighlight = searchHighlighter + } + }) + return ( - alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> + alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> diff --git a/packages/tui/src/routes/session/search-bar.tsx b/packages/tui/src/routes/session/search-bar.tsx new file mode 100644 index 000000000000..e0d166aba698 --- /dev/null +++ b/packages/tui/src/routes/session/search-bar.tsx @@ -0,0 +1,130 @@ +import { InputRenderable, TextAttributes } from "@opentui/core" +import { createMemo, createSignal, onMount } from "solid-js" +import { SplitBorder } from "../../ui/border" +import { useTheme } from "../../context/theme" +import { useTuiConfig } from "../../config" +import { useBindings } from "../../keymap" +import type { SearchDirection, SearchHit } from "../../util/session-search" + +const barCommands = ["search.previous", "search.next", "search.accept", "search.close"] as const + +export function SearchBar(props: { + query: string + hits: readonly SearchHit[] + index: number + onQuery: (value: string) => void + onMove: (direction: SearchDirection) => void + onClose: (accept: boolean) => void + onRecall: () => string | undefined + ref?: (input: InputRenderable) => void +}) { + const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const [inputTarget, setInputTarget] = createSignal() + let input: InputRenderable + + function previous() { + // Readline detail: ctrl+r on an empty bar recalls the previous query. + if (!input.value.trim()) { + const last = props.onRecall() + if (!last) return + input.value = last + props.onQuery(last) + return + } + props.onMove("previous") + } + + useBindings(() => ({ + target: inputTarget, + enabled: inputTarget() !== undefined, + // Search bar semantics must win over session-level bindings (escape interrupts, ctrl+r reopens). + priority: 1, + commands: [ + { + name: "search.previous", + title: "Previous search match", + category: "Search", + hidden: true, + run: previous, + }, + { + name: "search.next", + title: "Next search match", + category: "Search", + hidden: true, + run: () => props.onMove("next"), + }, + { + name: "search.accept", + title: "Accept search", + category: "Search", + hidden: true, + run: () => props.onClose(true), + }, + { + name: "search.close", + title: "Close search", + category: "Search", + hidden: true, + run: () => props.onClose(false), + }, + ], + bindings: tuiConfig.keybinds.gather("search", barCommands), + })) + + onMount(() => { + setTimeout(() => { + if (!input || input.isDestroyed) return + input.focus() + }, 1) + }) + + const counter = createMemo(() => { + if (props.hits.length) return `${Math.min(props.index + 1, props.hits.length)}/${props.hits.length}` + if (props.query.trim()) return "0/0" + return "" + }) + + return ( + + + props.onQuery(value)} + ref={(r: InputRenderable) => { + input = r + setInputTarget(r) + props.ref?.(r) + }} + placeholder="search session" + placeholderColor={theme.textMuted} + textColor={theme.text} + focusedTextColor={theme.text} + backgroundColor={theme.backgroundPanel} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.text} + /> + + {counter()} + + ↵ keep · esc back · ^R↑ · ^S↓ + + + ) +} diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index e8a5f2c5a973..ea6bbbcde5e2 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -619,6 +619,21 @@ function getSyntaxRules(theme: Theme) { bold: true, }, }, + { + scope: ["search.match"], + style: { + foreground: selectedForeground(theme, theme.textMuted), + background: theme.textMuted, + }, + }, + { + scope: ["search.match.active"], + style: { + foreground: selectedForeground(theme, theme.warning), + background: theme.warning, + bold: true, + }, + }, { scope: ["comment"], style: { diff --git a/packages/tui/src/util/session-search.ts b/packages/tui/src/util/session-search.ts new file mode 100644 index 000000000000..906a10709305 --- /dev/null +++ b/packages/tui/src/util/session-search.ts @@ -0,0 +1,199 @@ +export type SearchableMessage = { + id: string + role: string +} + +export type SearchablePart = { + id: string + type: string + text?: string + synthetic?: boolean +} + +export type SearchUnit = { + /** Scrollbox child id to jump to: message id for user messages, part id for assistant parts. */ + anchorID: string + messageID: string + role: "user" | "assistant" + kind: "text" | "reasoning" + /** Exactly the text the TUI renders for this unit, so hit offsets line up with what is on screen. */ + text: string +} + +export type SearchHit = SearchUnit & { + start: number + end: number + /** 0-based line index of the match start within the unit text. */ + line: number +} + +export type SearchDirection = "next" | "previous" + +const MAX_HITS = 1000 +const MAX_SEGMENTS = 200 + +export function collectSearchUnits( + messages: readonly SearchableMessage[], + parts: Record, + revertMessageID?: string, +): SearchUnit[] { + return messages.flatMap((message) => { + // Messages at or past the revert point render nothing (see the revert branch in routes/session). + if (revertMessageID && message.id >= revertMessageID) return [] + const list = parts[message.id] ?? [] + if (message.role === "user") { + // Mirrors UserMessage's text() memo: non-synthetic text parts joined with blank lines. + const text = list + .filter((part) => part.type === "text" && !part.synthetic && part.text) + .map((part) => part.text) + .join("\n\n") + if (!text) return [] + return [{ anchorID: message.id, messageID: message.id, role: "user" as const, kind: "text" as const, text }] + } + if (message.role !== "assistant") return [] + return list.flatMap((part): SearchUnit[] => { + if (part.type === "text") { + // Mirrors TextPart: trimmed text, skipped when blank. + const text = part.text?.trim() + if (!text) return [] + return [{ anchorID: part.id, messageID: message.id, role: "assistant" as const, kind: "text" as const, text }] + } + if (part.type === "reasoning") { + // Mirrors ReasoningPart's content() memo. + const text = part.text?.replace("[REDACTED]", "").trim() + if (!text) return [] + return [ + { anchorID: part.id, messageID: message.id, role: "assistant" as const, kind: "reasoning" as const, text }, + ] + } + return [] + }) + }) +} + +function searchPattern(query: string) { + const trimmed = query.trim() + if (!trimmed) return + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + // Smartcase: any uppercase letter in the query makes the search case-sensitive. + const flags = /\p{Lu}/u.test(query) ? "gu" : "giu" + return new RegExp(escaped, flags) +} + +export function findSearchHits(units: readonly SearchUnit[], query: string): SearchHit[] { + const pattern = searchPattern(query) + if (!pattern) return [] + + const hits: SearchHit[] = [] + for (const unit of units) { + pattern.lastIndex = 0 + let match = pattern.exec(unit.text) + while (match && hits.length < MAX_HITS) { + hits.push({ + ...unit, + start: match.index, + end: match.index + match[0].length, + line: countLines(unit.text, match.index), + }) + match = pattern.exec(unit.text) + } + if (hits.length >= MAX_HITS) break + } + return hits +} + +function countLines(text: string, end: number) { + let count = 0 + for (let index = text.indexOf("\n"); index !== -1 && index < end; index = text.indexOf("\n", index + 1)) { + count += 1 + } + return count +} + +export function moveSearchIndex(total: number, current: number, direction: SearchDirection) { + if (total <= 0) return -1 + const offset = direction === "next" ? 1 : -1 + return (current + offset + total) % total +} + +/** + * Picks the starting hit relative to the current viewport. + * "previous" (reverse search) prefers the last hit at or above the viewport bottom; + * "next" prefers the first hit at or below the viewport top. Both wrap. + */ +export function initialSearchIndex( + ys: readonly (number | undefined)[], + viewportTop: number, + viewportBottom: number, + direction: SearchDirection, +) { + const positioned = ys.flatMap((y, index) => (y === undefined ? [] : [{ index, y }])) + if (!positioned.length) return -1 + if (direction === "previous") { + const above = positioned.filter((entry) => entry.y <= viewportBottom) + return (above.at(-1) ?? positioned.at(-1))!.index + } + const below = positioned.find((entry) => entry.y >= viewportTop) + return (below ?? positioned[0]).index +} + +/** Structurally matches opentui's SimpleHighlight: [start, end, syntax style group]. */ +export type SearchHighlight = [number, number, string] + +export function searchHighlights(content: string, query: string, activeOffset?: number): SearchHighlight[] { + const pattern = searchPattern(query) + if (!pattern) return [] + const highlights: SearchHighlight[] = [] + let match = pattern.exec(content) + while (match && highlights.length < MAX_HITS) { + highlights.push([ + match.index, + match.index + match[0].length, + match.index === activeOffset ? "search.match.active" : "search.match", + ]) + match = pattern.exec(content) + } + return highlights +} + +export type HighlightSegment = { + text: string + start: number + match: boolean +} + +export function highlightSegments(text: string, query: string): HighlightSegment[] { + const pattern = searchPattern(query) + if (!pattern) return [{ text, start: 0, match: false }] + + const segments: HighlightSegment[] = [] + let cursor = 0 + let match = pattern.exec(text) + while (match && segments.length < MAX_SEGMENTS) { + if (match.index > cursor) segments.push({ text: text.slice(cursor, match.index), start: cursor, match: false }) + segments.push({ text: match[0], start: match.index, match: true }) + cursor = match.index + match[0].length + match = pattern.exec(text) + } + if (!segments.length) return [{ text, start: 0, match: false }] + if (cursor < text.length) segments.push({ text: text.slice(cursor), start: cursor, match: false }) + return segments +} + +/** + * Estimates the rendered row of a source line, accounting for soft wrapping: + * each source line before it contributes at least one row plus one per wrap. + */ +export function estimateRenderedLine(text: string, line: number, width: number) { + if (line <= 0) return 0 + const columns = Math.max(1, width) + let row = 0 + let start = 0 + for (let index = 0; index < line; index++) { + const end = text.indexOf("\n", start) + if (end === -1) break + row += Math.max(1, Math.ceil((end - start) / columns)) + start = end + 1 + } + return row +} diff --git a/packages/tui/test/util/session-search.test.ts b/packages/tui/test/util/session-search.test.ts new file mode 100644 index 000000000000..1df9d0c95d6e --- /dev/null +++ b/packages/tui/test/util/session-search.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test" +import { + collectSearchUnits, + estimateRenderedLine, + findSearchHits, + highlightSegments, + initialSearchIndex, + moveSearchIndex, + searchHighlights, +} from "../../src/util/session-search" + +describe("collectSearchUnits", () => { + const messages = [ + { id: "msg_1", role: "user" }, + { id: "msg_2", role: "assistant" }, + ] + const parts = { + msg_1: [ + { id: "prt_1", type: "text", text: "first part" }, + { id: "prt_2", type: "text", text: "synthetic part", synthetic: true }, + { id: "prt_3", type: "text", text: "second part" }, + { id: "prt_4", type: "file" }, + ], + msg_2: [ + { id: "prt_5", type: "text", text: " reply text " }, + { id: "prt_6", type: "reasoning", text: "[REDACTED]thinking hard" }, + { id: "prt_7", type: "text", text: " " }, + { id: "prt_8", type: "tool", text: "tool output" }, + ], + } + + test("mirrors rendered content per role", () => { + const units = collectSearchUnits(messages, parts) + expect(units).toEqual([ + { anchorID: "msg_1", messageID: "msg_1", role: "user", kind: "text", text: "first part\n\nsecond part" }, + { anchorID: "prt_5", messageID: "msg_2", role: "assistant", kind: "text", text: "reply text" }, + { anchorID: "prt_6", messageID: "msg_2", role: "assistant", kind: "reasoning", text: "thinking hard" }, + ]) + }) + + test("excludes messages hidden by revert", () => { + const units = collectSearchUnits(messages, parts, "msg_2") + expect(units.map((unit) => unit.messageID)).toEqual(["msg_1"]) + }) + + test("drops empty units and handles missing parts", () => { + const units = collectSearchUnits([{ id: "msg_x", role: "user" }], {}) + expect(units).toEqual([]) + }) +}) + +describe("findSearchHits", () => { + const unit = { + anchorID: "prt_1", + messageID: "msg_1", + role: "assistant" as const, + kind: "text" as const, + text: "alpha beta\ngamma Beta delta", + } + + test("finds every occurrence with offsets and lines", () => { + const hits = findSearchHits([unit], "beta") + expect(hits).toEqual([ + { ...unit, start: 6, end: 10, line: 0 }, + { ...unit, start: 17, end: 21, line: 1 }, + ]) + }) + + test("smartcase: uppercase in query forces case-sensitive match", () => { + const hits = findSearchHits([unit], "Beta") + expect(hits).toEqual([{ ...unit, start: 17, end: 21, line: 1 }]) + }) + + test("treats regex metacharacters literally", () => { + const weird = { ...unit, text: "a.b axb" } + expect(findSearchHits([weird], "a.b")).toEqual([{ ...weird, start: 0, end: 3, line: 0 }]) + }) + + test("matches are non-overlapping", () => { + const aaa = { ...unit, text: "aaa" } + expect(findSearchHits([aaa], "aa")).toEqual([{ ...aaa, start: 0, end: 2, line: 0 }]) + }) + + test("blank query yields nothing", () => { + expect(findSearchHits([unit], "")).toEqual([]) + expect(findSearchHits([unit], " ")).toEqual([]) + }) + + test("astral characters before the match keep offsets aligned", () => { + const emoji = { ...unit, text: "🚀🚀 beta" } + const hits = findSearchHits([emoji], "beta") + expect(hits).toHaveLength(1) + expect(emoji.text.slice(hits[0].start, hits[0].end)).toBe("beta") + }) + + test("astral characters in the query do not throw", () => { + const emoji = { ...unit, text: "ship 🚀 it" } + expect(findSearchHits([emoji], "🚀")).toHaveLength(1) + expect(findSearchHits([emoji], "{[(")).toEqual([]) + }) +}) + +describe("moveSearchIndex", () => { + test("wraps in both directions", () => { + expect(moveSearchIndex(3, 2, "next")).toBe(0) + expect(moveSearchIndex(3, 0, "previous")).toBe(2) + expect(moveSearchIndex(3, 1, "next")).toBe(2) + }) + + test("empty set yields -1", () => { + expect(moveSearchIndex(0, 0, "next")).toBe(-1) + }) +}) + +describe("initialSearchIndex", () => { + const ys = [2, 10, 20, 30, undefined] + + test("reverse picks the last hit at or above the viewport bottom", () => { + expect(initialSearchIndex(ys, 8, 18, "previous")).toBe(1) + }) + + test("reverse wraps to the bottom-most hit when everything is below", () => { + expect(initialSearchIndex(ys, 0, 1, "previous")).toBe(3) + }) + + test("forward picks the first hit at or below the viewport top", () => { + expect(initialSearchIndex(ys, 15, 25, "next")).toBe(2) + }) + + test("forward wraps to the first hit when everything is above", () => { + expect(initialSearchIndex(ys, 50, 60, "next")).toBe(0) + }) + + test("ignores unmounted anchors and empty sets", () => { + expect(initialSearchIndex([undefined, undefined], 0, 10, "previous")).toBe(-1) + }) +}) + +describe("highlightSegments", () => { + test("partitions text into matched and unmatched segments", () => { + const segments = highlightSegments("one two one", "one") + expect(segments).toEqual([ + { text: "one", start: 0, match: true }, + { text: " two ", start: 3, match: false }, + { text: "one", start: 8, match: true }, + ]) + expect(segments.map((segment) => segment.text).join("")).toBe("one two one") + }) + + test("returns a single segment when nothing matches", () => { + expect(highlightSegments("plain", "zzz")).toEqual([{ text: "plain", start: 0, match: false }]) + expect(highlightSegments("plain", "")).toEqual([{ text: "plain", start: 0, match: false }]) + }) +}) + +describe("searchHighlights", () => { + test("returns opentui highlight tuples for every occurrence", () => { + expect(searchHighlights("one two one", "one")).toEqual([ + [0, 3, "search.match"], + [8, 11, "search.match"], + ]) + }) + + test("blank query yields nothing", () => { + expect(searchHighlights("anything", "")).toEqual([]) + }) + + test("marks the occurrence at the active offset with the active scope", () => { + expect(searchHighlights("one two one", "one", 8)).toEqual([ + [0, 3, "search.match"], + [8, 11, "search.match.active"], + ]) + }) +}) + +describe("estimateRenderedLine", () => { + test("counts one row per short line", () => { + expect(estimateRenderedLine("a\nb\nc", 2, 80)).toBe(2) + }) + + test("adds rows for lines that soft-wrap", () => { + const text = `${"x".repeat(25)}\nshort\ntarget` + expect(estimateRenderedLine(text, 2, 10)).toBe(4) + }) + + test("line zero renders at row zero", () => { + expect(estimateRenderedLine("anything", 0, 10)).toBe(0) + }) +})