feat(vscode): add in-chat search for the current session

Add a search widget to the VS Code sidebar and editor-tab chat header.
Clicking the header search icon opens an inline, VS Code-style find bar
with match case, whole word, and regular expression options. Matches are
highlighted across the transcript via the CSS Custom Highlight API, and
next/previous controls step through each occurrence, recentering only
when a match drifts near the viewport edge.

All user-facing strings are localized across the 20 supported locales.

(cherry picked from commit 3f8b4443c3)
This commit is contained in:
Sylwester Liljegren
2026-07-10 01:21:48 +02:00
committed by marius-kilocode
parent f6149e8b1f
commit 7a7c28c271
30 changed files with 973 additions and 55 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.
@@ -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>
)
}
@@ -49,7 +49,9 @@ import {
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 type { Part, QuestionRequest, SuggestionRequest } from "../../types/messages"
interface MessageListProps {
onSelectSession?: (id: string) => void
@@ -135,6 +137,217 @@ export const MessageList: Component<MessageListProps> = (props) => {
prev,
)
})
const search = useTranscriptSearch()
function rowText(row: TranscriptRow): string {
if (row.type === "error") return row.error.name
if (row.type === "diff") return ""
const chunks: string[] = []
for (const part of row.parts) {
switch (part.type) {
case "text":
if (!part.synthetic) chunks.push(part.text)
break
case "reasoning":
chunks.push(part.text)
break
case "tool":
chunks.push(...toolText(part))
break
case "file":
if (part.filename) chunks.push(part.filename)
break
}
}
return chunks.join("\n")
}
// Extracts only the text kilo-ui's tool renderers actually put on screen —
// matched field-by-field rather than reading `state.title` generically.
// The bash/shell renderer never shows `state.title` (its header is a
// static "Shell" label); the visible command/description come from
// `state.input` instead, and its output is shown as plain scrollable
// text. Other tools' `state.output` is typically internal data (file
// contents, JSON) that isn't rendered inline, so including it — or bash's
// unused `title` — produces search matches with no corresponding
// highlight, which is what made navigation appear to skip past matches.
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 (part.tool !== "bash") return state.title ? [state.title] : []
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[] = []
if (command) chunks.push(command)
if (description) chunks.push(description)
if (state.output) chunks.push(state.output)
return chunks
}
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) {
pattern = `\\b(?:${pattern})\\b`
}
return new RegExp(pattern, matchCase ? "g" : "gi")
} catch {
return undefined
}
}
const matches = createMemo(() => {
const q = search.query()
if (!search.active() || !q) return []
const pattern = buildPattern(q, search.matchCase(), search.wholeWord(), search.regex())
if (!pattern) return []
const list = rows()
const result: SearchMatch[] = []
for (const row of list) {
const text = rowText(row)
pattern.lastIndex = 0
let occurrence = 0
let hit = pattern.exec(text)
while (hit) {
if (hit[0].length === 0) {
pattern.lastIndex += 1
hit = pattern.exec(text)
continue
}
result.push({ key: row.key, messageId: row.message.id, occurrence })
occurrence += 1
hit = pattern.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),
),
)
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 pendingCenter = false
const paintHighlights = () => {
const el = scrollEl()
if (!el || !search.active()) {
clearTranscriptHighlights()
return
}
const pattern = buildPattern(search.query(), search.matchCase(), search.wholeWord(), search.regex())
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.
const scheduleHighlight = () => {
if (highlightFrame !== undefined) return
highlightFrame = requestAnimationFrame(() => {
requestAnimationFrame(() => {
highlightFrame = 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)
clearTranscriptHighlights()
})
const [held, setHeld] = createSignal<TranscriptHold>()
createEffect(() => {
const id = activeUserID()
@@ -227,6 +440,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
const handleScroll = () => {
autoScroll.handleScroll()
maybeLoadOlder()
if (search.active()) scheduleHighlight()
}
let resize: ResizeObserver | undefined
@@ -371,19 +585,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,156 @@
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">
<div data-slot="transcript-search-box">
<input
ref={inputRef}
type="text"
data-slot="transcript-search-input"
placeholder={language.t("chat.search.placeholder")}
value={search.query()}
onInput={(e) => {
search.setQuery(e.currentTarget.value)
search.setIndex(0)
}}
onKeyDown={onKeyDown}
/>
<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.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,74 @@
import { createContext, useContext, createSignal, type Accessor, type Component } 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
}
const TranscriptSearchContext = createContext<TranscriptSearchContextValue>()
export const TranscriptSearchProvider: Component<{ children: any }> = (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)
return (
<TranscriptSearchContext.Provider
value={{
query,
setQuery,
matchCase,
setMatchCase,
wholeWord,
setWholeWord,
regex,
setRegex,
active,
setActive,
index,
setIndex,
count,
setCount,
jump,
requestJump: () => setJump((n) => n + 1),
}}
>
{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
}
+8
View File
@@ -1835,4 +1835,12 @@ 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": "إغلاق البحث",
}
+8
View File
@@ -1884,4 +1884,12 @@ 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",
}
+8
View File
@@ -1876,4 +1876,12 @@ 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",
}
+8
View File
@@ -1868,4 +1868,12 @@ 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",
}
@@ -1906,4 +1906,12 @@ 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",
} satisfies Partial<Record<Keys, string>>
@@ -1863,4 +1863,12 @@ 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",
}
+8
View File
@@ -1892,4 +1892,12 @@ 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",
}
+8
View File
@@ -1916,4 +1916,12 @@ 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",
}
+8
View File
@@ -1801,4 +1801,12 @@ 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",
} as const
+8
View File
@@ -1861,4 +1861,12 @@ 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": "検索を閉じる",
}
+8
View File
@@ -1843,4 +1843,12 @@ 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": "검색 닫기",
}
+8
View File
@@ -1903,4 +1903,12 @@ 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",
}
+8
View File
@@ -1861,4 +1861,12 @@ 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",
} satisfies Partial<Record<Keys, string>>
+8
View File
@@ -1874,4 +1874,12 @@ 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",
}
+8
View File
@@ -1872,4 +1872,12 @@ 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": "Закрыть поиск",
}
+8
View File
@@ -1841,4 +1841,12 @@ 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": "ปิดการค้นหา",
}
+8
View File
@@ -1890,4 +1890,12 @@ 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",
}
+8
View File
@@ -1887,4 +1887,12 @@ 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": "Закрити пошук",
}
+8
View File
@@ -1790,4 +1790,12 @@ 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": "关闭搜索",
} satisfies Partial<Record<Keys, string>>
+8
View File
@@ -1796,4 +1796,12 @@ 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": "關閉搜尋",
} satisfies Partial<Record<Keys, string>>
@@ -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,106 @@
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-nav"] {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
}
/* Token breakdown in expanded state */
.task-header-tokens {
display: flex;