mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
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:
@@ -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.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:22cfec1cfdfe2deb4299f62bd677eeee34b522c581504297fd356efbf26e9b44
|
||||
size 6351
|
||||
oid sha256:8ea257778cf09ea853b06843de79d114fa8fe77b5dea20c6ec15abe965b60d18
|
||||
size 6165
|
||||
|
||||
+2
-2
@@ -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 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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user