feat: Omni Search

This commit is contained in:
Catriel Müller
2026-05-27 20:44:36 -03:00
parent fb4f5cc322
commit 49d36f483a
4 changed files with 661 additions and 18 deletions
@@ -1,4 +1,5 @@
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { OmniSearch } from "./OmniSearch"
export function AppHeader() {
return (
@@ -13,10 +14,7 @@ export function AppHeader() {
</span>
</a>
<form class="omni-search" role="search">
<span aria-hidden="true">K</span>
<input type="search" placeholder="Search projects, config, providers..." aria-label="Omni search" />
</form>
<OmniSearch />
<nav class="notification-zone" aria-label="Notifications and status">
<IconButton icon="bubble-5" variant="ghost" aria-label="Notifications" />
@@ -0,0 +1,343 @@
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import {
discover,
forgetCached,
healthy,
loadCached,
loadVisibleProjects,
saveCached,
type ProjectItem,
type ProjectQuery,
} from "../../client"
import { configNav } from "../../routes/config/sections"
import { clean, friendly } from "../../shared/utils"
type Entry = {
kind: "NAV" | "PROJECT"
label: string
href: string
sub?: string
mono?: boolean
}
const ports = new Set(["3017", "3018"])
function shouldDiscover(input: URLSearchParams) {
if (input.get("server")) return false
return ports.has(window.location.port)
}
function base(input: URLSearchParams) {
const param = input.get("server")
if (param) return param
const cached = shouldDiscover(input) ? loadCached() : ""
if (cached) return cached
if (shouldDiscover(input)) return ""
return window.location.origin
}
function tail(input: URLSearchParams) {
const next = new URLSearchParams(input)
next.delete("directory")
const query = next.toString()
return query ? `?${query}` : ""
}
function link(path: string, input: URLSearchParams) {
return `${path}${tail(input)}`
}
function repo(input: string) {
const parts = input.split(/[\\/]/).filter(Boolean)
return parts.at(-1) ?? "Global"
}
function name(item: ProjectItem) {
return friendly(item.name?.trim() || repo(item.worktree) || item.id.slice(0, 8))
}
function short(input: string) {
if (input.length <= 8) return input
return input.slice(0, 8)
}
function nav(input: URLSearchParams): Entry[] {
const rows: Entry[] = [{ kind: "NAV", label: "Projects", href: link("/projects", input) }]
for (const item of configNav) {
if ("items" in item) {
for (const child of item.items) {
const prefix = item.id === "general" ? "Global Settings" : `Global Settings · ${item.label}`
rows.push({ kind: "NAV", label: `${prefix} · ${child.label}`, href: link(child.href, input) })
}
continue
}
rows.push({ kind: "NAV", label: `Global Settings · ${item.label}`, href: link(item.href, input) })
}
rows.push({ kind: "NAV", label: "Profile", href: link("/profile", input) })
return rows
}
function project(item: ProjectItem, input: URLSearchParams): Entry {
return {
kind: "PROJECT",
label: name(item),
href: link(`/projects/${encodeURIComponent(item.id)}`, input),
sub: short(item.id),
mono: true,
}
}
function fuzzy(hay: string, term: string) {
return [...term].reduce((pos, char) => {
if (pos < 0) return -1
const hit = hay.indexOf(char, pos)
if (hit < 0) return -1
return hit + 1
}, 0) >= 0
}
function matches(item: Entry, input: string) {
const term = input.trim().toLowerCase()
if (!term) return true
const hay = `${item.kind} ${item.label} ${item.sub ?? ""}`.toLowerCase()
if (hay.includes(term)) return true
return fuzzy(hay, term)
}
export function OmniSearch() {
const loc = useLocation()
const go = useNavigate()
const params = createMemo(() => new URLSearchParams(loc.search))
const discoverable = () => shouldDiscover(params())
const fallback = () => base(params())
const [url, setUrl] = createSignal(fallback())
const [open, setOpen] = createSignal(false)
const [term, setTerm] = createSignal("")
const [active, setActive] = createSignal(0)
let box: HTMLFormElement | undefined
let field: HTMLInputElement | undefined
const query = createMemo<ProjectQuery | undefined>(() => {
const target = clean(url()) || fallback()
if (!target) return undefined
return { url: target, dir: "" }
})
const [items] = createResource(query, loadVisibleProjects)
const entries = createMemo(() => [...nav(params()), ...[...(items() ?? [])].map((item) => project(item, params()))])
const filtered = createMemo(() => entries().filter((item) => matches(item, term())))
const server = createMemo(() => query()?.url ?? fallback())
function close() {
setOpen(false)
setTerm("")
field?.blur()
}
function focus() {
setOpen(true)
queueMicrotask(() => field?.focus())
}
function select(item: Entry | undefined) {
if (!item) return
go(item.href)
close()
}
function move(delta: number) {
const max = Math.max(filtered().length - 1, 0)
setActive((value) => Math.min(Math.max(value + delta, 0), max))
}
function keys(event: KeyboardEvent) {
if (event.key === "Escape" && open()) {
event.preventDefault()
close()
return
}
if (!open()) return
if (event.key === "ArrowDown") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Enter") {
event.preventDefault()
select(filtered()[active()])
}
}
function submit(event: SubmitEvent) {
event.preventDefault()
if (!open()) return
select(filtered()[active()])
}
createEffect(() => {
term()
setActive(0)
})
createEffect(() => {
const next = params().get("server")
if (next && next !== url()) setUrl(next)
})
createEffect(() => {
if (!discoverable()) return
const cached = loadCached()
void Promise.resolve(cached ? healthy(cached) : false)
.then((ok) => {
if (ok) return cached
forgetCached()
return discover()
})
.then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
createEffect(() => {
const current = query()
if (!items() || !current || !discoverable()) return
saveCached(current.url)
})
createEffect(() => {
if (!items.error || !discoverable()) return
const cached = loadCached()
if (!cached || cached !== url()) return
forgetCached()
setUrl("")
void discover().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
onMount(() => {
const combo = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return
event.preventDefault()
if (open()) {
close()
return
}
focus()
}
const pointer = (event: PointerEvent) => {
if (!open()) return
const target = event.target
if (target instanceof Node && box?.contains(target)) return
close()
}
window.addEventListener("keydown", combo)
window.addEventListener("pointerdown", pointer)
onCleanup(() => {
window.removeEventListener("keydown", combo)
window.removeEventListener("pointerdown", pointer)
})
})
return (
<form
ref={(node) => (box = node)}
class="omni-search"
classList={{ expanded: open() }}
role="search"
onKeyDown={keys}
onSubmit={submit}
>
<div
class="omni-search-head"
onPointerDown={(event) => {
if (event.target instanceof HTMLButtonElement) return
focus()
}}
>
<span class="omni-command" aria-hidden="true">
<svg class="omni-command-icon" viewBox="0 0 24 24" fill="none">
<path d="M7 9a2 2 0 1 1 2 -2v10a2 2 0 1 1 -2 -2h10a2 2 0 1 1 -2 2v-10a2 2 0 1 1 2 2h-10" />
</svg>
</span>
<input
ref={(node) => (field = node)}
type="search"
value={term()}
placeholder={open() ? "" : "Search settings, projects, models..."}
aria-label="Omni search"
aria-expanded={open()}
aria-controls="omni-search-results"
onFocus={() => setOpen(true)}
onInput={(event) => {
setTerm(event.currentTarget.value)
setOpen(true)
}}
/>
<button class="omni-escape" type="button" aria-label="Close search" onClick={close}>
esc
</button>
</div>
<Show when={open()}>
<div class="omni-results" id="omni-search-results" role="listbox" aria-label="Search results">
<Show when={filtered().length > 0} fallback={<div class="omni-empty">No matches.</div>}>
<For each={filtered()}>
{(item, index) => (
<button
type="button"
class="omni-entry"
classList={{ active: index() === active() }}
role="option"
aria-selected={index() === active()}
onClick={() => select(item)}
onMouseMove={() => setActive(index())}
>
<span class="omni-kind">{item.kind}</span>
<span class="omni-label" classList={{ mono: item.mono }}>
{item.label}
</span>
<Show when={item.sub}>
{(sub) => <span class="omni-sub">{sub()}</span>}
</Show>
<Show when={index() === active()}>
<span class="omni-enter" aria-hidden="true">
</span>
</Show>
</button>
)}
</For>
</Show>
</div>
<footer class="omni-footer">
<span class="omni-help">
<span class="omni-kbd"></span>
<span class="omni-kbd"></span>
Navigate
</span>
<span class="omni-help">
<span class="omni-kbd"></span>
Open
</span>
<span class="omni-server">{server()}</span>
</footer>
</Show>
</form>
)
}
@@ -1,4 +1,8 @@
@media (max-width: 980px) {
.kilo-console .app-header {
grid-template-columns: minmax(7rem, 10rem) minmax(0, 1fr) auto;
}
.kilo-console .config-page-header,
.kilo-console .config-toolbar,
.kilo-console .models-filter-primary,
@@ -66,6 +70,63 @@
}
}
@media (max-width: 760px) {
.kilo-console .app-header {
grid-template-columns: auto minmax(0, 1fr);
}
.kilo-console .header-title span,
.kilo-console .notification-zone {
display: none;
}
.kilo-console .omni-search {
width: 100%;
}
.kilo-console .omni-search.expanded {
top: 0.5rem;
width: min(35rem, calc(100vw - 1rem));
max-height: min(70vh, calc(100vh - 1rem));
border-radius: 0.75rem;
}
.kilo-console .omni-search.expanded .omni-search-head {
padding: 0.75rem;
}
.kilo-console .omni-search.expanded .omni-escape {
min-width: 1.25rem;
height: 1.25rem;
font-size: 0.625rem;
}
.kilo-console .omni-entry {
grid-template-columns: 4.75rem minmax(0, 1fr);
min-height: 2.5rem;
gap: 0.75rem;
padding: 0 0.75rem;
}
.kilo-console .omni-sub,
.kilo-console .omni-enter,
.kilo-console .omni-server {
display: none;
}
.kilo-console .omni-footer {
min-height: 2rem;
gap: 0.75rem;
padding: 0.375rem 0.75rem;
}
.kilo-console .omni-kbd {
min-width: 1.25rem;
height: 1.25rem;
font-size: 0.625rem;
}
}
@media (max-width: 560px) {
.kilo-console .explore-models,
.kilo-console .model-info-grid {
+255 -14
View File
@@ -30,6 +30,7 @@
}
.kilo-console .header-brand {
grid-column: 1;
display: inline-flex;
align-items: center;
min-width: 0;
@@ -86,39 +87,277 @@
}
.kilo-console .omni-search {
--omni-popover: lab(9.03835 1.15298 1.92955);
--omni-foreground: oklch(0.985 0.001 106.423);
--omni-muted: oklch(0.268 0.007 34.298);
--omni-muted-foreground: oklch(0.709 0.01 56.259);
--omni-border: oklch(1 0 0 / 10%);
--omni-input: oklch(1 0 0 / 15%);
position: relative;
grid-column: 2;
justify-self: center;
width: min(35rem, 100%);
height: 2.5rem;
min-width: 0;
overflow: visible;
border: 0;
border-radius: var(--radius-lg);
background: var(--omni-popover);
color: var(--omni-foreground);
box-shadow: 0 0 0 1px var(--omni-border);
padding: 0;
transition:
background 100ms ease,
box-shadow 100ms ease;
}
.kilo-console .omni-search.expanded {
position: fixed;
top: calc((var(--app-header-height) - 2.5rem) / 2);
left: 50%;
z-index: 80;
display: grid;
grid-template-rows: auto auto auto;
width: min(35rem, calc(100vw - 2rem));
height: auto;
max-height: min(70vh, calc(100vh - 1rem));
overflow: hidden;
border-radius: var(--radius-xl);
background: var(--omni-popover);
box-shadow:
0 1rem 3rem oklch(0.147 0.004 49.25 / 45%),
0 0 0 1px var(--omni-border);
transform: translateX(-50%);
}
.kilo-console .omni-search.expanded .omni-search-head,
.kilo-console .omni-search.expanded .omni-results,
.kilo-console .omni-search.expanded .omni-footer {
background: var(--omni-popover);
}
.kilo-console .omni-search.expanded::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background: oklch(0.147 0.004 49.25 / 72%);
backdrop-filter: blur(4px);
}
.kilo-console .omni-search-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
justify-self: center;
width: min(32rem, 100%);
height: 1.875rem;
gap: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--input-base);
padding: 0.25rem 0.5rem;
height: 100%;
gap: 0.5rem;
padding: 0 0.75rem;
}
.kilo-console .omni-search span {
.kilo-console .omni-search.expanded .omni-search-head {
grid-template-columns: auto minmax(0, 1fr) auto;
height: auto;
min-height: 0;
border-bottom: 1px solid var(--omni-border);
padding: 0.625rem 0.75rem;
}
.kilo-console .omni-escape,
.kilo-console .omni-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.25rem;
flex: 0 0 auto;
border: 0;
border-radius: var(--radius-xs);
background: var(--muted);
color: var(--muted-foreground);
background: var(--omni-muted);
color: var(--omni-muted-foreground);
font-size: 0.625rem;
font-weight: 500;
}
.kilo-console .omni-command {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 0.875rem;
height: 0.875rem;
color: var(--omni-muted-foreground);
}
.kilo-console .omni-command-icon {
width: 0.875rem;
height: 0.875rem;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.kilo-console .omni-search.expanded .omni-escape {
min-width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-xs);
font-size: 0.625rem;
}
.kilo-console .omni-escape {
display: none;
cursor: pointer;
font: inherit;
text-transform: lowercase;
}
.kilo-console .omni-search.expanded .omni-escape {
display: inline-flex;
}
.kilo-console .omni-escape:hover,
.kilo-console .omni-escape:focus-visible {
background: color-mix(in oklab, var(--omni-muted) 70%, var(--omni-foreground) 10%);
color: var(--omni-foreground);
outline: 0;
}
.kilo-console .omni-search input {
height: 1.25rem;
border: 0;
outline: 0;
background: transparent;
box-shadow: none;
color: var(--omni-foreground);
font-size: 0.875rem;
line-height: 1.75rem;
padding: 0;
}
.kilo-console .omni-search input:focus {
border-color: transparent;
outline: 0;
}
.kilo-console .omni-search input::placeholder {
color: var(--omni-muted-foreground);
}
.kilo-console .omni-search.expanded input {
font-size: 0.875rem;
}
.kilo-console .omni-results {
display: grid;
align-content: start;
min-height: 0;
overflow-y: auto;
max-height: 50vh;
padding: 0.25rem;
}
.kilo-console .omni-entry {
display: grid;
grid-template-columns: 3.5rem minmax(0, 1fr) auto auto;
align-items: center;
width: 100%;
min-height: 2rem;
gap: 0.625rem;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--omni-foreground);
cursor: pointer;
font: inherit;
padding: 0.5rem 0.625rem;
text-align: left;
}
.kilo-console .omni-entry:hover,
.kilo-console .omni-entry:focus-visible,
.kilo-console .omni-entry.active {
background: var(--omni-muted);
outline: 0;
}
.kilo-console .omni-kind {
color: var(--omni-muted-foreground);
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.kilo-console .omni-label,
.kilo-console .omni-sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .omni-label {
color: var(--omni-foreground);
font-size: 0.75rem;
line-height: 1.625;
}
.kilo-console .omni-label.mono,
.kilo-console .omni-sub,
.kilo-console .omni-server {
font-family: var(--font-family-mono, "SFMono-Regular", Consolas, monospace);
}
.kilo-console .omni-sub {
max-width: 7rem;
color: var(--omni-muted-foreground);
font-size: 0.6875rem;
}
.kilo-console .omni-enter {
color: var(--omni-muted-foreground);
font-size: 0.875rem;
}
.kilo-console .omni-empty {
color: var(--omni-muted-foreground);
font-size: 0.875rem;
padding: 2.5rem 1rem;
text-align: center;
}
.kilo-console .omni-footer {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 2rem;
border-top: 1px solid var(--omni-border);
color: var(--omni-muted-foreground);
font-size: 0.6875rem;
padding: 0.375rem 0.75rem;
}
.kilo-console .omni-help {
display: inline-flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.kilo-console .omni-kbd {
min-width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
}
.kilo-console .omni-server {
overflow: hidden;
margin-left: auto;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .notification-zone {
grid-column: 3;
display: inline-flex;
align-items: center;
justify-self: end;
@@ -167,7 +406,9 @@
border-radius: var(--radius-sm);
color: var(--muted-foreground);
text-decoration: none;
transition: background 120ms ease, color 120ms ease;
transition:
background 120ms ease,
color 120ms ease;
}
.kilo-console .rail-glyph {