diff --git a/packages/kilo-console/src/client.ts b/packages/kilo-console/src/client.ts index b88b96d718b..18bb72b1ec9 100644 --- a/packages/kilo-console/src/client.ts +++ b/packages/kilo-console/src/client.ts @@ -24,6 +24,7 @@ import type { ToolIdsResponse, ToolListResponse, TuiConfigGetResponse, + TuiKeybindListResponse, VcsInfo, Worktree, WorktreeDiffItem, @@ -75,6 +76,7 @@ export type Snapshot = { providers: ProviderListResponse authMethods: ProviderAuthResponse tui: TuiConfigGetResponse + keybinds: TuiKeybindListResponse tools: ToolIdsResponse toolDetails: ToolListResponse mcp: McpStatusResponse @@ -205,6 +207,26 @@ function model(input: unknown) { return { provider: input.slice(0, index), model: input.slice(index + 1) } } +function title(input: string) { + return input + .split("_") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") +} + +function fallback(input: TuiConfigGetResponse): TuiKeybindListResponse { + return { + keybinds: Object.entries(input.keybinds ?? {}).map(([id, binding]) => ({ + id, + label: title(id), + group: "Configured", + default: binding ?? "none", + description: "Configured TUI keybind", + })), + } +} + function norm(input: string) { const text = input.replace(/\\/g, "/").replace(/\/+$/, "") return text || "/" @@ -336,22 +358,25 @@ export async function resolveServer() { export async function load(input: Query): Promise { const sdk = client(input) - const [health, overlay, modelState, providers, authMethods, tui, tools, mcp, lsp, formatter, agents] = await Promise.all([ - sdk.global.health(), - sdk.config.overlay({ scope: input.scope }), - sdk.config.modelState(), - sdk.provider.list(), - sdk.provider.auth(), - sdk.tui.config.get(), - sdk.tool.ids(), - sdk.mcp.status(), - sdk.lsp.status(), - sdk.formatter.status(), - sdk.app.agents(), - ]) + const [health, overlay, modelState, providers, authMethods, tui, keybinds, tools, mcp, lsp, formatter, agents] = + await Promise.all([ + sdk.global.health(), + sdk.config.overlay({ scope: input.scope }), + sdk.config.modelState(), + sdk.provider.list(), + sdk.provider.auth(), + sdk.tui.config.get(), + maybe("TUI keybinds", sdk.tui.keybind.list()), + sdk.tool.ids(), + sdk.mcp.status(), + sdk.lsp.status(), + sdk.formatter.status(), + sdk.app.agents(), + ]) const resolved = demand("Config overlay", overlay) const ref = model(resolved.effective.model) const info = ref ? await maybe("Tool metadata", sdk.tool.list(ref)) : undefined + const cfg = demand("TUI config", tui) return { health: demand("Health", health), @@ -361,7 +386,8 @@ export async function load(input: Query): Promise { modelState: demand("Model state", modelState), providers: demand("Providers", providers), authMethods: demand("Provider auth methods", authMethods), - tui: demand("TUI config", tui), + tui: cfg, + keybinds: keybinds ?? fallback(cfg), tools: demand("Tools", tools), toolDetails: info ?? [], mcp: demand("MCP status", mcp), diff --git a/packages/kilo-console/src/routes/config/KeybindsRoute.tsx b/packages/kilo-console/src/routes/config/KeybindsRoute.tsx index c06569513c5..52b42145294 100644 --- a/packages/kilo-console/src/routes/config/KeybindsRoute.tsx +++ b/packages/kilo-console/src/routes/config/KeybindsRoute.tsx @@ -1,54 +1,152 @@ import { For, Show } from "solid-js" import { Button } from "@kilocode/kilo-web-ui/button" -import { ConfigPage, ConfigToolbar } from "./ConfigPage" +import { ConfigRow, SectionTitle } from "@kilocode/kilo-web-ui/console" +import { IconButton } from "@kilocode/kilo-web-ui/icon-button" +import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag" +import { SearchField } from "../../components/SearchField" +import { ConfigPage, SourceBadge } from "./ConfigPage" import { useKeybindSettings } from "./state/keybinds" export function KeybindsRoute() { const state = useKeybindSettings() return ( - - - - - - + + Keybinds + {state.keybinds().length} + + } + description="Review and override every terminal UI keybind command exposed by the CLI. Use none to disable a binding." + > + - - {([name]) => - - - -

Duplicate binding with: {state.conflicts().join(", ")}

-
- -
- - {([name, binding]) => ( -
- {name} - {binding} -
- )} -
+
+ No keybinds match this filter.

}> + + {(group) => ( +
+ {group.rows.length}}>{group.name} +
+ + {(row) => ( + + {row.item.id} + {row.item.description} + + } + status={ +
+ + {row.binding} + + + + Conflict + +
+ } + actions={ + state.open(row.item)} + /> + } + /> + )} +
+
+
+ )} +
+
+ + + {(item) => ( + <> +
+ + + )} + ) } diff --git a/packages/kilo-console/src/routes/config/state/keybinds.ts b/packages/kilo-console/src/routes/config/state/keybinds.ts index 9cacca01a2f..aeb8349c759 100644 --- a/packages/kilo-console/src/routes/config/state/keybinds.ts +++ b/packages/kilo-console/src/routes/config/state/keybinds.ts @@ -1,32 +1,194 @@ -import { createMemo, createSignal } from "solid-js" -import type { TuiPatch } from "../../../client" +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import type { Snapshot, TuiPatch } from "../../../client" import { useConfig } from "../../../context/config" -import { clean, dupBindings } from "../../../shared/utils" +import { clean, csv } from "../../../shared/utils" + +type Item = Snapshot["keybinds"]["keybinds"][number] + +type Row = { + item: Item + binding: string + source: "default" | "global" | "project" + conflicts: string[] +} + +const keys = new Set(["alt", "control", "meta", "shift"]) + +function norm(input: string) { + return input.trim().toLowerCase().replace(/\s+/g, "") +} + +function tokens(input: string) { + if (norm(input) === "none") return [] + return csv(input).map(norm) +} + +function key(input: string) { + const lower = input.toLowerCase() + if (lower === " ") return "space" + if (lower === "arrowleft") return "left" + if (lower === "arrowright") return "right" + if (lower === "arrowup") return "up" + if (lower === "arrowdown") return "down" + if (lower === "escape") return "escape" + if (lower === "enter") return "return" + if (lower === "pageup") return "pageup" + if (lower === "pagedown") return "pagedown" + if (lower === "backspace") return "backspace" + if (lower === "delete") return "delete" + if (lower === "home") return "home" + if (lower === "end") return "end" + if (lower === "tab") return "tab" + if (lower === "dead" || lower === "unidentified") return "" + return lower +} + +function combo(event: KeyboardEvent) { + const name = key(event.key) + if (!name || keys.has(name)) return "" + return [event.ctrlKey && "ctrl", event.altKey && "alt", event.metaKey && "super", event.shiftKey && "shift", name] + .filter(Boolean) + .join("+") +} export function useKeybindSettings() { const ctx = useConfig() const snap = () => ctx.data() - const [key, setKey] = createSignal("leader") + const [mode, setMode] = createSignal<"closed" | "edit">("closed") + const [search, setSearch] = createSignal("") + const [capture, setCapture] = createSignal(false) + const [keybind, setKeybind] = createSignal("") const [binding, setBinding] = createSignal("") - const keybinds = createMemo(() => Object.entries(snap()?.tui.keybinds ?? {}).slice(0, 24)) - const conflicts = createMemo(() => { - const data = snap() - if (!data) return [] - return dupBindings(data, key(), binding()) + const keybinds = createMemo(() => snap()?.keybinds.keybinds ?? []) + const ids = createMemo(() => new Set(keybinds().map((item) => item.id))) + + function current(item: Item) { + return snap()?.tui.keybinds?.[item.id] ?? item.default + } + + function duplicate(item: Item, value: string) { + const set = new Set(tokens(value)) + if (!set.size) return [] + return keybinds() + .filter((other) => other.id !== item.id && tokens(current(other)).some((token) => set.has(token))) + .map((other) => other.id) + } + + const rows = createMemo(() => + keybinds().map((item) => { + const value = current(item) + return { + item, + binding: value, + source: value === item.default ? "default" : (ctx.query()?.scope ?? "project"), + conflicts: value === item.default ? [] : duplicate(item, value), + } + }), + ) + + const visible = createMemo(() => { + const q = search().trim().toLowerCase() + if (!q) return rows() + return rows().filter((row) => + `${row.item.label} ${row.item.id} ${row.item.group} ${row.item.description} ${row.binding} ${row.item.default}` + .toLowerCase() + .includes(q), + ) }) + const groups = createMemo(() => + Array.from( + visible().reduce((map, row) => { + const group = map.get(row.item.group) ?? [] + group.push(row) + map.set(row.item.group, group) + return map + }, new Map()), + ([name, rows]) => ({ name, rows }), + ), + ) + + const selected = createMemo(() => keybinds().find((item) => item.id === keybind())) + const conflicts = createMemo(() => { + const item = selected() + if (!item) return [] + if (clean(binding()) === item.default) return [] + return duplicate(item, binding()) + }) + + const defaulted = createMemo(() => selected()?.default === clean(binding())) + + createEffect(() => { + if (!capture()) return + const handler = (event: KeyboardEvent) => { + event.preventDefault() + event.stopImmediatePropagation() + const value = combo(event) + if (!value) return + setBinding(value) + setCapture(false) + } + window.addEventListener("keydown", handler, true) + onCleanup(() => window.removeEventListener("keydown", handler, true)) + }) + + function open(item: Item) { + setKeybind(item.id) + setBinding(current(item)) + setCapture(false) + setMode("edit") + } + + function close() { + setCapture(false) + setMode("closed") + } + + function reset() { + const item = selected() + if (!item) return + setBinding(item.default) + } + + function none() { + setBinding("none") + } + function save() { - const data = snap() - const name = clean(key()) + const name = clean(keybind()) const value = clean(binding()) - if (!data || !name || !value) { + if (!snap() || !name || !value) { ctx.fail("Enter a TUI keybind name and binding before saving.") return } - const next: NonNullable = { ...data.tui.keybinds } - Object.assign(next, { [name]: value }) - ctx.tui({ keybinds: next }) + if (!ids().has(name)) { + ctx.fail(`Unknown TUI keybind: ${name}`) + return + } + ctx.tui({ keybinds: { [name]: value } as NonNullable }) + close() } - return { ctx, key, setKey, binding, setBinding, keybinds, conflicts, save } + return { + ctx, + mode, + close, + open, + search, + setSearch, + capture, + setCapture, + keybinds, + rows, + visible, + groups, + selected, + binding, + setBinding, + conflicts, + defaulted, + reset, + none, + save, + } } diff --git a/packages/kilo-console/src/styles.css b/packages/kilo-console/src/styles.css index c11445e17e7..cb98b606cfb 100644 --- a/packages/kilo-console/src/styles.css +++ b/packages/kilo-console/src/styles.css @@ -4,6 +4,7 @@ @import "./styles/overview.css"; @import "./styles/providers.css"; @import "./styles/permissions.css"; +@import "./styles/keybinds.css"; @import "./styles/models.css"; @import "./styles/agents-tools.css"; @import "./styles/projects.css"; diff --git a/packages/kilo-console/src/styles/keybinds.css b/packages/kilo-console/src/styles/keybinds.css new file mode 100644 index 00000000000..3035a90ec87 --- /dev/null +++ b/packages/kilo-console/src/styles/keybinds.css @@ -0,0 +1,165 @@ +.kilo-console .keybinds, +.kilo-console .keybind-group, +.kilo-console .keybind-rows { + display: grid; + gap: 0.5rem; +} + +.kilo-console .keybind-group [data-component="section-title"] { + margin: 0.75rem 0 0.25rem; +} + +.kilo-console .keybinds [data-component="config-row"] { + align-items: flex-start; +} + +.kilo-console .keybinds [data-slot="config-row-subtitle"] { + overflow: visible; + white-space: normal; +} + +.kilo-console .keybind-subtitle { + display: grid; + gap: 0.125rem; + min-width: 0; +} + +.kilo-console .keybind-id, +.kilo-console .keybind-binding, +.kilo-console .keybind-command-card code, +.kilo-console .keybind-default-card code { + font-family: var(--font-family-mono); +} + +.kilo-console .keybind-id { + overflow: hidden; + color: var(--muted-foreground); + text-overflow: ellipsis; + white-space: nowrap; +} + +.kilo-console .keybind-description { + overflow-wrap: anywhere; + white-space: normal; +} + +.kilo-console .keybind-meta { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} + +.kilo-console .keybind-binding { + display: inline-flex; + align-items: center; + max-width: 18rem; + min-height: 1.5rem; + overflow: hidden; + border: 1px solid var(--border-weak-base); + border-radius: var(--radius-md); + background: var(--surface-base); + color: var(--foreground); + font-size: 0.6875rem; + line-height: 1rem; + padding: 0.1875rem 0.5rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kilo-console .keybind-binding[data-empty="true"] { + color: var(--muted-foreground); +} + +.kilo-console .keybind-drawer { + width: min(38rem, calc(100vw - var(--app-rail-width))); +} + +.kilo-console .keybind-form { + grid-template-columns: minmax(0, 1fr); + align-content: start; + padding: 1rem; +} + +.kilo-console .keybind-form .wide { + grid-column: 1; +} + +.kilo-console .keybind-command-card, +.kilo-console .keybind-default-card { + display: grid; + gap: 0.35rem; + border: 1px solid var(--border-weak-base); + border-radius: var(--radius-lg); + background: var(--card); + padding: 0.75rem; +} + +.kilo-console .keybind-command-card span, +.kilo-console .keybind-default-card span { + color: var(--muted-foreground); + font-size: 0.6875rem; + line-height: 1.35; +} + +.kilo-console .keybind-command-card p { + margin: 0; + color: var(--foreground); + font-size: 0.75rem; + line-height: 1.45; +} + +.kilo-console .keybind-command-card code, +.kilo-console .keybind-default-card code { + overflow-wrap: anywhere; + color: var(--foreground); + font-size: 0.75rem; + line-height: 1.45; +} + +.kilo-console .keybind-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.kilo-console .keybind-input-row input, +.kilo-console .keybind-input-row [data-component="button"] { + height: 2.35rem; + min-height: 2.35rem; +} + +.kilo-console .keybind-input-row [data-component="button"] { + white-space: nowrap; +} + +.kilo-console .keybind-help, +.kilo-console .keybind-warning { + margin: 0; + font-size: 0.75rem; + line-height: 1.45; +} + +.kilo-console .keybind-help { + color: var(--muted-foreground); +} + +.kilo-console .keybind-warning { + border: 1px solid color-mix(in srgb, var(--warning) 45%, var(--border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--warning) 12%, var(--card)); + color: var(--foreground); + padding: 0.75rem; +} + +.kilo-console .keybind-footer { + flex-wrap: wrap; +} + +@media (max-width: 760px) { + .kilo-console .keybind-input-row { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a9e04cd57e7..8230486309c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -321,6 +321,7 @@ function App(props: { onSnapshot?: () => Promise }) { ) KiloApp.useSessionEffects({ route, sdk, sync }) // kilocode_change + KiloApp.useTuiConfigHotReload() // kilocode_change - hot reload TUI keybinds/theme/ui settings // Update terminal window title based on current route and session createEffect(() => { diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx index 05fdd025c7a..32439dcf7ef 100644 --- a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx @@ -1,9 +1,6 @@ -import { TuiConfig } from "@/cli/cmd/tui/config/tui" -import { createSimpleContext } from "./helper" +// kilocode_change start - reactive TUI config provider enables hot reload (impl in kilocode mirror) +import { KiloTuiConfig } from "@/kilocode/cli/cmd/tui/context/tui-config" -export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ - name: "TuiConfig", - init: (props: { config: TuiConfig.Info }) => { - return props.config - }, -}) +export const useTuiConfig = KiloTuiConfig.use +export const TuiConfigProvider = KiloTuiConfig.Provider +// kilocode_change end diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx index 245aac3b885..69ba599ad73 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/app.tsx @@ -27,6 +27,10 @@ import { DialogProcessList } from "@/kilocode/cli/cmd/tui/component/dialog-proce // Re-export so upstream can render the route without importing directly export { KiloClawView } from "@/kilocode/claw/view" +// Hot reload TUI-local settings (keybinds/theme/ui) when changed from the Kilo Console. +// Called from the App body (below SDKProvider and the TuiConfig provider). +export { useTuiConfigHotReload } from "@/kilocode/cli/cmd/tui/context/tui-config-hot-reload" + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config-hot-reload.ts b/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config-hot-reload.ts new file mode 100644 index 00000000000..e893676bb17 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config-hot-reload.ts @@ -0,0 +1,50 @@ +// kilocode_change - new file +/** + * Hot-reload wiring for the reactive TUI config store. + * + * On a `global.config.updated` event we refetch the effective TUI config from the server + * (`sdk.tui.config.get`) and reconcile it into the store via `KiloTuiConfig.useSet`. The + * `useKeybind`/`useTheme` consumers read the store reactively, so new values take effect on the + * next keypress / render. + * + * Kept separate from `tui-config.tsx` so the store factory has no SDK/event imports. + */ +import { onCleanup, onMount } from "solid-js" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { useSDK } from "@/cli/cmd/tui/context/sdk" +import { useEvent } from "@/cli/cmd/tui/context/event" +import { KiloTuiConfig } from "./tui-config" + +/** + * Subscribe to config-updated events and refetch the effective TUI config. Must be called + * inside the App body (below SDKProvider and the TuiConfig provider). + */ +export function useTuiConfigHotReload() { + const set = KiloTuiConfig.useSet() + const sdk = useSDK() + const event = useEvent() + + const state = { pending: false, again: false } + async function reload() { + if (state.pending) { + state.again = true + return + } + state.pending = true + const result = await sdk.client.tui.config.get().catch(() => undefined) + state.pending = false + // The generated response type is structurally wider than TuiConfig.Info (looser unions, no + // plugin_origins); reconcile only reads the known fields, so narrowing here is safe. + if (result?.data) set(result.data as unknown as TuiConfig.Info) + // Coalesce events that arrived mid-flight into a single follow-up fetch. + if (state.again) { + state.again = false + void reload() + } + } + + onMount(() => { + const unsub = event.on("global.config.updated", () => void reload()) + onCleanup(unsub) + }) +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx new file mode 100644 index 00000000000..2551f6d17d3 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui/context/tui-config.tsx @@ -0,0 +1,52 @@ +// kilocode_change - new file +/** + * Reactive TUI config provider with hot reload. + * + * Replaces the static upstream `TuiConfigProvider` so that keybinds, theme, and other + * declarative TUI settings apply live when changed from the Kilo Console — no restart. + * + * The reload wiring (subscribe + refetch) lives in `tui-config-hot-reload.ts` so this module + * stays free of SDK/event imports and the store factory can be unit-tested in isolation. + */ +import { createContext, useContext, type ParentProps } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" + +export type SetTuiConfig = (next: TuiConfig.Info) => void + +const ConfigContext = createContext() +const SetContext = createContext() + +export namespace KiloTuiConfig { + // Pure factory so the reactive behavior is unit-testable without JSX/contexts. + export function makeStore(initial: TuiConfig.Info) { + const [store, setStore] = createStore(initial) + // The fetched config is the server's effective TUI config and replaces the store. We only + // hot-reload declarative settings (keybinds/theme/ui); the plugin runtime is initialized + // once at startup and is unaffected by the store losing `plugin_origins` here. `merge: true` + // reconciles arrays by index instead of key-diffing (TUI config arrays have no `id`). + const set: SetTuiConfig = (next) => setStore(reconcile(next, { merge: true })) + return { config: store, set } + } + + export function Provider(props: ParentProps<{ config: TuiConfig.Info }>) { + const store = makeStore(props.config) + return ( + + {props.children} + + ) + } + + export function use() { + const value = useContext(ConfigContext) + if (!value) throw new Error("TuiConfig context must be used within a context provider") + return value + } + + export function useSet() { + const value = useContext(SetContext) + if (!value) throw new Error("TuiConfig context must be used within a context provider") + return value + } +} diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts b/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts index 0aa0d759c27..a3567a81d24 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts @@ -1,5 +1,6 @@ import { Config } from "@/config/config" import { ConfigPlugin } from "@/config/plugin" +import { KilocodeKeybinds } from "@/kilocode/tui/keybinds" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" @@ -80,6 +81,9 @@ const TuiConfigShape = { } export const TuiConfigResponse = Schema.Struct(TuiConfigShape).annotate({ identifier: "TuiConfigGetResponse" }) export const TuiConfigPatch = Schema.Struct(TuiConfigShape) +export const TuiKeybindListResponse = Schema.Struct({ keybinds: Schema.Array(KilocodeKeybinds.Info) }).annotate({ + identifier: "TuiKeybindListResponse", +}) export const ConfigConsolePaths = { sources: "/config/sources", @@ -87,6 +91,7 @@ export const ConfigConsolePaths = { overlay: "/config/overlay", modelState: "/config/model-state", tuiConfig: "/tui/config", + tuiKeybinds: "/tui/keybinds", } as const export const ConfigConsoleApi = HttpApi.make("config-console") @@ -161,6 +166,16 @@ export const ConfigConsoleApi = HttpApi.make("config-console") description: "Retrieve the effective TUI configuration for the current instance directory.", }), ), + HttpApiEndpoint.get("tuiKeybindList", ConfigConsolePaths.tuiKeybinds, { + success: described(TuiKeybindListResponse, "TUI keybind metadata"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "tui.keybind.list", + summary: "List TUI keybinds", + description: + "List valid TUI keybind commands, descriptions, groups, and default bindings from the CLI schema.", + }), + ), HttpApiEndpoint.patch("tuiConfigUpdate", ConfigConsolePaths.tuiConfig, { query: TuiConfigQuery, payload: TuiConfigPatch, diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts index 629f0f8a6dc..b943b9d5b6d 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/config-console.ts @@ -5,6 +5,7 @@ import * as InstanceState from "@/effect/instance-state" import { KilocodeConfigOverlay } from "@/kilocode/config/overlay" import { KilocodeConfigSources } from "@/kilocode/config/sources" import { KilocodeModelState } from "@/kilocode/config/model-state" +import { KilocodeKeybinds } from "@/kilocode/tui/keybinds" import { KilocodeTuiConfig } from "@/kilocode/tui/config" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" @@ -125,6 +126,10 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf return yield* Effect.promise(() => KilocodeTuiConfig.get({ directory: instance.directory })) }) + const tuiKeybindList = Effect.fn("ConfigConsoleHttpApi.tuiKeybindList")(function* () { + return { keybinds: KilocodeKeybinds.list() } + }) + const tuiConfigUpdate = Effect.fn("ConfigConsoleHttpApi.tuiConfigUpdate")(function* (ctx: { query: typeof TuiConfigQuery.Type payload: typeof TuiConfigPatch.Type @@ -157,6 +162,7 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf .handle("modelState", modelState) .handle("modelStateUpdate", modelStateUpdate) .handle("tuiConfigGet", tuiConfigGet) + .handle("tuiKeybindList", tuiKeybindList) .handle("tuiConfigUpdate", tuiConfigUpdate) }), ) diff --git a/packages/opencode/src/kilocode/server/httpapi/instance.ts b/packages/opencode/src/kilocode/server/httpapi/instance.ts index 5d9f3eddfee..880e7243a7f 100644 --- a/packages/opencode/src/kilocode/server/httpapi/instance.ts +++ b/packages/opencode/src/kilocode/server/httpapi/instance.ts @@ -30,6 +30,7 @@ export function register(app: Hono, handler: Handler, context: Context.Context handler(c.req.raw, context)) app.patch(ConfigConsolePaths.modelState, (c) => handler(c.req.raw, context)) app.get(ConfigConsolePaths.tuiConfig, (c) => handler(c.req.raw, context)) + app.get(ConfigConsolePaths.tuiKeybinds, (c) => handler(c.req.raw, context)) app.patch(ConfigConsolePaths.tuiConfig, (c) => handler(c.req.raw, context)) app.post("/permission/allow-everything", (c) => handler(c.req.raw, context)) app.post("/enhance-prompt", (c) => handler(c.req.raw, context)) diff --git a/packages/opencode/src/kilocode/tui/config.ts b/packages/opencode/src/kilocode/tui/config.ts index b7f878961ab..dcdd6cbbe8f 100644 --- a/packages/opencode/src/kilocode/tui/config.ts +++ b/packages/opencode/src/kilocode/tui/config.ts @@ -10,6 +10,8 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiInfo } from "@/cli/cmd/tui/config/tui-schema" import { Filesystem } from "@/util/filesystem" import { isRecord } from "@/util/record" +import { GlobalBus } from "@/bus/global" +import { Event } from "@/server/event" export namespace KilocodeTuiConfig { export const Scope = z.enum(["project", "global"]) @@ -41,6 +43,12 @@ export namespace KilocodeTuiConfig { const output = file.endsWith(".jsonc") ? patchJsonc(before, next) : JSON.stringify(next, null, 2) await Filesystem.write(file, output) + // Notify connected TUIs so they hot-reload keybinds/theme/ui settings. Mirrors + // Config.updateGlobal; directory "global" routes it to the TUI's global event handler. + GlobalBus.emit("event", { + directory: "global", + payload: { type: Event.ConfigUpdated.type, properties: {} }, + }) return get({ directory: input.directory }) } diff --git a/packages/opencode/src/kilocode/tui/keybinds.ts b/packages/opencode/src/kilocode/tui/keybinds.ts new file mode 100644 index 00000000000..fd02938e7c2 --- /dev/null +++ b/packages/opencode/src/kilocode/tui/keybinds.ts @@ -0,0 +1,69 @@ +import { ConfigKeybinds } from "@/config/keybinds" +import { Schema } from "effect" + +export namespace KilocodeKeybinds { + export const Info = Schema.Struct({ + id: Schema.String, + label: Schema.String, + group: Schema.String, + default: Schema.String, + description: Schema.String, + }).annotate({ identifier: "TuiKeybindInfo" }) + export type Info = Schema.Schema.Type + + const groups: Record = { + agent: "Agents", + app: "Application", + command: "Commands", + display: "Messages", + editor: "Editor", + history: "Input history", + input: "Input", + messages: "Messages", + model: "Models", + news: "Home", + plugin: "Plugins", + scrollbar: "Appearance", + session: "Sessions", + sidebar: "Appearance", + stash: "Sessions", + status: "Status", + terminal: "Terminal", + theme: "Appearance", + tips: "Home", + tool: "Tools", + username: "Appearance", + variant: "Models", + } + + const acronyms = new Set(["tui"]) + + function group(id: string) { + const prefix = id.split("_")[0] ?? id + return groups[prefix] ?? "General" + } + + function word(input: string) { + if (acronyms.has(input)) return input.toUpperCase() + return input.charAt(0).toUpperCase() + input.slice(1) + } + + function label(id: string) { + return id.split("_").map(word).join(" ") + } + + function fallback(id: string, value: string) { + if (process.platform === "win32" && id === "terminal_suspend") return "none" + return value + } + + export function list(): Info[] { + return Object.entries(ConfigKeybinds.Keybinds.shape).map(([id, schema]) => ({ + id, + label: label(id), + group: group(id), + default: fallback(id, schema.parse(undefined)), + description: schema.description ?? label(id), + })) + } +} diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts new file mode 100644 index 00000000000..e411c268d05 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/tui/context/tui-config.test.ts @@ -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 { + 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 = [] + const themes: Array = [] + 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 = [] + 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() + }) +}) diff --git a/packages/opencode/test/kilocode/server/agent-builder.test.ts b/packages/opencode/test/kilocode/server/agent-builder.test.ts index 3454b9d6527..c5d905badfa 100644 --- a/packages/opencode/test/kilocode/server/agent-builder.test.ts +++ b/packages/opencode/test/kilocode/server/agent-builder.test.ts @@ -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) + }) }) diff --git a/packages/opencode/test/kilocode/server/tui-config.test.ts b/packages/opencode/test/kilocode/server/tui-config.test.ts index a6d90b680b8..7292ee39c38 100644 --- a/packages/opencode/test/kilocode/server/tui-config.test.ts +++ b/packages/opencode/test/kilocode/server/tui-config.test.ts @@ -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,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) + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e7df7015c6a..37c0efb872d 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -271,6 +271,7 @@ import type { TuiControlResponseResponses, TuiExecuteCommandErrors, TuiExecuteCommandResponses, + TuiKeybindListResponses, TuiOpenHelpResponses, TuiOpenModelsResponses, TuiOpenSessionsResponses, @@ -5067,6 +5068,38 @@ export class Config3 extends HeyApiClient { } } +export class Keybind extends HeyApiClient { + /** + * List TUI keybinds + * + * List valid TUI keybind commands, descriptions, groups, and default bindings from the CLI schema. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/tui/keybinds", + ...options, + ...params, + }) + } +} + export class Tui extends HeyApiClient { /** * Append TUI prompt @@ -5448,6 +5481,11 @@ export class Tui extends HeyApiClient { get config(): Config3 { return (this._config ??= new Config3({ client: this.client })) } + + private _keybind?: Keybind + get keybind(): Keybind { + return (this._keybind ??= new Keybind({ client: this.client })) + } } export class AgentBuilder extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6e670125184..61d778b4db8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2096,6 +2096,18 @@ export type TuiConfigGetResponse = { mouse?: boolean } +export type TuiKeybindInfo = { + id: string + label: string + group: string + default: string + description: string +} + +export type TuiKeybindListResponse = { + keybinds: Array +} + export type EffectHttpApiErrorUnauthorized = { _tag: "Unauthorized" } @@ -7997,6 +8009,25 @@ export type TuiConfigUpdateResponses = { export type TuiConfigUpdateResponse = TuiConfigUpdateResponses[keyof TuiConfigUpdateResponses] +export type TuiKeybindListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/tui/keybinds" +} + +export type TuiKeybindListResponses = { + /** + * TUI keybind metadata + */ + 200: TuiKeybindListResponse +} + +export type TuiKeybindListResponse2 = TuiKeybindListResponses[keyof TuiKeybindListResponses] + export type EnhancePromptEnhanceData = { body?: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1e51549ed4f..dc5ebc8e45f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10465,6 +10465,50 @@ ] } }, + "/tui/keybinds": { + "get": { + "tags": ["config-console"], + "operationId": "tui.keybind.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "TUI keybind metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TuiKeybindListResponse" + } + } + } + } + }, + "description": "List valid TUI keybind commands, descriptions, groups, and default bindings from the CLI schema.", + "summary": "List TUI keybinds", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.tui.keybind.list({\n ...\n})" + } + ] + } + }, "/enhance-prompt": { "post": { "tags": ["enhance-prompt"], @@ -19812,6 +19856,41 @@ }, "additionalProperties": false }, + "TuiKeybindInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "group": { + "type": "string" + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["id", "label", "group", "default", "description"], + "additionalProperties": false + }, + "TuiKeybindListResponse": { + "type": "object", + "properties": { + "keybinds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TuiKeybindInfo" + } + } + }, + "required": ["keybinds"], + "additionalProperties": false + }, "effect_HttpApiError_Unauthorized": { "type": "object", "properties": {