mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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.
This commit is contained in:
@@ -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 Code–style 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
@@ -1832,4 +1832,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
@@ -1881,4 +1881,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
@@ -1873,4 +1873,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
@@ -1865,4 +1865,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",
|
||||
}
|
||||
|
||||
@@ -1903,4 +1903,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>>
|
||||
|
||||
@@ -1860,4 +1860,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
@@ -1889,4 +1889,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
@@ -1913,4 +1913,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
@@ -1798,4 +1798,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
@@ -1858,4 +1858,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
@@ -1840,4 +1840,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
@@ -1900,4 +1900,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
@@ -1858,4 +1858,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
@@ -1871,4 +1871,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
@@ -1869,4 +1869,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
@@ -1838,4 +1838,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
@@ -1887,4 +1887,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
@@ -1884,4 +1884,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
@@ -1787,4 +1787,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
@@ -1793,4 +1793,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;
|
||||
|
||||
Reference in New Issue
Block a user