mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
refactor: hot reload tui configs
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user