diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx
new file mode 100644
index 00000000000..60dccbd6fb7
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx
@@ -0,0 +1,230 @@
+import { BoxRenderable, TextareaRenderable, type KeyBinding } from "@opentui/core"
+import { createEffect, createMemo, createSignal, type JSX, onMount, Show } from "solid-js"
+import { useTheme } from "@tui/context/theme"
+import { EmptyBorder } from "@tui/component/border"
+import { createStore } from "solid-js/store"
+import { useKeybind } from "@tui/context/keybind"
+import { Locale } from "@/util/locale"
+import { useLocal } from "@tui/context/local"
+import { RGBA } from "@opentui/core"
+import { useSDK } from "@tui/context/sdk"
+import { useSync } from "@tui/context/sync"
+import { useExit } from "../../context/exit"
+
+export type SearchInputProps = {
+ disabled?: boolean
+ onSubmit?: (query: string) => void
+ onExit?: () => void
+ onInput?: (query: string) => void
+ onNext?: () => void
+ onPrevious?: () => void
+ matchInfo?: { current: number; total: number }
+ sessionID?: string
+ ref?: (ref: SearchInputRef) => void
+ placeholder?: string
+}
+
+export type SearchInputRef = {
+ focused: boolean
+ reset(): void
+ blur(): void
+ focus(): void
+ getValue(): string
+}
+
+export function SearchInput(props: SearchInputProps) {
+ let input: TextareaRenderable
+ let anchor: BoxRenderable
+
+ const exit = useExit()
+ const keybind = useKeybind()
+ const local = useLocal()
+ const sdk = useSDK()
+ const sync = useSync()
+ const { theme } = useTheme()
+
+ const highlight = createMemo(() => {
+ const agent = local.agent.current()
+ if (agent?.color) return RGBA.fromHex(agent.color)
+ const agents = local.agent.list()
+ const index = agents.findIndex((x) => x.name === "search")
+ const colors = [theme.secondary, theme.accent, theme.success, theme.warning, theme.primary, theme.error]
+ if (index === -1) return colors[0]
+ return colors[index % colors.length]
+ })
+
+ const textareaKeybindings = createMemo(() => {
+ const submitBindings = keybind.all.input_submit || []
+ return [
+ { name: "return", action: "submit" },
+ ...submitBindings.map((binding) => ({
+ name: binding.name,
+ ctrl: binding.ctrl || undefined,
+ meta: binding.meta || undefined,
+ shift: binding.shift || undefined,
+ action: "submit" as const,
+ })),
+ ] satisfies KeyBinding[]
+ })
+
+ const [store, setStore] = createStore<{
+ input: string
+ }>({
+ input: "",
+ })
+
+ createEffect(() => {
+ if (props.disabled) input.cursorColor = theme.backgroundElement
+ if (!props.disabled) input.cursorColor = theme.primary
+ })
+
+ props.ref?.({
+ get focused() {
+ return input.focused
+ },
+ focus() {
+ input.focus()
+ },
+ blur() {
+ input.blur()
+ },
+ reset() {
+ input.clear()
+ setStore("input", "")
+ },
+ getValue() {
+ return store.input
+ },
+ })
+
+ function submit() {
+ if (props.disabled) return
+ if (!store.input) return
+ props.onSubmit?.(store.input)
+ input.clear()
+ setStore("input", "")
+ }
+
+ onMount(() => {
+ input.focus()
+ })
+
+ return (
+ <>
+ (anchor = r)}>
+
+
+
+
+
+
+
+
+
+
+ ↑/↓ navigate
+
+
+ esc exit
+
+
+
+
+ >
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index 4223657ba0b..2ae55ea47d8 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -888,6 +888,14 @@ function getSyntaxRules(theme: Theme) {
foreground: theme.textMuted,
},
},
+ {
+ scope: ["markup.strikethrough"],
+ style: {
+ foreground: theme.background,
+ background: theme.primary,
+ strikethrough: true,
+ },
+ },
// Additional common highlight groups
{
scope: ["string.special", "string.special.url"],
@@ -981,12 +989,6 @@ function getSyntaxRules(theme: Theme) {
foreground: theme.syntaxOperator,
},
},
- {
- scope: ["markup.strikethrough"],
- style: {
- foreground: theme.textMuted,
- },
- },
{
scope: ["markup.underline"],
style: {
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index ede4e28384b..b44485610f9 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -62,6 +62,7 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
+import { SearchInput, type SearchInputRef } from "../../component/prompt/search.tsx"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
@@ -77,6 +78,14 @@ class CustomSpeedScroll implements ScrollAcceleration {
reset(): void {}
}
+type SearchMatch = {
+ messageID: string
+ partID?: string
+ text: string
+ index: number
+ charOffset: number
+}
+
const context = createContext<{
width: number
conceal: () => boolean
@@ -86,6 +95,9 @@ const context = createContext<{
showDetails: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType
+ searchQuery: () => string
+ currentMatchIndex: () => number
+ matches: () => SearchMatch[]
}>()
function use() {
@@ -185,11 +197,117 @@ export function Session() {
let scroll: ScrollBoxRenderable
let prompt: PromptRef
+ let search: SearchInputRef
+ const [searchMode, setSearchMode] = createSignal(false)
+ const [searchQuery, setSearchQuery] = createSignal("")
+ const [currentMatchIndex, setCurrentMatchIndex] = createSignal(0)
const keybind = useKeybind()
+ const matches = createMemo(() => {
+ const query = searchQuery().toLowerCase().trim()
+ if (!query) return []
+
+ const result: SearchMatch[] = []
+ let matchIndex = 0
+
+ for (const message of messages()) {
+ const parts = sync.data.part[message.id] ?? []
+ for (const part of parts) {
+ if (part.type === "text" && !part.synthetic) {
+ const text = part.text.toLowerCase()
+ let pos = 0
+ while ((pos = text.indexOf(query, pos)) !== -1) {
+ result.push({
+ messageID: message.id,
+ partID: part.id,
+ text: part.text.slice(pos, pos + query.length),
+ index: matchIndex++,
+ charOffset: pos,
+ })
+ pos += query.length
+ }
+ }
+ }
+ }
+ return result
+ })
+
+ createEffect(() => {
+ const m = matches()
+ if (m.length === 0) {
+ setCurrentMatchIndex(0)
+ } else if (currentMatchIndex() >= m.length) {
+ setCurrentMatchIndex(m.length - 1)
+ }
+ })
+
+ function handleNextMatch() {
+ const m = matches()
+ if (m.length === 0) return
+ const current = currentMatchIndex()
+ const next = (current + 1) % m.length
+ setCurrentMatchIndex(next)
+ scrollToMatch(next)
+ }
+
+ function handlePrevMatch() {
+ const m = matches()
+ if (m.length === 0) return
+ const current = currentMatchIndex()
+ const next = current === 0 ? m.length - 1 : current - 1
+ setCurrentMatchIndex(next)
+ scrollToMatch(next)
+ }
+
+ function scrollToMatch(index: number) {
+ const m = matches()
+ if (index < 0 || index >= m.length) return
+ const match = m[index]
+
+ if (!scroll) return
+
+ // Find the message containing the match
+ const child = scroll.getChildren?.()?.find((c) => c.id === match.messageID)
+ if (!child) return
+
+ // Get the part to calculate text metrics
+ const part = sync.data.part[match.messageID]?.find((p) => p.id === match.partID)
+ if (!part || part.type !== "text") {
+ // Fallback to message-level scrolling
+ const relativeY = child.y - scroll.y
+ if (relativeY < 0 || relativeY >= scroll.height) {
+ scroll.scrollBy(relativeY - Math.floor(scroll.height / 3))
+ }
+ return
+ }
+
+ // Estimate line number based on character offset
+ // Assume ~80 chars per line as a reasonable estimate for wrapped text
+ const charsPerLine = Math.max(40, contentWidth() - 10) // Account for padding and wrapping
+ const estimatedLine = Math.floor(match.charOffset / charsPerLine)
+
+ // Estimate Y offset within the message box (each line is ~1 unit tall)
+ const estimatedYOffset = estimatedLine
+
+ // Calculate target scroll position
+ const targetY = child.y + estimatedYOffset
+ const relativeY = targetY - scroll.y
+
+ // Scroll if the estimated match position is not in viewport
+ if (relativeY < 0 || relativeY >= scroll.height) {
+ scroll.scrollBy(relativeY - Math.floor(scroll.height / 3))
+ }
+ }
+
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
+ if (evt.ctrl && evt.name === "f") {
+ setSearchMode(!searchMode())
+ evt.preventDefault()
+ return
+ }
+
const first = permissions()[0]
if (first) {
const response = iife(() => {
@@ -824,6 +942,9 @@ export function Session() {
return contentWidth()
},
conceal,
+ searchQuery,
+ currentMatchIndex,
+ matches,
showThinking,
showTimestamps,
usernameVisible,
@@ -950,17 +1071,40 @@ export function Session() {
- {
- prompt = r
- promptRef.set(r)
- }}
- disabled={permissions().length > 0}
- onSubmit={() => {
- toBottom()
- }}
- sessionID={route.sessionID}
- />
+
+ {
+ prompt = r
+ promptRef.set(r)
+ }}
+ disabled={permissions().length > 0}
+ onSubmit={() => {
+ toBottom()
+ }}
+ sessionID={route.sessionID}
+ />
+
+
+ (search = r)}
+ sessionID={route.sessionID}
+ disabled={permissions().length > 0}
+ onInput={(query) => {
+ setSearchQuery(query)
+ setCurrentMatchIndex(0)
+ }}
+ onNext={handleNextMatch}
+ onPrevious={handlePrevMatch}
+ matchInfo={
+ matches().length > 0 ? { current: currentMatchIndex(), total: matches().length } : undefined
+ }
+ onExit={() => {
+ setSearchMode(false)
+ setSearchQuery("")
+ setCurrentMatchIndex(0)
+ }}
+ />
+
@@ -986,6 +1130,72 @@ const MIME_BADGE: Record = {
"application/x-directory": "dir",
}
+function SearchHighlighter(props: { text: string; query: string; messageID: string; partID?: string; fg?: any }) {
+ const ctx = use()
+ const { theme } = useTheme()
+
+ const segments = createMemo(() => {
+ const query = props.query.toLowerCase()
+ if (!query) return [{ text: props.text, highlight: false, isActive: false, matchIndex: -1 }]
+
+ const result: { text: string; highlight: boolean; isActive: boolean; matchIndex: number }[] = []
+ const text = props.text
+ const lower = text.toLowerCase()
+ let lastIndex = 0
+ let matchCount = 0
+
+ // Pre-filter matches for this message/part
+ const partMatches = ctx
+ .matches()
+ .filter((m) => m.messageID === props.messageID && (!props.partID || m.partID === props.partID))
+
+ let pos = 0
+ while ((pos = lower.indexOf(query, pos)) !== -1) {
+ if (pos > lastIndex) {
+ result.push({ text: text.slice(lastIndex, pos), highlight: false, isActive: false, matchIndex: -1 })
+ }
+
+ const globalMatch = partMatches[matchCount]
+ const isActive = globalMatch?.index === ctx.currentMatchIndex()
+
+ result.push({
+ text: text.slice(pos, pos + query.length),
+ highlight: true,
+ isActive,
+ matchIndex: globalMatch?.index ?? -1,
+ })
+ lastIndex = pos + query.length
+ pos = lastIndex
+ matchCount++
+ }
+
+ if (lastIndex < text.length) {
+ result.push({ text: text.slice(lastIndex), highlight: false, isActive: false, matchIndex: -1 })
+ }
+
+ return result
+ })
+
+ return (
+
+
+ {(segment) => (
+ {segment.text}>}>
+
+ {segment.text}
+
+
+ )}
+
+
+ )
+}
+
function UserMessage(props: {
message: UserMessage
parts: Part[]
@@ -1029,7 +1239,14 @@ function UserMessage(props: {
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
- {text()?.text}
+ {text()?.text}}>
+
+
@@ -1098,8 +1315,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created
})
+ // We need to provide Assistant messages with ID to be able to scrolled to when searched
return (
- <>
+
{(part, index) => {
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
@@ -1143,7 +1361,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
- >
+
)
}
@@ -1186,21 +1404,122 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
)
}
+function MarkdownSearchHighlighter(props: {
+ text: string
+ query: string
+ messageID: string
+ partID?: string
+ syntaxStyle: any
+ conceal: boolean
+}) {
+ const ctx = use()
+ const { theme } = useTheme()
+
+ // const content = createMemo(() => {
+ // if (!props.query) return props.text
+ // const escapedQuery = props.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+ // // Use ~~ for strikethrough to represent the highlighted system
+ // return props.text.replace(new RegExp(escapedQuery, "gi"), "~~$&~~")
+ // })
+
+ const content = createMemo(() => {
+ if (!props.query) return props.text
+ const escapedQuery = props.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+
+ // Regex to find:
+ // 1. Fenced code blocks (```...```)
+ // 2. Inline code blocks (`...`)
+ // 3. The query text (when outside code blocks)
+ const regex = new RegExp(`(\`{3,}[\\s\\S]*?\`{3,}|\`[^\`]*\`)|(${escapedQuery})`, "gi")
+
+ return props.text.replace(regex, (match, codeBlock, queryText) => {
+ if (codeBlock) {
+ // If the code block doesn't contain the query, leave it alone
+ if (!new RegExp(escapedQuery, "gi").test(codeBlock)) {
+ return match
+ }
+
+ // Determine delimiter (``` or `) based on the start of the match
+ const delimiterMatch = match.match(/^`+/)
+ const delimiter = delimiterMatch ? delimiterMatch[0] : "`"
+
+ // Extract the inner text (remove wrapping backticks)
+ const innerContent = match.slice(delimiter.length, -delimiter.length)
+
+ // Split content by query, using capturing group () to keep the matched text
+ const parts = innerContent.split(new RegExp(`(${escapedQuery})`, "gi"))
+
+ return parts
+ .map((part, index) => {
+ // Even indices: Content parts (code)
+ // Odd indices: Matched query (highlight)
+ if (index % 2 === 0) {
+ // If this code segment is empty (e.g. match at start/end), return nothing
+ // This prevents creating empty `` blocks
+ if (!part) return ""
+ return `${delimiter}${part}${delimiter}`
+ } else {
+ // This is the match -> Apply highlight style
+ return `~~${part}~~`
+ }
+ })
+ .join("")
+ }
+
+ // Match found outside of any code block
+ return `~~${match}~~`
+ })
+ })
+
+ return (
+
+ )
+}
+
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { theme, syntax } = useTheme()
+
+ const hasMatch = createMemo(() => {
+ const query = ctx.searchQuery().toLowerCase()
+ if (!query) return false
+ return props.part.text.toLowerCase().includes(query)
+ })
+
return (
-
+
+ }
+ >
+
+
)