mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
feat(vscode): toggle chat search from Command Palette, jump focus on close, and auto-expand matched blocks
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Toggle chat search from the Command Palette, jump focus back to the chat input when it closes, and auto-expand the collapsed tool call or reasoning block containing the current search match.
|
||||
@@ -142,6 +142,10 @@ export interface MessagePartProps {
|
||||
message: MessageType
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
/** True when this part contains the transcript search's current match —
|
||||
* forces a collapsed tool/reasoning block open so the user can see the
|
||||
* highlighted match without manually expanding it first. */
|
||||
forceOpen?: boolean
|
||||
reasoningAutoCollapse?: boolean
|
||||
showAssistantCopyPartID?: string | null
|
||||
showTurnDiffSummary?: boolean
|
||||
@@ -974,6 +978,7 @@ export function Part(props: MessagePartProps) {
|
||||
message={props.message}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
forceOpen={props.forceOpen}
|
||||
reasoningAutoCollapse={props.reasoningAutoCollapse}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
showTurnDiffSummary={props.showTurnDiffSummary}
|
||||
@@ -1198,6 +1203,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
status={part.state.status}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
forceOpen={props.forceOpen}
|
||||
animate
|
||||
reveal={props.animate}
|
||||
/>
|
||||
@@ -1265,6 +1271,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
attachments={part.state.attachments}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
forceOpen={props.forceOpen}
|
||||
animate
|
||||
reveal={props.animate}
|
||||
/>
|
||||
@@ -1519,6 +1526,18 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
|
||||
setOpen(value)
|
||||
}
|
||||
|
||||
// Reasoning has no built-in "force open" hook (unlike BasicTool's forceOpen
|
||||
// ratchet) — mirror that one-way-open behavior here so jumping a chat
|
||||
// search match to a collapsed reasoning block reveals it, the same as it
|
||||
// does for tool calls. Recorded into userOpened/userCollapsed the same way
|
||||
// a manual open would be, so it stays open across remounts/re-renders.
|
||||
createEffect(() => {
|
||||
if (!props.forceOpen || open()) return
|
||||
if (props.reasoningAutoCollapse) rememberReasoningState(userOpened, id)
|
||||
else userCollapsed.delete(id)
|
||||
setOpen(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.reasoningAutoCollapse) return
|
||||
// Skip auto-collapse for blocks the user explicitly opened.
|
||||
@@ -2588,6 +2607,15 @@ 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, and there's no per-file tracking of which file a match
|
||||
// falls in, so force-opening this tool expands every file's accordion
|
||||
// rather than guessing — better to over-reveal than leave the match
|
||||
// hidden behind a still-collapsed file.
|
||||
createEffect(() => {
|
||||
if (!props.forceOpen) return
|
||||
setExpanded(files().map((f) => f.filePath))
|
||||
})
|
||||
const subtitle = createMemo(() => {
|
||||
const count = files().length
|
||||
if (count === 0) return ""
|
||||
|
||||
@@ -408,6 +408,11 @@
|
||||
"title": "Focus Chat Input",
|
||||
"category": "Kilo Code"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.toggleChatSearch",
|
||||
"title": "Toggle Chat Search",
|
||||
"category": "Kilo Code"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.cycleAgentMode",
|
||||
"title": "Cycle Agent Mode",
|
||||
|
||||
@@ -557,7 +557,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
)
|
||||
|
||||
// Register code actions (editor context menus, terminal context menus, keyboard shortcuts)
|
||||
registerCodeActions(context, provider, agentManagerProvider)
|
||||
registerCodeActions(context, provider, agentManagerProvider, activeTabProvider)
|
||||
registerTerminalActions(context, provider, agentManagerProvider)
|
||||
|
||||
// Register CodeActionProvider (lightbulb quick fixes)
|
||||
|
||||
@@ -8,8 +8,9 @@ export function registerCodeActions(
|
||||
context: vscode.ExtensionContext,
|
||||
provider: KiloProvider,
|
||||
agentManager?: AgentManagerProvider,
|
||||
activeTabProvider?: () => KiloProvider | undefined,
|
||||
): void {
|
||||
const target = () => (agentManager?.isActive() ? agentManager : provider)
|
||||
const target = () => (agentManager?.isActive() ? agentManager : (activeTabProvider?.() ?? provider))
|
||||
const reveal = async () => {
|
||||
await vscode.commands.executeCommand("kilo-code.SidebarProvider.focus")
|
||||
await provider.waitForReady()
|
||||
@@ -82,5 +83,19 @@ export function registerCodeActions(
|
||||
}
|
||||
view.postMessage({ type: "action", action: "focusInput" })
|
||||
}),
|
||||
|
||||
// Command Palette only — no keybinding. A keybinding would need to
|
||||
// route through VS Code's keybinding-to-focused-webview forwarding,
|
||||
// which doesn't reliably reach a webview whose own input already has
|
||||
// focus; invoking straight from the palette sidesteps that path
|
||||
// entirely, the same way terminalAddToContext etc. do. Toggles: the
|
||||
// webview closes the search bar itself if it's already open.
|
||||
vscode.commands.registerCommand("kilo-code.new.toggleChatSearch", async () => {
|
||||
const view = target()
|
||||
if (view === provider) {
|
||||
await reveal()
|
||||
}
|
||||
view.postMessage({ type: "action", action: "focusSearch" })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -267,6 +267,10 @@ const AppContent: Component = () => {
|
||||
case "cyclePreviousAgentMode":
|
||||
if (document.hasFocus()) cycleAgent(-1)
|
||||
break
|
||||
case "focusSearch":
|
||||
setCurrentView("newTask")
|
||||
window.dispatchEvent(new CustomEvent("focusTranscriptSearch"))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ interface AssistantMessageProps {
|
||||
parts?: SDKPart[]
|
||||
showAssistantCopyPartID?: string | null
|
||||
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
|
||||
}
|
||||
|
||||
type ToolStateProps = {
|
||||
@@ -136,7 +140,7 @@ type ToolStateProps = {
|
||||
|
||||
type MemoryItem = MemoryMarkerMeta.Decoded
|
||||
|
||||
function TodoToolCard(props: { part: ToolPart }) {
|
||||
function TodoToolCard(props: { part: ToolPart; forceOpen?: boolean }) {
|
||||
const render = ToolRegistry.render(props.part.tool)
|
||||
const state = () => props.part.state as ToolStateProps
|
||||
return (
|
||||
@@ -152,6 +156,7 @@ function TodoToolCard(props: { part: ToolPart }) {
|
||||
output={state()?.output}
|
||||
status={state()?.status}
|
||||
defaultOpen
|
||||
forceOpen={props.forceOpen}
|
||||
reveal={false}
|
||||
/>
|
||||
)}
|
||||
@@ -159,7 +164,7 @@ function TodoToolCard(props: { part: ToolPart }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) {
|
||||
function BashToolCard(props: { part: ToolPart; defaultOpen: boolean; forceOpen?: boolean }) {
|
||||
const render = ToolRegistry.render(props.part.tool)
|
||||
const state = () => props.part.state as ToolStateProps
|
||||
return (
|
||||
@@ -176,6 +181,7 @@ function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) {
|
||||
output={state()?.output}
|
||||
status={state()?.status}
|
||||
defaultOpen={props.defaultOpen}
|
||||
forceOpen={props.forceOpen}
|
||||
animate
|
||||
reveal={state()?.status === "pending" || state()?.status === "running"}
|
||||
/>
|
||||
@@ -279,6 +285,7 @@ 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)
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -291,7 +298,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
PART_MAPPING[part.type]
|
||||
}
|
||||
>
|
||||
<div data-component="tool-part-wrapper" data-part-type={part.type}>
|
||||
<div data-component="tool-part-wrapper" data-part-type={part.type} data-part-id={part.id}>
|
||||
<Show
|
||||
when={activeQuestion()}
|
||||
fallback={
|
||||
@@ -312,6 +319,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
message={props.message as SDKMessage}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
defaultOpen={editOpen(part, edit())}
|
||||
forceOpen={forceOpen()}
|
||||
reasoningAutoCollapse={display.reasoningAutoCollapse()}
|
||||
feedback={props.feedback}
|
||||
animate={
|
||||
@@ -322,11 +330,17 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TodoToolCard part={part as unknown as ToolPart} />
|
||||
<TodoToolCard part={part as unknown as ToolPart} forceOpen={forceOpen()} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(tool) => <BashToolCard part={tool() as unknown as ToolPart} defaultOpen={open()} />}
|
||||
{(tool) => (
|
||||
<BashToolCard
|
||||
part={tool() as unknown as ToolPart}
|
||||
defaultOpen={open()}
|
||||
forceOpen={forceOpen()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useServer } from "../../context/server"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { WelcomeEmptyState } from "./WelcomeEmptyState"
|
||||
import { TranscriptRowView } from "./TranscriptRow"
|
||||
@@ -96,6 +97,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
const provider = useProvider()
|
||||
const i18n = useI18n()
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working: () => session.status() !== "idle",
|
||||
@@ -163,9 +165,21 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
|
||||
const search = useTranscriptSearch()
|
||||
|
||||
function rowText(row: TranscriptRow): string {
|
||||
if (row.type === "error") return errorText(row.error)
|
||||
if (row.type === "diff") return ""
|
||||
interface RowTextRange {
|
||||
start: number
|
||||
end: number
|
||||
partId: 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/
|
||||
@@ -174,13 +188,22 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
// 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) => {
|
||||
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 })
|
||||
pos += text.length
|
||||
}
|
||||
for (const part of row.parts) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
if (!part.synthetic) chunks.push(markdown ? stripMarkdownLinkUrls(part.text) : part.text)
|
||||
if (!part.synthetic) push(markdown ? stripMarkdownLinkUrls(part.text) : part.text, part.id)
|
||||
break
|
||||
case "reasoning":
|
||||
chunks.push(stripMarkdownLinkUrls(part.text))
|
||||
push(stripMarkdownLinkUrls(part.text), part.id)
|
||||
break
|
||||
case "tool":
|
||||
// Bash output is rendered via escapeHtml + syntax highlighting
|
||||
@@ -190,14 +213,18 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
// Stripping it here would search text that no longer matches the
|
||||
// literal characters on screen, the same class of mismatch this
|
||||
// rewrite fixes elsewhere.
|
||||
chunks.push(...toolText(part))
|
||||
for (const chunk of toolText(part)) push(chunk, part.id)
|
||||
break
|
||||
case "file":
|
||||
if (part.filename) chunks.push(part.filename)
|
||||
if (part.filename) push(part.filename, part.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
return chunks.join("\n")
|
||||
return { text: chunks.join("\n"), ranges }
|
||||
}
|
||||
|
||||
function partIdAt(ranges: RowTextRange[], index: number): string | undefined {
|
||||
return ranges.find((r) => index >= r.start && index < r.end)?.partId
|
||||
}
|
||||
|
||||
// Markdown link/image URLs are part of the raw source text but are never
|
||||
@@ -273,13 +300,51 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
// 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 <diffs-container> (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/todoread'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.
|
||||
const TODO_TOOLS = new Set(["todowrite", "todoread"])
|
||||
|
||||
function toolText(part: Part & { type: "tool" }): string[] {
|
||||
const state = part.state
|
||||
if (state.status === "running") return state.title ? [state.title] : []
|
||||
if (state.status === "error") return state.error ? [state.error] : []
|
||||
if (state.status !== "completed") return []
|
||||
// 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(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(state)
|
||||
if (TODO_TOOLS.has(part.tool)) return todoText(state)
|
||||
const chunks: string[] = []
|
||||
if (state.title) chunks.push(state.title)
|
||||
collectStrings(state.input, chunks)
|
||||
@@ -288,6 +353,98 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
return chunks
|
||||
}
|
||||
|
||||
function todoText(state: Extract<ToolState, { status: "completed" }>): 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
|
||||
const todos =
|
||||
viewTodos ??
|
||||
(Array.isArray(metadata?.todos) ? metadata.todos : undefined) ??
|
||||
(Array.isArray(input?.todos) ? input.todos : undefined) ??
|
||||
[]
|
||||
return (todos as { content?: unknown }[])
|
||||
.map((todo) => todo?.content)
|
||||
.filter((content): content is string => typeof content === "string" && content.length > 0)
|
||||
}
|
||||
|
||||
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
|
||||
// always `i18n.t("ui.tool.agent", { type })` regardless of status — 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 || part.tool
|
||||
const chunks = [i18n.t("ui.tool.agent", { type })]
|
||||
if (input?.description) chunks.push(input.description)
|
||||
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).
|
||||
function questionText(state: ToolState): string[] {
|
||||
const input = state.input as
|
||||
| { questions?: { question?: string; options?: { label?: string; description?: string }[] }[] }
|
||||
| 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 chunks: string[] = []
|
||||
questions.forEach((q, i) => {
|
||||
if (q.question) chunks.push(q.question)
|
||||
if (!done) {
|
||||
for (const option of q.options ?? []) {
|
||||
if (option.label) chunks.push(option.label)
|
||||
if (option.description) chunks.push(option.description)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (dismissed) return
|
||||
for (const value of answers?.[i] ?? []) {
|
||||
if (typeof value === "string" && value) chunks.push(value)
|
||||
}
|
||||
})
|
||||
return chunks
|
||||
}
|
||||
|
||||
function editWriteText(state: Extract<ToolState, { status: "completed" }>): string[] {
|
||||
const input = state.input as { filePath?: string } | undefined
|
||||
const metadata = state.metadata as { filepath?: string; filediff?: { file?: string } } | undefined
|
||||
const path = input?.filePath ?? metadata?.filediff?.file ?? metadata?.filepath
|
||||
if (!path) return []
|
||||
return [path, path]
|
||||
}
|
||||
|
||||
function applyPatchText(state: Extract<ToolState, { status: "completed" }>): string[] {
|
||||
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, on top of that file's own accordion header.
|
||||
const perFile = files.length === 1 ? 2 : 1
|
||||
const chunks: string[] = []
|
||||
for (const file of files) {
|
||||
const path = file.relativePath ?? file.filePath
|
||||
if (!path) continue
|
||||
for (let i = 0; i < perFile; i += 1) chunks.push(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
|
||||
@@ -409,7 +566,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
const list = rows()
|
||||
const result: SearchMatch[] = []
|
||||
for (const row of list) {
|
||||
const text = rowText(row)
|
||||
const { text, ranges } = rowText(row)
|
||||
p.lastIndex = 0
|
||||
let occurrence = 0
|
||||
let hit = p.exec(text)
|
||||
@@ -419,7 +576,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
hit = p.exec(text)
|
||||
continue
|
||||
}
|
||||
result.push({ key: row.key, messageId: row.message.id, occurrence })
|
||||
result.push({ key: row.key, occurrence, partId: partIdAt(ranges, hit.index) })
|
||||
occurrence += 1
|
||||
hit = p.exec(text)
|
||||
}
|
||||
@@ -482,6 +639,26 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
|
||||
const activeMatch = createMemo(() => matches()[search.index()])
|
||||
|
||||
// Maps a row's key to the part ids the data model could attribute matches
|
||||
// to there. A row with NO entry here has zero data-level matches, so the
|
||||
// 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.
|
||||
const matchedPartsByRow = createMemo(() => {
|
||||
const map = new Map<string, Set<string>>()
|
||||
for (const match of matches()) {
|
||||
const set = map.get(match.key) ?? new Set<string>()
|
||||
if (match.partId) set.add(match.partId)
|
||||
map.set(match.key, set)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
// Highlights every rendered occurrence of the query (not just matching
|
||||
// rows) via the CSS Custom Highlight API, and returns the precise Range of
|
||||
// the current occurrence so navigation can judge whether it needs to
|
||||
@@ -496,7 +673,12 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
return
|
||||
}
|
||||
const active = activeMatch()
|
||||
const range = applyTranscriptHighlights(el, pattern(), active && { key: active.key, occurrence: active.occurrence })
|
||||
const range = applyTranscriptHighlights(
|
||||
el,
|
||||
pattern(),
|
||||
active && { key: active.key, occurrence: active.occurrence },
|
||||
matchedPartsByRow(),
|
||||
)
|
||||
if (!pendingCenter) return
|
||||
pendingCenter = false
|
||||
if (!range) return
|
||||
@@ -820,6 +1002,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
index={index()}
|
||||
onForkMessage={props.onForkMessage}
|
||||
activeSearch={activeKey() === row.key}
|
||||
activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined}
|
||||
/>
|
||||
)}
|
||||
</Virtualizer>
|
||||
@@ -830,6 +1013,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
row={lookup().get(key)!}
|
||||
onForkMessage={props.onForkMessage}
|
||||
activeSearch={activeKey() === key}
|
||||
activeSearchPartID={activeKey() === key ? activeMatch()?.partId : undefined}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -839,7 +1023,13 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
<RevertBanner />
|
||||
</Show>
|
||||
<For each={partition().queued}>
|
||||
{(row) => <TranscriptRowView row={row} activeSearch={activeKey() === row.key} />}
|
||||
{(row) => (
|
||||
<TranscriptRowView
|
||||
row={row}
|
||||
activeSearch={activeKey() === row.key}
|
||||
activeSearchPartID={activeKey() === row.key ? activeMatch()?.partId : undefined}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<WorkingIndicator />
|
||||
<TurnOutcome />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* session activity) and a context window progress bar.
|
||||
*/
|
||||
|
||||
import { Component, For, Show, createMemo, createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal, createEffect, on, onMount, onCleanup } from "solid-js"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
@@ -111,6 +111,34 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
|
||||
window.addEventListener("message", handler)
|
||||
onCleanup(() => window.removeEventListener("message", handler))
|
||||
|
||||
// "Kilo Code: Search Current Chat" (Command Palette) toggles the search
|
||||
// bar from here rather than TranscriptSearch.tsx itself: that component
|
||||
// only mounts once search.active() is already true (it's behind a
|
||||
// <Show>), so it can never be what turns search on in the first place —
|
||||
// and it also wouldn't exist anymore to react to a request to close it.
|
||||
// TaskHeader is mounted the whole time there's an active chat, so it's
|
||||
// the right place to react to the external toggle request.
|
||||
const toggleSearch = () => search.setActive(!search.active())
|
||||
window.addEventListener("focusTranscriptSearch", toggleSearch)
|
||||
onCleanup(() => window.removeEventListener("focusTranscriptSearch", toggleSearch))
|
||||
|
||||
// Whenever search closes — the header toggle button, the command palette
|
||||
// toggle above, the search bar's own "X", or Escape — send focus back to
|
||||
// the chat input rather than leaving it stranded on whatever control was
|
||||
// just clicked/removed. `defer: true` skips the initial run so mounting
|
||||
// with search already inactive doesn't steal focus from wherever it
|
||||
// already was.
|
||||
createEffect(
|
||||
on(
|
||||
() => search.active(),
|
||||
(active) => {
|
||||
if (active) return
|
||||
window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } }))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const toggle = () => {
|
||||
const next = !expanded()
|
||||
setExpanded(next)
|
||||
|
||||
@@ -18,6 +18,9 @@ interface TranscriptRowViewProps {
|
||||
index?: number
|
||||
onForkMessage?: (sessionId: string, messageId: string) => void
|
||||
activeSearch?: boolean
|
||||
/** id of the part (tool call/reasoning block) containing the current chat
|
||||
* search match within this row, if any. */
|
||||
activeSearchPartID?: string
|
||||
}
|
||||
|
||||
export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
|
||||
@@ -78,6 +81,7 @@ export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
|
||||
message={row().message as unknown as SDKAssistantMessage}
|
||||
parts={row().parts as unknown as SDKPart[]}
|
||||
showAssistantCopyPartID={row().copy}
|
||||
forceOpenPartID={props.activeSearchPartID}
|
||||
feedback={{
|
||||
enabled: feedback.telemetryEnabled(),
|
||||
rating: feedback.getRating(row().message.id),
|
||||
|
||||
@@ -90,11 +90,24 @@ export function scanScope(scope: HTMLElement, pattern: RegExp): Range[] {
|
||||
* actually found in the DOM, so a data/DOM count mismatch (e.g. content the
|
||||
* renderer collapses or reformats) still always highlights *something* in
|
||||
* the active row rather than silently highlighting nothing.
|
||||
*
|
||||
* `matchedParts` maps a row key to the part ids MessageList's data model
|
||||
* could attribute a match to there. A row with **no entry** has zero
|
||||
* data-level matches, so nothing in it is scanned at all — otherwise some
|
||||
* unindexed text (a static button label, a different non-matching part in
|
||||
* the same message) could get highlighted despite never being counted. A
|
||||
* row *with* an entry (even one with an empty part-id set, e.g. an
|
||||
* error/diff row with no per-part attribution) always has SOME genuine
|
||||
* match, so scanning falls back to the whole row whenever the part-scoped
|
||||
* DOM lookup doesn't resolve to anything mounted (a part id with no
|
||||
* `[data-part-id]` marker at all — e.g. user messages — or not yet
|
||||
* expanded) — that's a lookup failure, not a signal to scan nothing.
|
||||
*/
|
||||
export function applyTranscriptHighlights(
|
||||
root: HTMLElement,
|
||||
pattern: RegExp | undefined,
|
||||
active: { key: string; occurrence: number } | undefined,
|
||||
matchedParts?: Map<string, Set<string>>,
|
||||
): Range | undefined {
|
||||
const api = highlightApi()
|
||||
if (!api) return undefined
|
||||
@@ -107,7 +120,13 @@ export function applyTranscriptHighlights(
|
||||
const current: Range[] = []
|
||||
let currentRange: Range | undefined
|
||||
for (const scope of scopes) {
|
||||
const ranges = scanScope(scope, pattern)
|
||||
// Every search scope within a row contributes to ONE combined range
|
||||
// list before the active-occurrence index is resolved — clamping it
|
||||
// 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).
|
||||
const ranges = resolveSearchScopes(scope, matchedParts).flatMap((searchScope) => scanScope(searchScope, pattern))
|
||||
if (ranges.length === 0) continue
|
||||
const isActiveRow = !!active && scope.dataset.rowKey === active.key
|
||||
const activeIdx = isActiveRow ? Math.min(active!.occurrence, ranges.length - 1) : -1
|
||||
@@ -125,6 +144,28 @@ export function applyTranscriptHighlights(
|
||||
return currentRange
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides which element(s) within a row to actually scan for text. A row
|
||||
* with no entry in `matchedParts` has zero data-level matches, so it's
|
||||
* skipped entirely. A row with an entry scans just its known parts' DOM
|
||||
* subtrees when they're mounted, and falls back to the whole row whenever
|
||||
* that lookup comes up empty — whether because a match couldn't be
|
||||
* attributed to a specific part at all, or because the part it WAS
|
||||
* attributed to has no `[data-part-id]` marker (or isn't mounted yet) — a
|
||||
* row with a real match should never end up scanning nothing.
|
||||
*/
|
||||
function resolveSearchScopes(scope: HTMLElement, matchedParts: Map<string, Set<string>> | undefined): HTMLElement[] {
|
||||
const rowKey = scope.dataset.rowKey
|
||||
const partIds = rowKey ? matchedParts?.get(rowKey) : undefined
|
||||
if (matchedParts && !partIds) return []
|
||||
const partScopes = partIds
|
||||
? Array.from(partIds)
|
||||
.map((id) => scope.querySelector<HTMLElement>(`[data-part-id="${CSS.escape(id)}"]`))
|
||||
.filter((el): el is HTMLElement => !!el)
|
||||
: []
|
||||
return partScopes.length > 0 ? partScopes : [scope]
|
||||
}
|
||||
|
||||
export function clearTranscriptHighlights(): void {
|
||||
const api = highlightApi()
|
||||
if (!api) return
|
||||
|
||||
@@ -2,9 +2,12 @@ import { createContext, useContext, createSignal, type Accessor, type ParentComp
|
||||
|
||||
export interface SearchMatch {
|
||||
key: string
|
||||
messageId: 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. */
|
||||
partId?: string
|
||||
}
|
||||
|
||||
interface TranscriptSearchContextValue {
|
||||
|
||||
Reference in New Issue
Block a user