Merge pull request #12155 from Kilo-Org/feat/vscode-chat-search-supersede

feat(vscode): add in-chat search for the current session
This commit is contained in:
Marius
2026-07-13 10:44:58 +02:00
committed by GitHub
33 changed files with 1305 additions and 64 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add in-chat search to the VS Code sidebar and editor tabs. Click the search icon in the session header to find text across the current conversation, with match case, whole word, and regular expression options, then step through highlighted matches with the next/previous controls.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22cfec1cfdfe2deb4299f62bd677eeee34b522c581504297fd356efbf26e9b44
size 6351
oid sha256:8ea257778cf09ea853b06843de79d114fa8fe77b5dea20c6ec15abe965b60d18
size 6165
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99f328f5169276d97c51d182185a920e8b45ecd0f3c699a64ce8e1fc7ad6196d
size 15361
oid sha256:04037b1aac2456ba09ffafc1bed2cd1c9c00a09cf926453518231b1bd14fc974
size 16470
@@ -24,6 +24,7 @@ import { useLanguage } from "../../context/language"
import { useWorktreeMode } from "../../context/worktree-mode"
import { useServer } from "../../context/server"
import { useAgentRequirements } from "../../context/agent-requirements"
import { TranscriptSearchProvider } from "../../context/transcript-search"
import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils"
interface ChatViewProps {
@@ -325,59 +326,61 @@ export const ChatView: Component<ChatViewProps> = (props) => {
)
return (
<div class="chat-view">
<TaskHeader readonly={props.readonly} />
<div class="chat-messages-wrapper">
<div class="chat-messages">
<Show
when={!props.readonly && requirements.visible()}
fallback={
<MessageList
onSelectSession={props.onSelectSession}
onShowHistory={props.onShowHistory}
onForkMessage={props.onForkMessage}
questions={standaloneQuestions}
suggestions={standaloneSuggestions}
readonly={props.readonly}
emptyState={props.emptyState}
announce={isSidebar()}
/>
}
>
<AgentRequirements />
</Show>
<TranscriptSearchProvider>
<div class="chat-view">
<TaskHeader readonly={props.readonly} />
<div class="chat-messages-wrapper">
<div class="chat-messages">
<Show
when={!props.readonly && requirements.visible()}
fallback={
<MessageList
onSelectSession={props.onSelectSession}
onShowHistory={props.onShowHistory}
onForkMessage={props.onForkMessage}
questions={standaloneQuestions}
suggestions={standaloneSuggestions}
readonly={props.readonly}
emptyState={props.emptyState}
announce={isSidebar()}
/>
}
>
<AgentRequirements />
</Show>
</div>
</div>
</div>
<Show when={dock()}>
<div class="chat-input">
<Show when={server.connectionState() === "error" && server.errorMessage()}>
<StartupErrorBanner errorMessage={server.errorMessage()!} errorDetails={server.errorDetails()!} />
</Show>
<Show when={permissionRequest()} keyed>
{(perm) => (
<PermissionDock
request={perm}
responding={session.respondingPermissions().has(perm.id)}
onDecide={decide}
<Show when={dock()}>
<div class="chat-input">
<Show when={server.connectionState() === "error" && server.errorMessage()}>
<StartupErrorBanner errorMessage={server.errorMessage()!} errorDetails={server.errorDetails()!} />
</Show>
<Show when={permissionRequest()} keyed>
{(perm) => (
<PermissionDock
request={perm}
responding={session.respondingPermissions().has(perm.id)}
onDecide={decide}
/>
)}
</Show>
<Show when={!props.readonly && idle() && !blocked() && hasActions(hasMessages())}>
{renderActions(hasMessages())}
</Show>
<Show when={!props.readonly}>
<PromptInput
blocked={blocked}
blockedReason={requirementReason}
suggesting={suggesting}
questioning={questioning}
boxId={props.promptBoxId}
pendingSessionID={props.pendingSessionID}
/>
)}
</Show>
<Show when={!props.readonly && idle() && !blocked() && hasActions(hasMessages())}>
{renderActions(hasMessages())}
</Show>
<Show when={!props.readonly}>
<PromptInput
blocked={blocked}
blockedReason={requirementReason}
suggesting={suggesting}
questioning={questioning}
boxId={props.promptBoxId}
pendingSessionID={props.pendingSessionID}
/>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</TranscriptSearchProvider>
)
}
@@ -16,8 +16,10 @@ import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
import { useSession } from "../../context/session"
import { useServer } from "../../context/server"
import { useLanguage } from "../../context/language"
import { useProvider } from "../../context/provider"
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"
@@ -46,10 +48,20 @@ import {
partitionRows,
retainTurn,
transcriptRows,
type TranscriptErrorRow,
type TranscriptHold,
type TranscriptRow,
} from "../../context/transcript-rows"
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
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"
interface MessageListProps {
onSelectSession?: (id: string) => void
@@ -71,6 +83,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
const session = useSession()
const server = useServer()
const language = useLanguage()
const provider = useProvider()
const autoScroll = createAutoScroll({
working: () => session.status() !== "idle",
@@ -135,6 +148,424 @@ export const MessageList: Component<MessageListProps> = (props) => {
prev,
)
})
const search = useTranscriptSearch()
function rowText(row: TranscriptRow): string {
if (row.type === "error") return errorText(row.error)
if (row.type === "diff") return ""
// 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[] = []
for (const part of row.parts) {
switch (part.type) {
case "text":
if (!part.synthetic) chunks.push(markdown ? stripMarkdownLinkUrls(part.text) : part.text)
break
case "reasoning":
chunks.push(stripMarkdownLinkUrls(part.text))
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.
chunks.push(...toolText(part))
break
case "file":
if (part.filename) chunks.push(part.filename)
break
}
}
return chunks.join("\n")
}
// 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"])
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 []
if (CONTEXT_GROUP_TOOLS.has(part.tool)) return state.title ? [state.title] : []
if (part.tool === "bash") return bashText(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
}
// 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<ToolState, { status: "completed" }>) {
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<string, unknown>)) {
collectStrings((value as Record<string, unknown>)[key], out, depth + 1)
}
}
}
function buildPattern(query: string, matchCase: boolean, wholeWord: boolean, regex: boolean): RegExp | undefined {
if (!query) return undefined
try {
let pattern = query
if (!regex) {
pattern = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
if (wholeWord) {
// Unicode-aware boundary: plain `\b` only treats ASCII letters/
// digits/underscore as "word" characters, so it silently breaks
// whole-word matching for Cyrillic, Arabic, CJK, and other non-ASCII
// text. `\p{L}`/`\p{M}`/`\p{N}` (letters/marks/numbers) require the
// `u` flag, applied below for every pattern, not just this one.
pattern = `(?<![\\p{L}\\p{M}\\p{N}_])(?:${pattern})(?![\\p{L}\\p{M}\\p{N}_])`
}
return new RegExp(pattern, matchCase ? "gu" : "giu")
} catch {
return undefined
}
}
const pattern = createMemo(() => {
const q = search.query()
if (!search.active() || !q) return undefined
return buildPattern(q, search.matchCase(), search.wholeWord(), search.regex())
})
// An invalid regex (e.g. an unbalanced group) compiles to `undefined` from
// buildPattern, which otherwise looks identical to "no matches" — surface
// it explicitly so the widget can show a real error instead.
createEffect(() => {
const q = search.query()
search.setInvalid(search.active() && !!q && search.regex() && !pattern())
})
// Sessions only load the most recent page (session.tsx's MESSAGE_PAGE_LIMIT)
// up front; matches() only ever sees currently-loaded rows(). Without this,
// an active search would silently miss everything in older, not-yet-loaded
// history — undermining the main "find something in a long session" use
// case, and a partial match count while some history remains unsearched
// could actively mislead a user into the wrong conclusion. While a query
// is active, keep requesting older pages until there aren't any more or
// the search is no longer active; each completed load feeds back into
// hasOlderMessages()/loadingOlderMessages(), both tracked here, so this
// effect naturally re-fires and continues the chain without an explicit
// loop. searchingHistory (surfaced to the widget) stays true for that
// whole stretch, so "No results"/a final count aren't shown until the
// entire session has actually been searched.
//
// Deliberately uncapped: an earlier revision capped this and offered an
// opt-in to search further, but a possibly-incomplete count is worse than
// the cost of loading a very long session's full history. Revisit with a
// cap (or a lazy/incremental search strategy) in a follow-up if this
// proves too slow/expensive in practice on very long sessions.
createEffect(() => {
const searching = search.active() && !!search.query() && session.hasOlderMessages()
search.setSearchingHistory(searching)
if (!searching || session.loadingOlderMessages()) return
session.loadOlderMessages()
})
const matches = createMemo(() => {
const p = pattern()
if (!p) return []
const list = rows()
const result: SearchMatch[] = []
for (const row of list) {
const text = rowText(row)
p.lastIndex = 0
let occurrence = 0
let hit = p.exec(text)
while (hit) {
if (hit[0].length === 0) {
p.lastIndex += 1
hit = p.exec(text)
continue
}
result.push({ key: row.key, messageId: row.message.id, occurrence })
occurrence += 1
hit = p.exec(text)
}
}
return result
})
createEffect(
on(matches, (m) => {
search.setCount(m.length)
if (m.length === 0) {
search.setIndex(0)
return
}
const idx = search.index()
if (idx >= m.length) search.setIndex(m.length - 1)
}),
)
createEffect(
on(
() => [search.query(), search.matchCase(), search.wholeWord(), search.regex()],
() => {
search.setIndex(0)
// Jump straight to the first match as the user types/toggles an
// option, instead of leaving them to press Enter/an arrow just to
// see where the current query actually landed. `requestJump` is a
// no-op when there are no matches (guarded in the jump effect).
search.requestJump()
},
),
)
// Closing/switching to a different session leaves stale query/matches
// bound to a transcript that's no longer displayed if left untouched —
// reset the whole widget whenever the current session changes. `defer:
// true` skips the initial run so mounting doesn't immediately "reset" a
// session that was never open in this search widget.
createEffect(
on(
() => session.currentSessionID(),
() => {
search.setActive(false)
search.setQuery("")
search.setMatchCase(false)
search.setWholeWord(false)
search.setRegex(false)
search.setIndex(0)
search.setCount(0)
},
{ defer: true },
),
)
const activeKey = createMemo(() => {
const m = matches()
const idx = search.index()
return m[idx]?.key
})
const activeMatch = createMemo(() => matches()[search.index()])
// 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
// scroll at all (several occurrences can share one message).
let highlightFrame: number | undefined
let highlightFrameInner: number | undefined
let pendingCenter = false
const paintHighlights = () => {
const el = scrollEl()
if (!el || !search.active()) {
clearTranscriptHighlights()
return
}
const active = activeMatch()
const range = applyTranscriptHighlights(el, pattern(), active && { key: active.key, occurrence: active.occurrence })
if (!pendingCenter) return
pendingCenter = false
if (!range) return
// Only nudge the scroll position when the match isn't already
// comfortably placed — re-centering on every single step (even when
// the match is already visible) reads as constant, distracting jumping
// when several occurrences share one message.
const rect = range.getClientRects()[0]
if (!rect) return
const box = el.getBoundingClientRect()
const fullyVisible = rect.top >= box.top && rect.bottom <= box.bottom
// Comfort band covers the middle 70% of the viewport (15% margin top
// and bottom) — wide enough that most steps between nearby matches
// don't scroll at all, while still recentering before a match gets
// uncomfortably close to the edge.
const comfortMargin = box.height * 0.35
const centered = Math.abs(rect.top + rect.height / 2 - (box.top + box.height / 2)) <= comfortMargin
if (fullyVisible && centered) return
const container = range.startContainer
const target = container instanceof Element ? container : container?.parentElement
target?.scrollIntoView({ block: "center", inline: "nearest" })
}
// Two frames of margin so the virtualizer has settled the DOM for the new
// scroll position before we scan it for the precise occurrence to center.
// Both frame ids are tracked so cleanup can cancel whichever leg of the
// chain hasn't fired yet — cancelling only the outer id left the inner,
// already-scheduled frame free to fire (and touch reactive state) after
// the component had already unmounted.
const scheduleHighlight = () => {
if (highlightFrame !== undefined) return
highlightFrame = requestAnimationFrame(() => {
highlightFrameInner = requestAnimationFrame(() => {
highlightFrame = undefined
highlightFrameInner = undefined
paintHighlights()
})
})
}
createEffect(
on(
() => [search.query(), search.matchCase(), search.wholeWord(), search.regex(), search.active(), activeMatch()],
scheduleHighlight,
),
)
createEffect(
on(
() => search.jump(),
() => {
const m = matches()
const idx = search.index()
if (!m.length || idx < 0 || idx >= m.length) return
const match = m[idx]
if (!match) return
autoScroll.pause()
pendingCenter = true
const el = scrollEl()
const mounted = el?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(match.key)}"]`)
// Only force the coarse row-level scroll when the row isn't in the
// DOM at all (virtualized out). If it's already mounted, defer
// entirely to the precise per-occurrence check in paintHighlights,
// which only scrolls when the exact match actually needs it.
if (!mounted) {
const index = keys().indexOf(match.key)
if (index >= 0) {
virtualizer()?.scrollToIndex(index, { align: "center" })
}
}
scheduleHighlight()
},
),
)
onCleanup(() => {
if (highlightFrame !== undefined) cancelAnimationFrame(highlightFrame)
if (highlightFrameInner !== undefined) cancelAnimationFrame(highlightFrameInner)
clearTranscriptHighlights()
})
const [held, setHeld] = createSignal<TranscriptHold>()
createEffect(() => {
const id = activeUserID()
@@ -227,6 +658,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
const handleScroll = () => {
autoScroll.handleScroll()
maybeLoadOlder()
if (search.active()) scheduleHighlight()
}
let resize: ResizeObserver | undefined
@@ -371,19 +803,32 @@ export const MessageList: Component<MessageListProps> = (props) => {
itemSize={260}
>
{(row, index) => (
<TranscriptRowView row={row} index={index()} onForkMessage={props.onForkMessage} />
<TranscriptRowView
row={row}
index={index()}
onForkMessage={props.onForkMessage}
activeSearch={activeKey() === row.key}
/>
)}
</Virtualizer>
</Show>
<For each={tail()}>
{(key) => <TranscriptRowView row={lookup().get(key)!} onForkMessage={props.onForkMessage} />}
{(key) => (
<TranscriptRowView
row={lookup().get(key)!}
onForkMessage={props.onForkMessage}
activeSearch={activeKey() === key}
/>
)}
</For>
</div>
</Show>
<Show when={revert()}>
<RevertBanner />
</Show>
<For each={partition().queued}>{(row) => <TranscriptRowView row={row} />}</For>
<For each={partition().queued}>
{(row) => <TranscriptRowView row={row} activeSearch={activeKey() === row.key} />}
</For>
<WorkingIndicator />
<TurnOutcome />
<For each={props.questions?.()}>{(req) => <QuestionDock request={req} />}</For>
@@ -21,6 +21,8 @@ import { useVSCode } from "../../context/vscode"
import { TaskTimeline } from "./TaskTimeline"
import { ContextProgress } from "./ContextProgress"
import { TaskUsage } from "./TaskUsage"
import { TranscriptSearch } from "./TranscriptSearch"
import { useTranscriptSearch } from "../../context/transcript-search"
import { hasModelUsage, tokenSummary } from "../../context/model-usage"
import { SessionRenameEditor } from "../shared/SessionRenameEditor"
import { target as todoTarget } from "../../context/todo-revert"
@@ -35,6 +37,7 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
const session = useSession()
const memory = useMemory()
const language = useLanguage()
const search = useTranscriptSearch()
const title = createMemo(() => session.currentSession()?.title ?? language.t("command.session.new"))
const canRename = createMemo(() => !props.readonly && !!session.currentSession())
@@ -228,6 +231,18 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
</Tooltip>
</Show>
<Show when={hasMessages()}>
<Tooltip value={language.t("chat.search.toggle")} placement="bottom">
<IconButton
icon="magnifying-glass"
size="small"
variant="ghost"
class="task-header-search-toggle"
data-active={search.active() ? "" : undefined}
onClick={() => search.setActive(!search.active())}
aria-label={language.t("chat.search.toggle")}
aria-pressed={search.active()}
/>
</Tooltip>
<button
data-slot="task-header-expand"
onClick={toggle}
@@ -239,6 +254,14 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
</Show>
</div>
</div>
{/* Standalone search bar, directly under the header, so it has room for
the VS Codestyle inline options and doesn't require the timeline
to be expanded. */}
<Show when={search.active()}>
<div data-component="task-header-search">
<TranscriptSearch />
</div>
</Show>
{/* Expanded graph section: timeline + context bar + token breakdown */}
<Show when={expanded() && hasTimeline()}>
<div data-component="task-header-graph">
@@ -17,6 +17,7 @@ interface TranscriptRowViewProps {
row: TranscriptRow
index?: number
onForkMessage?: (sessionId: string, messageId: string) => void
activeSearch?: boolean
}
export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
@@ -40,6 +41,7 @@ export const TranscriptRowView: Component<TranscriptRowViewProps> = (props) => {
data-row-index={props.index}
data-turn={props.row.turn}
data-live={props.row.live ? "" : undefined}
data-search-active={props.activeSearch ? "" : undefined}
>
<Show when={props.row.type === "user" ? props.row : undefined}>
{(row) => (
@@ -0,0 +1,167 @@
import { Component, Show, onMount } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useTranscriptSearch } from "../../context/transcript-search"
import { useLanguage } from "../../context/language"
export const TranscriptSearch: Component = () => {
const search = useTranscriptSearch()
const language = useLanguage()
let inputRef: HTMLInputElement | undefined
const next = () => {
const c = search.count()
if (!c) return
search.setIndex((search.index() + 1) % c)
search.requestJump()
}
const prev = () => {
const c = search.count()
if (!c) return
search.setIndex((search.index() - 1 + c) % c)
search.requestJump()
}
const close = () => {
search.setActive(false)
search.setQuery("")
search.setCount(0)
search.setIndex(0)
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
if (e.shiftKey) prev()
else next()
return
}
if (e.key === "Escape") {
e.preventDefault()
close()
return
}
}
// This component only mounts once the header's search icon toggles it
// visible, so focusing here is exactly "focus when opened". Kobalte's
// IconButton/Tooltip can re-assert focus on the clicked icon shortly
// after mount, so a single rAF isn't reliable — mirrors the multi-attempt
// retry PromptInput.tsx already uses for the same class of focus-steal.
onMount(() => {
const focus = () => inputRef?.focus({ preventScroll: true })
focus()
queueMicrotask(focus)
requestAnimationFrame(() => {
focus()
requestAnimationFrame(focus)
setTimeout(focus, 0)
setTimeout(focus, 50)
})
})
return (
<Show when={search.active()}>
<div data-component="transcript-search" onKeyDown={onKeyDown}>
<div data-slot="transcript-search-box">
<input
ref={inputRef}
type="text"
data-slot="transcript-search-input"
placeholder={language.t("chat.search.placeholder")}
aria-label={language.t("chat.search.toggle")}
value={search.query()}
onInput={(e) => {
search.setQuery(e.currentTarget.value)
search.setIndex(0)
}}
/>
<div data-slot="transcript-search-inline-options">
<Tooltip value={language.t("chat.search.matchCase")} placement="bottom">
<button
data-slot="transcript-search-option"
data-active={search.matchCase() ? "" : undefined}
onClick={() => search.setMatchCase(!search.matchCase())}
aria-label={language.t("chat.search.matchCase")}
aria-pressed={search.matchCase()}
>
Aa
</button>
</Tooltip>
<Tooltip value={language.t("chat.search.matchWholeWord")} placement="bottom">
<button
data-slot="transcript-search-option"
data-active={search.wholeWord() ? "" : undefined}
onClick={() => search.setWholeWord(!search.wholeWord())}
aria-label={language.t("chat.search.matchWholeWord")}
aria-pressed={search.wholeWord()}
>
ab
</button>
</Tooltip>
<Tooltip value={language.t("chat.search.useRegex")} placement="bottom">
<button
data-slot="transcript-search-option"
data-active={search.regex() ? "" : undefined}
onClick={() => search.setRegex(!search.regex())}
aria-label={language.t("chat.search.useRegex")}
aria-pressed={search.regex()}
>
.*
</button>
</Tooltip>
</div>
</div>
<Show when={search.invalid()}>
<span data-slot="transcript-search-error">{language.t("chat.search.invalidRegex")}</span>
</Show>
<Show when={!search.invalid() && search.searchingHistory() && search.count() === 0}>
<span data-slot="transcript-search-empty">{language.t("chat.search.searchingHistory")}</span>
</Show>
<Show
when={!search.invalid() && !search.searchingHistory() && search.query().length > 0 && search.count() === 0}
>
<span data-slot="transcript-search-empty">{language.t("chat.search.noResults")}</span>
</Show>
<Show when={!search.invalid() && search.count() > 0}>
<span data-slot="transcript-search-counter">
{search.index() + 1} / {search.count()}
</span>
</Show>
<div data-slot="transcript-search-nav">
<Tooltip value={language.t("chat.search.previousMatch")} placement="bottom">
<IconButton
icon="chevron-down"
size="small"
variant="ghost"
style={{ transform: "rotate(180deg)" }}
onClick={prev}
disabled={search.count() === 0}
aria-label={language.t("chat.search.previousMatch")}
/>
</Tooltip>
<Tooltip value={language.t("chat.search.nextMatch")} placement="bottom">
<IconButton
icon="chevron-down"
size="small"
variant="ghost"
onClick={next}
disabled={search.count() === 0}
aria-label={language.t("chat.search.nextMatch")}
/>
</Tooltip>
</div>
<Tooltip value={language.t("chat.search.close")} placement="bottom">
<IconButton
icon="close"
size="small"
variant="ghost"
onClick={close}
aria-label={language.t("chat.search.close")}
/>
</Tooltip>
</div>
</Show>
)
}
@@ -0,0 +1,133 @@
/**
* Highlights every rendered occurrence of the current transcript search query
* using the CSS Custom Highlight API (same technique as kilo-ui's code find
* widget). Operates only on currently mounted DOM — virtualized rows that
* aren't rendered yet are covered by the row-level match list in MessageList,
* not by this highlighter.
*/
const MATCH_NAME = "kilo-transcript-search-match"
const ACTIVE_NAME = "kilo-transcript-search-match-active"
interface HighlightCtor {
new (...ranges: Range[]): unknown
}
interface HighlightRegistry {
set: (name: string, value: unknown) => void
delete: (name: string) => void
}
function highlightApi(): { registry: HighlightRegistry; ctor: HighlightCtor } | undefined {
const g = globalThis as unknown as { CSS?: { highlights?: HighlightRegistry }; Highlight?: HighlightCtor }
if (!g.CSS?.highlights || typeof g.Highlight !== "function") return undefined
return { registry: g.CSS.highlights, ctor: g.Highlight }
}
/** Builds a flat text + node-offset map for a scope so matches can span across inline elements. */
export function scanScope(scope: HTMLElement, pattern: RegExp): Range[] {
const text = scope.textContent
if (!text) return []
pattern.lastIndex = 0
const spans: { start: number; end: number }[] = []
let match = pattern.exec(text)
while (match) {
if (match[0].length === 0) {
pattern.lastIndex += 1
match = pattern.exec(text)
continue
}
spans.push({ start: match.index, end: match.index + match[0].length })
match = pattern.exec(text)
}
if (spans.length === 0) return []
const nodes: Text[] = []
const ends: number[] = []
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
let pos = 0
while (node) {
if (node instanceof Text) {
pos += node.data.length
nodes.push(node)
ends.push(pos)
}
node = walker.nextNode()
}
if (nodes.length === 0) return []
const locate = (at: number) => {
let lo = 0
let hi = ends.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (ends[mid]! >= at) hi = mid
else lo = mid + 1
}
const prev = lo === 0 ? 0 : ends[lo - 1]!
return { node: nodes[lo]!, offset: at - prev }
}
const ranges: Range[] = []
for (const span of spans) {
const start = locate(span.start)
const end = locate(span.end)
const range = document.createRange()
range.setStart(start.node, start.offset)
range.setEnd(end.node, end.offset)
ranges.push(range)
}
return ranges
}
/**
* Re-scans the currently mounted `[data-row-key]` rows under `root` and
* re-registers highlights. Returns the resolved "current" Range (if the
* active row is mounted) so the caller can scroll to that exact occurrence
* instead of just the row. The occurrence index is clamped to the ranges
* 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.
*/
export function applyTranscriptHighlights(
root: HTMLElement,
pattern: RegExp | undefined,
active: { key: string; occurrence: number } | undefined,
): Range | undefined {
const api = highlightApi()
if (!api) return undefined
api.registry.delete(MATCH_NAME)
api.registry.delete(ACTIVE_NAME)
if (!pattern) return undefined
const scopes = root.querySelectorAll<HTMLElement>("[data-row-key]")
const rest: Range[] = []
const current: Range[] = []
let currentRange: Range | undefined
for (const scope of scopes) {
const ranges = scanScope(scope, 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
for (let i = 0; i < ranges.length; i += 1) {
if (i === activeIdx) {
current.push(ranges[i]!)
currentRange = ranges[i]!
continue
}
rest.push(ranges[i]!)
}
}
if (rest.length > 0) api.registry.set(MATCH_NAME, new api.ctor(...rest))
if (current.length > 0) api.registry.set(ACTIVE_NAME, new api.ctor(...current))
return currentRange
}
export function clearTranscriptHighlights(): void {
const api = highlightApi()
if (!api) return
api.registry.delete(MATCH_NAME)
api.registry.delete(ACTIVE_NAME)
}
@@ -0,0 +1,91 @@
import { createContext, useContext, createSignal, type Accessor, type ParentComponent } from "solid-js"
export interface SearchMatch {
key: string
messageId: string
/** Index (0-based) of this occurrence among all matches within the same row. */
occurrence: number
}
interface TranscriptSearchContextValue {
query: Accessor<string>
setQuery: (value: string) => void
matchCase: Accessor<boolean>
setMatchCase: (value: boolean) => void
wholeWord: Accessor<boolean>
setWholeWord: (value: boolean) => void
regex: Accessor<boolean>
setRegex: (value: boolean) => void
active: Accessor<boolean>
setActive: (value: boolean) => void
index: Accessor<number>
setIndex: (value: number) => void
count: Accessor<number>
setCount: (value: number) => void
/** Bumped on every explicit next/prev/Enter navigation, even when the
* resulting index is unchanged (e.g. a single match). MessageList scrolls
* off this instead of `index` so navigation always jumps to the match. */
jump: Accessor<number>
requestJump: () => void
/** True when "Use Regular Expression" is on and the current query fails to
* compile — lets the widget show an explicit error instead of looking
* indistinguishable from a plain "no matches". */
invalid: Accessor<boolean>
setInvalid: (value: boolean) => void
/** True while MessageList is auto-loading older message pages to search
* them too — the session only loads the most recent page by default, so
* without this the widget would report "No results"/a final count while
* older history hadn't been searched yet. */
searchingHistory: Accessor<boolean>
setSearchingHistory: (value: boolean) => void
}
const TranscriptSearchContext = createContext<TranscriptSearchContextValue>()
export const TranscriptSearchProvider: ParentComponent = (props) => {
const [query, setQuery] = createSignal("")
const [matchCase, setMatchCase] = createSignal(false)
const [wholeWord, setWholeWord] = createSignal(false)
const [regex, setRegex] = createSignal(false)
const [active, setActive] = createSignal(false)
const [index, setIndex] = createSignal(0)
const [count, setCount] = createSignal(0)
const [jump, setJump] = createSignal(0)
const [invalid, setInvalid] = createSignal(false)
const [searchingHistory, setSearchingHistory] = createSignal(false)
return (
<TranscriptSearchContext.Provider
value={{
query,
setQuery,
matchCase,
setMatchCase,
wholeWord,
setWholeWord,
regex,
setRegex,
active,
setActive,
index,
setIndex,
count,
setCount,
jump,
requestJump: () => setJump((n) => n + 1),
invalid,
setInvalid,
searchingHistory,
setSearchingHistory,
}}
>
{props.children}
</TranscriptSearchContext.Provider>
)
}
export function useTranscriptSearch(): TranscriptSearchContextValue {
const ctx = useContext(TranscriptSearchContext)
if (!ctx) throw new Error("useTranscriptSearch must be used within TranscriptSearchProvider")
return ctx
}
+11
View File
@@ -1835,4 +1835,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "جارٍ تحميل الفروع…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "الخطة جاهزة:",
"chat.search.placeholder": "البحث في المحادثة…",
"chat.search.toggle": "البحث في المحادثة",
"chat.search.matchCase": "مطابقة حالة الأحرف",
"chat.search.matchWholeWord": "مطابقة الكلمة بأكملها",
"chat.search.useRegex": "استخدام تعبير عادي",
"chat.search.previousMatch": "المطابقة السابقة",
"chat.search.nextMatch": "المطابقة التالية",
"chat.search.close": "إغلاق البحث",
"chat.search.invalidRegex": "تعبير عادي غير صالح",
"chat.search.noResults": "لا توجد نتائج",
"chat.search.searchingHistory": "جارٍ البحث في الرسائل السابقة…",
}
+11
View File
@@ -1884,4 +1884,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Carregando branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plano pronto:",
"chat.search.placeholder": "Pesquisar na conversa…",
"chat.search.toggle": "Pesquisar na conversa",
"chat.search.matchCase": "Diferenciar maiúsculas de minúsculas",
"chat.search.matchWholeWord": "Coincidir palavra inteira",
"chat.search.useRegex": "Usar expressão regular",
"chat.search.previousMatch": "Correspondência anterior",
"chat.search.nextMatch": "Próxima correspondência",
"chat.search.close": "Fechar pesquisa",
"chat.search.invalidRegex": "Expressão regular inválida",
"chat.search.noResults": "Nenhum resultado",
"chat.search.searchingHistory": "Pesquisando mensagens anteriores…",
}
+11
View File
@@ -1876,4 +1876,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plan je spreman:",
"chat.search.placeholder": "Pretraži chat…",
"chat.search.toggle": "Pretraži chat",
"chat.search.matchCase": "Podudaranje velikih/malih slova",
"chat.search.matchWholeWord": "Podudaranje cijele riječi",
"chat.search.useRegex": "Koristi regularni izraz",
"chat.search.previousMatch": "Prethodno podudaranje",
"chat.search.nextMatch": "Sljedeće podudaranje",
"chat.search.close": "Zatvori pretragu",
"chat.search.invalidRegex": "Nevažeći regularni izraz",
"chat.search.noResults": "Nema rezultata",
"chat.search.searchingHistory": "Pretraživanje ranijih poruka…",
}
+11
View File
@@ -1868,4 +1868,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Planen er klar:",
"chat.search.placeholder": "Søg i chat…",
"chat.search.toggle": "Søg i chat",
"chat.search.matchCase": "Forskel på store/små bogstaver",
"chat.search.matchWholeWord": "Match helt ord",
"chat.search.useRegex": "Brug regulært udtryk",
"chat.search.previousMatch": "Forrige match",
"chat.search.nextMatch": "Næste match",
"chat.search.close": "Luk søgning",
"chat.search.invalidRegex": "Ugyldigt regulært udtryk",
"chat.search.noResults": "Ingen resultater",
"chat.search.searchingHistory": "Søger i tidligere beskeder…",
}
@@ -1906,4 +1906,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Branches werden geladen…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plan ist bereit:",
"chat.search.placeholder": "Chat durchsuchen…",
"chat.search.toggle": "Chat durchsuchen",
"chat.search.matchCase": "Groß-/Kleinschreibung beachten",
"chat.search.matchWholeWord": "Ganzes Wort suchen",
"chat.search.useRegex": "Regulären Ausdruck verwenden",
"chat.search.previousMatch": "Vorheriger Treffer",
"chat.search.nextMatch": "Nächster Treffer",
"chat.search.close": "Suche schließen",
"chat.search.invalidRegex": "Ungültiger regulärer Ausdruck",
"chat.search.noResults": "Keine Ergebnisse",
"chat.search.searchingHistory": "Frühere Nachrichten werden durchsucht…",
} satisfies Partial<Record<Keys, string>>
@@ -1863,4 +1863,15 @@ export const dict = {
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plan is ready:",
"chat.search.placeholder": "Search chat…",
"chat.search.toggle": "Search chat",
"chat.search.matchCase": "Match Case",
"chat.search.matchWholeWord": "Match Whole Word",
"chat.search.useRegex": "Use Regular Expression",
"chat.search.previousMatch": "Previous match",
"chat.search.nextMatch": "Next match",
"chat.search.close": "Close search",
"chat.search.invalidRegex": "Invalid regular expression",
"chat.search.noResults": "No results",
"chat.search.searchingHistory": "Searching earlier messages…",
}
+11
View File
@@ -1892,4 +1892,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Cargando ramas…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "El plan está listo:",
"chat.search.placeholder": "Buscar en el chat…",
"chat.search.toggle": "Buscar en el chat",
"chat.search.matchCase": "Coincidir mayúsculas y minúsculas",
"chat.search.matchWholeWord": "Solo palabras completas",
"chat.search.useRegex": "Usar expresión regular",
"chat.search.previousMatch": "Coincidencia anterior",
"chat.search.nextMatch": "Coincidencia siguiente",
"chat.search.close": "Cerrar búsqueda",
"chat.search.invalidRegex": "Expresión regular no válida",
"chat.search.noResults": "Sin resultados",
"chat.search.searchingHistory": "Buscando en mensajes anteriores…",
}
+11
View File
@@ -1916,4 +1916,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Chargement des branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Le plan est prêt :",
"chat.search.placeholder": "Rechercher dans la conversation…",
"chat.search.toggle": "Rechercher dans la conversation",
"chat.search.matchCase": "Respecter la casse",
"chat.search.matchWholeWord": "Mot entier",
"chat.search.useRegex": "Utiliser une expression régulière",
"chat.search.previousMatch": "Résultat précédent",
"chat.search.nextMatch": "Résultat suivant",
"chat.search.close": "Fermer la recherche",
"chat.search.invalidRegex": "Expression régulière non valide",
"chat.search.noResults": "Aucun résultat",
"chat.search.searchingHistory": "Recherche dans les messages précédents…",
}
+11
View File
@@ -1801,4 +1801,15 @@ export const dict = {
"speechToText.error.emptyTranscript": "Nessun parlato rilevato.",
"speechToText.error.encoding": "Impossibile codificare la registrazione.",
"speechToText.toast.transcribed": "Trascrizione inserita",
"chat.search.placeholder": "Cerca nella chat…",
"chat.search.toggle": "Cerca nella chat",
"chat.search.matchCase": "Maiuscole/minuscole",
"chat.search.matchWholeWord": "Parola intera",
"chat.search.useRegex": "Usa espressione regolare",
"chat.search.previousMatch": "Risultato precedente",
"chat.search.nextMatch": "Risultato successivo",
"chat.search.close": "Chiudi ricerca",
"chat.search.invalidRegex": "Espressione regolare non valida",
"chat.search.noResults": "Nessun risultato",
"chat.search.searchingHistory": "Ricerca nei messaggi precedenti…",
} as const
+11
View File
@@ -1861,4 +1861,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "ブランチを読み込み中…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "プランの準備ができました:",
"chat.search.placeholder": "チャットを検索…",
"chat.search.toggle": "チャットを検索",
"chat.search.matchCase": "大文字と小文字を区別する",
"chat.search.matchWholeWord": "単語単位で検索する",
"chat.search.useRegex": "正規表現を使用する",
"chat.search.previousMatch": "前の一致",
"chat.search.nextMatch": "次の一致",
"chat.search.close": "検索を閉じる",
"chat.search.invalidRegex": "正規表現が無効です",
"chat.search.noResults": "見つかりませんでした",
"chat.search.searchingHistory": "以前のメッセージを検索しています…",
}
+11
View File
@@ -1843,4 +1843,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "브랜치 로딩 중…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "계획이 준비되었습니다:",
"chat.search.placeholder": "채팅 검색…",
"chat.search.toggle": "채팅 검색",
"chat.search.matchCase": "대/소문자 구분",
"chat.search.matchWholeWord": "단어 단위로 검색",
"chat.search.useRegex": "정규식 사용",
"chat.search.previousMatch": "이전 검색 결과",
"chat.search.nextMatch": "다음 검색 결과",
"chat.search.close": "검색 닫기",
"chat.search.invalidRegex": "정규식이 잘못되었습니다",
"chat.search.noResults": "검색 결과 없음",
"chat.search.searchingHistory": "이전 메시지를 검색하는 중…",
}
+11
View File
@@ -1903,4 +1903,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plan is klaar:",
"chat.search.placeholder": "Chat doorzoeken…",
"chat.search.toggle": "Chat doorzoeken",
"chat.search.matchCase": "Hoofdlettergevoelig",
"chat.search.matchWholeWord": "Heel woord",
"chat.search.useRegex": "Reguliere expressie gebruiken",
"chat.search.previousMatch": "Vorige overeenkomst",
"chat.search.nextMatch": "Volgende overeenkomst",
"chat.search.close": "Zoeken sluiten",
"chat.search.invalidRegex": "Ongeldige reguliere expressie",
"chat.search.noResults": "Geen resultaten",
"chat.search.searchingHistory": "Eerdere berichten doorzoeken…",
}
+11
View File
@@ -1861,4 +1861,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Planen er klar:",
"chat.search.placeholder": "Søk i chat…",
"chat.search.toggle": "Søk i chat",
"chat.search.matchCase": "Skill mellom store og små bokstaver",
"chat.search.matchWholeWord": "Treff hele ord",
"chat.search.useRegex": "Bruk regulært uttrykk",
"chat.search.previousMatch": "Forrige treff",
"chat.search.nextMatch": "Neste treff",
"chat.search.close": "Lukk søk",
"chat.search.invalidRegex": "Ugyldig regulært uttrykk",
"chat.search.noResults": "Ingen resultater",
"chat.search.searchingHistory": "Søker i tidligere meldinger…",
} satisfies Partial<Record<Keys, string>>
+11
View File
@@ -1874,4 +1874,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plan jest gotowy:",
"chat.search.placeholder": "Szukaj w czacie…",
"chat.search.toggle": "Szukaj w czacie",
"chat.search.matchCase": "Uwzględnij wielkość liter",
"chat.search.matchWholeWord": "Całe wyrazy",
"chat.search.useRegex": "Użyj wyrażenia regularnego",
"chat.search.previousMatch": "Poprzednie dopasowanie",
"chat.search.nextMatch": "Następne dopasowanie",
"chat.search.close": "Zamknij wyszukiwanie",
"chat.search.invalidRegex": "Nieprawidłowe wyrażenie regularne",
"chat.search.noResults": "Brak wyników",
"chat.search.searchingHistory": "Wyszukiwanie we wcześniejszych wiadomościach…",
}
+11
View File
@@ -1872,4 +1872,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Загрузка веток…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "План готов:",
"chat.search.placeholder": "Поиск в чате…",
"chat.search.toggle": "Поиск в чате",
"chat.search.matchCase": "Учитывать регистр",
"chat.search.matchWholeWord": "Слово целиком",
"chat.search.useRegex": "Использовать регулярное выражение",
"chat.search.previousMatch": "Предыдущее совпадение",
"chat.search.nextMatch": "Следующее совпадение",
"chat.search.close": "Закрыть поиск",
"chat.search.invalidRegex": "Недопустимое регулярное выражение",
"chat.search.noResults": "Нет результатов",
"chat.search.searchingHistory": "Поиск в более ранних сообщениях…",
}
+11
View File
@@ -1841,4 +1841,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "แผนพร้อมแล้ว:",
"chat.search.placeholder": "ค้นหาในแชท…",
"chat.search.toggle": "ค้นหาในแชท",
"chat.search.matchCase": "ตรงตามตัวพิมพ์ใหญ่-เล็ก",
"chat.search.matchWholeWord": "ตรงทั้งคำ",
"chat.search.useRegex": "ใช้นิพจน์ทั่วไป",
"chat.search.previousMatch": "รายการที่ตรงกันก่อนหน้า",
"chat.search.nextMatch": "รายการที่ตรงกันถัดไป",
"chat.search.close": "ปิดการค้นหา",
"chat.search.invalidRegex": "นิพจน์ทั่วไปไม่ถูกต้อง",
"chat.search.noResults": "ไม่มีผลลัพธ์",
"chat.search.searchingHistory": "กำลังค้นหาข้อความก่อนหน้า…",
}
+11
View File
@@ -1890,4 +1890,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "Plan hazır:",
"chat.search.placeholder": "Sohbette ara…",
"chat.search.toggle": "Sohbette ara",
"chat.search.matchCase": "Büyük/küçük harf eşleştir",
"chat.search.matchWholeWord": "Tam sözcük eşleştir",
"chat.search.useRegex": "Normal ifade kullan",
"chat.search.previousMatch": "Önceki eşleşme",
"chat.search.nextMatch": "Sonraki eşleşme",
"chat.search.close": "Aramayı kapat",
"chat.search.invalidRegex": "Geçersiz normal ifade",
"chat.search.noResults": "Sonuç yok",
"chat.search.searchingHistory": "Önceki mesajlarda aranıyor…",
}
+11
View File
@@ -1887,4 +1887,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "Loading branches…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "План готовий:",
"chat.search.placeholder": "Пошук у чаті…",
"chat.search.toggle": "Пошук у чаті",
"chat.search.matchCase": "Враховувати регістр",
"chat.search.matchWholeWord": "Слово цілком",
"chat.search.useRegex": "Використовувати регулярний вираз",
"chat.search.previousMatch": "Попередній збіг",
"chat.search.nextMatch": "Наступний збіг",
"chat.search.close": "Закрити пошук",
"chat.search.invalidRegex": "Недійсний регулярний вираз",
"chat.search.noResults": "Немає результатів",
"chat.search.searchingHistory": "Пошук у попередніх повідомленнях…",
}
+11
View File
@@ -1790,4 +1790,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "正在加载分支…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "计划已准备就绪:",
"chat.search.placeholder": "搜索聊天…",
"chat.search.toggle": "搜索聊天",
"chat.search.matchCase": "区分大小写",
"chat.search.matchWholeWord": "全字匹配",
"chat.search.useRegex": "使用正则表达式",
"chat.search.previousMatch": "上一个匹配项",
"chat.search.nextMatch": "下一个匹配项",
"chat.search.close": "关闭搜索",
"chat.search.invalidRegex": "正则表达式无效",
"chat.search.noResults": "无结果",
"chat.search.searchingHistory": "正在搜索更早的消息…",
} satisfies Partial<Record<Keys, string>>
+11
View File
@@ -1796,4 +1796,15 @@ export const dict = {
"diffViewer.baseBranch.loading": "正在載入分支…",
"diffViewer.baseBranch.none": "—",
"plan.exit.ready": "計畫已準備就緒:",
"chat.search.placeholder": "搜尋聊天…",
"chat.search.toggle": "搜尋聊天",
"chat.search.matchCase": "區分大小寫",
"chat.search.matchWholeWord": "全字拼寫須相符",
"chat.search.useRegex": "使用規則運算式",
"chat.search.previousMatch": "上一個相符項",
"chat.search.nextMatch": "下一個相符項",
"chat.search.close": "關閉搜尋",
"chat.search.invalidRegex": "規則運算式無效",
"chat.search.noResults": "無結果",
"chat.search.searchingHistory": "正在搜尋較早的訊息…",
} satisfies Partial<Record<Keys, string>>
@@ -35,6 +35,7 @@ import { LanguageContext } from "../context/language"
import { IndexingProvider } from "../context/indexing"
import { KiloEmbeddingModelsProvider } from "../context/kilo-embedding-models"
import { MemoryProvider } from "../context/memory"
import { TranscriptSearchProvider } from "../context/transcript-search"
import { dict as uiEn } from "@kilocode/kilo-ui/i18n/en"
import { dict as appEn } from "../i18n/en"
import { dict as amEn } from "../../agent-manager/i18n/en"
@@ -453,11 +454,13 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
<CodeComponentProvider component={Code}>
<FileComponentProvider component={File}>
<MarkedProvider>
{props.noPadding ? (
props.children
) : (
<div style={{ padding: "12px" }}>{props.children}</div>
)}
<TranscriptSearchProvider>
{props.noPadding ? (
props.children
) : (
<div style={{ padding: "12px" }}>{props.children}</div>
)}
</TranscriptSearchProvider>
</MarkedProvider>
</FileComponentProvider>
</CodeComponentProvider>
@@ -332,3 +332,38 @@
display: inline-flex;
align-items: center;
}
/* ============================================
Search Active Match Highlight
============================================ */
.vscode-session-turn[data-search-active] {
outline: 2px solid var(--vscode-focusBorder);
outline-offset: -2px;
border-radius: 4px;
animation: search-pulse 1.5s ease-in-out infinite;
}
@keyframes search-pulse {
0%,
100% {
outline-color: var(--vscode-focusBorder);
}
50% {
outline-color: color-mix(in srgb, var(--vscode-focusBorder) 30%, transparent);
}
}
/* Occurrence-level highlights painted via the CSS Custom Highlight API (see
transcript-search-highlight.ts). Falls back to no visual effect in engines
without ::highlight() support row-level scroll navigation still works.
Uses VS Code's own editor find-match theme colors so this matches
whatever color scheme the user has configured, rather than a fixed
palette that can clash with some themes. */
::highlight(kilo-transcript-search-match) {
background-color: var(--vscode-editor-findMatchHighlightBackground, rgba(234, 190, 0, 0.35));
}
::highlight(kilo-transcript-search-match-active) {
background-color: var(--vscode-editor-findMatchBackground, rgba(255, 165, 0, 0.65));
}
@@ -250,6 +250,120 @@
padding: 0 8px;
}
/* ============================================
Transcript Search
============================================ */
.task-header-search-toggle[data-active] {
background: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-foreground);
}
[data-component="task-header-search"] {
padding: 6px 8px;
border-bottom: 1px solid var(--border-weak-base);
background-color: var(--background-base);
}
[data-component="transcript-search"] {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
[data-slot="transcript-search-box"] {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
[data-slot="transcript-search-input"] {
box-sizing: border-box;
width: 100%;
height: 26px;
padding: 0 82px 0 8px;
border: 1px solid var(--vscode-input-border, var(--border-weak-base));
border-radius: 3px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-size: var(--kilo-font-size-12);
outline: none;
}
[data-slot="transcript-search-input"]:focus {
border-color: var(--vscode-focusBorder);
}
[data-slot="transcript-search-inline-options"] {
position: absolute;
top: 50%;
right: 4px;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 1px;
}
[data-slot="transcript-search-option"] {
all: unset;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 20px;
border-radius: 3px;
border: 1px solid transparent;
font-size: var(--kilo-font-size-10);
font-family: var(--font-family-sans);
color: var(--vscode-descriptionForeground);
cursor: pointer;
user-select: none;
}
[data-slot="transcript-search-option"]:hover {
background: var(--vscode-toolbar-hoverBackground);
}
[data-slot="transcript-search-option"][data-active] {
background: var(--vscode-inputOption-activeBackground, var(--vscode-toolbar-hoverBackground));
color: var(--vscode-inputOption-activeForeground, var(--vscode-foreground));
border-color: var(--vscode-inputOption-activeBorder, transparent);
font-weight: 600;
}
[data-slot="transcript-search-counter"] {
font-size: var(--kilo-font-size-11);
color: var(--vscode-descriptionForeground);
font-variant-numeric: tabular-nums;
min-width: 42px;
text-align: center;
flex-shrink: 0;
}
[data-slot="transcript-search-error"] {
font-size: var(--kilo-font-size-11);
color: var(--vscode-errorForeground, #f14c4c);
white-space: nowrap;
flex-shrink: 0;
}
[data-slot="transcript-search-empty"] {
font-size: var(--kilo-font-size-11);
color: var(--vscode-descriptionForeground);
white-space: nowrap;
flex-shrink: 0;
}
[data-slot="transcript-search-nav"] {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
}
/* Token breakdown in expanded state */
.task-header-tokens {
display: flex;