diff --git a/.changeset/chat-search-messages-only.md b/.changeset/chat-search-messages-only.md new file mode 100644 index 000000000000..b4b12438175e --- /dev/null +++ b/.changeset/chat-search-messages-only.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Restrict chat search to user and assistant messages, so reasoning blocks, tool calls, and tool output no longer produce matches diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 9a02487741d1..0b8af35734ba 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -159,10 +159,6 @@ export interface MessagePartProps { * forces a collapsed tool/reasoning block open so the user can see the * highlighted match without manually expanding it first. */ forceOpen?: boolean - /** For a multi-file apply_patch part, the specific file path (matching - * that file's `filePath`) whose accordion contains the current match — - * lets that one nested item open instead of every file in the patch. */ - forceOpenFile?: string /** How reasoning blocks render: expanded (open body), preview (capped * scrolling viewport), or headline (header only until opened). */ reasoningDisplay?: ReasoningDisplay @@ -1090,7 +1086,6 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} forceOpen={props.forceOpen} - forceOpenFile={props.forceOpenFile} reasoningDisplay={props.reasoningDisplay} settled={props.settled} showAssistantCopyPartID={props.showAssistantCopyPartID} @@ -1121,9 +1116,6 @@ export interface ToolProps { hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean - /** For a multi-file apply_patch part, the specific file path whose - * accordion contains the current transcript search match. */ - forceOpenFile?: string locked?: boolean animate?: boolean reveal?: boolean @@ -1503,7 +1495,6 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} forceOpen={props.forceOpen} - forceOpenFile={props.forceOpenFile} animate reveal={props.animate} readonly={props.readonly} @@ -3165,31 +3156,12 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) - // Deleted files start collapsed above; a chat search match could be - // inside one. `forceOpenFile` (from MessageList's per-chunk file - // attribution) names exactly which file's accordion to open. This is - // tracked separately from the user's own manual toggles: replacing it - // on every navigation (rather than appending to `expanded`, which never - // shrinks) closes the previously force-opened file again, so its Pierre - // diff instance unmounts instead of accumulating one per visited match. - const [searchOpenFile, setSearchOpenFile] = createSignal() + // Deleted files start collapsed above. A generic forceOpen (still part of + // this component's API) expands everything rather than nothing. createEffect(() => { - if (props.forceOpenFile) { - setSearchOpenFile(props.forceOpenFile) - return - } - // Defensive fallback for forceOpen without a known file (MessageList - // always attributes apply_patch matches to a specific file today): - // expand everything rather than nothing. - setSearchOpenFile(undefined) if (!props.forceOpen) return setExpanded(files().map((f) => f.filePath)) }) - const allExpanded = createMemo(() => { - const search = searchOpenFile() - if (!search) return expanded() - return expanded().includes(search) ? expanded() : [...expanded(), search] - }) const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -3244,21 +3216,17 @@ ToolRegistry.register({ multiple data-scope="apply-patch" style={{ "--sticky-accordion-offset": "37px" }} - value={allExpanded()} + value={expanded()} onChange={(value) => { const next = Array.isArray(value) ? value : value ? [value] : [] - // The user explicitly closed the search-forced file — - // stop treating it as force-open so it doesn't reopen - // itself out of `allExpanded()` on the next render. - if (searchOpenFile() && !next.includes(searchOpenFile()!)) setSearchOpenFile(undefined) - setExpanded(next.filter((path) => path !== searchOpenFile())) + setExpanded(next) }} > {(file) => { // Diff defers its own expensive render; mounting the container // here avoids dropping the last item during batch expansion. - const active = createMemo(() => allExpanded().includes(file.filePath)) + const active = createMemo(() => expanded().includes(file.filePath)) return ( diff --git a/packages/kilo-vscode/tests/unit/transcript-search-text.test.ts b/packages/kilo-vscode/tests/unit/transcript-search-text.test.ts new file mode 100644 index 000000000000..3455d2cae475 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/transcript-search-text.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test" +import { rowSearchText } from "../../webview-ui/src/components/chat/transcript-search-text" +import type { TranscriptRow } from "../../webview-ui/src/context/transcript-rows" +import type { Part } from "../../webview-ui/src/types/messages" + +const part = (value: Record) => value as unknown as Part + +const row = (type: "user" | "assistant", parts: Part[]) => + ({ + type, + key: "row-1", + turn: "turn-1", + partial: false, + queued: false, + live: false, + message: {}, + parts, + }) as unknown as TranscriptRow + +const reasoning = part({ id: "reasoning-1", type: "reasoning", text: "penguin reasoning" }) +const tool = part({ + id: "tool-1", + type: "tool", + tool: "bash", + state: { status: "completed", input: { command: "walrus" }, output: "walrus output", title: "Run" }, +}) +const file = part({ id: "file-1", type: "file", mime: "text/plain", filename: "narwhal.ts", url: "file:///narwhal.ts" }) +const text = (id: string, value: string) => part({ id, type: "text", text: value }) + +describe("rowSearchText", () => { + it("searches message text only and skips every non-message part", () => { + const result = rowSearchText(row("assistant", [reasoning, text("text-1", "swallow helper"), tool, file])) + expect(result.text).toBe("swallow helper") + expect(result.ranges).toEqual([{ start: 0, end: 14, partId: "text-1" }]) + }) + + it("joins multiple text parts and attributes a range to each", () => { + const result = rowSearchText(row("assistant", [text("text-1", "alpha"), reasoning, text("text-2", "beta")])) + expect(result.text).toBe("alpha\nbeta") + expect(result.ranges).toEqual([ + { start: 0, end: 5, partId: "text-1" }, + { start: 6, end: 10, partId: "text-2" }, + ]) + }) + + it("skips synthetic text parts", () => { + const result = rowSearchText( + row("assistant", [part({ id: "synthetic-1", type: "text", text: "hidden", synthetic: true })]), + ) + expect(result.text).toBe("") + expect(result.ranges).toEqual([]) + }) + + it("strips hidden markdown link URLs from assistant text", () => { + const result = rowSearchText(row("assistant", [text("text-1", "see [marked.tsx](src/marked.tsx) now")])) + expect(result.text).toBe("see marked.tsx now") + // Ranges must be measured against the stripped text, not the raw source. + expect(result.ranges).toEqual([{ start: 0, end: 18, partId: "text-1" }]) + }) + + it("keeps brackets in user text, which renders literally", () => { + const result = rowSearchText(row("user", [text("text-1", "see [marked.tsx](src/marked.tsx) now")])) + expect(result.text).toBe("see [marked.tsx](src/marked.tsx) now") + }) + + it("returns nothing for diff and error rows", () => { + const diff = { + type: "diff", + key: "row-1", + turn: "turn-1", + partial: false, + queued: false, + live: false, + message: {}, + diffs: [], + } + const error = { + type: "error", + key: "row-2", + turn: "turn-1", + partial: false, + queued: false, + live: false, + message: {}, + error: {}, + } + expect(rowSearchText(diff as unknown as TranscriptRow)).toEqual({ text: "", ranges: [] }) + expect(rowSearchText(error as unknown as TranscriptRow)).toEqual({ text: "", ranges: [] }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 437e59c5519c..61ffc6e0edec 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -118,13 +118,6 @@ interface AssistantMessageProps { * action row once the turn settles. */ timing?: TurnTiming feedback?: MessageFeedbackControls - /** id of the part containing the current chat-search match, if any — forces - * that part's collapsed tool/reasoning content open so the user can see - * the highlighted match without manually expanding it first. */ - forceOpenPartID?: string - /** For a multi-file apply_patch match, the specific file within that part — - * lets that one nested item open instead of every file in the patch. */ - forceOpenFile?: string /** Part behind the currently hovered/focused task-timeline bar, if any. */ highlight?: () => TimelineHighlight | undefined readonly?: boolean @@ -138,7 +131,7 @@ type ToolStateProps = { status?: string } -function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) { +function TodoToolCard(props: { part: ToolPart }) { const render = ToolRegistry.render(props.part.tool) const state = () => props.part.state as ToolStateProps const language = useLanguage() @@ -156,7 +149,6 @@ function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) { output={state()?.output} status={state()?.status} defaultOpen - forceOpen={props.forceOpen} reveal={false} /> @@ -165,7 +157,7 @@ function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) { ) } -function BashToolCard(props: { part: ToolPart; defaultOpen: boolean; forceOpen?: boolean }) { +function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) { const render = ToolRegistry.render(props.part.tool) const state = () => props.part.state as ToolStateProps const language = useLanguage() @@ -184,7 +176,6 @@ function BashToolCard(props: { part: ToolPart; defaultOpen: boolean; forceOpen?: output={state()?.output} status={state()?.status} defaultOpen={props.defaultOpen} - forceOpen={props.forceOpen} animate reveal={state()?.status === "pending" || state()?.status === "running"} /> @@ -302,7 +293,6 @@ export const AssistantMessage: Component = (props) => { if (!planExitInfo(part)) return return part as unknown as ToolPart }) - const forceOpen = createMemo(() => !!props.forceOpenPartID && part.id === props.forceOpenPartID) // Reasoning blocks are excluded: they animate their own height and // their header and body bleed 6px past this wrapper, so the grow-in // clip would trim their sides for the whole stream and then release @@ -401,8 +391,6 @@ export const AssistantMessage: Component = (props) => { message={props.message as SDKMessage} showAssistantCopyPartID={props.showAssistantCopyPartID} defaultOpen={toolDefaultOpen(part, open(), edit(), mcp())} - forceOpen={forceOpen()} - forceOpenFile={forceOpen() ? props.forceOpenFile : undefined} reasoningDisplay={display.reasoningDisplay()} settled={settled()} feedback={props.feedback} @@ -417,17 +405,11 @@ export const AssistantMessage: Component = (props) => { /> } > - + } > - {(tool) => ( - - )} + {(tool) => } } > diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index c437c78567e8..f74ee4f2eb57 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -21,18 +21,13 @@ import { import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Icon } from "@kilocode/kilo-ui/icon" import { Spinner } from "@kilocode/kilo-ui/spinner" -import { relativizeProjectPath } from "@kilocode/kilo-ui/message-part" import { createAutoScroll } from "@kilocode/kilo-ui/hooks" import { useSession } from "../../context/session" import { useServer } from "../../context/server" import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" -import { useI18n } from "@kilocode/kilo-ui/context/i18n" -import { useProvider } from "../../context/provider" -import { useWorktreeMode } from "../../context/worktree-mode" import { WelcomeEmptyState } from "./WelcomeEmptyState" import { TranscriptRowView } from "./TranscriptRow" -import type { ErrorDisplayProps } from "./ErrorDisplay" import { RevertBanner } from "./RevertBanner" import { AccountSwitcher } from "../shared/AccountSwitcher" import { KiloNotifications } from "./KiloNotifications" @@ -56,16 +51,10 @@ import { stableMessageTurns, type MessageTurn, } from "../../context/session-queue" -import { childID } from "../../context/session-utils" -import { taskResult } from "./task-tool-state" -import { activeQuestionTab, tr } from "./question-dock-utils" -import { useData } from "@kilocode/kilo-ui/context/data" -import { getDirectory as getRawDirectory, getFilename } from "@opencode-ai/core/util/path" import { partitionRows, retainTurn, transcriptRows, - type TranscriptErrorRow, type TranscriptHold, type TranscriptRow, } from "../../context/transcript-rows" @@ -74,14 +63,8 @@ import { capacity, historyAction, promptItems, railEntries, type PromptRailItem import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight" import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search" import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight" -import { - isUnauthorizedPaidModelError, - isUnauthorizedPromotionLimitError, - parseAssistantError, - parseProviderAuthError, - unwrapError, -} from "../../utils/errorUtils" -import type { Part, QuestionRequest, SuggestionRequest, ToolState } from "../../types/messages" +import { rowSearchText, type SearchTextRange } from "./transcript-search-text" +import type { QuestionRequest, SuggestionRequest } from "../../types/messages" interface MessageListProps { onSelectSession?: (id: string) => boolean | void @@ -113,18 +96,6 @@ export const MessageList: Component = (props) => { const server = useServer() const vscode = useVSCode() const language = useLanguage() - const provider = useProvider() - const i18n = useI18n() - const data = useData() - // Only present inside Agent Manager (see worktree-mode.tsx). Agent Manager - // never calls registerExpandedTaskTool(), so its "task" cards always fall - // back to kilo-ui's default hideDetails renderer, which never shows a - // task's result text — indexing it there would produce a phantom match. - const inAgentManager = !!useWorktreeMode() - - function getDirectory(path: string | undefined) { - return relativizeProjectPath(getRawDirectory(path), data.directory) - } const autoScroll = createAutoScroll({ working: () => session.status() !== "idle", @@ -210,469 +181,10 @@ export const MessageList: Component = (props) => { const search = useTranscriptSearch() - interface RowTextRange { - start: number - end: number - partId: string - /** For a multi-file apply_patch chunk, the file this range belongs to. */ - file?: string - } - - /** A tool-text chunk, optionally attributed to a specific file within a - * multi-file tool part (currently only apply_patch's per-file chunks). */ - type ToolChunk = string | { text: string; file: string } - - // Returns the row's full searchable text plus, for every chunk that came - // from a specific part (tool call/reasoning/text/file), the character - // range it occupies within that text. Lets a match's character index be - // attributed back to the part it came from, so navigation can force that - // exact collapsed tool/reasoning block open instead of just scrolling to - // the row. - function rowText(row: TranscriptRow): { text: string; ranges: RowTextRange[] } { - if (row.type === "error") return { text: errorText(row.error), ranges: [] } - if (row.type === "diff") return { text: "", ranges: [] } - // User message text is rendered by UserMessageDisplay/HighlightedText - // (message-part.tsx), which never parses markdown at all — [label](url) - // always shows literally, brackets and all, unlike assistant text/ - // reasoning/tool content which goes through the real Markdown renderer. - // Stripping link URLs there would wrongly collapse two genuinely - // visible occurrences (the literal label and the literal URL) into one. - const markdown = row.type !== "user" - const chunks: string[] = [] - const ranges: RowTextRange[] = [] - let pos = 0 - const push = (text: string, partId: string, file?: string) => { - if (!text) return - if (chunks.length > 0) pos += 1 // account for the "\n" chunk joiner below - chunks.push(text) - ranges.push({ start: pos, end: pos + text.length, partId, file }) - pos += text.length - } - for (const part of row.parts) { - switch (part.type) { - case "text": - if (!part.synthetic) push(markdown ? stripMarkdownLinkUrls(part.text) : part.text, part.id) - break - case "reasoning": - push(stripMarkdownLinkUrls(part.text), part.id) - break - case "tool": - // Bash output is rendered via escapeHtml + syntax highlighting - // (never through Markdown at all), and the generic/MCP fallback - // renderer wraps its output in a fenced code block before ever - // reaching Markdown — both show link-like `[x](y)` text literally. - // Stripping it here would search text that no longer matches the - // literal characters on screen, the same class of mismatch this - // rewrite fixes elsewhere. - for (const chunk of toolText(part)) { - if (typeof chunk === "string") push(chunk, part.id) - else push(chunk.text, part.id, chunk.file) - } - break - case "file": - if (part.filename) push(part.filename, part.id) - break - } - } - return { text: chunks.join("\n"), ranges } - } - - function rangeAt(ranges: RowTextRange[], index: number): RowTextRange | undefined { + function rangeAt(ranges: SearchTextRange[], index: number): SearchTextRange | undefined { return ranges.find((r) => index >= r.start && index < r.end) } - // Markdown link/image URLs are part of the raw source text but are never - // rendered as visible text (only used as the href/src attribute) — a - // common assistant pattern like [marked.tsx](path/to/marked.tsx) makes the - // query match twice in raw text but appear only once in the DOM. Strips - // that hidden half so counting mirrors what's actually on screen. Images - // are removed entirely (their alt text isn't shown unless the image - // fails to load); links keep only their visible label. - // - // Code fences/spans suppress all inline markdown parsing, so bracket/ - // paren text a user or assistant writes inside one (e.g. asking the - // model to echo `[label](url)` verbatim) renders as literal, fully - // visible text — split those segments out first and leave them alone, or - // this would wrongly collapse two genuinely visible occurrences into one. - function stripMarkdownLinkUrls(text: string): string { - const segments = text.split(/(```[\s\S]*?```|`[^`\n]*`)/g) - return segments.map((segment, i) => (i % 2 === 1 ? segment : stripLinks(segment))).join("") - } - - function stripLinks(text: string): string { - return text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") - } - - // Mirrors ErrorDisplay.tsx's exact Switch/Match classification so search - // text matches what's actually on screen for every error variant, not - // just the default card: the paid-model and promotion-limit prompts - // render fixed localized copy (no user data at all), and the provider - // auth prompt only renders when canAuth() would be true there too — - // otherwise ErrorDisplay itself falls through to the default card. - function errorText(error: TranscriptErrorRow["error"]): string { - const value = error as ErrorDisplayProps["error"] - const parsed = parseAssistantError(value) - if (isUnauthorizedPaidModelError(parsed)) { - return [language.t("error.paidModel.title"), language.t("error.paidModel.description")].join("\n") - } - if (isUnauthorizedPromotionLimitError(parsed)) { - return [language.t("error.promotionLimit.title"), language.t("error.promotionLimit.description")].join("\n") - } - const auth = parseProviderAuthError(value) - const authProvider = auth ? provider.providers()[auth.providerID] : undefined - const authMethods = auth ? (provider.authMethods()[auth.providerID] ?? []) : [] - if (auth && authProvider && authMethods.length > 0) { - const oauth = auth.providerID === "openai" && authMethods.some((method) => method.type === "oauth") - const name = authProvider.name ?? auth.providerID - const title = oauth - ? language.t("error.providerAuth.chatgpt.title") - : language.t("error.providerAuth.title", { provider: name }) - const description = oauth - ? language.t("error.providerAuth.chatgpt.description") - : language.t("error.providerAuth.description", { provider: name }) - return [title, description].join("\n") - } - const msg = error.data?.message - if (typeof msg !== "string") return "" - return unwrapError(msg) - } - - // Extracts only the text kilo-ui's tool renderers actually put on screen — - // matched field-by-field rather than reading `state.title` generically. - // Uses one canonical extraction for both counting/navigation (this - // function) and highlighting (transcript-search-highlight.ts scans the - // rendered DOM) rather than two independently maintained notions of "the - // tool's text" — a hand-picked field list here previously missed content - // that's genuinely always on screen (e.g. a todowrite checklist's item - // text), so a visible match could be highlighted in the DOM while the - // counter still reported "No results" and navigation was disabled. - // - // read/glob/grep/list are the one confirmed exception: kilo-ui always - // collapses them into a context-group summary (context-tool-results.tsx) - // that never renders raw input/output text, even expanded — including - // that text here would count matches with no corresponding highlight, - // the same class of bug this rewrite fixes for every other tool. - const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) - - // edit/write/apply_patch render their actual diff content through - // @pierre/diffs inside a shadow-DOM (packages/ui/src/ - // pierre/file-runtime.ts's getViewerRoot()), which a light-DOM text scan - // can never reach — and diff-mode rendering is virtualized by default, so - // even piercing the shadow root wouldn't guarantee off-screen lines are - // mounted. state.input/state.metadata also duplicate the full before/ - // after file content and the path itself several times over (a unified - // patch string with the path repeated in its `---`/`+++` headers, a - // separate raw `metadata.diff` copy, write's extra top-level - // `metadata.filepath`), none of which corresponds 1:1 with what's on - // screen. Rather than collect+dedupe those redundant fields, mirror the - // renderer's fixed, known layout directly: edit/write always show one - // file's path in exactly two places (the BasicTool trigger's - // ToolMetaLine and that file's own accordion header); apply_patch's - // trigger only adds a third, single-file ToolMetaLine when there's - // exactly one file (message-part.tsx's `single()`) — for a multi-file - // patch each file's name only appears once, in its own accordion header. - const DIFF_TOOLS = new Set(["edit", "write"]) - // todowrite's renderer resolves the shown list from a fallback chain - // (metadata.view.todos, else metadata.todos, else input.todos) — - // packages/opencode/src/tool/todo.ts sets metadata.todos to the exact - // same array as input.todos, and metadata.view.todos to the exact same - // content again whenever the view is in "full" mode (the common case, - // see packages/opencode/src/kilocode/todo-view.ts). Recursively collecting - // both input and metadata would count every todo's text 2-3x even though - // shown() only ever renders it once. - // todoread is deliberately excluded: AssistantMessage.tsx's isRenderable() - // only shows a completed part from UPSTREAM_SUPPRESSED_TOOLS when - // ToolRegistry has a renderer for it, and only "todowrite" is registered — - // a completed todoread never reaches the DOM, so indexing it would count - // matches with nothing to highlight or navigate to. - const TODO_TOOLS = new Set(["todowrite"]) - - function toolText(part: Part & { type: "tool" }): ToolChunk[] { - const state = part.state - if (state.status === "error") return state.error ? [state.error] : [] - // task's trigger (title "{type} Agent" + input.description subtitle) and - // question's dock (question text + full option list) render the same - // way whether the call is still pending/running or already completed — - // unlike every other tool, where only a bare title shows until - // completion — so both need handling before the completed-only gate - // below, or an in-progress task/question would index nothing at all. - if (part.tool === "task") return taskText(part, state) - if (part.tool === "question") return questionText(part, state) - if (state.status !== "completed") return "title" in state && state.title ? [state.title] : [] - if (CONTEXT_GROUP_TOOLS.has(part.tool)) return state.title ? [state.title] : [] - if (part.tool === "bash") return bashText(state) - if (part.tool === "apply_patch") return applyPatchText(state) - if (DIFF_TOOLS.has(part.tool)) return editWriteText(part.tool, state) - if (TODO_TOOLS.has(part.tool)) return todoText(state) - const chunks: string[] = [] - if (state.title) chunks.push(state.title) - collectStrings(state.input, chunks) - collectStrings(state.metadata, chunks) - if (typeof state.output === "string" && state.output) chunks.push(mcpOutputText(state.output)) - return chunks - } - - // transcript-search-highlight.ts's scanScope() walks the whole [data-part- - // id] subtree for this tool, which includes the ToolTriggerRow's title - // ("To-dos") and "completed/total" subtitle alongside each item's content - // — indexing only item content undercounts what's actually scanned there, - // shifting which range resolveSearchScopes/scanScope picks as "active". - function todoText(state: Extract): string[] { - const metadata = state.metadata as { todos?: unknown; view?: unknown } | undefined - const input = state.input as { todos?: unknown } | undefined - const view = metadata?.view - const viewTodos = isTodoView(view) ? view.todos : undefined - // Matches the renderer's own `todos()` memo (used for the "N/M" - // subtitle): metadata.todos, else input.todos — never the (possibly - // view-truncated) compact list. - const full = - (Array.isArray(metadata?.todos) ? metadata.todos : undefined) ?? - (Array.isArray(input?.todos) ? input.todos : undefined) ?? - [] - const shown = viewTodos ?? full - const chunks: string[] = [i18n.t("ui.tool.todos")] - if (full.length > 0) { - const completed = (full as { status?: unknown }[]).filter((t) => t?.status === "completed").length - chunks.push(`${completed}/${full.length}`) - } - for (const content of (shown as { content?: unknown }[]).map((todo) => todo?.content)) { - if (typeof content === "string" && content.length > 0) chunks.push(content) - } - return chunks - } - - function isTodoView(value: unknown): value is { todos?: { content?: unknown }[] } { - return !!value && typeof value === "object" && Array.isArray((value as { todos?: unknown }).todos) - } - - // Matches TaskToolExpanded.tsx (the renderer this webview actually - // registers for "task", overriding kilo-ui's default) exactly: title is - // `i18n.t("ui.tool.agent", { type })` once subagent_type is known, and - // `ui.tool.agent.default` while it is still absent. The "capitalize" CSS - // class only changes how it *looks*, the DOM text node itself is the raw, - // lowercase subagent_type. The "(N)" child-tool-count suffix shown there is - // a live value from session.getSessionToolCount(), not stored on the part at - // all, so it can't be indexed from a snapshot — searching for that count - // isn't meaningful content anyway. - function taskText(part: Part & { type: "tool" }, state: ToolState): string[] { - const input = state.input as { subagent_type?: string; description?: string } | undefined - const type = input?.subagent_type - const chunks = [type ? i18n.t("ui.tool.agent", { type }) : i18n.t("ui.tool.agent.default")] - if (input?.description) chunks.push(input.description) - // TaskToolExpanded.tsx only shows the raw body when there's - // no live child session to display instead (result() there resolves to - // undefined once a child session exists) — mirror that exactly so a - // completed task with no child session stays searchable, without - // indexing text that's actually replaced by the child tool list. Agent - // Manager never registers TaskToolExpanded at all (it always uses - // kilo-ui's default hideDetails task card, which never shows result - // text there), so skip this entirely in that surface. - if (state.status === "completed" && !inAgentManager) { - const child = childID({ - type: "tool", - tool: part.tool, - metadata: part.metadata as { sessionId?: string } | undefined, - state: { metadata: state.metadata }, - }) - const result = taskResult(state.output, child) - if (result) chunks.push(stripMarkdownLinkUrls(result)) - } - return chunks - } - - // QuestionDock renders very different content depending on whether the - // question is still awaiting an answer or already resolved: while - // pending/running it shows the full clickable option list (label + - // description per option); once completed it only shows the question - // text plus whichever answer was actually given (dismissed questions show - // neither the options nor a real answer, just a static "dismissed" - // label that isn't meaningful content to index). - type QuestionOption = { label?: string; description?: string; labelKey?: string; descriptionKey?: string } - type QuestionInfo = { question?: string; questionKey?: string; options?: QuestionOption[] } - - // Correlates a "question" tool part to its live QuestionRequest the same - // way AssistantMessage.tsx's matchToolRequest does (by callID/messageID), - // so the pending branch below can read which page QuestionDock actually - // has mounted (question-dock-utils.ts's activeQuestionTab) instead of - // assuming it's always the first one. - function liveQuestionRequestId(part: Part & { type: "tool" }): string | undefined { - return session.questions().find((r) => r.tool?.callID === part.callID && r.tool?.messageID === part.messageID)?.id - } - - function questionText(part: Part & { type: "tool" }, state: ToolState): string[] { - const input = state.input as { questions?: QuestionInfo[] } | undefined - const questions = input?.questions ?? [] - const done = state.status === "completed" - const metadata = done ? (state.metadata as { answers?: unknown; dismissed?: unknown } | undefined) : undefined - const answers = Array.isArray(metadata?.answers) ? (metadata!.answers as unknown[][]) : undefined - const dismissed = metadata?.dismissed === true - const requestId = done ? undefined : liveQuestionRequestId(part) - const mountedTab = requestId ? activeQuestionTab(requestId) : 0 - const chunks: string[] = [] - questions.forEach((q, i) => { - // The pending QuestionDock renders localized text via questionKey/ - // labelKey/descriptionKey (see question-dock-utils.ts's tr()), with - // the raw wire strings kept only as untranslated fallbacks/reply - // values. Once completed, kilo-ui's question renderer instead shows - // the raw `q.question` directly (no questionKey lookup) — index - // whichever one that surface actually displays. - const questionLabel = done ? (q.question ?? "") : tr(language.t, q.questionKey, q.question ?? "") - if (questionLabel) chunks.push(questionLabel) - if (!done) { - // QuestionDock only ever mounts one page (store.tab) of a pending - // multi-question at a time — limit to the page it has published as - // mounted (question-dock-utils.ts), which stays correct as the user - // navigates instead of freezing on page 0. - if (i !== mountedTab) return - for (const option of q.options ?? []) { - const label = tr(language.t, option.labelKey, option.label ?? "") - if (label) chunks.push(label) - const description = option.description ? tr(language.t, option.descriptionKey, option.description) : "" - if (description) chunks.push(description) - } - return - } - if (dismissed) return - for (const value of answers?.[i] ?? []) { - if (typeof value === "string" && value) chunks.push(value) - } - }) - return chunks - } - - type Diagnostic = { severity: number; range: { start: { line: number; character: number } }; message: string } - - // Mirrors getDiagnostics()/DiagnosticsDisplay in message-part.tsx: up to 3 - // severity-1 diagnostics for the file, each shown as an "Error" label, a - // "[line:char]" location, and the diagnostic message. - function editWriteDiagnostics(metadata: unknown, filePath: string | undefined): string[] { - const byFile = (metadata as { diagnostics?: Record } | undefined)?.diagnostics - if (!byFile || !filePath) return [] - const diagnostics = (byFile[filePath] ?? []).filter((d) => d.severity === 1).slice(0, 3) - const chunks: string[] = [] - for (const d of diagnostics) { - chunks.push(i18n.t("ui.messagePart.diagnostic.error")) - chunks.push(`[${d.range.start.line + 1}:${d.range.start.character + 1}]`) - if (d.message) chunks.push(d.message) - } - return chunks - } - - // edit/write show a file's path in two places with different layouts: - // the BasicTool trigger's ToolMetaLine (filename, then — only when the raw - // input path has a directory — the directory, bidi-isolated) and that - // file's own accordion header (directory-then-filename, same bidi - // wrapper). edit's accordion can source a different path than the - // trigger (metadata.filediff.file falls back to input.filePath); write - // uses input.filePath for both. Mirror both occurrences exactly rather - // than indexing the raw path string, which never appears in the DOM as a - // single contiguous run. - function editWriteText(tool: string, state: Extract): string[] { - const input = state.input as { filePath?: string } | undefined - const metadata = state.metadata as - | { filepath?: string; filediff?: { file?: string }; diagnostics?: Record } - | undefined - const triggerPath = input?.filePath - const accordionPath = tool === "edit" ? (metadata?.filediff?.file ?? triggerPath) : triggerPath - const chunks: string[] = [] - if (triggerPath) { - chunks.push(getFilename(triggerPath)) - if (triggerPath.includes("/")) chunks.push(`\u2066${getDirectory(triggerPath)}\u2069`) - } - if (accordionPath) { - if (accordionPath.includes("/")) chunks.push(`\u2066${getDirectory(accordionPath)}\u2069`) - chunks.push(getFilename(accordionPath)) - } - chunks.push(...editWriteDiagnostics(metadata, triggerPath)) - return chunks - } - - function applyPatchText(state: Extract): ToolChunk[] { - const files = ((state.metadata as { files?: { filePath?: string; relativePath?: string }[] } | undefined)?.files ?? - []) as { filePath?: string; relativePath?: string }[] - // Only when there's exactly one file does the trigger also show a - // ToolMetaLine for it (filename, then bidi-isolated directory), ON TOP - // OF that file's own ToolFileAccordion header (bidi-isolated directory, - // then filename) — both are rendered simultaneously for a single-file - // patch, same as edit/write's two-location layout in editWriteText. - // For a multi-file patch each file's name only appears once, in its own - // accordion header. Multi-file chunks are tagged with the file's - // `filePath` (matching the accordion's item key) so a match can force - // just that one nested item open instead of every file. - const single = files.length === 1 - const chunks: ToolChunk[] = [] - for (const file of files) { - const path = file.relativePath ?? file.filePath - if (!path || !file.filePath) continue - const dir = path.includes("/") ? `\u2066${getDirectory(path)}\u2069` : undefined - if (single) { - chunks.push(getFilename(path)) - if (dir) chunks.push(dir) - if (dir) chunks.push(dir) - chunks.push(getFilename(path)) - continue - } - const tag = (text: string): ToolChunk => ({ text, file: file.filePath! }) - if (dir) chunks.push(tag(dir)) - chunks.push(tag(getFilename(path))) - } - return chunks - } - - // Mirrors McpTool's formattedOutput(): if `output` parses as JSON, the - // renderer pretty-prints it inside a fenced ```json block, so it's shown - // literally, same as bash. If it isn't valid JSON — the common case for a - // tool returning prose or markdown — the renderer feeds the raw string - // straight into the real Markdown component, which *does* parse - // `[label](url)` into an actual link, hiding the URL half. Strip it there - // the same as text/reasoning chunks, or a non-JSON tool result reintroduces - // the exact mismatch this rewrite otherwise fixes. - function mcpOutputText(output: string): string { - try { - JSON.parse(output) - return output - } catch { - return stripMarkdownLinkUrls(output) - } - } - - function bashText(state: Extract) { - const input = state.input as { command?: string; description?: string } | undefined - const metadata = state.metadata as { command?: string; description?: string } | undefined - const command = input?.command ?? metadata?.command - const description = input?.description ?? metadata?.description - const chunks: string[] = [] - // DOM order: description renders as the header subtitle (above the - // command box), command renders below it, output last — keep this in - // sync with shell-rolling-results.tsx so occurrence numbering lines up - // with what's actually highlighted on screen. - if (description) chunks.push(description) - if (command) chunks.push(command) - if (state.output) chunks.push(state.output) - return chunks - } - - // Recursively collects every string leaf value from a tool's `input`/ - // `metadata` (JSON-like objects/arrays of unknown shape), so nested - // rendered text — a todo item's `content`, a question's `question` text, - // a skill's `name` — is included without hand-modeling each tool's shape. - function collectStrings(value: unknown, out: string[], depth = 0): void { - if (depth > 4 || value === undefined || value === null) return - if (typeof value === "string") { - if (value) out.push(value) - return - } - if (Array.isArray(value)) { - for (const item of value) collectStrings(item, out, depth + 1) - return - } - if (typeof value === "object") { - for (const key of Object.keys(value as Record)) { - collectStrings((value as Record)[key], out, depth + 1) - } - } - } - function buildPattern(query: string, matchCase: boolean, wholeWord: boolean, regex: boolean): RegExp | undefined { if (!query) return undefined try { @@ -740,7 +252,7 @@ export const MessageList: Component = (props) => { const list = rows() const result: SearchMatch[] = [] for (const row of list) { - const { text, ranges } = rowText(row) + const { text, ranges } = rowSearchText(row) p.lastIndex = 0 let occurrence = 0 let hit = p.exec(text) @@ -751,7 +263,7 @@ export const MessageList: Component = (props) => { continue } const range = rangeAt(ranges, hit.index) - result.push({ key: row.key, occurrence, partId: range?.partId, partFile: range?.file }) + result.push({ key: row.key, occurrence, partId: range?.partId }) occurrence += 1 hit = p.exec(text) } @@ -819,11 +331,9 @@ export const MessageList: Component = (props) => { // highlighter must scan nothing in it at all — otherwise unindexed text (a // static button label, a sibling part that didn't match) could get // highlighted despite never being counted. An entry always exists for - // every row that has at least one match, even with an empty part-id set - // (a match that couldn't be attributed to a specific part, e.g. error/diff - // rows) — the highlighter treats "entry exists" as "this row has a real - // match" and falls back to scanning the whole row whenever the part-id - // lookup doesn't resolve to a mounted element. + // every row that has at least one match, and a match in a row with no + // resolvable part scope (e.g. a user message, whose parts carry no + // data-part-id marker) falls back to scanning the whole row. const matchedPartsByRow = createMemo(() => { const map = new Map>() for (const match of matches()) { @@ -1311,8 +821,6 @@ export const MessageList: Component = (props) => { editDisabled={props.editDisabled} highlight={highlight} activeSearch={activeKey() === entry.row.key} - activeSearchPartID={activeKey() === entry.row.key ? activeMatch()?.partId : undefined} - activeSearchPartFile={activeKey() === entry.row.key ? activeMatch()?.partFile : undefined} readonly={props.readonly} interactivePrompts={props.interactivePrompts} /> @@ -1414,8 +922,6 @@ export const MessageList: Component = (props) => { queuedDisabled={props.queuedDisabled} editDisabled={props.editDisabled} activeSearch={activeKey() === row.key} - activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined} - activeSearchPartFile={activeKey() === row.key ? activeMatch()?.partFile : undefined} readonly={props.readonly} interactivePrompts={props.interactivePrompts} /> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 45843d0fa543..87f9fcfdfbe8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -4,7 +4,7 @@ * Uses kilo-ui's DockPrompt component for proper surface styling. */ -import { For, Show, createMemo, createEffect, onCleanup } from "solid-js" +import { For, Show, createMemo, createEffect } from "solid-js" import type { Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@kilocode/kilo-ui/button" @@ -13,11 +13,9 @@ import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" import type { QuestionRequest } from "../../types/messages" import { - clearActiveQuestionTab, pickOutcome, resolveOptimisticQuestionAgent, resolveSelectedQuestionMode, - setActiveQuestionTab, toggleAnswer, tr, } from "./question-dock-utils" @@ -27,7 +25,6 @@ import { isTextControl } from "../../utils/focus" export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const session = useSession() const language = useLanguage() - const id = props.request.id const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -59,12 +56,6 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } }) - // Chat search indexes only the mounted page's options, and there's no - // other signal exposing which page that is — publish it here so search - // stays in sync as the user navigates instead of always assuming page 0. - createEffect(() => setActiveQuestionTab(id, store.tab)) - onCleanup(() => clearActiveQuestionTab(id)) - const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx index e2bd411ca4e0..e3f981c236bb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx @@ -24,11 +24,6 @@ interface TranscriptRowViewProps { /** Part behind the currently hovered/focused task-timeline bar, if any. */ highlight?: () => TimelineHighlight | undefined activeSearch?: boolean - /** id of the part (tool call/reasoning block) containing the current chat - * search match within this row, if any. */ - activeSearchPartID?: string - /** For a multi-file apply_patch match, the specific file within that part. */ - activeSearchPartFile?: string readonly?: boolean interactivePrompts?: boolean queuedDisabled?: boolean @@ -109,8 +104,6 @@ export const TranscriptRowView: Component = (props) => { parts={row().parts as unknown as SDKPart[]} showAssistantCopyPartID={row().copy} timing={row().timing} - forceOpenPartID={props.activeSearchPartID} - forceOpenFile={props.activeSearchPartFile} highlight={props.highlight} readonly={props.readonly} interactivePrompts={props.interactivePrompts} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts index 51ed4500db94..615aa02fb3db 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/question-dock-utils.ts @@ -1,32 +1,5 @@ -import { createSignal } from "solid-js" import type { QuestionOption } from "../../types/messages" -// Which page (question index) of a pending multi-question is currently -// mounted in the DOM, keyed by request id. QuestionDock only ever mounts -// `store.tab`, and nothing else observes which page that is — chat search -// needs it to index exactly the visible page instead of guessing it's -// always the first one (true only right after initial mount, not once the -// user has navigated). -const [activeQuestionTabs, setActiveQuestionTabs] = createSignal>({}) - -export function setActiveQuestionTab(requestId: string, tab: number): void { - setActiveQuestionTabs((prev) => (prev[requestId] === tab ? prev : { ...prev, [requestId]: tab })) -} - -export function clearActiveQuestionTab(requestId: string): void { - setActiveQuestionTabs((prev) => { - if (!(requestId in prev)) return prev - const next = { ...prev } - delete next[requestId] - return next - }) -} - -/** Returns the currently-mounted page for a request, defaulting to the first page. */ -export function activeQuestionTab(requestId: string): number { - return activeQuestionTabs()[requestId] ?? 0 -} - /** * Translate a backend-provided i18n key, falling back to the canonical label when no key is * set or the key is missing from the dictionary. diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-highlight.ts b/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-highlight.ts index c90359fc9be3..9c8bd23561f8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-highlight.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-highlight.ts @@ -125,7 +125,7 @@ export function applyTranscriptHighlights( // per search-scope instead would treat `active.occurrence` as local to // whichever part happened to be scanned first, misattributing which // occurrence is "current" for any row with more than one contributing - // part (e.g. a reasoning block followed by a tool call). + // part (e.g. an assistant reply split across several text parts). const ranges = resolveSearchScopes(scope, matchedParts).flatMap((searchScope) => scanScope(searchScope, pattern)) if (ranges.length === 0) continue const isActiveRow = !!active && scope.dataset.rowKey === active.key diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-text.ts b/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-text.ts new file mode 100644 index 000000000000..56a3ca20879a --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/transcript-search-text.ts @@ -0,0 +1,63 @@ +import type { TranscriptRow } from "../../context/transcript-rows" + +/** Character range one message part occupies within a row's searchable text. */ +export interface SearchTextRange { + start: number + end: number + partId: string +} + +/** + * Chat transcript search only scans real message text: user prompts and + * assistant replies. Tool calls and their output (bash included), reasoning + * blocks, file attachments, and error rows are deliberately out of scope. + * They dominate a long transcript by volume, so matching them buries the + * conversational text a search is actually after. + * + * Returns the row's combined search text plus, for every part that contributed + * to it, the character range it occupies. The range lets a match be attributed + * back to the part it came from, so highlighting can scan just that part's DOM + * subtree instead of every element in the row. + */ +export function rowSearchText(row: TranscriptRow): { text: string; ranges: SearchTextRange[] } { + if (row.type !== "user" && row.type !== "assistant") return { text: "", ranges: [] } + // User message text renders literally (never parsed as markdown), so + // [label](url) shows with its brackets and URL intact. Assistant text goes + // through the real Markdown renderer, which hides link/image URLs, so strip + // those there to keep the match count aligned with the visible characters. + const markdown = row.type === "assistant" + const chunks: string[] = [] + const ranges: SearchTextRange[] = [] + let pos = 0 + for (const part of row.parts) { + if (part.type !== "text" || part.synthetic) continue + const text = markdown ? stripMarkdownLinkUrls(part.text) : part.text + if (!text) continue + if (chunks.length > 0) pos += 1 // account for the "\n" chunk joiner below + chunks.push(text) + ranges.push({ start: pos, end: pos + text.length, partId: part.id }) + pos += text.length + } + return { text: chunks.join("\n"), ranges } +} + +// Markdown link/image URLs are part of the raw source text but are never +// rendered as visible text (only used as the href/src attribute), so a common +// assistant pattern like [marked.tsx](path/to/marked.tsx) makes the query +// match twice in raw text but appear only once in the DOM. Strips that hidden +// half so counting mirrors what's actually on screen. Images are removed +// entirely (their alt text isn't shown unless the image fails to load); links +// keep only their visible label. +// +// Code fences/spans suppress all inline markdown parsing, so bracket/paren +// text written inside one renders as literal, fully visible text. Split those +// segments out first and leave them alone, or two genuinely visible +// occurrences would wrongly collapse into one. +function stripMarkdownLinkUrls(text: string): string { + const segments = text.split(/(```[\s\S]*?```|`[^`\n]*`)/g) + return segments.map((segment, i) => (i % 2 === 1 ? segment : stripLinks(segment))).join("") +} + +function stripLinks(text: string): string { + return text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") +} diff --git a/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx b/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx index 1edb13bdc116..1ebbcaf1a3ec 100644 --- a/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/transcript-search.tsx @@ -4,14 +4,10 @@ export interface SearchMatch { key: string /** Index (0-based) of this occurrence among all matches within the same row. */ occurrence: number - /** id of the part (tool call/reasoning block/text) this occurrence falls - * within, if it could be attributed to one — lets navigation force a - * collapsed part open instead of just scrolling to the row. */ + /** id of the message part this occurrence falls within, if it could be + * attributed to one — lets highlighting scan just that part's DOM subtree + * instead of every element in the row. */ partId?: string - /** For a multi-file apply_patch part, the specific file path this - * occurrence falls within — lets navigation open just that file's nested - * accordion instead of every file in the patch. */ - partFile?: string } interface TranscriptSearchContextValue {