Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chat-search-messages-only.md
Original file line number Diff line number Diff line change
@@ -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
42 changes: 5 additions & 37 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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<string | undefined>()
// 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 ""
Expand Down Expand Up @@ -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)
}}
>
<For each={files()}>
{(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 (
<Accordion.Item value={file.filePath} data-type={file.type}>
Expand Down
90 changes: 90 additions & 0 deletions packages/kilo-vscode/tests/unit/transcript-search-text.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => 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")
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
// 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: [] })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -156,7 +149,6 @@ function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) {
output={state()?.output}
status={state()?.status}
defaultOpen
forceOpen={props.forceOpen}
reveal={false}
/>
</ToolApprovalProvider>
Expand All @@ -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()
Expand All @@ -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"}
/>
Expand Down Expand Up @@ -302,7 +293,6 @@ export const AssistantMessage: Component<AssistantMessageProps> = (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
Expand Down Expand Up @@ -401,8 +391,6 @@ export const AssistantMessage: Component<AssistantMessageProps> = (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}
Expand All @@ -417,17 +405,11 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
/>
}
>
<TodoToolCard part={part as unknown as ToolPart} forceOpen={forceOpen()} />
<TodoToolCard part={part as unknown as ToolPart} />
</Show>
}
>
{(tool) => (
<BashToolCard
part={tool() as unknown as ToolPart}
defaultOpen={open()}
forceOpen={forceOpen()}
/>
)}
{(tool) => <BashToolCard part={tool() as unknown as ToolPart} defaultOpen={open()} />}
</Show>
}
>
Expand Down
Loading
Loading