fix(vscode): address chat search review findings from testing round

High: visible tool content (e.g. a todowrite checklist) could be
highlighted in the DOM while the counter still reported "No results"
and navigation was disabled. toolText() previously only indexed
state.title for non-bash tools, independently of what
transcript-search-highlight.ts actually scans in the DOM. Rewrote it
to recursively collect every string leaf from state.input/state.metadata
plus state.output for any tool, so matching and highlighting draw from
one canonical, much more comprehensive notion of "the tool's text"
instead of two divergent ones. read/glob/grep/list remain excluded --
kilo-ui's context-tool-results.tsx confirmed they never render raw
input/output text, even expanded, so including it there would
reintroduce the same class of mismatch for those tools.

High: search only covered the initially-loaded message page (80
messages), silently missing older history in long sessions. MessageList
now auto-requests older pages (session.loadOlderMessages()) while a
search is active with a non-empty query, looping via reactivity on
hasOlderMessages()/loadingOlderMessages() until history is exhausted.
A new searchingHistory state surfaces this to the widget, which shows
"Searching earlier messages..." and withholds a final "No results"
until the whole session has actually been searched (a live match count
still updates progressively as pages load).

Medium: whole-word matching used plain \b, which only treats ASCII
letters/digits/underscore as word characters and silently breaks for
Cyrillic, Arabic, CJK, and similar text. Replaced with Unicode-aware
boundary lookarounds using \p{L}/\p{M}/\p{N} property escapes, with the
`u` flag applied to every compiled pattern.

New chat.search.searchingHistory i18n key added across all 20 locales.

(cherry picked from commit 47d4ebd73b)
This commit is contained in:
Sylwester Liljegren
2026-07-10 11:08:41 +02:00
committed by marius-kilocode
parent 3d28bfc50e
commit f96771d302
23 changed files with 108 additions and 12 deletions
@@ -61,7 +61,7 @@ import {
parseProviderAuthError,
unwrapError,
} from "../../utils/errorUtils"
import type { Part, QuestionRequest, SuggestionRequest } from "../../types/messages"
import type { Part, QuestionRequest, SuggestionRequest, ToolState } from "../../types/messages"
interface MessageListProps {
onSelectSession?: (id: string) => void
@@ -210,19 +210,37 @@ export const MessageList: Component<MessageListProps> = (props) => {
// 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.
// 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 (part.tool !== "bash") return state.title ? [state.title] : []
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(state.output)
return chunks
}
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
@@ -238,6 +256,27 @@ export const MessageList: Component<MessageListProps> = (props) => {
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 {
@@ -246,9 +285,14 @@ export const MessageList: Component<MessageListProps> = (props) => {
pattern = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
if (wholeWord) {
pattern = `\\b(?:${pattern})\\b`
// 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 ? "g" : "gi")
return new RegExp(pattern, matchCase ? "gu" : "giu")
} catch {
return undefined
}
@@ -268,6 +312,24 @@ export const MessageList: Component<MessageListProps> = (props) => {
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. While a query is active, keep requesting older pages until there
// either 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 whole session has actually been searched.
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 []
@@ -116,7 +116,12 @@ export const TranscriptSearch: Component = () => {
<Show when={search.invalid()}>
<span data-slot="transcript-search-error">{language.t("chat.search.invalidRegex")}</span>
</Show>
<Show when={!search.invalid() && search.query().length > 0 && search.count() === 0}>
<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}>
@@ -32,6 +32,12 @@ interface TranscriptSearchContextValue {
* 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>()
@@ -46,6 +52,7 @@ export const TranscriptSearchProvider: ParentComponent = (props) => {
const [count, setCount] = createSignal(0)
const [jump, setJump] = createSignal(0)
const [invalid, setInvalid] = createSignal(false)
const [searchingHistory, setSearchingHistory] = createSignal(false)
return (
<TranscriptSearchContext.Provider
@@ -68,6 +75,8 @@ export const TranscriptSearchProvider: ParentComponent = (props) => {
requestJump: () => setJump((n) => n + 1),
invalid,
setInvalid,
searchingHistory,
setSearchingHistory,
}}
>
{props.children}
+1
View File
@@ -1845,4 +1845,5 @@ export const dict = {
"chat.search.close": "إغلاق البحث",
"chat.search.invalidRegex": "تعبير عادي غير صالح",
"chat.search.noResults": "لا توجد نتائج",
"chat.search.searchingHistory": "جارٍ البحث في الرسائل السابقة…",
}
+1
View File
@@ -1894,4 +1894,5 @@ export const dict = {
"chat.search.close": "Fechar pesquisa",
"chat.search.invalidRegex": "Expressão regular inválida",
"chat.search.noResults": "Nenhum resultado",
"chat.search.searchingHistory": "Pesquisando mensagens anteriores…",
}
+1
View File
@@ -1886,4 +1886,5 @@ export const dict = {
"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…",
}
+1
View File
@@ -1878,4 +1878,5 @@ export const dict = {
"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…",
}
@@ -1916,4 +1916,5 @@ export const dict = {
"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>>
@@ -1873,4 +1873,5 @@ export const dict = {
"chat.search.close": "Close search",
"chat.search.invalidRegex": "Invalid regular expression",
"chat.search.noResults": "No results",
"chat.search.searchingHistory": "Searching earlier messages…",
}
+1
View File
@@ -1902,4 +1902,5 @@ export const dict = {
"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…",
}
+1
View File
@@ -1926,4 +1926,5 @@ export const dict = {
"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…",
}
+1
View File
@@ -1811,4 +1811,5 @@ export const dict = {
"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
+1
View File
@@ -1871,4 +1871,5 @@ export const dict = {
"chat.search.close": "検索を閉じる",
"chat.search.invalidRegex": "正規表現が無効です",
"chat.search.noResults": "見つかりませんでした",
"chat.search.searchingHistory": "以前のメッセージを検索しています…",
}
+1
View File
@@ -1853,4 +1853,5 @@ export const dict = {
"chat.search.close": "검색 닫기",
"chat.search.invalidRegex": "정규식이 잘못되었습니다",
"chat.search.noResults": "검색 결과 없음",
"chat.search.searchingHistory": "이전 메시지를 검색하는 중…",
}
+1
View File
@@ -1913,4 +1913,5 @@ export const dict = {
"chat.search.close": "Zoeken sluiten",
"chat.search.invalidRegex": "Ongeldige reguliere expressie",
"chat.search.noResults": "Geen resultaten",
"chat.search.searchingHistory": "Eerdere berichten doorzoeken…",
}
+1
View File
@@ -1871,4 +1871,5 @@ export const dict = {
"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>>
+1
View File
@@ -1884,4 +1884,5 @@ export const dict = {
"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…",
}
+1
View File
@@ -1882,4 +1882,5 @@ export const dict = {
"chat.search.close": "Закрыть поиск",
"chat.search.invalidRegex": "Недопустимое регулярное выражение",
"chat.search.noResults": "Нет результатов",
"chat.search.searchingHistory": "Поиск в более ранних сообщениях…",
}
+1
View File
@@ -1851,4 +1851,5 @@ export const dict = {
"chat.search.close": "ปิดการค้นหา",
"chat.search.invalidRegex": "นิพจน์ทั่วไปไม่ถูกต้อง",
"chat.search.noResults": "ไม่มีผลลัพธ์",
"chat.search.searchingHistory": "กำลังค้นหาข้อความก่อนหน้า…",
}
+1
View File
@@ -1900,4 +1900,5 @@ export const dict = {
"chat.search.close": "Aramayı kapat",
"chat.search.invalidRegex": "Geçersiz normal ifade",
"chat.search.noResults": "Sonuç yok",
"chat.search.searchingHistory": "Önceki mesajlarda aranıyor…",
}
+1
View File
@@ -1897,4 +1897,5 @@ export const dict = {
"chat.search.close": "Закрити пошук",
"chat.search.invalidRegex": "Недійсний регулярний вираз",
"chat.search.noResults": "Немає результатів",
"chat.search.searchingHistory": "Пошук у попередніх повідомленнях…",
}
+1
View File
@@ -1800,4 +1800,5 @@ export const dict = {
"chat.search.close": "关闭搜索",
"chat.search.invalidRegex": "正则表达式无效",
"chat.search.noResults": "无结果",
"chat.search.searchingHistory": "正在搜索更早的消息…",
} satisfies Partial<Record<Keys, string>>
+1
View File
@@ -1806,4 +1806,5 @@ export const dict = {
"chat.search.close": "關閉搜尋",
"chat.search.invalidRegex": "規則運算式無效",
"chat.search.noResults": "無結果",
"chat.search.searchingHistory": "正在搜尋較早的訊息…",
} satisfies Partial<Record<Keys, string>>