refactor: hot reload tui configs

This commit is contained in:
Catriel Müller
2026-05-29 11:54:34 -03:00
parent 520ee03d80
commit 0ef8e5e0a5
21 changed files with 1022 additions and 79 deletions
@@ -0,0 +1,64 @@
/**
* Locks in the reactive TUI config store used for hot reload. `useKeybind`/`useTheme` read the
* store proxy reactively, so `set()` (driven by `global.config.updated`) must propagate new
* keybinds/theme to tracked reads — otherwise the TUI would still require a restart.
*/
import { describe, expect, test } from "bun:test"
import { createEffect, createRoot } from "solid-js"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { KiloTuiConfig } from "@/kilocode/cli/cmd/tui/context/tui-config"
function cfg(input: Partial<TuiConfig.Info>): TuiConfig.Info {
return input as TuiConfig.Info
}
describe("KiloTuiConfig.makeStore", () => {
test("reactive reads update when set() reconciles a new config", () => {
const store = KiloTuiConfig.makeStore(cfg({ keybinds: { app_exit: "ctrl+c" }, theme: "kilo" }))
const exits: Array<string | undefined> = []
const themes: Array<string | undefined> = []
let dispose!: () => void
createRoot((d) => {
dispose = d
createEffect(() => exits.push(store.config.keybinds?.app_exit))
createEffect(() => themes.push(store.config.theme))
})
// Initial tracked reads.
expect(exits).toEqual(["ctrl+c"])
expect(themes).toEqual(["kilo"])
store.set(cfg({ keybinds: { app_exit: "ctrl+q", leader: "ctrl+x" }, theme: "nord" }))
// Direct store reads reflect the update synchronously.
expect(store.config.keybinds?.app_exit).toBe("ctrl+q")
expect(store.config.keybinds?.leader).toBe("ctrl+x")
expect(store.config.theme).toBe("nord")
// Tracked reactive reads re-ran with the new values (the hot-reload contract).
expect(exits).toEqual(["ctrl+c", "ctrl+q"])
expect(themes).toEqual(["kilo", "nord"])
dispose()
})
test("set() does not re-notify a tracked read when its value is unchanged", () => {
const store = KiloTuiConfig.makeStore(cfg({ keybinds: { app_exit: "ctrl+c" }, theme: "kilo" }))
const exits: Array<string | undefined> = []
let dispose!: () => void
createRoot((d) => {
dispose = d
createEffect(() => exits.push(store.config.keybinds?.app_exit))
})
// Only the theme changes; the tracked keybind stays "ctrl+c".
store.set(cfg({ keybinds: { app_exit: "ctrl+c" }, theme: "nord" }))
expect(store.config.theme).toBe("nord")
expect(exits).toEqual(["ctrl+c"])
dispose()
})
})
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../../src/server/server"
import { GlobalBus, type GlobalEvent } from "../../../src/bus/global"
import { resetDatabase } from "../../fixture/db"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
@@ -110,4 +111,33 @@ describe("agent builder routes", () => {
expect(preview.status).toBe(400)
})
// Regression: saving must dispose the instance so open TUIs hot-reload the agent list. The
// dispose is the reload trigger — the server agent cache is keyed by config
// (KiloAgent.cacheKey), not file-based `.md` agents, so without it a new agent would not
// surface until restart. The TUI reacts to `server.instance.disposed` by re-bootstrapping and
// refetching `app.agents`.
test("disposes the instance after save so open TUIs hot-reload agents", async () => {
await using tmp = await tmpdir()
const events: GlobalEvent[] = []
const handler = (event: GlobalEvent) => events.push(event)
GlobalBus.on("event", handler)
try {
const saved = await req(tmp.path, "/agent-builder/hotreload", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", prompt: "Hot reload me." }),
})
expect(saved.status).toBe(200)
} finally {
GlobalBus.off("event", handler)
}
expect(events.some((event) => event.payload?.type === "server.instance.disposed")).toBe(true)
// After the dispose, the next request rebuilds the instance and re-reads the agent files.
const agents = (await (await req(tmp.path, "/agent")).json()) as Agent[]
expect(agents.some((item) => item.name === "hotreload")).toBe(true)
})
})
@@ -3,6 +3,7 @@ import path from "path"
import fs from "fs/promises"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../../src/server/server"
import { GlobalBus, type GlobalEvent } from "../../../src/bus/global"
import { resetDatabase } from "../../fixture/db"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
@@ -42,6 +43,28 @@ describe("TUI config routes", () => {
expect(body.plugin_origins).toBeUndefined()
})
test("lists valid TUI keybinds", async () => {
await using tmp = await tmpdir()
const response = await Server.Legacy().app.request("/tui/keybinds", {
headers: { "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = (await response.json()) as {
keybinds: Array<{ id: string; default: string; description: string }>
}
const ids = new Set(body.keybinds.map((item) => item.id))
const exit = body.keybinds.find((item) => item.id === "app_exit")
const suspend = body.keybinds.find((item) => item.id === "terminal_suspend")
expect(ids.has("leader")).toBe(true)
expect(ids.has("input_submit")).toBe(true)
expect(exit?.default).toBe("ctrl+c,ctrl+d,<leader>q")
expect(exit?.description).toBe("Exit the application")
expect(suspend?.default).toBe(process.platform === "win32" ? "none" : "ctrl+z")
})
test("patches project TUI config", async () => {
await using tmp = await tmpdir()
@@ -61,4 +84,27 @@ describe("TUI config routes", () => {
const saved = await Bun.file(path.join(tmp.path, ".kilo", "tui.json")).json()
expect(saved).toEqual({ theme: "nord" })
})
test("emits global.config.updated when patching TUI config so open TUIs hot-reload", async () => {
await using tmp = await tmpdir()
const events: GlobalEvent[] = []
const handler = (event: GlobalEvent) => events.push(event)
GlobalBus.on("event", handler)
try {
const response = await Server.Legacy().app.request("/tui/config?scope=project", {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-kilo-directory": tmp.path,
},
body: JSON.stringify({ keybinds: { app_exit: "ctrl+q" } }),
})
expect(response.status).toBe(200)
} finally {
GlobalBus.off("event", handler)
}
expect(events.some((event) => event.payload?.type === "global.config.updated")).toBe(true)
})
})