diff --git a/packages/app/pr/screenshots/chat-find-highlight-highlighted-v2.png b/packages/app/pr/screenshots/chat-find-highlight-highlighted-v2.png new file mode 100644 index 00000000..a4ff6787 Binary files /dev/null and b/packages/app/pr/screenshots/chat-find-highlight-highlighted-v2.png differ diff --git a/packages/app/src/app/components/part-view.tsx b/packages/app/src/app/components/part-view.tsx index c9c315ea..3eeaf35b 100644 --- a/packages/app/src/app/components/part-view.tsx +++ b/packages/app/src/app/components/part-view.tsx @@ -2,7 +2,7 @@ import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCle import { marked } from "marked"; import type { Part } from "@opencode-ai/sdk/v2/client"; import { File } from "lucide-solid"; -import { safeStringify } from "../utils"; +import { classifyTool, safeStringify } from "../utils"; type Props = { part: Part; @@ -17,6 +17,32 @@ function clampText(text: string, max = 800) { return `${text.slice(0, max)}\n\n… (truncated)`; } +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function splitWithHighlights(text: string, query: string) { + const q = query.trim(); + if (!q) return [{ text, match: false }]; + const re = new RegExp(escapeRegExp(q), "gi"); + const parts: { text: string; match: boolean }[] = []; + let lastIndex = 0; + let m: RegExpExecArray | null; + // Safety guard against pathological regex behavior. + let iterations = 0; + while ((m = re.exec(text)) && iterations < 500) { + iterations += 1; + const start = m.index; + const end = start + m[0].length; + if (start > lastIndex) parts.push({ text: text.slice(lastIndex, start), match: false }); + if (end > start) parts.push({ text: text.slice(start, end), match: true }); + lastIndex = end; + if (m[0].length === 0) re.lastIndex += 1; + } + if (lastIndex < text.length) parts.push({ text: text.slice(lastIndex), match: false }); + return parts.length ? parts : [{ text, match: false }]; +} + function useThrottledValue(value: () => T, delayMs = 80) { const [state, setState] = createSignal(value()); let timer: ReturnType | undefined; @@ -238,6 +264,52 @@ export default function PartView(props: Props) { const toolOutput = () => normalizeToolText(toolState()?.output); + const toolCategory = createMemo(() => classifyTool(toolName())); + const isSearchTool = createMemo(() => toolCategory() === "search"); + const searchQuery = createMemo(() => { + const state = toolState(); + const raw = state?.pattern ?? state?.query ?? state?.search ?? state?.text ?? state?.term; + if (typeof raw === "string" && raw.trim()) return raw.trim(); + // Some tools set `state.title` to the query/pattern (e.g. grep). + const title = toolTitle(); + if (typeof title === "string") { + const trimmed = title.trim(); + if (trimmed && trimmed.toLowerCase() !== toolName().toLowerCase() && trimmed.length <= 120) { + return trimmed; + } + } + return ""; + }); + + const searchLines = createMemo(() => { + if (!isSearchTool()) return [] as string[]; + const raw = toolOutput(); + if (!raw.trim()) return []; + const lines = raw.split("\n"); + return lines + .map((line) => line.replace(/\r$/, "")) + .filter((line) => { + const trimmed = line.trim(); + if (!trimmed) return false; + // Hide generic summaries so the list focuses on found items. + if (/^Found\s+\d+\s+matches?/i.test(trimmed)) return false; + if (/^No\s+(matches|files)\s+found/i.test(trimmed)) return false; + return true; + }); + }); + + const [expandedSearch, setExpandedSearch] = createSignal(false); + const isSearchCompleted = createMemo(() => { + const status = toolStatus().toLowerCase(); + return status === "completed" || status === "done" || status === "success"; + }); + const searchPreviewMax = 12; + const visibleSearchLines = createMemo(() => { + const lines = searchLines(); + if (expandedSearch()) return lines; + return lines.slice(0, searchPreviewMax); + }); + const toolError = () => { const error = toolState()?.error; return typeof error === "string" ? error : null; @@ -504,6 +576,51 @@ export default function PartView(props: Props) { + 0}> +
+
+
+ Results + + + {searchQuery()} + + +
+ searchPreviewMax}> + + +
+ +
+ + {(line) => ( +
+ + {(part) => ( + {part.text}} + > + + {part.text} + + + )} + +
+ )} +
+
+
+
+
 (
           
- + { + if (props.developerMode && (part.type !== "tool" || props.showThinking)) return true; + if (part.type !== "tool") return false; + const toolName = (part as any).tool ? String((part as any).tool) : ""; + if (classifyTool(toolName) !== "search") return false; + const state = (part as any).state ?? {}; + const output = typeof state?.output === "string" ? state.output.trim() : ""; + return Boolean(output); + })()} + >