diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 31e26fc18b..a5420b6e4e 100644 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -7,6 +7,7 @@ "dev": "vite --host 127.0.0.1 --port 3017", "build": "vite build", "preview": "vite preview --host 127.0.0.1 --port 3018", + "test": "bun test src", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/packages/kilo-console/src/client.test.ts b/packages/kilo-console/src/client.test.ts new file mode 100644 index 0000000000..5932d3ad98 --- /dev/null +++ b/packages/kilo-console/src/client.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" + +function setup() { + const calls: Array<{ url: string; method: string; body: unknown }> = [] + const win = { + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init) + calls.push({ url: req.url, method: req.method, body: await req.json() }) + return new Response(JSON.stringify({ permission: { edit: { "*": "allow" } } }), { + headers: { "content-type": "application/json" }, + }) + }, + } + + Object.defineProperty(globalThis, "window", { value: win, configurable: true }) + return calls +} + +test("config writes include the selected directory", async () => { + const calls = setup() + const client = await import("./client") + const query = { url: "http://kilo:secret@127.0.0.1:4097", dir: "/tmp/project", scope: "project" as const } + + await client.saveConfig(query, { permission: { edit: { "*": "allow" } } }) + await client.unsetConfig(query, [["permission", "edit"]]) + + expect(calls).toHaveLength(2) + + const save = calls[0] + const unset = calls[1] + expect(save.method).toBe("PATCH") + expect(new URL(save.url).searchParams.get("directory")).toBe("/tmp/project") + expect(save.body).toEqual({ scope: "project", set: { permission: { edit: { "*": "allow" } } } }) + + expect(unset.method).toBe("PATCH") + expect(new URL(unset.url).searchParams.get("directory")).toBe("/tmp/project") + expect(unset.body).toEqual({ scope: "project", unset: [["permission", "edit"]] }) +}) diff --git a/packages/kilo-console/src/client.ts b/packages/kilo-console/src/client.ts index dd8603ccdf..b88b96d718 100644 --- a/packages/kilo-console/src/client.ts +++ b/packages/kilo-console/src/client.ts @@ -108,10 +108,16 @@ type Result = { error?: unknown } +type Hit = { + url: string + count: number + time: number +} + const ports = Array.from({ length: 20 }, (_, index) => 4097 + index) const day = 24 * 60 * 60 * 1000 -const hidden = new Set(["global"]) const key = "kilo.config.server" +const discovery: { run?: Promise } = {} const fetcher = window.fetch.bind(window) as typeof fetch @@ -211,6 +217,11 @@ function inside(root: string, input: string) { return dir.startsWith(`${base}/`) } +function visible(item: ProjectItem) { + if (item.id !== "global") return true + return norm(item.worktree) !== "/" +} + function score(item: ProjectItem, dir: string) { return [item.worktree, ...item.sandboxes].reduce((best, root) => { if (!inside(root, dir)) return best @@ -241,6 +252,44 @@ async function probe(url: string) { .finally(() => window.clearTimeout(timer)) } +function projects(input: unknown) { + if (!Array.isArray(input)) return [] + return input.filter((item): item is ProjectItem => { + if (!item || typeof item !== "object") return false + const row = item as Partial + return typeof row.id === "string" && typeof row.worktree === "string" && typeof row.time?.updated === "number" + }) +} + +function newest(items: ProjectItem[]) { + return items.reduce((best, item) => Math.max(best, item.time.updated), 0) +} + +async function inspect(url: string): Promise { + const hit = await probe(url) + if (!hit) return undefined + + const ctl = new AbortController() + const timer = window.setTimeout(() => ctl.abort(), 400) + const info = server(url) + return await fetcher(`${info.url}/project`, { headers: { Authorization: `Basic ${info.token}` }, signal: ctl.signal }) + .then(async (res) => { + if (!res.ok) return { url, count: 0, time: 0 } + const rows = projects(await res.json()).filter(visible) + return { url, count: rows.length, time: newest(rows) } + }) + .catch(() => ({ url, count: 0, time: 0 })) + .finally(() => window.clearTimeout(timer)) +} + +function port(input: string) { + return Number(new URL(input).port) || 0 +} + +function local(input: string) { + return new URL(input).hostname === "127.0.0.1" ? 1 : 0 +} + export function loadCached() { return window.localStorage.getItem(key) ?? "" } @@ -257,17 +306,32 @@ export async function healthy(url: string) { return (await probe(url)) !== undefined } -export async function discover() { +async function scan() { const urls = ports.flatMap((port) => [`http://127.0.0.1:${port}`, `http://localhost:${port}`]) - const hit = await Promise.any( - urls.map((url) => - probe(url).then((value) => { - if (value) return value - throw new Error(`${url} unavailable`) - }), - ), - ).catch(() => undefined) - return hit + const hits = (await Promise.all(urls.map(inspect))).filter((item): item is Hit => item !== undefined) + return hits.toSorted( + (a, b) => b.count - a.count || b.time - a.time || port(b.url) - port(a.url) || local(b.url) - local(a.url), + )[0]?.url +} + +export async function discover() { + if (discovery.run) return await discovery.run + const run = scan().finally(() => { + if (discovery.run === run) discovery.run = undefined + }) + discovery.run = run + return await run +} + +export async function resolveServer() { + const hit = await discover() + if (hit) return hit + + const cached = loadCached() + if (!cached) return undefined + if (await healthy(cached)) return cached + forgetCached() + return undefined } export async function load(input: Query): Promise { @@ -316,7 +380,7 @@ export async function loadProjects(input: ProjectQuery): Promise export async function loadVisibleProjects(input: ProjectQuery): Promise { const items = await loadProjects(input) - return items.filter((item) => !hidden.has(item.id)) + return items.filter(visible) } export async function loadRecentProjects(input: ProjectQuery): Promise { @@ -414,10 +478,18 @@ export async function loadProjectTerminals(input: ProjectQuery, dir: string): Pr }) } +function opened(items: ProjectPtyInfo[]) { + return new Set(items.flatMap((item) => (item.sessionID ? [item.sessionID] : []))) +} + function pending(items: Array) { return new Set(items.map((item) => item.sessionID)) } +function requested(items: Array, open: Set) { + return items.some((item) => open.has(item.sessionID)) +} + export type ProjectLiveStatus = { busy: boolean attention: boolean @@ -425,16 +497,24 @@ export type ProjectLiveStatus = { export async function loadProjectLiveStatus(input: ProjectQuery, dir: string): Promise { const sdk = client({ url: input.url, dir }) - const [status, permissions, questions] = await Promise.all([ + const [status, terminals, permissions, questions] = await Promise.all([ maybe("Session status", sdk.session.status({ directory: dir })), + maybe("Terminals", sdk.pty.list({ directory: dir })), maybe("Pending permissions", sdk.permission.list({ directory: dir })), maybe("Pending questions", sdk.question.list({ directory: dir })), ]) - const busy = Object.values(status ?? {}).some((s) => s.type !== "idle") - const attention = (permissions ?? []).length > 0 || (questions ?? []).length > 0 + const open = opened(terminals ?? []) + const busy = Object.entries(status ?? {}).some(([id, s]) => open.has(id) && s.type !== "idle") + const attention = requested(permissions ?? [], open) || requested(questions ?? [], open) return { busy, attention } } +export async function loadProjectOpenSessions(input: ProjectQuery, dir: string) { + const sdk = client({ url: input.url, dir }) + const terminals = await maybe("Terminals", sdk.pty.list({ directory: dir })) + return opened(terminals ?? []) +} + function attention(id: string, permissions: Set, questions: Set) { if (permissions.has(id)) return "permission" if (questions.has(id)) return "question" @@ -504,13 +584,13 @@ export function ptyWsUrl(input: Query, pty: string, cursor = 0) { export async function saveConfig(input: Query, patch: Partial) { const sdk = client(input) - const result = await sdk.config.overlayUpdate({ scope: input.scope, set: patch }) + const result = await sdk.config.overlayUpdate({ directory: value(input.dir), scope: input.scope, set: patch }) return demand("Update config", result) } export async function unsetConfig(input: Query, unset: ConfigUnset) { const sdk = client(input) - const result = await sdk.config.overlayUpdate({ scope: input.scope, unset }) + const result = await sdk.config.overlayUpdate({ directory: value(input.dir), scope: input.scope, unset }) return demand("Update config", result) } diff --git a/packages/kilo-console/src/components/app-header/OmniSearch.tsx b/packages/kilo-console/src/components/app-header/OmniSearch.tsx index 164da2c23c..e64086da3b 100644 --- a/packages/kilo-console/src/components/app-header/OmniSearch.tsx +++ b/packages/kilo-console/src/components/app-header/OmniSearch.tsx @@ -3,9 +3,9 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup, import { discover, forgetCached, - healthy, loadCached, loadVisibleProjects, + resolveServer, saveCached, type ProjectItem, type ProjectQuery, @@ -200,18 +200,11 @@ export function OmniSearch() { 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) - }) + void resolveServer().then((value) => { + if (!value) return + saveCached(value) + setUrl(value) + }) }) createEffect(() => { diff --git a/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx b/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx index 42a359772e..11182374a4 100644 --- a/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx +++ b/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx @@ -3,10 +3,11 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup import { discover, forgetCached, - healthy, loadRecentProjects, loadProjectLiveStatus, + loadProjectOpenSessions, loadCached, + resolveServer, saveCached, subscribeProjectEvents, type ProjectConsoleEvent, @@ -25,6 +26,7 @@ import { markAttention, clearAttention, markUnread, + clearUnread, clearBusy, markBusy, type GlobalEvent, @@ -120,6 +122,7 @@ export function AppSidebar(props: Props) { return { url: target, dir: "" } }) const [items, { refetch }] = createResource(query, loadRecentProjects) + const checks = new Map() // project currently rendered by ProjectConsoleRoute — it owns unread tracking for its terminals const activeProject = createMemo(() => { @@ -148,6 +151,32 @@ export function AppSidebar(props: Props) { }, 150) } + function bump(type: string, project: string, session: string) { + const key = `${type}\0${project}\0${session}` + const rev = (checks.get(key) ?? 0) + 1 + checks.set(key, rev) + return { key, rev } + } + + function gated( + type: string, + input: ProjectQuery, + project: string, + dir: string, + session: string, + run: () => void, + clear: () => void, + ) { + const check = bump(type, project, session) + void loadProjectOpenSessions(input, dir) + .then((open) => { + if (checks.get(check.key) !== check.rev) return + if (open.has(session)) run() + else clear() + }) + .catch((err) => console.warn("Project open sessions:", err)) + } + // ── server URL tracking ────────────────────────────────────────────────────── createEffect(() => { @@ -157,18 +186,11 @@ export function AppSidebar(props: Props) { 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) - }) + void resolveServer().then((value) => { + if (!value) return + saveCached(value) + setUrl(value) + }) }) createEffect(() => { @@ -216,7 +238,7 @@ export function AppSidebar(props: Props) { if (s.attention) markAttention(item.id, "__hydrated__") else clearAttention(item.id, "__hydrated__") }) - .catch(() => {}) + .catch((err) => console.warn("Project live status:", err)) } }) @@ -240,24 +262,34 @@ export function AppSidebar(props: Props) { const proj = projectForDir(list, ge.directory) if (!proj) return - const sid = eventSessionId(ge) ?? "__unknown__" + const sid = eventSessionId(ge) // session.turn.close: error | completed if (t === "session.turn.close") { const payload = (ge.payload as Record).properties as | { reason?: string } | undefined + if (!sid) return if (payload?.reason === "error") { - markError(proj.id, sid) - } else { - clearError(proj.id, sid) - if (payload?.reason === "completed") { - // When the user is on this project, ProjectConsoleRoute owns unread tracking - // with per-terminal awareness (it knows which terminal is active). - // AppSidebar only handles cross-project: projects the user is NOT currently viewing. - if (proj.id !== activeProject()) { - markUnread(proj.id, sid) - } + gated("error", current, proj.id, ge.directory, sid, () => markError(proj.id, sid), () => clearError(proj.id, sid)) + return + } + bump("error", proj.id, sid) + clearError(proj.id, sid) + if (payload?.reason === "completed") { + // When the user is on this project, ProjectConsoleRoute owns unread tracking + // with per-terminal awareness (it knows which terminal is active). + // AppSidebar only handles cross-project: projects the user is NOT currently viewing. + if (proj.id !== activeProject()) { + gated( + "unread", + current, + proj.id, + ge.directory, + sid, + () => markUnread(proj.id, sid), + () => clearUnread(proj.id, sid), + ) } } return @@ -265,7 +297,16 @@ export function AppSidebar(props: Props) { // permission / question → attention if (t === "permission.asked" || t === "question.asked") { - markAttention(proj.id, sid) + if (!sid) return + gated( + "attention", + current, + proj.id, + ge.directory, + sid, + () => markAttention(proj.id, sid), + () => clearAttention(proj.id, sid), + ) return } if ( @@ -274,6 +315,8 @@ export function AppSidebar(props: Props) { t === "question.replied" || t === "question.rejected" ) { + if (!sid) return + bump("attention", proj.id, sid) clearAttention(proj.id, sid) return } @@ -284,9 +327,12 @@ export function AppSidebar(props: Props) { | { status?: { type?: string } } | undefined const stype = sstatus?.status?.type + if (!sid) return if (stype === "busy" || stype === "retry") { - markBusy(proj.id, sid) + gated("busy", current, proj.id, ge.directory, sid, () => markBusy(proj.id, sid), () => clearBusy(proj.id, sid)) } else if (stype === "idle") { + bump("busy", proj.id, sid) + bump("error", proj.id, sid) clearBusy(proj.id, sid) clearError(proj.id, sid) } @@ -299,6 +345,7 @@ export function AppSidebar(props: Props) { onCleanup(() => { if (timers.refetch) window.clearTimeout(timers.refetch) + checks.clear() }) return ( diff --git a/packages/kilo-console/src/context/ConfigProvider.tsx b/packages/kilo-console/src/context/ConfigProvider.tsx index a7744bce79..7005be4e56 100644 --- a/packages/kilo-console/src/context/ConfigProvider.tsx +++ b/packages/kilo-console/src/context/ConfigProvider.tsx @@ -3,10 +3,10 @@ import type { JSX } from "solid-js" import { discover, forgetCached, - healthy, load, loadCached, loadProjects, + resolveServer, saveCached, saveConfig, saveTui, @@ -86,18 +86,11 @@ export function ConfigProvider(props: { children?: JSX.Element }) { 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) - }) + void resolveServer().then((value) => { + if (!value) return + saveCached(value) + setUrl(value) + }) }) createEffect(() => { diff --git a/packages/kilo-console/src/routes/config/PermissionsRoute.tsx b/packages/kilo-console/src/routes/config/PermissionsRoute.tsx index aa1b1a7e84..d38d114cb9 100644 --- a/packages/kilo-console/src/routes/config/PermissionsRoute.tsx +++ b/packages/kilo-console/src/routes/config/PermissionsRoute.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@kilocode/kilo-web-ui/icon-button" import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag" import { toolName } from "../../shared/utils" import { ConfigPage, SourceBadge } from "./ConfigPage" -import { actions, defs, usePermissionSettings, type PermissionAction, type PermissionRule } from "./state/permissions" +import { actions, usePermissionSettings, type PermissionAction, type PermissionRule } from "./state/permissions" function tone(action: PermissionAction) { if (action === "allow") return "success" @@ -26,6 +26,56 @@ function RuleMeta(props: { rule: PermissionRule }) { ) } +function ActionSelect(props: { + label: string + value: PermissionAction + disabled?: boolean + onSelect: (value: PermissionAction) => void +}) { + const current = () => label(props.value) + + function choose(value: PermissionAction, event: MouseEvent & { currentTarget: HTMLButtonElement }) { + if (props.disabled) return + props.onSelect(value) + event.currentTarget.closest("details")?.removeAttribute("open") + } + + function toggle(event: Event & { currentTarget: HTMLDetailsElement }) { + if (props.disabled) { + event.currentTarget.removeAttribute("open") + return + } + if (!event.currentTarget.open) return + event.currentTarget.parentElement?.querySelectorAll(".models-select[open]").forEach((node) => { + if (node !== event.currentTarget) node.removeAttribute("open") + }) + } + + return ( +
+ + {current()} + +
+ + {(item) => ( + + )} + +
+
+ ) +} + export function PermissionsRoute() { const state = usePermissionSettings() @@ -34,26 +84,33 @@ export function PermissionsRoute() { title={ Permissions - {state.rules().length} + {state.groups().length + state.settings().length} } description="Control what tools agents can use by default and add pattern-specific allow, ask, or deny rules." - actions={ - - } >
{(group) => (
- {group.rules.length}} - description={`${group.id} · ${group.description}`} - > - {group.title} - +
+
+

{group.title}

+ {group.id} +

{group.description}

+
+
+ {group.rules.length} + +
+
} actions={ - + onSelect={(action) => state.setDefault(group.id, action)} + /> } /> @@ -85,15 +139,13 @@ export function PermissionsRoute() { subtitle={`${group.title} ${group.noun} rule`} status={} actions={ - - state.revert(rule)} - /> - + state.remove(rule)} + /> } /> )} @@ -104,6 +156,35 @@ export function PermissionsRoute() { )} +
+ {state.settings().length}} description="Default methods for additional built-in tool permissions."> + Tool Defaults + +
+ + {(item) => ( + + +
+ } + actions={ + state.setDefault(item.id, action)} + /> + } + /> + )} + +
+ +
{state.other().length}} description="Additional tool permission rules from config."> @@ -122,15 +203,13 @@ export function PermissionsRoute() { } status={} actions={ - - state.revert(rule)} - /> - + state.remove(rule)} + /> } /> )} @@ -145,8 +224,8 @@ export function PermissionsRoute() {