Merge branch 'main' into generated-flame

This commit is contained in:
kirillk
2026-06-02 23:09:32 -04:00
969 changed files with 89714 additions and 17734 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Allow clearing agent model and variant overrides from settings.
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Skip inferred background-process port scanning in VS Code sessions to avoid unnecessary Bun subprocess polling.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Show a retryable connection error and preserve unsent prompts when the VS Code background CLI process exits.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Make Marketplace skill installation resilient to missing project directories and overlapping install attempts.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep Kilo Console terminals and worktree changes visible while refreshing diffs.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Use the updated favicon in Kilo Console.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix Claude Opus 4.8 reasoning on Amazon Bedrock by treating it as an adaptive thinking model like Opus 4.7. This resolves the "thinking.type.enabled is not supported for this model" error and exposes the full low/medium/high/xhigh/max reasoning effort range.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix queued plan prompts stalling in VS Code after a completed turn.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show shell command descriptions in permission approval prompts.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep chat auto-scroll stable while edit, write, and patch tools hand off between assistant steps.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Preserve the Changes review scroll position while agents update files.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Restore full-session forks in Agent Manager after the HTTP API migration.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix empty TUI session list when launching kilo from inside a git submodule. `git worktree list --porcelain` reports the submodule's gitdir (`<repo>/.git/modules/<sub>`) instead of the working tree, so the worktree-family filter dropped every session whose directory was the actual submodule path. Include `Instance.worktree` in the returned set so submodule sessions stay in scope.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Speed up VS Code session switching for long transcripts by lazily mounting collapsed historical tool details, sharing timeline hover infrastructure across activity bars, omitting transcript metadata that the webview does not use, and avoiding shimmer markup for inactive historical labels.
+5
View File
@@ -78,6 +78,11 @@ jobs:
env:
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
+18 -15
View File
@@ -13,22 +13,25 @@ jobs:
is_fork: ${{ steps.fork-check.outputs.is_fork }}
steps:
- uses: actions/checkout@v6 # kilocode_change
- uses: Kilo-Org/paths-filter@master
# kilocode_change start
- name: Check changed files
id: filter
with:
filters: |
matched:
- "packages/kilo-ui/**"
- "packages/ui/**"
- "packages/util/**"
- "packages/sdk/js/**"
- "packages/kilo-vscode/webview-ui/**"
- "packages/kilo-vscode/.storybook/**"
- "packages/kilo-vscode/tests/visual-regression*"
- "packages/kilo-vscode/tests/permission-dock-dropdown*"
- "packages/kilo-vscode/tests/accessibility*" # kilocode_change
- "packages/kilo-docs/public/img/screenshot-tests/**"
- ".github/workflows/visual-regression.yml"
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
matched=false
while IFS= read -r file; do
case "$file" in
packages/kilo-ui/*|packages/ui/*|packages/util/*|packages/sdk/js/*|packages/kilo-vscode/webview-ui/*|packages/kilo-vscode/.storybook/*|packages/kilo-vscode/tests/visual-regression*|packages/kilo-vscode/tests/permission-dock-dropdown*|packages/kilo-vscode/tests/accessibility*|packages/kilo-docs/public/img/screenshot-tests/*|.github/workflows/visual-regression.yml)
matched=true
break
;;
esac
done < <(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --jq '.[].filename')
echo "matched=$matched" >> "$GITHUB_OUTPUT"
echo "matched=$matched"
# kilocode_change end
- name: Check if PR is from a fork
id: fork-check
run: |
+1
View File
@@ -5,6 +5,7 @@ node_modules
.worktrees
.sst
.env
.env.local
.idea/*
!.idea/gradle.xml
!.idea/.run
+5
View File
@@ -0,0 +1,5 @@
# Fake secret-looking strings used by HTTP recorder redaction tests.
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71
+1 -1
View File
@@ -1 +1 @@
v1.14.41
v1.14.42
+1
View File
@@ -18,4 +18,5 @@
"github-triage": false,
"github-pr-search": false,
},
"disabled_providers": [],
}
+367 -285
View File
@@ -1,35 +1,62 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
import { useTerminalDimensions, type JSX } from "@opentui/solid"
import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras"
import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
const command = {
modal: "smoke_modal",
screen: "smoke_screen",
alert: "smoke_alert",
confirm: "smoke_confirm",
prompt: "smoke_prompt",
select: "smoke_select",
host: "smoke_host",
home: "smoke_home",
toast: "smoke_toast",
dialog_close: "smoke_dialog_close",
local_push: "smoke_local_push",
local_pop: "smoke_local_pop",
screen_home: "smoke_screen_home",
screen_left: "smoke_screen_left",
screen_right: "smoke_screen_right",
screen_up: "smoke_screen_up",
screen_down: "smoke_screen_down",
screen_modal: "smoke_screen_modal",
screen_local: "smoke_screen_local",
screen_host: "smoke_screen_host",
screen_alert: "smoke_screen_alert",
screen_confirm: "smoke_screen_confirm",
screen_prompt: "smoke_screen_prompt",
screen_select: "smoke_screen_select",
modal_accept: "smoke_modal_accept",
modal_close: "smoke_modal_close",
}
type SmokeBindings = BindingConfig<Renderable, KeyEvent>
const defaultKeymap = {
[command.modal]: "ctrl+shift+m",
[command.screen]: "ctrl+shift+o",
[command.dialog_close]: "escape",
[command.local_push]: "enter,return",
[command.local_pop]: "escape,q,backspace",
[command.screen_home]: "escape,ctrl+h",
[command.screen_left]: "left,h",
[command.screen_right]: "right,l",
[command.screen_up]: "up,k",
[command.screen_down]: "down,j",
[command.screen_modal]: "ctrl+shift+m",
[command.screen_local]: "x",
[command.screen_host]: "z",
[command.screen_alert]: "a",
[command.screen_confirm]: "c",
[command.screen_prompt]: "p",
[command.screen_select]: "s",
[command.modal_accept]: "enter,return",
[command.modal_close]: "escape",
}
const pick = (value: unknown, fallback: string) => {
@@ -43,16 +70,14 @@ const num = (value: unknown, fallback: number) => {
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value)
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
keybinds: SmokeBindings | undefined
}
type Route = {
@@ -74,7 +99,7 @@ const cfg = (options: Record<string, unknown> | undefined) => {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined,
}
}
@@ -85,7 +110,12 @@ const names = (input: Cfg) => {
}
}
type Keys = TuiKeybindSet
function createKeys(input: SmokeBindings | undefined) {
return createBindingLookup({ ...defaultKeymap, ...input })
}
type Keys = ReturnType<typeof createKeys>
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
@@ -292,125 +322,174 @@ const Screen = (props: {
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
set(Math.max(0, next.local - 1), next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
const screenActive = () => props.api.route.current.name === props.route.screen
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
useBindings(() => ({
enabled: () => screenActive() && props.api.ui.dialog.open,
commands: [
{
name: command.dialog_close,
run() {
props.api.ui.dialog.clear()
},
},
],
bindings: props.keys.gather("smoke.dialog", [command.dialog_close]),
}))
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
useBindings(() => ({
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0,
commands: [
{
name: command.local_push,
run() {
push(current(props.api, props.route))
},
},
{
name: command.local_pop,
run() {
pop(current(props.api, props.route))
},
},
],
bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]),
}))
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
useBindings(() => ({
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0,
commands: [
{
name: command.screen_home,
run() {
props.api.route.navigate("home")
},
},
{
name: command.screen_left,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
},
},
{
name: command.screen_right,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
},
},
{
name: command.screen_up,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
},
},
{
name: command.screen_down,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
},
},
{
name: command.screen_modal,
run() {
props.api.route.navigate(props.route.modal, current(props.api, props.route))
},
},
{
name: command.screen_local,
run() {
open()
},
},
{
name: command.screen_host,
run() {
host(props.api, props.input, skin)
},
},
{
name: command.screen_alert,
run() {
warn(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_confirm,
run() {
check(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_prompt,
run() {
entry(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_select,
run() {
picker(props.api, props.route, current(props.api, props.route))
},
},
],
bindings: props.keys.gather("smoke.screen", [
command.screen_home,
command.screen_left,
command.screen_right,
command.screen_up,
command.screen_down,
command.screen_modal,
command.screen_local,
command.screen_host,
command.screen_alert,
command.screen_confirm,
command.screen_prompt,
command.screen_select,
]),
}))
const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: [
command.screen_home,
command.screen_up,
command.screen_down,
command.screen_modal,
command.screen_alert,
command.screen_confirm,
command.screen_prompt,
command.screen_select,
command.screen_local,
command.screen_host,
command.local_push,
command.local_pop,
],
})
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
return {
screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "",
screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "",
screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "",
screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "",
screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "",
screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "",
screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "",
screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "",
screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "",
screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "",
local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "",
local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "",
}
})
@@ -430,7 +509,7 @@ const Screen = (props: {
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
<text fg={skin.muted}>{shortcuts().screen_home} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
@@ -477,7 +556,7 @@ const Screen = (props: {
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
{shortcuts().screen_up} / {shortcuts().screen_down} change value
</text>
</box>
) : null}
@@ -485,17 +564,16 @@ const Screen = (props: {
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
{shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "}
confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
{shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
<text fg={skin.muted}>{shortcuts().screen_home} returns home</text>
</box>
) : null}
</box>
@@ -548,7 +626,7 @@ const Screen = (props: {
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
{shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
@@ -571,20 +649,35 @@ const Modal = (props: {
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
useBindings(() => ({
enabled: () => props.api.route.current.name === props.route.modal,
commands: [
{
name: command.modal_accept,
run() {
props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" })
},
},
{
name: command.modal_close,
run() {
props.api.route.navigate("home")
},
},
],
bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]),
}))
const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: [command.modal, command.screen, command.modal_accept, command.modal_close],
})
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return {
modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "",
screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "",
modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "",
modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "",
}
})
@@ -595,10 +688,10 @@ const Modal = (props: {
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>{shortcuts().modal} modal command</text>
<text fg={skin.muted}>{shortcuts().screen} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
{shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
@@ -651,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const Prompt = api.ui.Prompt
const Slot = api.ui.Slot
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
@@ -791,109 +867,115 @@ const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
api.keymap.registerLayer({
commands: [
{
name: command.modal,
title: `${input.label} modal`,
category: "Plugin",
namespace: "palette",
slashName: "smoke",
run() {
api.route.navigate(route.modal, { source: "command" })
},
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
{
name: command.screen,
title: `${input.label} screen`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-screen",
run() {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
{
name: command.alert,
title: `${input.label} alert dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-alert",
run() {
warn(api, route, current(api, route))
},
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
{
name: command.confirm,
title: `${input.label} confirm dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-confirm",
run() {
check(api, route, current(api, route))
},
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
{
name: command.prompt,
title: `${input.label} prompt dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-prompt",
run() {
entry(api, route, current(api, route))
},
},
onSelect: () => {
warn(api, route, current(api, route))
{
name: command.select,
title: `${input.label} select dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-select",
run() {
picker(api, route, current(api, route))
},
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
{
name: command.host,
title: `${input.label} host overlay`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-host",
run() {
host(api, input, tone(api))
},
},
onSelect: () => {
check(api, route, current(api, route))
{
name: command.home,
title: `${input.label} go home`,
category: "Plugin",
namespace: "palette",
enabled: () => api.route.current.name !== "home",
run() {
api.route.navigate("home")
},
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
{
name: command.toast,
title: `${input.label} toast`,
category: "Plugin",
namespace: "palette",
run() {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
],
bindings: keys.gather("smoke.global", [
command.modal,
command.screen,
command.alert,
command.confirm,
command.prompt,
command.select,
command.host,
command.home,
command.toast,
]),
})
}
const tui: TuiPlugin = async (api, options, meta) => {
@@ -902,9 +984,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const value = cfg(options)
const route = names(value)
const keys = api.keybind.create(bind, value.keybinds)
const keys = createKeys(value.keybinds)
const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post)
+1 -15
View File
@@ -1,18 +1,4 @@
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
[
"./plugins/tui-smoke.tsx",
{
"enabled": false,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
}
}
]
]
"keybinds": {}
}
+229 -66
View File
@@ -33,7 +33,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "7.3.18",
"version": "7.3.22",
"bin": {
"opencode": "./bin/opencode",
},
@@ -66,9 +66,43 @@
"@types/semver": "catalog:",
},
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "7.3.22",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/kilo-console": {
"name": "@kilocode/kilo-console",
"version": "7.3.22",
"dependencies": {
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/router": "catalog:",
"ghostty-web": "0.4.0",
"solid-js": "catalog:",
"yaml": "2.8.3",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -93,12 +127,12 @@
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vitest": "^3.2.3",
"vitest": "4.1.0",
},
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
@@ -134,7 +168,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.3.18",
"version": "7.3.22",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -144,7 +178,7 @@
},
"packages/kilo-indexing": {
"name": "@kilocode/kilo-indexing",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
"@aws-sdk/credential-provider-ini": "3.972.31",
@@ -176,11 +210,11 @@
},
"packages/kilo-jetbrains": {
"name": "@kilocode/kilo-jetbrains",
"version": "7.3.18",
"version": "7.3.22",
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"posthog-node": "4.4.0",
@@ -194,7 +228,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "0.13.11",
@@ -231,7 +265,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-gateway": "workspace:*",
@@ -240,6 +274,7 @@
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
@@ -294,9 +329,42 @@
"vite-plugin-solid": "2.11.10",
},
},
"packages/kilo-web-ui": {
"name": "@kilocode/kilo-web-ui",
"version": "7.3.22",
"dependencies": {
"@kilocode/kilo-ui": "workspace:*",
"@kobalte/core": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "7.3.22",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
"effect": "catalog:",
},
"devDependencies": {
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.3.18",
"version": "7.3.22",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -356,9 +424,12 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@secretlint/core": "10.2.2",
"@secretlint/secretlint-rule-preset-recommend": "10.2.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
@@ -456,7 +527,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -464,6 +535,7 @@
},
"devDependencies": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
@@ -471,17 +543,19 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.2",
"@opentui/solid": ">=0.2.2",
"@opentui/core": ">=0.2.6",
"@opentui/keymap": ">=0.2.6",
"@opentui/solid": ">=0.2.6",
},
"optionalPeers": [
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",
],
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"semver": "^7.6.3",
},
@@ -492,7 +566,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -507,7 +581,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.3.18",
"version": "7.3.22",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -530,7 +604,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.3.18",
"version": "7.3.22",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -621,8 +695,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@opentui/core": "0.2.6",
"@opentui/keymap": "0.2.6",
"@opentui/solid": "0.2.6",
"@pierre/diffs": "1.1.22",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -1231,6 +1306,8 @@
"@kilocode/cli": ["@kilocode/cli@workspace:packages/opencode"],
"@kilocode/kilo-console": ["@kilocode/kilo-console@workspace:packages/kilo-console"],
"@kilocode/kilo-docs": ["@kilocode/kilo-docs@workspace:packages/kilo-docs"],
"@kilocode/kilo-gateway": ["@kilocode/kilo-gateway@workspace:packages/kilo-gateway"],
@@ -1245,6 +1322,8 @@
"@kilocode/kilo-ui": ["@kilocode/kilo-ui@workspace:packages/kilo-ui"],
"@kilocode/kilo-web-ui": ["@kilocode/kilo-web-ui@workspace:packages/kilo-web-ui"],
"@kilocode/plugin": ["@kilocode/plugin@workspace:packages/plugin"],
"@kilocode/sdk": ["@kilocode/sdk@workspace:packages/sdk/js"],
@@ -1413,6 +1492,10 @@
"@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"],
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.12", "", { "dependencies": { "@opencode-ai/sdk": "1.4.12", "effect": "4.0.0-beta.48", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.100", "@opentui/solid": ">=0.1.100" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-4CVk2NFnFltrOfnXqBEb6qY9aRxh6toBx68DDrFxGDnguyH3TtYT16Reyvm83TNAAtkkdOC2f14dGPhqt+u2CA=="],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
@@ -1453,21 +1536,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
"@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
"@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
"@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2077,8 +2162,6 @@
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/diff": ["@types/diff@6.0.0", "", {}, "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA=="],
@@ -2111,8 +2194,6 @@
"@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/mssql": ["@types/mssql@9.1.11", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-vcujgrDbDezCxNDO4KY6gjwduLYOKfrexpRUwhoysRvcXZ3+IgZ/PMYFDgh8c3cQIxZ6skAwYo+H6ibMrBWPjQ=="],
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
@@ -2215,19 +2296,19 @@
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
"@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="],
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
"@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="],
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="],
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="],
"@vscode/codicons": ["@vscode/codicons@0.0.44", "", {}, "sha512-F7qPRumUK3EHjNdopfICLGRf3iNPoZQt+McTHAn4AlOWPB3W2kL4H0S7uqEqbyZ6rCxaeDjpAn3MCUnwTu/VJQ=="],
@@ -2367,7 +2448,7 @@
"aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="],
"aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"axe-core": ["axe-core@4.11.3", "", {}, "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg=="],
@@ -2463,8 +2544,6 @@
"c8": ["c8@10.1.3", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", "test-exclude": "^7.0.1", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "monocart-coverage-reports": "^2" }, "optionalPeers": ["monocart-coverage-reports"], "bin": { "c8": "bin/c8.js" } }, "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="],
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
@@ -2483,7 +2562,7 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
@@ -2819,7 +2898,7 @@
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
@@ -3025,6 +3104,8 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
@@ -3585,6 +3666,8 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="],
@@ -4067,7 +4150,7 @@
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
@@ -4099,8 +4182,6 @@
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"structured-source": ["structured-source@4.0.0", "", { "dependencies": { "boundary": "^2.0.0" } }, "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA=="],
@@ -4165,13 +4246,11 @@
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
@@ -4311,15 +4390,13 @@
"vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
"vite-plugin-icons-spritesheet": ["vite-plugin-icons-spritesheet@3.0.1", "", { "dependencies": { "chalk": "^5.4.1", "glob": "^11.0.1", "node-html-parser": "^7.0.1", "tinyexec": "^0.3.2" }, "peerDependencies": { "vite": ">=5.2.0" } }, "sha512-Cr0+Z6wRMwSwKisWW9PHeTjqmQFv0jwRQQMc3YgAhAgZEe03j21el0P/CA31KN/L5eiL1LhR14VTXl96LetonA=="],
"vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
@@ -4421,8 +4498,6 @@
"@actions/http-client/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="],
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -4457,8 +4532,6 @@
"@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -4575,8 +4648,6 @@
"@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@openauthjs/openauth/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/storybook/@storybook/addon-a11y": ["@storybook/addon-a11y@10.3.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.3.5" } }, "sha512-5k6lpgfIeLxvNhE8v3wEzdiu73ONKjF4gmH1AHvfqYd8kIVzQJai0KCDxgvqNncXHQhIWkaf1fg6+9hKaYJyaw=="],
@@ -4667,6 +4738,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@vscode/ripgrep/yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"@vscode/test-cli/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -4901,8 +4974,6 @@
"nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -4917,6 +4988,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
"ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
@@ -4983,8 +5058,14 @@
"spdx-correct/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="],
"sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -4993,8 +5074,6 @@
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"supertap/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -5029,6 +5108,8 @@
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vite-plugin-icons-spritesheet/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"vite-plugin-solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
@@ -5135,6 +5216,10 @@
"@opencode-ai/storybook/@storybook/addon-docs/react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"@opencode-ai/storybook/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@opencode-ai/storybook/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@opencode-ai/storybook/storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@opencode-ai/ui/@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
@@ -5147,12 +5232,28 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@storybook/addon-links/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/addon-links/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@storybook/addon-links/storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@storybook/addon-onboarding/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/addon-onboarding/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@storybook/addon-onboarding/storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@storybook/addon-vitest/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/addon-vitest/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@storybook/addon-vitest/storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@storybook/builder-vite/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/builder-vite/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@storybook/builder-vite/storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@tailwindcss/vite/@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
@@ -5217,8 +5318,6 @@
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"ai-gateway-provider/@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -5399,6 +5498,20 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -5427,6 +5540,12 @@
"secretlint/globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
"storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -5471,6 +5590,36 @@
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/storybook/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@opencode-ai/storybook/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@opencode-ai/storybook/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@storybook/addon-links/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@storybook/addon-links/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@storybook/addon-links/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@storybook/addon-onboarding/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@storybook/addon-onboarding/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@storybook/addon-onboarding/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@storybook/addon-vitest/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@storybook/addon-vitest/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@storybook/addon-vitest/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@storybook/builder-vite/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@storybook/builder-vite/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@storybook/builder-vite/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@tailwindcss/vite/@tailwindcss/node/lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
@@ -5589,6 +5738,8 @@
"mocha/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"pkg-conf/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
@@ -5609,6 +5760,8 @@
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"tar-fs/tar-stream/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"test-exclude/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
@@ -5619,6 +5772,16 @@
"@manypkg/find-root/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"@opencode-ai/storybook/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@storybook/addon-links/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@storybook/addon-onboarding/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@storybook/addon-vitest/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@storybook/builder-vite/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@vscode/test-cli/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-/cQ10dEr62YjcG5Fqm1lSHzhssvGPHMeaNHong+y/T4=",
"aarch64-linux": "sha256-RjA9KOoLg0AAzkSg1ApgeW/kUlOMxJhlKuGJwh+jLtE=",
"aarch64-darwin": "sha256-nfWpwSyC4CaU1Ad3PY+3pEzTyITAUVf12cxYsxaHjqk=",
"x86_64-darwin": "sha256-ytU4wj6Ywoe1a6pBldRqXFFoXRaUXNTV/9gTqA3fz3o="
"x86_64-linux": "sha256-STfDdoDiRwXnQ3B8lueNSZp4iJFL9TAME6R+dnxI1eU=",
"aarch64-linux": "sha256-v/N0eCo1pa3n5QTMdLHBYICIEdI0BiNEsMzgRbOEXfA=",
"aarch64-darwin": "sha256-gC6iPlR+UTo336ukkHIzHKQMwxFOdyTIl9UCIh/XVdw=",
"x86_64-darwin": "sha256-es6bcIwldn0sKHnC7TqOaL+IST3zZ3JrHbH8ojGbQQ0="
}
}
+6 -5
View File
@@ -10,7 +10,7 @@
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"test:ci": "bun turbo test:ci",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts",
"prepare": "husky",
"random": "echo 'Random script'",
@@ -32,8 +32,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@opentui/core": "0.2.6",
"@opentui/solid": "0.2.6",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -77,7 +77,8 @@
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.12",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
"@lydell/node-pty": "1.2.0-beta.10",
"@opentui/keymap": "0.2.6"
}
},
"devDependencies": {
@@ -150,6 +151,6 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
"version": "7.3.18",
"version": "7.3.22",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.3.18",
"version": "7.3.22",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+2 -13
View File
@@ -1,5 +1,4 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -11,10 +10,6 @@ function falsy(key: string) {
return value === "false" || value === "0"
}
// Channels that default to the new effect-httpapi server backend. The legacy
// hono backend remains the default for stable (`prod`/`latest`) installs.
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -73,7 +68,9 @@ export const Flag = {
KILO_EXPERIMENTAL_LSP_TY: truthy("KILO_EXPERIMENTAL_LSP_TY"),
KILO_EXPERIMENTAL_LSP_TOOL: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_LSP_TOOL"),
KILO_EXPERIMENTAL_PLAN_MODE: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_PLAN_MODE"),
KILO_EXPERIMENTAL_SCOUT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SCOUT"),
KILO_EXPERIMENTAL_MARKDOWN: !falsy("KILO_EXPERIMENTAL_MARKDOWN"),
KILO_ENABLE_PARALLEL: truthy("KILO_ENABLE_PARALLEL") || truthy("KILO_EXPERIMENTAL_PARALLEL"),
KILO_MODELS_URL: process.env["KILO_MODELS_URL"],
KILO_MODELS_PATH: process.env["KILO_MODELS_PATH"],
KILO_DISABLE_EMBEDDED_WEB_UI: truthy("KILO_DISABLE_EMBEDDED_WEB_UI"),
@@ -83,14 +80,6 @@ export const Flag = {
KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"),
KILO_WORKSPACE_ID: process.env["KILO_WORKSPACE_ID"],
// Defaults to true on dev/beta/local channels so internal users exercise the
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
// on the legacy hono backend until the rollout is complete. An explicit env
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
// stable users and an escape hatch for dev/beta users.
KILO_EXPERIMENTAL_HTTPAPI:
truthy("KILO_EXPERIMENTAL_HTTPAPI") ||
(!falsy("KILO_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
KILO_EXPERIMENTAL_WORKSPACES: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_WORKSPACES"),
KILO_EXPERIMENTAL_EVENT_SYSTEM: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EVENT_SYSTEM"),
+4
View File
@@ -32,6 +32,7 @@ const paths = {
data,
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
repos: path.join(data, "repos"),
cache,
config,
state,
@@ -49,6 +50,7 @@ await Promise.all([
ensureRealDir(Path.tmp), // kilocode_change
ensureRealDir(Path.log), // kilocode_change
ensureRealDir(Path.bin), // kilocode_change
ensureRealDir(Path.repos), // kilocode_change
])
// kilocode_change start - keep generated Kilo data out of macOS Spotlight
@@ -66,6 +68,7 @@ export interface Interface {
readonly tmp: string
readonly bin: string
readonly log: string
readonly repos: string
}
export function make(input: Partial<Interface> = {}): Interface {
@@ -78,6 +81,7 @@ export function make(input: Partial<Interface> = {}): Interface {
tmp: Path.tmp,
bin: Path.bin,
log: Path.log,
repos: Path.repos,
...input,
}
}
@@ -111,7 +111,13 @@ describe("cross-spawn spawner", () => {
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
),
)
expect(out).toBe(tmp.path)
const cwd = yield* Effect.promise(() => fs.realpath(tmp.path))
const actual = yield* Effect.promise(() => fs.realpath(out))
const normalize = (value: string) => {
const normalized = path.normalize(value)
return process.platform === "win32" ? normalized.toLowerCase() : normalized
}
expect(normalize(actual)).toBe(normalize(cwd))
}),
)
+1 -1
View File
@@ -6,7 +6,7 @@ import { Global } from "@opencode-ai/core/global"
describe("global paths", () => {
test("tmp path is under the system temp directory", () => {
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "kilo")) // kilocode_change
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
+18 -12
View File
@@ -58,27 +58,34 @@ function run(msg: Msg) {
})
}
// kilocode_change start - make worker finalization await the process close event without a Windows race
const closed = new WeakMap<ReturnType<typeof spawn>, Promise<void>>()
function spawnWorker(msg: Msg) {
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
closed.set(proc, new Promise((resolve) => proc.once("close", () => resolve())))
return proc
}
function stopWorker(proc: ReturnType<typeof spawnWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve()
async function stopWorker(proc: ReturnType<typeof spawnWorker>) {
const close = closed.get(proc) ?? Promise.resolve()
if (proc.exitCode !== null || proc.signalCode !== null) return close
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return Promise.resolve()
return close
}
return new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
await new Promise<void>((resolve) => {
const kill = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true })
kill.once("error", () => resolve())
kill.once("close", () => resolve())
})
proc.kill()
return close
}
// kilocode_change end
async function waitForFile(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
@@ -362,8 +369,7 @@ describe("util.effect-flock", () => {
try {
await waitForFile(ready, 5_000)
await stopWorker(proc)
await new Promise((resolve) => proc.on("close", resolve))
await stopWorker(proc) // kilocode_change - stopWorker now awaits close before returning
// Backdate lock files so they're past STALE_MS (60s)
const lockDir = lock(dir, "eflock:crash")
+19 -14
View File
@@ -81,29 +81,36 @@ function run(msg: Msg) {
})
}
// kilocode_change start - make worker finalization await the process close event without a Windows race
const closed = new WeakMap<ReturnType<typeof spawn>, Promise<void>>()
function spawnWorker(msg: Msg) {
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
closed.set(proc, new Promise((resolve) => proc.once("close", () => resolve())))
return proc
}
function stopWorker(proc: ReturnType<typeof spawnWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve()
async function stopWorker(proc: ReturnType<typeof spawnWorker>) {
const close = closed.get(proc) ?? Promise.resolve()
if (proc.exitCode !== null || proc.signalCode !== null) return close
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return Promise.resolve()
return close
}
return new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
await new Promise<void>((resolve) => {
const kill = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true })
kill.once("error", () => resolve())
kill.once("close", () => resolve())
})
proc.kill()
return close
}
// kilocode_change end
async function readJson<T>(p: string): Promise<T> {
return JSON.parse(await fs.readFile(p, "utf8"))
@@ -174,8 +181,7 @@ describe("util.flock", () => {
expect(seen.length).toBeGreaterThan(0)
expect(seen.every((x) => x === key)).toBe(true)
} finally {
await stopWorker(proc).catch(() => undefined)
await new Promise((resolve) => proc.on("close", resolve))
await stopWorker(proc).catch(() => undefined) // kilocode_change - stopWorker now awaits close before returning
}
}, 15_000)
@@ -194,8 +200,7 @@ describe("util.flock", () => {
})
await wait(ready, 5_000)
await stopWorker(proc)
await new Promise((resolve) => proc.on("close", resolve))
await stopWorker(proc) // kilocode_change - stopWorker now awaits close before returning
let hit = false
await Flock.withLock(
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.3.18"
version = "7.3.22"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.18/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.18/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.18/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.18/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.18/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.22/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.3.22",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:"
}
}
+108
View File
@@ -0,0 +1,108 @@
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
import * as path from "node:path"
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
export interface Entry {
readonly name: string
readonly path: string
}
export interface Interface {
readonly path: (name: string) => string
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly append: (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) => Effect.Effect<
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
export const layer = (options: { readonly directory?: string } = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
const pathFor = (name: string) => cassettePath(name, directory)
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () {
const entries = yield* fileSystem
.readDirectory(directory)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(directory, entry)
return fileSystem.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
})
return nested.flat()
})
const read = Effect.fn("Cassette.read")(function* (name: string) {
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
})
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
})
const append = Effect.fn("Cassette.append")(function* (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) {
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
)
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
const findings = cassetteSecretFindings(cassette)
if (findings.length === 0) yield* write(name, cassette)
return { cassette, findings }
})
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
const list = Effect.fn("Cassette.list")(function* () {
return (yield* walk(directory))
.filter((file) => file.endsWith(".json"))
.map((file) => ({
name: path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
path: file,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
})
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}),
)
export const defaultLayer = layer()
export * as Cassette from "./cassette"
+95
View File
@@ -0,0 +1,95 @@
import { Option } from "effect"
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
import { decodeJson } from "./matching"
import { REDACTED, redactUrl, secretFindings } from "./redaction"
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
if (!text) return String(value)
return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
if (Object.is(expected, received)) return []
if (
expected &&
received &&
typeof expected === "object" &&
typeof received === "object" &&
!Array.isArray(expected) &&
!Array.isArray(received)
) {
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
.toSorted()
.flatMap((key) =>
valueDiffs(
(expected as Record<string, unknown>)[key],
(received as Record<string, unknown>)[key],
`${base}.${key}`,
limit,
),
)
.slice(0, limit)
}
if (Array.isArray(expected) && Array.isArray(received)) {
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
.slice(0, limit)
}
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
const lines = []
if (expected.method !== received.method) {
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
}
if (expected.url !== received.url) {
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
}
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
const interactions = httpInteractions(cassette)
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
HttpClientRequest.makeWith(
request.method,
redactUrl(request.url),
UrlParams.empty,
Option.none(),
Headers.empty,
HttpBody.empty,
)
+211
View File
@@ -0,0 +1,211 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option, Ref } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff"
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import {
httpInteractions,
type Cassette,
type CassetteMetadata,
type HttpInteraction,
type ResponseSnapshot,
} from "./schema"
import * as CassetteService from "./cassette"
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
export type RecordReplayMode = "record" | "replay" | "passthrough"
export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
readonly directory?: string
readonly metadata?: CassetteMetadata
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly responseHeaders?: ReadonlyArray<string>
readonly redactBody?: (body: unknown) => unknown
readonly dispatch?: "match" | "sequential"
readonly match?: RequestMatcher
}
const responseHeaders = (
response: HttpClientResponse.HttpClientResponse,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> | undefined,
) => {
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
return merged
}
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
const isBinaryContentType = (contentType: string | undefined) => {
if (!contentType) return false
const lower = contentType.toLowerCase()
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
}
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
isBinaryContentType(contentType)
? response.arrayBuffer.pipe(
Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })),
)
: response.text.pipe(Effect.map((body) => ({ body })))
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
}),
})
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
}),
})
const unsafeCassette = (
request: HttpClientRequest.HttpClientRequest,
name: string,
findings: ReadonlyArray<SecretFinding>,
) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
}),
})
export const recordingLayer = (
name: string,
options: Omit<RecordReplayOptions, "directory"> = {},
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const upstream = yield* HttpClient.HttpClient
const cassetteService = yield* CassetteService.Service
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
const match = options.match ?? defaultMatcher
const mode = options.mode ?? "replay"
const sequential = options.dispatch === "sequential"
const replay = yield* Ref.make<Cassette | undefined>(undefined)
const cursor = yield* Ref.make(0)
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
const raw = yield* Effect.promise(() => web.text())
const body = options.redactBody
? Option.match(decodeJson(raw), {
onNone: () => raw,
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
})
: raw
return {
method: web.method,
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
headers: redactHeaders(
Object.fromEntries(web.headers.entries()),
requestHeadersAllow,
options.redact?.headers,
),
body,
}
})
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
Effect.gen(function* () {
const interactions = httpInteractions(cassette)
if (sequential) {
const index = yield* Ref.get(cursor)
const interaction = interactions[index]
if (!interaction)
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
if (!match(incoming, interaction.request)) {
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
}
yield* Ref.update(cursor, (n) => n + 1)
return { interaction, detail: "" }
}
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
})
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
yield* Ref.set(replay, cassette)
return cassette
})
return HttpClient.make((request) => {
if (mode === "passthrough") return upstream.execute(request)
if (mode === "record") {
return Effect.gen(function* () {
const currentRequest = yield* snapshotRequest(request)
const response = yield* upstream.execute(request)
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
const captured = yield* captureResponseBody(response, headers["content-type"])
const interaction: HttpInteraction = {
transport: "http",
request: currentRequest,
response: { status: response.status, headers, ...captured },
}
const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
const findings = result.findings
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
)
})
}
return Effect.gen(function* () {
const cassette = yield* loadReplay(request)
const incoming = yield* snapshotRequest(request)
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
if (!interaction) return yield* fixtureMismatch(request, name, detail)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
)
})
})
}),
)
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe(
Layer.provide(CassetteService.layer({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
)
+10
View File
@@ -0,0 +1,10 @@
export * from "./schema"
export * from "./redaction"
export * from "./matching"
export * from "./diff"
export * from "./storage"
export * from "./websocket"
export * from "./effect"
export * as Cassette from "./cassette"
export * as HttpRecorder from "."
+36
View File
@@ -0,0 +1,36 @@
import { Option, Schema } from "effect"
import type { RequestSnapshot } from "./schema"
const JsonValue = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value)
export const canonicalizeJson = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(canonicalizeJson)
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value)
.toSorted()
.map((key) => [key, canonicalizeJson(value[key])]),
)
}
return value
}
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
JSON.stringify({
method: snapshot.method,
url: snapshot.url,
headers: canonicalizeJson(snapshot.headers),
body: Option.match(decodeJson(snapshot.body), {
onNone: () => snapshot.body,
onSome: canonicalizeJson,
}),
})
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
+116
View File
@@ -0,0 +1,116 @@
import type { Cassette } from "./schema"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
"authorization",
"cookie",
"proxy-authorization",
"set-cookie",
"x-api-key",
"x-amz-security-token",
"x-goog-api-key",
]
const DEFAULT_REDACT_QUERY = [
"access_token",
"api-key",
"api_key",
"apikey",
"code",
"key",
"signature",
"sig",
"token",
"x-amz-credential",
"x-amz-security-token",
"x-amz-signature",
]
const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
{ label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
{ label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
{ label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
{ label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
{ label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
{ label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
]
const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
const envSecrets = () =>
Object.entries(process.env).flatMap(([name, value]) => {
if (!value) return []
if (!ENV_SECRET_NAMES.test(name)) return []
if (value.length < 12) return []
if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
return [{ name, value }]
})
const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
if (typeof value === "string") return [{ path: base, value }]
if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
}
return []
}
const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
export type UrlRedactor = (url: string) => string
export const redactUrl = (
raw: string,
query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
urlRedactor?: UrlRedactor,
) => {
if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw
const url = new URL(raw)
if (url.username) url.username = REDACTED
if (url.password) url.password = REDACTED
const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
for (const key of [...url.searchParams.keys()]) {
if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
}
return urlRedactor?.(url.toString()) ?? url.toString()
}
export const redactHeaders = (
headers: Record<string, string>,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
) => {
const allowed = new Set(allow.map((name) => name.toLowerCase()))
const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
return Object.fromEntries(
Object.entries(headers)
.map(([name, value]) => [name.toLowerCase(), value] as const)
.filter(([name]) => allowed.has(name))
.map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
.toSorted(([a], [b]) => a.localeCompare(b)),
)
}
export type SecretFinding = {
readonly path: string
readonly reason: string
}
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
stringEntries(value).flatMap((entry) => [
...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
path: entry.path,
reason: item.label,
})),
...envSecrets()
.filter((item) => entry.value.includes(item.value))
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
])
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
+67
View File
@@ -0,0 +1,67 @@
import { Schema } from "effect"
export const RequestSnapshotSchema = Schema.Struct({
method: Schema.String,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.String,
})
export type RequestSnapshot = Schema.Schema.Type<typeof RequestSnapshotSchema>
export const ResponseSnapshotSchema = Schema.Struct({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.String,
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
})
export type ResponseSnapshot = Schema.Schema.Type<typeof ResponseSnapshotSchema>
export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
export type CassetteMetadata = Schema.Schema.Type<typeof CassetteMetadataSchema>
export const HttpInteractionSchema = Schema.Struct({
transport: Schema.tag("http"),
request: RequestSnapshotSchema,
response: ResponseSnapshotSchema,
})
export type HttpInteraction = Schema.Schema.Type<typeof HttpInteractionSchema>
export const WebSocketFrameSchema = Schema.Union([
Schema.Struct({ kind: Schema.tag("text"), body: Schema.String }),
Schema.Struct({ kind: Schema.tag("binary"), body: Schema.String, bodyEncoding: Schema.Literal("base64") }),
])
export type WebSocketFrame = Schema.Schema.Type<typeof WebSocketFrameSchema>
export const WebSocketInteractionSchema = Schema.Struct({
transport: Schema.tag("websocket"),
open: Schema.Struct({
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
}),
client: Schema.Array(WebSocketFrameSchema),
server: Schema.Array(WebSocketFrameSchema),
})
export type WebSocketInteraction = Schema.Schema.Type<typeof WebSocketInteractionSchema>
export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
Schema.toTaggedUnion("transport"),
)
export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1),
metadata: Schema.optional(CassetteMetadataSchema),
interactions: Schema.Array(InteractionSchema),
})
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
export const encodeCassette = Schema.encodeSync(CassetteSchema)
+34
View File
@@ -0,0 +1,34 @@
import { Option } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({
name,
recordedAt: new Date().toISOString(),
...(metadata ?? {}),
})
export const cassetteFor = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: metadataFor(name, metadata),
interactions,
})
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
const file = cassettePath(name, options.directory)
if (!fs.existsSync(file)) return false
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
}
+204
View File
@@ -0,0 +1,204 @@
import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
export interface WebSocketRequest {
readonly url: string
readonly headers: Headers.Headers
}
export interface WebSocketConnection<E> {
readonly sendText: (message: string) => Effect.Effect<void, E>
readonly messages: Stream.Stream<string | Uint8Array, E>
readonly close: Effect.Effect<void>
}
export interface WebSocketExecutor<E> {
readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
}
export interface WebSocketRecordReplayOptions<E> {
readonly name: string
readonly mode?: "record" | "replay" | "passthrough"
readonly metadata?: CassetteMetadata
readonly cassette: CassetteService.Interface
readonly live: WebSocketExecutor<E>
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly compareClientMessagesAsJson?: boolean
}
const headersRecord = (headers: Headers.Headers) =>
Object.fromEntries(
Object.entries(headers as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.toSorted(([a], [b]) => a.localeCompare(b)),
)
const openSnapshot = (
request: WebSocketRequest,
options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
) => ({
url: redactUrl(request.url, options.redact?.query, options.redact?.url),
headers: redactHeaders(
headersRecord(request.headers),
options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
options.redact?.headers,
),
})
const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
const frameText = (frame: WebSocketFrame) => {
if (frame.kind === "text") return frame.body
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
}
const frameMessage = (frame: WebSocketFrame) =>
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
typeof message === "string"
? textFrame(message)
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
new Error(
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
)
const mismatch = (message: string, actual: unknown, expected: unknown) =>
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
Effect.sync(() => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return
throw mismatch(message, actual, expected)
})
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
if (!expected)
return Effect.sync(() => {
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
})
const expectedText = frameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
}
export const makeWebSocketExecutor = <E>(
options: WebSocketRecordReplayOptions<E>,
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
Effect.gen(function* () {
const mode = options.mode ?? "replay"
if (mode === "passthrough") return options.live
if (mode === "record") {
return {
open: (request) =>
Effect.gen(function* () {
const client: WebSocketFrame[] = []
const server: WebSocketFrame[] = []
const connection = yield* options.live.open(request)
const closed = yield* Ref.make(false)
const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close
const result = yield* options.cassette
.append(
options.name,
{ transport: "websocket", open: openSnapshot(request, options), client, server },
options.metadata,
)
.pipe(Effect.orDie)
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
})
return {
sendText: (message: string) =>
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
messages: connection.messages.pipe(
Stream.map((message) => {
server.push(receivedFrame(message))
return message
}),
),
close: closeOnce,
}
}),
}
}
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
undefined,
)
const cursor = yield* Ref.make(0)
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const input = yield* Ref.get(replay)
if (!input) return
yield* assertEqual(
`Unused recorded WebSocket interactions in ${options.name}`,
yield* Ref.get(cursor),
input.interactions.length,
)
}),
)
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const input = {
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
}
yield* Ref.set(replay, input)
return input
})
return {
open: (request) => {
return Effect.gen(function* () {
const input = yield* loadReplay()
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
const interaction = input.interactions[index]
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
const messageIndex = yield* Ref.make(0)
return {
sendText: (message: string) =>
Effect.gen(function* () {
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
yield* compareClientMessage(
message,
interaction.client[current],
current,
options.compareClientMessagesAsJson === true,
)
}),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
close: Effect.gen(function* () {
yield* assertEqual(
`WebSocket client frame count for interaction ${index + 1}`,
yield* Ref.get(messageIndex),
interaction.client.length,
)
}),
}
})
},
}
})
@@ -0,0 +1,41 @@
{
"version": 1,
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/echo",
"headers": {
"content-type": "application/json"
},
"body": "{\"step\":1}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"reply\":\"first\"}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/echo",
"headers": {
"content-type": "application/json"
},
"body": "{\"step\":2}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"reply\":\"second\"}"
}
}
]
}
@@ -0,0 +1,41 @@
{
"version": 1,
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/poll",
"headers": {
"content-type": "application/json"
},
"body": "{\"id\":\"job_1\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"status\":\"pending\"}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/poll",
"headers": {
"content-type": "application/json"
},
"body": "{\"id\":\"job_1\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"status\":\"complete\"}"
}
}
]
}
@@ -0,0 +1,320 @@
import { NodeFileSystem } from "@effect/platform-node"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Scope, Stream } from "effect"
import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/diff"
const post = (url: string, body: object) =>
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const request = HttpClientRequest.post(url, {
headers: { "content-type": "application/json" },
body: HttpBody.text(JSON.stringify(body), "application/json"),
})
const response = yield* http.execute(request)
return yield* response.text
})
const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step"))))
const runWith = <A, E>(
name: string,
options: HttpRecorder.RecordReplayOptions,
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options))))
const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Service | Scope.Scope>) =>
Effect.runPromise(
Effect.scoped(
effect.pipe(
Effect.provide(
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
),
Effect.provide(NodeFileSystem.layer),
),
),
)
const failureText = (exit: Exit.Exit<unknown, unknown>) => {
if (Exit.isSuccess(exit)) return ""
return Cause.prettyErrors(exit.cause).join("\n")
}
describe("http-recorder", () => {
test("redacts sensitive URL query parameters", () => {
expect(
HttpRecorder.redactUrl(
"https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
),
).toBe(
"https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
)
})
test("redacts URL credentials", () => {
expect(HttpRecorder.redactUrl("https://user:password@example.test/path?safe=value")).toBe(
"https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
)
})
test("applies custom URL redaction after built-in redaction", () => {
expect(
HttpRecorder.redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) =>
url.replace("/accounts/real-account/", "/accounts/{account}/"),
),
).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D")
})
test("redacts sensitive headers when allow-listed", () => {
expect(
HttpRecorder.redactHeaders(
{
authorization: "Bearer secret-token",
"content-type": "application/json",
"x-custom-token": "custom-secret",
"x-api-key": "secret-key",
"x-goog-api-key": "secret-google-key",
},
["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
["x-custom-token"],
),
).toEqual({
authorization: "[REDACTED]",
"content-type": "application/json",
"x-api-key": "[REDACTED]",
"x-custom-token": "[REDACTED]",
"x-goog-api-key": "[REDACTED]",
})
})
test("redacts error requests without retaining headers, params, or body", () => {
const request = HttpClientRequest.post("https://example.test/path", {
headers: { authorization: "Bearer super-secret" },
body: HttpBody.text("super-secret-body", "text/plain"),
}).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
expect(redactedErrorRequest(request).toJSON()).toMatchObject({
url: "https://example.test/path",
urlParams: { params: [] },
headers: {},
body: { _tag: "Empty" },
})
})
test("detects secret-looking values without returning the secret", () => {
expect(
HttpRecorder.cassetteSecretFindings({
version: 1,
interactions: [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/path?key=sk-123456789012345678901234",
headers: {},
body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }),
},
response: {
status: 200,
headers: {},
body: "Bearer abcdefghijklmnopqrstuvwxyz",
},
},
],
}),
).toEqual([
{ path: "interactions[0].request.url", reason: "API key" },
{ path: "interactions[0].request.body", reason: "Google API key" },
{ path: "interactions[0].response.body", reason: "bearer token" },
])
})
test("detects secret-looking values inside metadata", () => {
expect(
HttpRecorder.cassetteSecretFindings({
version: 1,
metadata: { token: "sk-123456789012345678901234" },
interactions: [],
}),
).toEqual([{ path: "metadata.token", reason: "API key" }])
})
test("formats websocket cassettes with shared metadata", () => {
const cassette = HttpRecorder.cassetteFor(
"websocket/basic",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
{ provider: "openai" },
)
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
})
test("replays websocket interactions from the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* cassette.write(
"websocket/replay",
HttpRecorder.cassetteFor(
"websocket/replay",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
undefined,
),
)
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}),
)
})
test("records websocket interactions into the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/record",
mode: "record",
metadata: { provider: "test" },
cassette,
live: {
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromIterable([JSON.stringify({ type: "response.completed" })]),
close: Effect.void,
}),
},
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
yield* connection.messages.pipe(Stream.runDrain)
yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject({
metadata: { name: "websocket/record", provider: "test" },
interactions: [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
})
}),
)
})
test("default matcher dispatches multi-interaction cassettes by request shape", async () => {
await run(
Effect.gen(function* () {
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
}),
)
})
test("sequential dispatch returns recorded responses in order for identical requests", async () => {
await runWith(
"record-replay/retry",
{ dispatch: "sequential" },
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
}),
)
})
test("default matcher returns the first match for identical requests", async () => {
await runWith(
"record-replay/retry",
{},
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
}),
)
})
test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
yield* post("https://example.test/echo", { step: 2 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
test("sequential dispatch still validates each recorded request", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain("$.step expected 2, received 3")
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
}),
)
})
test("mismatch diagnostics show closest redacted request differences", async () => {
await run(
Effect.gen(function* () {
const exit = yield* Effect.exit(
post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
)
const message = failureText(exit)
expect(message).toContain("closest interaction: #1")
expect(message).toContain("url:")
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
expect(message).toContain("body:")
expect(message).toContain("$.step expected 1, received 3")
expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
expect(message).not.toContain("sk-123456789012345678901234")
}),
)
})
})
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["bun"], // kilocode_change - include Bun test globals in workspace typecheck
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Kilo Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@kilocode/kilo-console",
"version": "7.3.22",
"private": true,
"type": "module",
"scripts": {
"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": {
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/router": "catalog:",
"ghostty-web": "0.4.0",
"solid-js": "catalog:",
"yaml": "2.8.3"
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-solid": "catalog:"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 32 32">
<rect width="32" height="32" fill="oklch(95% 0.15 108)"/>
<g fill="#000000">
<path d="M23,26v-2h3v-5l-2-2h-4v2h-3v5l2,2h4ZM20,20h3v3h-3v-3Z"/>
<rect x="12" y="17" width="3" height="3"/>
<polygon points="26 12 23 12 23 9 20 6 17 6 17 9 20 9 20 12 17 12 17 15 26 15 26 12"/>
<path d="M0,0v32h32V0H0ZM29,29H3V3h26v26Z"/>
<polygon points="15 26 15 23 9 23 9 17 6 17 6 23.1875 8.8125 26 15 26"/>
<rect x="12" y="6" width="3" height="3"/>
<polygon points="9 12 12 12 12 15 15 15 15 12 12 9 9 9 9 6 6 6 6 15 9 15 9 12"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 686 B

+17
View File
@@ -0,0 +1,17 @@
import { createMemo } from "solid-js"
import type { JSX } from "solid-js"
import { useLocation } from "@solidjs/router"
import { ThemeProvider } from "@kilocode/kilo-web-ui/theme"
import { ConsoleLayout } from "./layouts/ConsoleLayout"
import { path as route } from "./shared/navigation"
export default function App(props: { children?: JSX.Element }) {
const loc = useLocation()
const current = createMemo(() => route(loc.pathname))
return (
<ThemeProvider defaultTheme="kilo">
<ConsoleLayout path={current()}>{props.children}</ConsoleLayout>
</ThemeProvider>
)
}
+38
View File
@@ -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"]] })
})
+703
View File
@@ -0,0 +1,703 @@
import { createKiloClient, type Config as EffectiveConfig } from "@kilocode/sdk/v2/client"
import type {
AgentBuilderPreviewResponse,
AgentBuilderSaveResponse,
Auth,
AppAgentsResponse,
ConfigOverlayResponse,
ConfigModelStateResponse,
ConfigSourcesResponse,
FormatterStatusResponse,
GlobalHealthResponse,
GlobalEvent,
LspStatusResponse,
McpStatusResponse,
Pty as PtyInfo,
PermissionRequest,
Project as KiloProject,
QuestionRequest,
ProviderAuthAuthorization,
ProviderAuthResponse,
ProviderListResponse,
Session as KiloSession,
SessionStatus,
ToolIdsResponse,
ToolListResponse,
TuiConfigGetResponse,
TuiKeybindListResponse,
VcsInfo,
Worktree,
WorktreeDiffItem,
} from "@kilocode/sdk/v2/client"
export type Scope = "global" | "project"
export type Query = {
url: string
dir: string
scope: Scope
}
export type ProjectQuery = Pick<Query, "url" | "dir">
export type ProjectItem = KiloProject
export type RecentProjectItem = ProjectItem & {
sessions: number
}
export type ProjectConsoleQuery = ProjectQuery & {
project: string
}
export type ProjectConsoleSnapshot = {
project: ProjectItem
vcs: VcsInfo
worktrees: string[]
terminals: ProjectTerminalItem[]
}
export type ProjectWorktreeItem = Worktree
export type ProjectDiffItem = WorktreeDiffItem
export type ProjectPtyInfo = PtyInfo
export type ProjectTerminalItem = ProjectPtyInfo & {
directory: string
session?: KiloSession
sessionStatus?: SessionStatus
attention?: "permission" | "question"
}
export type ProjectConsoleEvent = GlobalEvent
export type Snapshot = {
health: GlobalHealthResponse
effective: EffectiveConfig
overlay: ConfigOverlayResponse
sources: ConfigSourcesResponse
modelState: ConfigModelStateResponse
providers: ProviderListResponse
authMethods: ProviderAuthResponse
tui: TuiConfigGetResponse
keybinds: TuiKeybindListResponse
tools: ToolIdsResponse
toolDetails: ToolListResponse
mcp: McpStatusResponse
lsp: LspStatusResponse
formatter: FormatterStatusResponse
agents: AppAgentsResponse
}
export type ConfigPatch = Partial<EffectiveConfig>
export type ConfigUnset = string[][]
export type ModelRef = ConfigModelStateResponse["favorite"][number]
export type TuiPatch = Partial<TuiConfigGetResponse>
export type AgentPayload = {
scope: Scope
id: string
description?: string
mode: "primary" | "subagent" | "all"
model?: string
color?: string
steps?: number
tools?: string[]
permission?: Record<string, unknown>
prompt: string
}
type Result<T> = {
data: T | undefined
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 key = "kilo.config.server"
const discovery: { run?: Promise<string | undefined> } = {}
const fetcher = window.fetch.bind(window) as typeof fetch
function encode(input: string) {
const bytes = new TextEncoder().encode(input)
return btoa(String.fromCharCode(...bytes))
}
function decode(input: string) {
if (!input) return ""
return decodeURIComponent(input)
}
function server(input: string) {
const url = new URL(input)
const user = decode(url.username) || "kilo"
const pass = decode(url.password) || "kilo"
url.username = ""
url.password = ""
url.search = ""
url.hash = ""
return {
url: url.toString().replace(/\/$/, ""),
token: encode(`${user}:${pass}`),
}
}
function client(input: ProjectQuery) {
const info = server(input.url)
return createKiloClient({
baseUrl: info.url,
directory: value(input.dir),
headers: {
Authorization: `Basic ${info.token}`,
},
fetch: fetcher,
})
}
function value(input: string) {
const trimmed = input.trim()
if (trimmed) return trimmed
return undefined
}
function message(input: unknown) {
if (input instanceof Error) return input.message
if (typeof input === "string") return input
if (input === undefined || input === null) return "Unknown error"
return JSON.stringify(input)
}
function demand<T>(label: string, result: Result<T>) {
if (result.error) throw new Error(`${label}: ${message(result.error)}`)
if (result.data === undefined) throw new Error(`${label}: empty response`)
return result.data
}
async function resolved(input: ProjectConsoleQuery) {
const items = await loadVisibleProjects(input)
const item = items.find((row) => row.id === input.project)
if (!item) throw new Error(`Project not found: ${input.project}`)
return item
}
async function maybe<T>(label: string, result: Promise<Result<T>>) {
return await result
.then((value) => {
if (value.error) {
console.warn(`${label}: ${message(value.error)}`)
return undefined
}
return value.data
})
.catch((err) => {
console.warn(`${label}: ${message(err)}`)
return undefined
})
}
function model(input: unknown) {
if (typeof input !== "string") return undefined
const index = input.indexOf("/")
if (index <= 0 || index >= input.length - 1) return undefined
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 || "/"
}
function inside(root: string, input: string) {
const base = norm(root)
const dir = norm(input)
if (dir === base) return true
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
return Math.max(best, norm(root).length)
}, -1)
}
function owner(items: ProjectItem[], session: Pick<KiloSession, "projectID" | "directory">) {
const exact = items.find((item) => item.id === session.projectID)
if (exact) return exact
return items.reduce<{ item?: ProjectItem; score: number }>(
(best, item) => {
const next = score(item, session.directory)
if (next <= best.score) return best
return { item, score: next }
},
{ score: -1 },
).item
}
async function probe(url: string) {
const ctl = new AbortController()
const timer = window.setTimeout(() => ctl.abort(), 400)
const info = server(url)
return await fetcher(`${info.url}/global/health`, {
headers: { Authorization: `Basic ${info.token}` },
signal: ctl.signal,
})
.then((res) => (res.ok ? url : undefined))
.catch(() => undefined)
.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<ProjectItem>
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<Hit | undefined> {
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) ?? ""
}
export function saveCached(url: string) {
window.localStorage.setItem(key, url)
}
export function forgetCached() {
window.localStorage.removeItem(key)
}
export async function healthy(url: string) {
return (await probe(url)) !== undefined
}
async function scan() {
const urls = ports.flatMap((port) => [`http://127.0.0.1:${port}`, `http://localhost:${port}`])
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<Snapshot> {
const sdk = client(input)
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),
effective: resolved.effective,
overlay: resolved,
sources: { sources: resolved.sources },
modelState: demand("Model state", modelState),
providers: demand("Providers", providers),
authMethods: demand("Provider auth methods", authMethods),
tui: cfg,
keybinds: keybinds ?? fallback(cfg),
tools: demand("Tools", tools),
toolDetails: info ?? [],
mcp: demand("MCP status", mcp),
lsp: demand("LSP status", lsp),
formatter: demand("Formatter status", formatter),
agents: demand("Agents", agents),
}
}
export async function loadProjects(input: ProjectQuery): Promise<ProjectItem[]> {
const sdk = client(input)
const dir = value(input.dir)
const result = await sdk.project.list(dir ? { directory: dir } : undefined)
return demand("Projects", result)
}
export async function loadVisibleProjects(input: ProjectQuery): Promise<ProjectItem[]> {
const items = await loadProjects(input)
return items.filter(visible)
}
export async function loadRecentProjects(input: ProjectQuery): Promise<RecentProjectItem[]> {
const sdk = client(input)
const [items, sessions] = await Promise.all([
loadVisibleProjects(input),
sdk.experimental.session.list({ archived: false, start: Date.now() - day, limit: 500 }),
])
const rows = demand("Recent sessions", sessions)
const counts = new Map<string, number>()
for (const row of rows) {
const item = owner(items, row)
if (!item) continue
counts.set(item.id, (counts.get(item.id) ?? 0) + 1)
}
return items.map((item) => ({ ...item, sessions: counts.get(item.id) ?? 0 })).filter((item) => item.sessions > 0)
}
export async function loadProjectConsole(input: ProjectConsoleQuery): Promise<ProjectConsoleSnapshot> {
const project = await resolved(input)
const query = { url: input.url, dir: project.worktree }
const sdk = client(query)
const [vcs, worktrees] = await Promise.all([
sdk.vcs.get({ directory: query.dir }),
sdk.worktree.list({ directory: query.dir }),
])
const dirs = demand("Worktrees", worktrees)
const terminals = await Promise.all(
[query.dir, ...dirs].map((dir) => loadProjectTerminals({ url: input.url, dir }, dir)),
)
return {
project,
vcs: demand("VCS", vcs),
worktrees: dirs,
terminals: terminals.flat(),
}
}
export async function createProjectWorktree(input: Query, name?: string): Promise<ProjectWorktreeItem> {
const sdk = client(input)
const trimmed = name?.trim()
const result = await sdk.worktree.create({
directory: input.dir,
...(trimmed ? { worktreeCreateInput: { name: trimmed } } : {}),
})
return demand("Create worktree", result)
}
export async function removeProjectWorktree(input: Query, dir: string) {
const sdk = client(input)
const result = await sdk.worktree.remove({ directory: input.dir, worktreeRemoveInput: { directory: dir } })
return demand("Remove worktree", result)
}
export async function resetProjectWorktree(input: Query, dir: string) {
const sdk = client(input)
const result = await sdk.worktree.reset({ directory: input.dir, worktreeResetInput: { directory: dir } })
return demand("Reset worktree", result)
}
export async function loadProjectTerminals(input: ProjectQuery, dir: string): Promise<ProjectTerminalItem[]> {
const sdk = client({ url: input.url, dir })
const [result, statusResult, permissionResult, questionResult] = await Promise.all([
sdk.pty.list({ directory: dir }),
maybe("Session status", sdk.session.status({ directory: dir })),
maybe("Pending permissions", sdk.permission.list({ directory: dir })),
maybe("Pending questions", sdk.question.list({ directory: dir })),
])
const rows = demand("Terminals", result)
const status = statusResult ?? {}
const permissions = pending(permissionResult ?? [])
const questions = pending(questionResult ?? [])
const sessions = new Map<string, KiloSession>()
await Promise.all(
rows.map(async (item) => {
if (!item.sessionID || sessions.has(item.sessionID)) return
const session = await maybe("Session", sdk.session.get({ directory: dir, sessionID: item.sessionID }))
if (session) sessions.set(item.sessionID, session)
}),
)
return rows.map((item) => {
const session = item.sessionID ? sessions.get(item.sessionID) : undefined
return {
...item,
directory: dir,
session,
sessionStatus: item.sessionID ? status[item.sessionID] : undefined,
attention: item.sessionID ? attention(item.sessionID, permissions, questions) : undefined,
}
})
}
function opened(items: ProjectPtyInfo[]) {
return new Set(items.flatMap((item) => (item.sessionID ? [item.sessionID] : [])))
}
function pending(items: Array<PermissionRequest | QuestionRequest>) {
return new Set(items.map((item) => item.sessionID))
}
function requested(items: Array<PermissionRequest | QuestionRequest>, open: Set<string>) {
return items.some((item) => open.has(item.sessionID))
}
export type ProjectLiveStatus = {
busy: boolean
attention: boolean
}
export async function loadProjectLiveStatus(input: ProjectQuery, dir: string): Promise<ProjectLiveStatus> {
const sdk = client({ url: input.url, dir })
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 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<string>, questions: Set<string>) {
if (permissions.has(id)) return "permission"
if (questions.has(id)) return "question"
return undefined
}
export async function loadProjectDiff(input: Query, dir: string): Promise<ProjectDiffItem[]> {
const sdk = client({ url: input.url, dir })
const result = await sdk.worktree.diffSummary({ directory: dir })
return demand("Worktree diff", result)
}
export async function loadProjectDiffFile(input: Query, dir: string, file: string): Promise<ProjectDiffItem | null> {
const sdk = client({ url: input.url, dir })
const result = await sdk.worktree.diffFile({ directory: dir, file })
return demand("Worktree diff file", result)
}
export async function createProjectPty(input: Query, dir: string, title = "Kilo session"): Promise<ProjectPtyInfo> {
const sdk = client({ url: input.url, dir })
const result = await sdk.pty.create({ directory: dir, command: "kilo", cwd: dir, title })
return demand("Create terminal", result)
}
export async function removeProjectPty(input: Query, pty: string) {
const sdk = client({ url: input.url, dir: input.dir })
const result = await sdk.pty.remove({ directory: input.dir, ptyID: pty })
return demand("Remove terminal", result)
}
export async function viewProjectSessions(input: ProjectQuery, focused: string[], open: string[]) {
const sdk = client(input)
const result = await sdk.session.viewed({ directory: input.dir, focused, open })
return demand("Viewed sessions", result)
}
export function subscribeProjectEvents(input: ProjectQuery, handler: (event: ProjectConsoleEvent) => void) {
const sdk = client(input)
const ctl = new AbortController()
void (async () => {
const events = await sdk.global.event({ signal: ctl.signal, sseMaxRetryAttempts: 0 })
for await (const event of events.stream) {
if (ctl.signal.aborted) return
handler(event)
}
})().catch((err) => {
if (!ctl.signal.aborted) console.warn(`Project events: ${message(err)}`)
})
return () => ctl.abort()
}
export async function resizeProjectPty(input: Query, pty: string, cols: number, rows: number) {
const sdk = client({ url: input.url, dir: input.dir })
const result = await sdk.pty.update({ directory: input.dir, ptyID: pty, size: { cols, rows } })
return demand("Resize terminal", result)
}
export function ptyWsUrl(input: Query, pty: string, cursor = 0) {
const info = server(input.url)
const url = new URL(`/pty/${encodeURIComponent(pty)}/connect`, info.url)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.searchParams.set("directory", input.dir)
url.searchParams.set("cursor", String(cursor))
url.searchParams.set("auth_token", info.token)
return url.toString()
}
export async function saveConfig(input: Query, patch: Partial<ConfigPatch>) {
const sdk = client(input)
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({ directory: value(input.dir), scope: input.scope, unset })
return demand("Update config", result)
}
export async function saveModelState(input: Query, favorite: ModelRef[]) {
const sdk = client(input)
const result = await sdk.config.modelStateUpdate({ favorite })
return demand("Update model state", result)
}
export async function connectProvider(input: Query, id: string, key: string, metadata?: Record<string, string>) {
const sdk = client(input)
const auth: Auth = metadata ? { type: "api", key, metadata } : { type: "api", key }
const result = await sdk.auth.set({ providerID: id, auth })
demand("Connect provider", result)
await sdk.global.dispose()
}
export async function authorizeProvider(
input: Query,
id: string,
method: number,
inputs?: Record<string, string>,
): Promise<ProviderAuthAuthorization> {
const sdk = client(input)
const result = await sdk.provider.oauth.authorize({ providerID: id, method, inputs })
return demand("Authorize provider", result)
}
export async function completeProvider(input: Query, id: string, method: number, code?: string) {
const sdk = client(input)
const result = await sdk.provider.oauth.callback({ providerID: id, method, code })
demand("Complete provider authorization", result)
await sdk.global.dispose()
}
export async function connectMcp(input: Query, name: string) {
const sdk = client(input)
const result = await sdk.mcp.connect({ name })
return demand("Connect MCP server", result)
}
export async function disconnectMcp(input: Query, name: string) {
const sdk = client(input)
const result = await sdk.mcp.disconnect({ name })
return demand("Disconnect MCP server", result)
}
export async function authenticateMcp(input: Query, name: string) {
const sdk = client(input)
const result = await sdk.mcp.auth.authenticate({ name })
return demand("Authenticate MCP server", result)
}
export async function saveTui(input: Query, patch: TuiPatch) {
const sdk = client(input)
const result = await sdk.tui.config.update({ scope: input.scope, ...patch })
return demand("Update TUI config", result)
}
export async function previewAgent(input: Query, payload: AgentPayload): Promise<AgentBuilderPreviewResponse> {
const sdk = client(input)
const result = await sdk.agentBuilder.preview(payload)
return demand("Preview agent", result)
}
export async function saveAgent(input: Query, payload: AgentPayload): Promise<AgentBuilderSaveResponse> {
const sdk = client(input)
const result = await sdk.agentBuilder.save({
path_id: payload.id,
scope: payload.scope,
description: payload.description,
mode: payload.mode,
model: payload.model,
color: payload.color,
steps: payload.steps,
tools: payload.tools,
permission: payload.permission,
prompt: payload.prompt,
})
return demand("Save agent", result)
}
@@ -0,0 +1,42 @@
import { Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Icon } from "@kilocode/kilo-web-ui/icon"
type Props = {
open: boolean
title: string
message?: string
confirm?: string
cancel?: string
busy?: boolean
onCancel: () => void
onConfirm: () => void
}
export function ConfirmDialog(props: Props) {
return (
<Show when={props.open}>
<div class="confirm-scrim">
<section class="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="confirm-body">
<div class="confirm-icon" aria-hidden="true">
<Icon name="warning" />
</div>
<div>
<h2 id="confirm-title">{props.title}</h2>
<Show when={props.message}>{(text) => <p>{text()}</p>}</Show>
</div>
</div>
<footer class="confirm-actions">
<Button variant="ghost" disabled={props.busy} onClick={props.onCancel}>
{props.cancel ?? "Cancel"}
</Button>
<Button variant="primary" disabled={props.busy} onClick={props.onConfirm}>
{props.confirm ?? "Confirm"}
</Button>
</footer>
</section>
</div>
</Show>
)
}
@@ -0,0 +1,65 @@
import { For } from "solid-js"
export type SelectOption<T extends string = string> = {
value: T
label: string
disabled?: boolean
}
export function CustomSelect<T extends string>(props: {
label: string
value: T
options: readonly SelectOption<T>[]
class?: string
disabled?: boolean
invalid?: boolean
onSelect: (value: T) => void
}) {
const current = () => props.options.find((option) => option.value === props.value)?.label ?? props.value
function choose(option: SelectOption<T>, event: MouseEvent & { currentTarget: HTMLButtonElement }) {
if (props.disabled || option.disabled) return
props.onSelect(option.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 (
<details
class={`models-select ${props.class ?? ""}`}
classList={{ disabled: props.disabled, invalid: props.invalid }}
onToggle={toggle}
>
<summary aria-label={props.label} aria-disabled={props.disabled}>
{current()}
</summary>
<div class="models-select-menu" role="listbox" aria-label={props.label}>
<For each={props.options}>
{(option) => (
<button
class="models-select-option"
classList={{ selected: option.value === props.value, disabled: option.disabled }}
type="button"
role="option"
aria-selected={option.value === props.value}
disabled={props.disabled || option.disabled}
onClick={(event) => choose(option, event)}
>
{option.label}
</button>
)}
</For>
</div>
</details>
)
}
@@ -0,0 +1,45 @@
import { splitProps, type ComponentProps } from "solid-js"
type Props = Omit<ComponentProps<"input">, "onInput" | "type" | "value"> & {
label: string
value: string
variant?: "list" | "drawer"
hideLabel?: boolean
inputClass?: string
onValue: (value: string) => void
}
export function SearchField(props: Props) {
const [local, rest] = splitProps(props, [
"label",
"value",
"variant",
"hideLabel",
"inputClass",
"class",
"classList",
"onValue",
])
return (
<label
data-component="search-field"
data-variant={local.variant ?? "list"}
data-label={local.hideLabel === false ? "visible" : "hidden"}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<span data-slot="search-label">{local.label}</span>
<input
{...rest}
class={local.inputClass}
type="search"
value={local.value}
aria-label={local.hideLabel === false ? undefined : local.label}
onInput={(event) => local.onValue(event.currentTarget.value)}
/>
</label>
)
}
@@ -0,0 +1,26 @@
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { OmniSearch } from "./OmniSearch"
export function AppHeader() {
return (
<header class="app-header">
<a class="header-brand" href="/projects" aria-label="Kilo Console home">
<span class="header-mark" aria-hidden="true">
K
</span>
<span class="header-title">
<strong>Kilo</strong>
<span>Console</span>
</span>
</a>
<OmniSearch />
<nav class="notification-zone" aria-label="Notifications and status">
<IconButton icon="bubble-5" variant="ghost" aria-label="Notifications" />
<IconButton icon="help" variant="ghost" aria-label="Help" />
<IconButton icon="circle-check" variant="ghost" aria-label="System status" />
</nav>
</header>
)
}
@@ -0,0 +1,340 @@
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import {
discover,
forgetCached,
loadCached,
loadVisibleProjects,
resolveServer,
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())))
createEffect(() => {
document.body.classList.toggle("omni-search-open", open())
})
onCleanup(() => document.body.classList.remove("omni-search-open"))
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
void resolveServer().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>
</footer>
</Show>
</form>
)
}
@@ -0,0 +1,426 @@
import { A, useLocation } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup } from "solid-js"
import {
discover,
forgetCached,
loadRecentProjects,
loadProjectLiveStatus,
loadProjectOpenSessions,
loadCached,
resolveServer,
saveCached,
subscribeProjectEvents,
type ProjectConsoleEvent,
type RecentProjectItem,
type ProjectQuery,
} from "../../client"
import { strip, type Path } from "../../shared/navigation"
import { clean, friendly } from "../../shared/utils"
import {
projectStatus,
projectForDir,
eventTypeName,
eventSessionId,
markError,
clearError,
markAttention,
clearAttention,
markUnread,
clearUnread,
clearBusy,
markBusy,
type GlobalEvent,
} from "../../shared/terminal-status"
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 repo(input: string) {
const parts = input.split(/[\\/]/).filter(Boolean)
return parts.at(-1) ?? "Global"
}
function name(item: RecentProjectItem) {
return friendly(item.name?.trim() || repo(item.worktree) || item.id.slice(0, 8))
}
function mark(item: RecentProjectItem) {
const parts = name(item)
.split(/[\s._/-]+/)
.filter(Boolean)
const text = `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? parts[0]?.[1] ?? ""}`
return text.toUpperCase() || "KG"
}
function tail(input: URLSearchParams) {
const next = new URLSearchParams(input)
next.delete("directory")
const query = next.toString()
return query ? `?${query}` : ""
}
function href(item: RecentProjectItem, input: URLSearchParams) {
return `/projects/${encodeURIComponent(item.id)}${tail(input)}`
}
// ─── component ────────────────────────────────────────────────────────────────
type Props = {
path: Path
}
function Glyph(props: { name: "projects" | "settings" | "profile" }) {
if (props.name === "projects") {
return (
<svg class="rail-glyph" viewBox="0 0 24 24" aria-hidden="true" fill="none">
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2" />
</svg>
)
}
if (props.name === "settings") {
return (
<svg class="rail-glyph" viewBox="0 0 24 24" aria-hidden="true" fill="none">
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37.996 .608 2.296 .07 2.572 -1.065z" />
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>
)
}
return (
<svg class="rail-glyph" viewBox="0 0 24 24" aria-hidden="true" fill="none">
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
<path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />
</svg>
)
}
export function AppSidebar(props: Props) {
const loc = useLocation()
const params = createMemo(() => new URLSearchParams(loc.search))
const route = createMemo(() => strip(loc.pathname))
const discoverable = () => shouldDiscover(params())
const fallback = () => base(params())
const [url, setUrl] = createSignal(fallback())
const timers = { refetch: undefined as number | undefined }
const query = createMemo<ProjectQuery | undefined>(() => {
const target = clean(url()) || fallback()
if (!target) return undefined
return { url: target, dir: "" }
})
const [items, { refetch }] = createResource(query, loadRecentProjects)
const checks = new Map<string, number>()
// project currently rendered by ProjectConsoleRoute — it owns unread tracking for its terminals
const activeProject = createMemo(() => {
const match = route().match(/^\/projects\/([^/]+)/)
return match ? decodeURIComponent(match[1]) : undefined
})
const settings = () => `/settings${tail(params())}`
const selected = (item: RecentProjectItem) =>
route().startsWith(`/projects/${encodeURIComponent(item.id)}/`) ||
route() === `/projects/${encodeURIComponent(item.id)}`
const nav = () => [{ href: "/projects", label: "Projects", name: "projects", path: "/projects" }] as const
const bottom = () =>
[
{ href: "/profile", label: "Profile", name: "profile", path: "/profile" },
{ href: settings(), label: "Settings", name: "settings", path: "/settings" },
] as const
function scheduleRefetch() {
if (timers.refetch) return
timers.refetch = window.setTimeout(() => {
timers.refetch = undefined
void refetch()
}, 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(() => {
const next = params().get("server")
if (next && next !== url()) setUrl(next)
})
createEffect(() => {
if (!discoverable()) return
void resolveServer().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)
})
})
// ── polling fallback ─────────────────────────────────────────────────────────
createEffect(() => {
if (!query()) return
const timer = window.setInterval(() => void refetch(), 5000)
onCleanup(() => window.clearInterval(timer))
})
// ── initial status hydration ─────────────────────────────────────────────────
// Called whenever items reload. Fills busy + attention from server state.
// Unread is written by ProjectConsoleRoute (same-project) and SSE turn.close (cross-project).
createEffect(() => {
const list = items()
const current = query()
if (!list || !current) return
for (const item of list) {
void loadProjectLiveStatus(current, item.worktree)
.then((s) => {
// busy: project has at least one non-idle session
// we don't have per-session IDs here, use a sentinel "__hydrated__"
if (s.busy) markBusy(item.id, "__hydrated__")
else clearBusy(item.id, "__hydrated__")
if (s.attention) markAttention(item.id, "__hydrated__")
else clearAttention(item.id, "__hydrated__")
})
.catch((err) => console.warn("Project live status:", err))
}
})
// ── SSE event handler ────────────────────────────────────────────────────────
createEffect(() => {
const current = query()
if (!current) return
const stop = subscribeProjectEvents(current, (event) => {
const ge = event as unknown as GlobalEvent
const list = items() ?? []
// refresh project list on session lifecycle events
const t = eventTypeName(ge)
if (t.startsWith("session.created") || t.startsWith("session.updated") || t.startsWith("session.deleted")) {
scheduleRefetch()
}
if (!list.length || !ge.directory) return
const proj = projectForDir(list, ge.directory)
if (!proj) return
const sid = eventSessionId(ge)
// session.turn.close: error | completed
if (t === "session.turn.close") {
const payload = (ge.payload as Record<string, unknown>).properties as { reason?: string } | undefined
if (!sid) return
if (payload?.reason === "error") {
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
}
// permission / question → attention
if (t === "permission.asked" || t === "question.asked") {
if (!sid) return
gated(
"attention",
current,
proj.id,
ge.directory,
sid,
() => markAttention(proj.id, sid),
() => clearAttention(proj.id, sid),
)
return
}
if (
t === "permission.replied" ||
t === "permission.rejected" ||
t === "question.replied" ||
t === "question.rejected"
) {
if (!sid) return
bump("attention", proj.id, sid)
clearAttention(proj.id, sid)
return
}
// session.status → busy / idle
if (t === "session.status") {
const sstatus = (ge.payload as Record<string, unknown>).properties as { status?: { type?: string } } | undefined
const stype = sstatus?.status?.type
if (!sid) return
if (stype === "busy" || stype === "retry") {
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)
}
return
}
})
onCleanup(stop)
})
onCleanup(() => {
if (timers.refetch) window.clearTimeout(timers.refetch)
checks.clear()
})
return (
<aside class="rail" aria-label="Primary navigation">
<nav class="rail-nav" aria-label="Primary">
<For each={nav()}>
{(item) => (
<A
class="rail-action"
classList={{ active: props.path === item.path }}
href={item.href}
aria-label={item.label}
aria-current={props.path === item.path ? "page" : undefined}
title={item.label}
>
<Glyph name={item.name} />
</A>
)}
</For>
</nav>
<div class="rail-favorites" aria-label="Projects with recent sessions">
<For each={items() ?? []}>
{(item) => {
const status = () => projectStatus(item.id)
return (
<A
class="favorite-project"
classList={{
active: selected(item),
"status-error": status() === "error",
"status-attention": status() === "attention",
"status-unread": status() === "unread",
"status-busy": status() === "busy",
}}
href={href(item, params())}
aria-label={name(item)}
aria-current={selected(item) ? "page" : undefined}
title={name(item)}
>
{mark(item)}
</A>
)
}}
</For>
</div>
<nav class="rail-bottom" aria-label="Account and settings">
<For each={bottom()}>
{(item) => (
<A
class="rail-action"
classList={{ active: props.path === item.path }}
href={item.href}
aria-label={item.label}
aria-current={props.path === item.path ? "page" : undefined}
title={item.label}
>
<Glyph name={item.name} />
</A>
)}
</For>
</nav>
</aside>
)
}
@@ -0,0 +1,158 @@
import { createEffect, createMemo, createResource, createSignal } from "solid-js"
import type { JSX } from "solid-js"
import {
discover,
forgetCached,
load,
loadCached,
loadProjects,
resolveServer,
saveCached,
saveConfig,
saveTui,
unsetConfig,
type ConfigPatch,
type ConfigUnset,
type Query,
type Scope,
type TuiPatch,
} from "../client"
import { ConfigContext, type Task } from "./config"
import { strip } from "../shared/navigation"
import { clean, errMsg } from "../shared/utils"
import { useLocation, useParams } from "@solidjs/router"
const params = new URLSearchParams(window.location.search)
const ui = new Set(["3017", "3018"])
function shouldDiscover(input = params) {
if (input.get("server")) return false
return ui.has(window.location.port)
}
function base(input = params) {
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
}
export function ConfigProvider(props: { children?: JSX.Element }) {
const loc = useLocation()
const params = useParams()
const search = createMemo(() => new URLSearchParams(loc.search))
const discoverable = () => shouldDiscover(search())
const fallback = () => base(search())
const [url, setUrl] = createSignal(fallback())
const route = createMemo(() => strip(loc.pathname))
const scope = createMemo<Scope>(() => (route().startsWith("/projects/") ? "project" : "global"))
const [saving, setSaving] = createSignal<string | undefined>()
const [failure, setFailure] = createSignal<string | undefined>()
const needs = createMemo(() => scope() === "project")
const projects = createMemo(() => {
const target = clean(url()) || fallback()
if (!target || !needs()) return undefined
return { url: target, dir: "" }
})
const [items] = createResource(projects, loadProjects)
const resolved = createMemo(() => {
if (!needs()) return ""
return items()?.find((item) => item.id === params.project)?.worktree ?? ""
})
const query = createMemo<Query | undefined>(() => {
const target = clean(url()) || fallback()
if (!target) return undefined
if (needs() && !resolved()) return undefined
return { url: target, dir: resolved(), scope: scope() }
})
const [data, { refetch }] = createResource(query, load)
function target() {
const item = query()
if (!item) throw new Error("Kilo server discovery is still running")
return item
}
createEffect(() => {
if (!needs() || items.loading || items.error || !items()) return
if (!resolved()) setFailure(`Project not found: ${params.project}`)
})
createEffect(() => {
const next = search().get("server")
if (next && next !== url()) setUrl(next)
})
createEffect(() => {
if (!discoverable()) return
void resolveServer().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
createEffect(() => {
const snap = data()
const item = query()
if (!snap || !item || !discoverable()) return
saveCached(item.url)
})
createEffect(() => {
if (!data.error || !discoverable()) return
const cached = loadCached()
if (!cached || cached !== url()) return
forgetCached()
setUrl("")
void discover().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
function fail(message: string) {
setFailure(message)
}
function run(label: string, job: () => Promise<unknown>, task?: Task) {
setSaving(label)
setFailure(undefined)
void job()
.then(() => (task?.refetch === false ? undefined : refetch()))
.then(() => undefined)
.catch((err: unknown) => setFailure(errMsg(err)))
.finally(() => setSaving(undefined))
}
function save(patch: Partial<ConfigPatch>) {
run("Saving config", () => saveConfig(target(), patch))
}
function unset(paths: ConfigUnset) {
run("Saving config", () => unsetConfig(target(), paths))
}
function tui(patch: TuiPatch) {
run("Saving TUI config", () => saveTui(target(), patch))
}
const ctx = {
data,
query,
saving,
failure,
target,
fail,
run,
save,
unset,
tui,
}
return <ConfigContext.Provider value={ctx}>{props.children}</ConfigContext.Provider>
}
@@ -0,0 +1,29 @@
import { createContext, useContext } from "solid-js"
import type { Accessor, Resource } from "solid-js"
import type { Query, Snapshot, ConfigPatch, ConfigUnset, TuiPatch } from "../client"
export type Task = {
refetch?: boolean
}
export type Ctx = {
data: Resource<Snapshot>
query: Accessor<Query | undefined>
saving: Accessor<string | undefined>
failure: Accessor<string | undefined>
target: () => Query
fail: (message: string) => void
run: (label: string, job: () => Promise<unknown>, task?: Task) => void
save: (patch: Partial<ConfigPatch>) => void
unset: (paths: ConfigUnset) => void
tui: (patch: TuiPatch) => void
}
export const ConfigContext = createContext<Ctx>()
export function useConfig() {
const ctx = useContext(ConfigContext)
if (!ctx) throw new Error("useConfig must be used within ConfigLayout")
return ctx
}
+40
View File
@@ -0,0 +1,40 @@
import "@kilocode/kilo-web-ui/styles"
import { Router, Route } from "@solidjs/router"
import { render } from "solid-js/web"
import App from "./App"
import "./styles.css"
import { ProjectConsoleRoute } from "./routes/projects/ProjectConsoleRoute"
import { ProjectsRoute } from "./routes/projects/ProjectsRoute"
import { ProfileRoute } from "./routes/profile/ProfileRoute"
import { ConfigLayout } from "./layouts/ConfigLayout"
import { configSections } from "./routes/config/sections"
const root = document.getElementById("root")
if (!root) throw new Error("Missing root element")
const base = import.meta.env.BASE_URL.replace(/\/$/, "")
function routes() {
return configSections.map((item) => <Route path={item.path} component={item.component} />)
}
render(
() => (
<Router root={App} base={base || undefined}>
<Route path="/projects" component={ProjectsRoute} />
<Route path="/projects/:project" component={ProjectConsoleRoute} />
<Route path="/projects/:project/settings" component={ConfigLayout}>
{routes()}
</Route>
<Route path="/profile" component={ProfileRoute} />
<Route path="/settings" component={ConfigLayout}>
{routes()}
</Route>
<Route path="/config" component={ConfigLayout}>
{routes()}
</Route>
<Route path="*" component={ProjectsRoute} />
</Router>
),
root,
)
@@ -0,0 +1,54 @@
import { Show } from "solid-js"
import type { JSX } from "solid-js"
import { Card } from "@kilocode/kilo-web-ui/card"
import { ConfigProvider } from "../context/ConfigProvider"
import { useConfig } from "../context/config"
import { ConfigSidebar } from "../routes/config/ConfigSidebar"
import { errMsg } from "../shared/utils"
export function ConfigLayout(props: { children?: JSX.Element }) {
return (
<ConfigProvider>
<ConfigContent>{props.children}</ConfigContent>
</ConfigProvider>
)
}
function ConfigContent(props: { children?: JSX.Element }) {
const ctx = useConfig()
return (
<section class="config-shell">
<ConfigSidebar />
<section class="content">
<Show when={ctx.failure()}>
{(item) => (
<Card class="banner" variant="error">
<strong>Dashboard update failed</strong>
<span>{item()}</span>
</Card>
)}
</Show>
<Show when={ctx.saving()}>
{(item) => (
<Card class="banner" variant="info">
{item()}...
</Card>
)}
</Show>
<Show when={ctx.data.error}>
<Card class="banner" variant="error">
<strong>Dashboard request failed</strong>
<span>{errMsg(ctx.data.error)}</span>
</Card>
</Show>
<Show when={ctx.data.loading && !ctx.data()}>
<Card class="banner" variant="info">
Loading dashboard data...
</Card>
</Show>
{props.children}
</section>
</section>
)
}
@@ -0,0 +1,21 @@
import type { JSX } from "solid-js"
import { AppHeader } from "../components/app-header/AppHeader"
import { AppSidebar } from "../components/app-sidebar/AppSidebar"
import type { Path } from "../shared/navigation"
type Props = {
children: JSX.Element
path: Path
}
export function ConsoleLayout(props: Props) {
return (
<div class="console-shell kilo-console dark">
<AppHeader />
<div class="console-body">
<AppSidebar path={props.path} />
<main class="console-main">{props.children}</main>
</div>
</div>
)
}
@@ -0,0 +1,786 @@
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
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 { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { useConfig } from "../../context/config"
import { settings } from "../../shared/navigation"
import { toolCapabilities, toolName } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { ActionSelect, label as actionLabel, tone as actionTone } from "./PermissionsRoute"
import { agentEditable, agentTitle, snippets, useAgentBuilder, type AgentEntry, type AgentItem } from "./state/agents"
import type { PermissionAction } from "./state/permissions"
type Row = { item: AgentItem; entry?: AgentEntry; rank: number }
const colors = ["#0e639c", "#89d185", "#cca700", "#f14c4c", "#3794ff", "#c586c0", "#9cdcfe"]
const modes = [
{ value: "primary", label: "Primary" },
{ value: "subagent", label: "Subagent" },
{ value: "all", label: "Both" },
] satisfies SelectOption<"primary" | "subagent" | "all">[]
function desc(item: AgentItem) {
return item.description ?? "No description available."
}
function label(rank: number) {
if (rank === 0) return "Project Agents"
return "Global Agents"
}
function useAgentLinks() {
const loc = useLocation()
const nav = useNavigate()
const href = (id?: string) => {
const suffix = id ? `/${encodeURIComponent(id)}` : ""
return `${settings(loc.pathname)}/agents${suffix}${loc.search}`
}
return { href, nav }
}
function countTools(input: string[]) {
if (input.length === 1) return "1 tool"
return `${input.length} tools`
}
function FieldCard(props: { label: string; description?: string; actions?: JSX.Element; children: JSX.Element }) {
return (
<div class="ui-field agent-builder-field">
<div class="agent-builder-field-head">
<div>
<span>{props.label}</span>
<Show when={props.description}>{(description) => <small>{description()}</small>}</Show>
</div>
<Show when={props.actions}>
<div class="agent-builder-field-actions">{props.actions}</div>
</Show>
</div>
<div class="agent-builder-control">{props.children}</div>
</div>
)
}
function AgentRuleMeta(props: { action: PermissionAction }) {
return (
<div class="permission-row-meta">
<Tag tone={actionTone(props.action)}>{actionLabel(props.action)}</Tag>
</div>
)
}
export function AgentsRoute() {
const links = useAgentLinks()
const ctx = useConfig()
const [search, setSearch] = createSignal("")
const snap = () => ctx.data()
const rows = createMemo(() => {
const data = snap()
if (!data) return []
const scope = ctx.query()?.scope ?? "global"
const entries = new Map((data.overlay.collections.agent ?? []).map((entry) => [entry.key, entry]))
const local = new Set(
(data.overlay.collections.agent ?? []).filter((entry) => entry.source === "project").map((entry) => entry.key),
)
return data.agents
.filter((item) => {
const entry = entries.get(item.name)
if (scope === "global") return !entry?.local
return local.has(item.name) || !entry?.local
})
.map((item) => ({ item, entry: entries.get(item.name), rank: local.has(item.name) ? 0 : 1 }))
.sort((a, b) => a.rank - b.rank || agentTitle(a.item).localeCompare(agentTitle(b.item)))
})
const visible = createMemo(() => {
const q = search().trim().toLowerCase()
if (!q) return rows()
return rows().filter((row) =>
`${agentTitle(row.item)} ${row.item.name} ${desc(row.item)}`.toLowerCase().includes(q),
)
})
const groups = createMemo(() =>
[0, 1].map((rank) => visible().filter((row) => row.rank === rank)).filter((row) => row.length),
)
const empty = createMemo(() => {
if (!rows().length) return "No agents loaded."
return "No agents match this filter."
})
return (
<Show when={snap()}>
{(_data) => (
<ConfigPage
title={
<span class="config-title-count">
Agents
<CountTag>{rows().length}</CountTag>
</span>
}
description="Agents are reusable model, prompt, and tool configurations. Primary agents drive sessions; subagents are delegated to."
actions={
<Button icon="plus" variant="primary" onClick={() => links.nav(links.href("new"))}>
New agent
</Button>
}
>
<SearchField
label="Filter agents"
value={search()}
placeholder="Filter by name or ID..."
onValue={setSearch}
/>
<div class="agents">
<Show when={groups().length} fallback={<p class="empty">{empty()}</p>}>
<For each={groups()}>
{(group) => (
<section class="agent-section">
<Show when={ctx.query()?.scope === "project"}>
<SectionTitle>{label(group[0]?.rank ?? 1)}</SectionTitle>
</Show>
<For each={group}>
{(row) => (
<div classList={{ inherited: row.entry?.inherited }}>
<ConfigRow
leading={<span class="agent-mode-dot" data-mode={row.item.mode} aria-hidden="true" />}
title={agentTitle(row.item)}
subtitle={
<span class="agent-subtitle">
<span class="agent-id">{row.item.name}</span>
<span class="agent-description">{desc(row.item)}</span>
</span>
}
status={
<div class="tags agent-tags">
<Tag tone={row.item.mode === "primary" ? "brand" : "neutral"}>{row.item.mode}</Tag>
<Tag>{row.item.native ? "Native" : "Custom"}</Tag>
<Show when={row.item.hidden}>
<Tag>Hidden</Tag>
</Show>
<Show when={row.item.deprecated}>
<Tag>Deprecated</Tag>
</Show>
<Show when={row.entry}>
{(entry) => (
<SourceBadge
source={entry().source}
inherited={entry().inherited}
overridden={entry().overridden}
/>
)}
</Show>
</div>
}
actions={
<IconButton
icon={agentEditable(row.item, row.entry) ? "edit" : "eye"}
variant="ghost"
aria-label={`${agentEditable(row.item, row.entry) ? "Edit" : "Inspect"} ${agentTitle(row.item)}`}
onClick={() => links.nav(links.href(row.item.name))}
/>
}
/>
</div>
)}
</For>
</section>
)}
</For>
</Show>
</div>
</ConfigPage>
)}
</Show>
)
}
export function AgentBuilderRoute() {
const links = useAgentLinks()
const params = useParams()
const agent = () => (params.agentID === "new" ? undefined : params.agentID)
const state = useAgentBuilder(agent)
const title = createMemo(() => {
if (!agent()) return "Agent Builder"
if (state.locked()) return "Inspect Agent"
return "Edit Agent"
})
return (
<Show when={state.snap()}>
{(_data) => (
<ConfigPage
title={title()}
description="Build a reusable agent by combining a prompt, model, mode, and tool permissions."
actions={
<>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving()) || !state.ready()}
onClick={state.openMarkdown}
>
Markdown
</Button>
<Show when={!state.locked()}>
<Button variant="primary" disabled={Boolean(state.ctx.saving()) || !state.ready()} onClick={state.save}>
Save
</Button>
</Show>
<IconButton
icon="close"
variant="secondary"
aria-label="Close agent builder"
onClick={() => links.nav(links.href())}
/>
</>
}
>
<div class="builder agent-builder">
<section class="builder-form agent-builder-stack">
<Card class="ui-card agent-builder-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Identity</h2>
<p>Name, mode, and display metadata for this agent.</p>
</div>
</header>
<div class="ui-form agent-builder-form">
<FieldCard label="Agent id" description="Used in @ mentions, commands, and agent files.">
<input
class="mono"
value={state.id()}
placeholder="reviewer"
readOnly={state.locked()}
spellcheck={false}
onInput={(event) => state.setId(event.currentTarget.value)}
/>
</FieldCard>
<FieldCard label="Description" description="Shown when choosing or delegating to the agent.">
<input
value={state.desc()}
placeholder="Review code and report risks"
readOnly={state.locked()}
onInput={(event) => state.setDesc(event.currentTarget.value)}
/>
</FieldCard>
<FieldCard label="Mode">
<CustomSelect
label="Agent mode"
value={state.mode()}
options={modes}
disabled={state.locked()}
onSelect={state.setMode}
/>
</FieldCard>
<FieldCard label="Color">
<div class="agent-color-control">
<input
class="mono"
value={state.color()}
placeholder="#0e639c"
readOnly={state.locked()}
onInput={(event) => state.setColor(event.currentTarget.value)}
/>
<div class="agent-color-swatches" aria-label="Preset colors">
<For each={colors}>
{(color) => (
<button
class="agent-color-swatch"
classList={{ selected: state.color() === color }}
type="button"
aria-label={`Use color ${color}`}
disabled={state.locked()}
style={{ "background-color": color }}
onClick={() => state.setColor(color)}
/>
)}
</For>
</div>
</div>
</FieldCard>
<FieldCard label="Max steps" description="Optional hard cap on tool-call iterations.">
<input
class="mono"
value={state.steps()}
placeholder="optional"
inputMode="numeric"
readOnly={state.locked()}
onInput={(event) => state.setSteps(event.currentTarget.value)}
/>
</FieldCard>
</div>
</Card>
<Card class="ui-card agent-builder-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Model And Tools</h2>
<p>Choose the model and default tool access written into the agent frontmatter.</p>
</div>
</header>
<div class="ui-form agent-builder-form">
<FieldCard
label="Model"
actions={
<>
<Show when={!state.locked() && state.model()}>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.clearModel}>
Use Default
</Button>
</Show>
<IconButton
icon="edit"
variant="secondary"
aria-label="Edit agent model"
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={state.openModel}
/>
</>
}
>
<Show
when={state.selected()}
fallback={
<>
<strong>{state.model() || "Inherit default model"}</strong>
<Show when={state.model()}>{(value) => <span class="mono">{value()}</span>}</Show>
</>
}
>
{(model) => (
<>
<strong>{`${model().provider.name} / ${model().model.name}`}</strong>
<span class="mono">{model().id}</span>
</>
)}
</Show>
</FieldCard>
<FieldCard
label="Tool access"
description="Selected tools are written as allow permissions."
actions={
<>
<Show when={!state.locked() && state.tools().length}>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.clearTools}>
Clear
</Button>
</Show>
<IconButton
icon="edit"
variant="secondary"
aria-label="Edit tool access"
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={state.openTools}
/>
</>
}
>
<Show
when={state.tools().length}
fallback={
<>
<strong>No tools selected</strong>
<span>No allow permissions will be written.</span>
</>
}
>
<strong>{countTools(state.tools())}</strong>
<div class="tag-cloud agent-tool-summary">
<For each={state.tools()}>{(tool) => <Tag>{tool}</Tag>}</For>
</div>
</Show>
</FieldCard>
</div>
</Card>
<Card class="ui-card agent-builder-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>System Prompt</h2>
<p>Markdown instructions that define how this agent behaves.</p>
</div>
</header>
<div class="ui-form agent-builder-form">
<FieldCard label="Prompt">
<textarea
class="mono agent-prompt-input"
value={state.prompt()}
placeholder="Describe how this agent should behave."
readOnly={state.locked()}
onInput={(event) => state.setPrompt(event.currentTarget.value)}
/>
</FieldCard>
<div class="agent-snippets">
<div class="block-title">
<strong>Prompt snippets</strong>
<span>Insert a starter instruction into the prompt, then customize it.</span>
</div>
<div class="snippet-list">
<For each={snippets}>
{(snippet) => (
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={() => state.insert(snippet)}
>
{snippet}
</Button>
)}
</For>
</div>
</div>
</div>
</Card>
<Card class="ui-card agent-builder-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Agent Permissions</h2>
<p>Add scalar or pattern-specific overrides before previewing markdown.</p>
</div>
<CountTag>{state.rules().length}</CountTag>
</header>
<div class="ui-card-body agent-permissions-body">
<div class="permissions agent-permissions">
<For each={state.permissionGroups()}>
{(group) => (
<section class="permission-group">
<header class="permission-group-header">
<div class="permission-group-copy">
<h2>{group.title}</h2>
<span>{group.id}</span>
<p>{group.description}</p>
</div>
<div class="permission-section-actions">
<CountTag>{group.rules.length}</CountTag>
<Button
icon="plus"
variant="primary"
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={() => state.openPermission(group.id)}
>
Add rule
</Button>
</div>
</header>
<ConfigRow
title="Default method"
subtitle={`Used for ${group.noun}s that do not match a specific rule.`}
actions={
<ActionSelect
label={`Default method for ${group.title}`}
value={group.action}
disabled={Boolean(state.ctx.saving()) || state.locked()}
onSelect={(action) => state.setPermissionDefault(group.id, action)}
/>
}
/>
<div class="permission-rules">
<Show
when={group.rules.length}
fallback={<p class="permission-empty">No specific {group.noun} rules.</p>}
>
<For each={group.rules}>
{(rule) => (
<ConfigRow
title={<span class="permission-pattern">{rule.pattern}</span>}
subtitle={`${group.title} ${group.noun} rule`}
status={<AgentRuleMeta action={rule.action} />}
actions={
<IconButton
icon="trash"
variant="ghost"
aria-label={`Delete ${group.title} rule ${rule.pattern}`}
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={() => state.removePermission(rule)}
/>
}
/>
)}
</For>
</Show>
</div>
</section>
)}
</For>
<section class="permission-group">
<SectionTitle
trailing={<CountTag>{state.permissionDefaults().length}</CountTag>}
description="Default methods for additional built-in tool permissions."
>
Tool Defaults
</SectionTitle>
<div class="permission-rules">
<For each={state.permissionDefaults()}>
{(item) => (
<ConfigRow
title={item.title}
subtitle={`${item.id} · ${item.description}`}
actions={
<ActionSelect
label={`Default method for ${item.title}`}
value={item.action}
disabled={Boolean(state.ctx.saving()) || state.locked()}
onSelect={(action) => state.setPermissionDefault(item.id, action)}
/>
}
/>
)}
</For>
</div>
</section>
<Show when={state.permissionOther().length}>
<section class="permission-group">
<SectionTitle
trailing={<CountTag>{state.permissionOther().length}</CountTag>}
description="Additional tool permission rules from this agent."
>
Other Permissions
</SectionTitle>
<div class="permission-rules">
<For each={state.permissionOther()}>
{(rule) => (
<ConfigRow
title={toolName(rule.tool)}
subtitle={
<span class="permission-subtitle">
<span>{rule.tool}</span>
<span class="permission-pattern">{rule.pattern}</span>
</span>
}
status={<AgentRuleMeta action={rule.action} />}
actions={
<IconButton
icon="trash"
variant="ghost"
aria-label={`Delete ${rule.tool} rule ${rule.pattern}`}
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={() => state.removePermission(rule)}
/>
}
/>
)}
</For>
</div>
</section>
</Show>
</div>
</div>
</Card>
</section>
</div>
<Show when={state.panel() === "model"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer" aria-label="Agent model selector">
<header class="drawer-header">
<div>
<h2>Choose Model</h2>
<span>Favorites are listed first, then models are sorted alphabetically.</span>
</div>
<Button variant="ghost" aria-label="Close model selector" onClick={state.close}>
X
</Button>
</header>
<SearchField
class="drawer-search"
hideLabel={false}
label="Filter models"
value={state.picker()}
variant="drawer"
placeholder="Search by name, provider, or ID"
onValue={state.setPicker}
/>
<div class="provider-picker model-picker">
<Show when={state.models().length} fallback={<p class="empty">No models match this filter.</p>}>
<For each={state.models()}>
{(item) => (
<button
class="provider-option model-option"
classList={{ selected: state.choice() === item.id }}
type="button"
onClick={() => state.selectModel(item)}
>
<span class="model-star" classList={{ active: state.fav(item) }} aria-hidden="true" />
<div>
<strong>{item.model.name}</strong>
<span>{item.id}</span>
</div>
<div class="tags">
<Tag>{item.provider.name}</Tag>
<Tag>{item.model.isFree ? "free" : "paid"}</Tag>
</div>
</button>
)}
</For>
</Show>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || state.locked() || !state.choice()}
onClick={state.saveModel}
>
Save
</Button>
</footer>
</aside>
</Show>
<Show when={state.panel() === "tools"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer" aria-label="Agent tool access selector">
<header class="drawer-header">
<div>
<h2>Choose Tool Access</h2>
<span>Selected tools are written as allow permissions in the agent frontmatter.</span>
</div>
<Button variant="ghost" aria-label="Close tool selector" onClick={state.close}>
X
</Button>
</header>
<SearchField
class="drawer-search"
hideLabel={false}
label="Filter tools"
value={state.search()}
variant="drawer"
placeholder="Search by tool name, ID, or capability"
onValue={state.setSearch}
/>
<div class="provider-picker tool-picker">
<Show when={state.options().length} fallback={<p class="empty">No tools match this filter.</p>}>
<For each={state.options()}>
{(tool) => (
<button
class="provider-option tool-option"
classList={{ selected: state.pickedDraft().has(tool.id) }}
aria-pressed={state.pickedDraft().has(tool.id)}
type="button"
onClick={() => state.toggleTool(tool.id)}
>
<div class="model-main tool-main">
<div class="model-title">
<div>
<strong>{toolName(tool.id)}</strong>
<span>{tool.id}</span>
</div>
</div>
<div class="tags">
<Tag>{state.pickedDraft().has(tool.id) ? "Allowed" : "Off"}</Tag>
</div>
</div>
<ul class="tool-capabilities">
<For each={toolCapabilities(tool)}>{(cap) => <li>{cap}</li>}</For>
</ul>
</button>
)}
</For>
</Show>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={state.saveTools}
>
Save
</Button>
</footer>
</aside>
</Show>
<Show when={state.panel() === "permission"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer permission-drawer" aria-label="Agent permission rule configuration">
<header class="drawer-header">
<div>
<h2>{`Add ${state.selectedPermission().title} Rule`}</h2>
<span>{`${state.selectedPermission().id} · ${state.selectedPermission().description}`}</span>
</div>
<Button variant="ghost" aria-label="Close permission rule overlay" onClick={state.close}>
X
</Button>
</header>
<div class="provider-form permission-form">
<label class="required-field wide">
{state.selectedPermission().noun === "command" ? "Command pattern" : "Path pattern"}
<input
value={state.permPattern()}
placeholder={state.selectedPermission().placeholder}
spellcheck={false}
readOnly={state.locked()}
onInput={(event) => state.setPermPattern(event.currentTarget.value)}
/>
</label>
<label class="required-field wide">
Method
<ActionSelect
label="Rule method"
value={state.permAction()}
disabled={Boolean(state.ctx.saving()) || state.locked()}
onSelect={state.setPermAction}
/>
</label>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || state.locked()}
onClick={state.addPermission}
>
Save Rule
</Button>
</footer>
</aside>
</Show>
<Show when={state.panel() === "markdown"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer" aria-label="Generated agent markdown">
<header class="drawer-header">
<div>
<h2>Markdown</h2>
<span>{state.draft()?.path ?? "Previewing the current agent configuration."}</span>
</div>
<Button variant="ghost" aria-label="Close markdown preview" onClick={state.close}>
X
</Button>
</header>
<div class="provider-picker markdown-picker">
<Show when={state.draft()} fallback={<p class="empty">Generating markdown preview...</p>}>
{(draft) => <pre class="markdown-preview">{draft().markdown}</pre>}
</Show>
</div>
</aside>
</Show>
</ConfigPage>
)}
</Show>
)
}
@@ -0,0 +1,91 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { ConfigPage } from "./ConfigPage"
import { useTuiNotificationSettings } from "./state/ui"
function Toggle(props: {
label: string
description: string
checked: boolean
disabled?: boolean
onChange: () => void
}) {
return (
<button
class="ui-toggle"
classList={{ selected: props.checked }}
type="button"
aria-pressed={props.checked}
disabled={props.disabled}
onClick={props.onChange}
>
<span>
<strong>{props.label}</strong>
<small>{props.description}</small>
</span>
<Tag tone={props.checked ? "success" : "neutral"}>{props.checked ? "On" : "Off"}</Tag>
</button>
)
}
export function CliNotificationsRoute() {
const state = useTuiNotificationSettings()
return (
<ConfigPage
title="CLI Notifications"
description="Configure TUI attention alerts, desktop notifications, and sound defaults."
actions={
<Button variant="primary" disabled={Boolean(state.ctx.saving()) || !state.dirty()} onClick={state.save}>
Save
</Button>
}
>
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Attention</h2>
<p>Control when the TUI asks for attention and how it notifies you.</p>
</div>
</header>
<div class="ui-form attention-form">
<Toggle
label="Attention alerts"
description="Turn on TUI attention events."
checked={state.alert()}
disabled={Boolean(state.ctx.saving())}
onChange={() => state.setAlert(!state.alert())}
/>
<Toggle
label="Desktop notifications"
description="Show desktop notifications when attention alerts fire."
checked={state.notify()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setNotify(!state.notify())}
/>
<Toggle
label="Sound"
description="Play an attention sound when alerts fire."
checked={state.sound()}
disabled={Boolean(state.ctx.saving()) || !state.alert()}
onChange={() => state.setSound(!state.sound())}
/>
<label class="ui-field">
<span>Volume</span>
<input
type="number"
min="0"
max="1"
step="0.05"
value={state.volume()}
disabled={!state.alert()}
onInput={(event) => state.setVolume(event.currentTarget.value)}
/>
<small>Use a value from 0 to 1. The docs example uses 0.4.</small>
</label>
</div>
</Card>
</ConfigPage>
)
}
@@ -0,0 +1,197 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage } from "./ConfigPage"
import { type Theme, themeTitle, useTuiUiSettings } from "./state/ui"
const diffs = [
{ value: "auto", label: "Auto" },
{ value: "stacked", label: "Stacked" },
] satisfies SelectOption<"auto" | "stacked">[]
function ThemeSwatches(props: { swatches: string[] }) {
return (
<div class="theme-swatches" aria-hidden="true">
<For each={props.swatches}>{(color) => <span class="theme-swatch" style={{ "background-color": color }} />}</For>
</div>
)
}
function ThemePreview(props: { item: Theme }) {
return (
<div class="theme-preview">
<div>
<strong>{themeTitle(props.item.id)}</strong>
<span class="mono">{props.item.custom ? `${props.item.id} / custom` : props.item.id}</span>
</div>
<ThemeSwatches swatches={props.item.swatches} />
</div>
)
}
function Toggle(props: {
label: string
description: string
checked: boolean
disabled?: boolean
onChange: () => void
}) {
return (
<button
class="ui-toggle"
classList={{ selected: props.checked }}
type="button"
aria-pressed={props.checked}
disabled={props.disabled}
onClick={props.onChange}
>
<span>
<strong>{props.label}</strong>
<small>{props.description}</small>
</span>
<Tag tone={props.checked ? "success" : "neutral"}>{props.checked ? "On" : "Off"}</Tag>
</button>
)
}
export function CliUiRoute() {
const state = useTuiUiSettings()
return (
<ConfigPage
title="CLI UI"
description="Configure terminal UI preferences saved in tui.json."
actions={
<Button variant="primary" disabled={Boolean(state.ctx.saving()) || !state.dirty()} onClick={state.save}>
Save
</Button>
}
>
<div class="ui-settings">
<Card class="ui-card ui-theme-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Theme</h2>
<p>Choose the TUI palette. Custom theme IDs can still be typed in the selector search.</p>
</div>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.edit}>
Change Theme
</Button>
</header>
<div class="ui-card-body">
<ThemePreview item={state.active()} />
</div>
</Card>
<Card class="ui-card" padding={0}>
<header class="ui-card-header">
<div>
<h2>Interaction</h2>
<p>Control mouse capture, scrolling, and diff rendering behavior in the terminal.</p>
</div>
</header>
<div class="ui-form">
<label class="ui-field">
<span>Diff style</span>
<CustomSelect label="Diff style" value={state.diff()} options={diffs} onSelect={state.setDiff} />
<small>Auto adapts to terminal width; stacked always uses a single-column diff.</small>
</label>
<label class="ui-field">
<span>Scroll speed</span>
<input
type="number"
min="0.001"
step="0.25"
value={state.speed()}
onInput={(event) => state.setSpeed(event.currentTarget.value)}
/>
<small>Default is 3. Use lower values for slower scrolling.</small>
</label>
<Toggle
label="Mouse capture"
description="Allow the TUI to receive mouse input. Defaults to on."
checked={state.mouse()}
disabled={Boolean(state.ctx.saving())}
onChange={() => state.setMouse(!state.mouse())}
/>
<Toggle
label="Scroll acceleration"
description="Enable macOS-style scroll acceleration."
checked={state.accel()}
disabled={Boolean(state.ctx.saving())}
onChange={() => state.setAccel(!state.accel())}
/>
</div>
</Card>
</div>
<Show when={state.mode() === "theme"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer theme-drawer" aria-label="Theme selector">
<header class="drawer-header">
<div>
<h2>Choose Theme</h2>
<span>Select a built-in theme or type a custom theme ID in search.</span>
</div>
<Button variant="ghost" aria-label="Close theme selector" onClick={state.close}>
X
</Button>
</header>
<SearchField
class="drawer-search"
hideLabel={false}
label="Filter themes"
value={state.picker()}
variant="drawer"
placeholder="Search theme or type a custom ID"
onValue={state.setPicker}
/>
<div class="provider-picker theme-picker">
<Show when={state.options().length} fallback={<p class="empty">No themes match this filter.</p>}>
<For each={state.options()}>
{(item) => (
<button
class="provider-option theme-option"
classList={{ selected: state.choice() === item.id }}
type="button"
onClick={() => state.select(item)}
>
<div>
<strong>{themeTitle(item.id)}</strong>
<span>{item.custom ? `${item.id} / custom theme` : item.id}</span>
</div>
<ThemeSwatches swatches={item.swatches} />
</button>
)}
</For>
</Show>
</div>
<footer class="drawer-footer theme-footer">
<ThemePreview item={state.selected()} />
<div>
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || !state.choice()}
onClick={state.saveTheme}
>
Save Theme
</Button>
</div>
</footer>
</aside>
</Show>
</ConfigPage>
)
}
@@ -0,0 +1,43 @@
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { SourceBadge as UiSourceBadge } from "@kilocode/kilo-web-ui/console"
export function ConfigPage(props: {
title: JSX.Element
description?: JSX.Element
actions?: JSX.Element
children?: JSX.Element
}) {
return (
<main class="config-page">
<section class="config-page-header">
<div>
<h1>{props.title}</h1>
<Show when={props.description}>{(description) => <p>{description()}</p>}</Show>
</div>
<Show when={props.actions}>{(actions) => <div class="config-page-actions">{actions()}</div>}</Show>
</section>
{props.children}
</main>
)
}
export function ConfigToolbar(props: {
title?: string
description?: string
meta?: JSX.Element
children?: JSX.Element
}) {
return (
<Show when={props.children || props.meta}>
<section class="config-toolbar">
<Show when={props.children}>{(children) => <div class="config-toolbar-controls">{children()}</div>}</Show>
<Show when={props.meta}>{(meta) => <div class="config-toolbar-meta">{meta()}</div>}</Show>
</section>
</Show>
)
}
export function SourceBadge(props: { source?: string; inherited?: boolean; overridden?: boolean }) {
return <UiSourceBadge {...props} />
}
@@ -0,0 +1,9 @@
import type { JSX } from "solid-js"
type Props = {
children: JSX.Element
}
export function ConfigRoute(props: Props) {
return <>{props.children}</>
}
@@ -0,0 +1,78 @@
import { A, useLocation, useParams } from "@solidjs/router"
import { createMemo, For } from "solid-js"
import { Icon } from "@kilocode/kilo-web-ui/icon"
import { configNav, type ConfigGroup, type ConfigNode } from "./sections"
import { friendly } from "../../shared/utils"
import { settings, strip } from "../../shared/navigation"
import { useConfig } from "../../context/config"
function repo(input: string) {
const parts = input.split(/[\\/]/).filter(Boolean)
return parts.at(-1) ?? "Project"
}
export function ConfigSidebar() {
const loc = useLocation()
const params = useParams()
const ctx = useConfig()
const route = createMemo(() => strip(loc.pathname))
const project = createMemo(() => route().startsWith("/projects/"))
const scope = createMemo(() => {
if (!project()) return "Global"
const dir = ctx.query()?.dir
if (dir) return friendly(repo(dir))
return friendly(decodeURIComponent(params.project ?? "Project"))
})
const base = createMemo(() => settings(route()))
const active = createMemo(() => {
const rest = route().slice(base().length)
if (rest === "/models") return "/models/default"
return rest || "/"
})
const href = (path: string) => `${path === "/" ? base() : `${base()}${path}`}${loc.search}`
const current = (path: string) => path === active() || (path !== "/" && active().startsWith(`${path}/`))
const group = (item: ConfigNode): item is ConfigGroup => "items" in item
return (
<aside class="config-sidebar" aria-label="Configuration sections">
<div class="config-sidebar-title">
<span>Settings</span>
<span class="config-sidebar-scope">
<span>{scope()}</span>
</span>
</div>
<nav class="config-options">
<For each={configNav}>
{(item) => {
if (!group(item)) {
return (
<A class="config-top-option" classList={{ active: current(item.path) }} href={href(item.path)}>
<Icon name={item.icon} size="small" />
<span>{item.label}</span>
</A>
)
}
return (
<section class="config-group">
<div class="config-group-title">
<span>{item.label}</span>
</div>
<div id={`config-group-${item.id}`} class="config-group-items">
<For each={item.items}>
{(child) => (
<A class="config-option" classList={{ active: current(child.path) }} href={href(child.path)}>
<Icon name={child.icon} size="small" />
<span>{child.label}</span>
</A>
)}
</For>
</div>
</section>
)
}}
</For>
</nav>
</aside>
)
}
@@ -0,0 +1,381 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
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 { ConfigPage, SourceBadge } from "./ConfigPage"
import { useFormatterSettings, type ToolRow } from "./state/formatters"
type Kind = "formatter" | "lsp"
type Settings = ReturnType<typeof useFormatterSettings>
function tone(input: ToolRow["state"]) {
if (input === "active" || input === "connected") return "success"
if (input === "error") return "critical"
if (input === "missing") return "warning"
return "neutral"
}
function label(input: ToolRow["state"]) {
if (input === "active") return "Available"
if (input === "connected") return "Connected"
if (input === "disabled") return "Disabled"
if (input === "error") return "Error"
if (input === "idle") return "Idle"
if (input === "missing") return "Missing requirement"
return "Enabled"
}
function Meta(props: { row: ToolRow }) {
return (
<div class="formatter-row-meta">
<Show when={props.row.note}>{(note) => <Tag tone="warning">{note()}</Tag>}</Show>
<Tag tone={tone(props.row.state)}>{label(props.row.state)}</Tag>
<Show when={props.row.entry}>
{(entry) => (
<SourceBadge source={entry().source} inherited={entry().inherited} overridden={entry().overridden} />
)}
</Show>
</div>
)
}
function Detail(props: { row: ToolRow }) {
return (
<span class="formatter-row-subtitle">
<span class="formatter-row-id">{props.row.id}</span>
<span>{props.row.ext.length ? props.row.ext.join(", ") : "No extensions configured"}</span>
<span>{props.row.req}</span>
<Show when={props.row.command.length}>
<span class="formatter-command">{props.row.command.join(" ")}</span>
</Show>
</span>
)
}
function Row(props: { kind: Kind; row: ToolRow; state: Settings }) {
return (
<ConfigRow
title={props.row.title ?? props.row.id}
subtitle={<Detail row={props.row} />}
status={<Meta row={props.row} />}
highlightOverride={Boolean(props.row.entry && !props.row.entry.inherited)}
actions={
<>
<IconButton
icon="edit"
variant="ghost"
aria-label={`Edit ${props.row.id}`}
disabled={Boolean(props.state.ctx.saving())}
onClick={() => props.state.open(props.kind, props.row)}
/>
<Show when={props.row.removable}>
<IconButton
icon="trash"
variant="ghost"
aria-label={`Delete ${props.row.id}`}
disabled={Boolean(props.state.ctx.saving())}
onClick={() => props.state.remove(props.row)}
/>
</Show>
</>
}
/>
)
}
function List(props: { kind: Kind; rows: ToolRow[]; state: Settings }) {
return (
<div class="formatter-rules">
<For each={props.rows} fallback={<p class="formatter-empty">No entries configured.</p>}>
{(row) => <Row kind={props.kind} row={row} state={props.state} />}
</For>
</div>
)
}
function FormatterDrawer(props: { state: Settings }) {
const editing = () => props.state.locked()
return (
<Show when={props.state.mode() === "formatter"}>
<div class="drawer-scrim" onClick={props.state.close} />
<aside class="provider-drawer formatter-drawer" aria-label="Formatter configuration">
<header class="drawer-header">
<div>
<h2>{editing() ? `Edit ${props.state.name()}` : "Add Formatter"}</h2>
<span>Configure command, extensions, and environment for a formatter.</span>
</div>
<Button variant="ghost" aria-label="Close formatter overlay" onClick={props.state.close}>
X
</Button>
</header>
<div class="provider-form formatter-form">
<label class="required-field wide">
Name
<input
value={props.state.name()}
disabled={props.state.locked()}
placeholder="custom-formatter"
spellcheck={false}
onInput={(event) => props.state.setName(event.currentTarget.value)}
/>
</label>
<label class="required-field wide">
Command
<input
value={props.state.command()}
placeholder="prettier --write $FILE"
spellcheck={false}
onInput={(event) => props.state.setCommand(event.currentTarget.value)}
/>
</label>
<label class="required-field wide">
Extensions
<input
value={props.state.extensions()}
placeholder=".js, .ts, .tsx"
spellcheck={false}
onInput={(event) => props.state.setExtensions(event.currentTarget.value)}
/>
</label>
<label class="optional-field wide">
Environment
<textarea
value={props.state.environment()}
placeholder="NODE_ENV=development"
spellcheck={false}
onInput={(event) => props.state.setEnvironment(event.currentTarget.value)}
/>
</label>
<label class="formatter-check wide">
<input
type="checkbox"
checked={props.state.disabled()}
onChange={(event) => props.state.setDisabled(event.currentTarget.checked)}
/>
Disable this formatter
</label>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={props.state.close}>
Cancel
</Button>
<Button variant="primary" disabled={Boolean(props.state.ctx.saving())} onClick={props.state.saveFormatter}>
Save Formatter
</Button>
</footer>
</aside>
</Show>
)
}
function LspDrawer(props: { state: Settings }) {
const editing = () => props.state.locked()
const optional = () => props.state.disabled()
return (
<Show when={props.state.mode() === "lsp"}>
<div class="drawer-scrim" onClick={props.state.close} />
<aside class="provider-drawer formatter-drawer" aria-label="LSP server configuration">
<header class="drawer-header">
<div>
<h2>{editing() ? `Edit ${props.state.name()}` : "Add LSP Server"}</h2>
<span>Configure command, extensions, environment, and initialization options.</span>
</div>
<Button variant="ghost" aria-label="Close LSP overlay" onClick={props.state.close}>
X
</Button>
</header>
<div class="provider-form formatter-form">
<label class="required-field wide">
Name
<input
value={props.state.name()}
disabled={props.state.locked()}
placeholder="custom-lsp"
spellcheck={false}
onInput={(event) => props.state.setName(event.currentTarget.value)}
/>
</label>
<label class="wide" classList={{ "required-field": !optional(), "optional-field": optional() }}>
Command
<input
value={props.state.command()}
placeholder="custom-lsp --stdio"
spellcheck={false}
onInput={(event) => props.state.setCommand(event.currentTarget.value)}
/>
</label>
<label class="required-field wide">
Extensions
<input
value={props.state.extensions()}
placeholder=".ts, .tsx"
spellcheck={false}
onInput={(event) => props.state.setExtensions(event.currentTarget.value)}
/>
</label>
<label class="optional-field wide">
Environment
<textarea
value={props.state.environment()}
placeholder="RUST_LOG=debug"
spellcheck={false}
onInput={(event) => props.state.setEnvironment(event.currentTarget.value)}
/>
</label>
<label class="optional-field wide">
Initialization JSON
<textarea
value={props.state.initialization()}
placeholder={'{"preferences":{"importModuleSpecifierPreference":"relative"}}'}
spellcheck={false}
onInput={(event) => props.state.setInitialization(event.currentTarget.value)}
/>
</label>
<label class="formatter-check wide">
<input
type="checkbox"
checked={props.state.disabled()}
onChange={(event) => props.state.setDisabled(event.currentTarget.checked)}
/>
Disable this LSP server
</label>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={props.state.close}>
Cancel
</Button>
<Button variant="primary" disabled={Boolean(props.state.ctx.saving())} onClick={props.state.saveLsp}>
Save LSP Server
</Button>
</footer>
</aside>
</Show>
)
}
export function FormattersRoute() {
const state = useFormatterSettings()
return (
<ConfigPage
title={
<span class="config-title-count">
Formatters
<CountTag>{state.builtinFmt().length + state.customFmt().length}</CountTag>
</span>
}
description="Control native and custom formatters Kilo can run after editing files."
actions={
<>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving())}
onClick={state.fmtOn() ? state.disableFmt : state.enableFmt}
>
{state.fmtOn() ? "Disable All" : "Enable Built-ins"}
</Button>
<Button
icon="plus"
variant="primary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.open("formatter")}
>
Add Formatter
</Button>
</>
}
>
<div class="formatters">
<section class="formatter-group">
<SectionTitle
trailing={<CountTag>{state.builtinFmt().length}</CountTag>}
description="Native formatter integrations from OpenCode. Edit a row to override command, extensions, or environment."
>
Built-in Formatters
</SectionTitle>
<List kind="formatter" rows={state.builtinFmt()} state={state} />
</section>
<Show when={state.customFmt().length}>
<section class="formatter-group">
<SectionTitle
trailing={<CountTag>{state.customFmt().length}</CountTag>}
description="Project or global formatter entries added through config."
>
Custom Formatters
</SectionTitle>
<List kind="formatter" rows={state.customFmt()} state={state} />
</section>
</Show>
</div>
<FormatterDrawer state={state} />
</ConfigPage>
)
}
export function LspRoute() {
const state = useFormatterSettings()
return (
<ConfigPage
title={
<span class="config-title-count">
LSP Servers
<CountTag>{state.builtinLsp().length + state.customLsp().length}</CountTag>
</span>
}
description="Control native and custom Language Server Protocol integrations for diagnostics and code intelligence."
actions={
<>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving())}
onClick={state.lspOn() ? state.disableLsp : state.enableLsp}
>
{state.lspOn() ? "Disable All" : "Enable Built-ins"}
</Button>
<Button
icon="plus"
variant="primary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.open("lsp")}
>
Add LSP Server
</Button>
</>
}
>
<div class="formatters">
<section class="formatter-group">
<SectionTitle
trailing={<CountTag>{state.builtinLsp().length}</CountTag>}
description="Native LSP servers from OpenCode. Edit a row to disable it or provide a custom launch command."
>
Built-in LSP Servers
</SectionTitle>
<List kind="lsp" rows={state.builtinLsp()} state={state} />
</section>
<Show when={state.customLsp().length}>
<section class="formatter-group">
<SectionTitle
trailing={<CountTag>{state.customLsp().length}</CountTag>}
description="Project or global LSP entries added through config."
>
Custom LSP Servers
</SectionTitle>
<List kind="lsp" rows={state.customLsp()} state={state} />
</section>
</Show>
</div>
<LspDrawer state={state} />
</ConfigPage>
)
}
@@ -0,0 +1,160 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
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 (
<ConfigPage
title={
<span class="config-title-count">
Keybinds
<CountTag>{state.keybinds().length}</CountTag>
</span>
}
description="Review and override every terminal UI keybind command exposed by the CLI. Use none to disable a binding."
>
<SearchField
label="Filter keybinds"
value={state.search()}
placeholder="Filter by command, group, binding, or description..."
onValue={state.setSearch}
/>
<div class="keybinds">
<Show when={state.groups().length} fallback={<p class="empty">No keybinds match this filter.</p>}>
<For each={state.groups()}>
{(group) => (
<section class="keybind-group">
<SectionTitle trailing={<CountTag>{group.rows.length}</CountTag>}>{group.name}</SectionTitle>
<div class="keybind-rows">
<For each={group.rows}>
{(row) => (
<ConfigRow
title={row.item.label}
subtitle={
<span class="keybind-subtitle">
<span class="keybind-id">{row.item.id}</span>
<span class="keybind-description">{row.item.description}</span>
</span>
}
status={
<div class="keybind-meta">
<span class="keybind-binding" data-empty={row.binding === "none" || undefined}>
{row.binding}
</span>
<SourceBadge source={row.source} />
<Show when={row.conflicts.length}>
<Tag tone="warning">Conflict</Tag>
</Show>
</div>
}
actions={
<IconButton
icon="edit"
variant="ghost"
aria-label={`Edit ${row.item.label}`}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.open(row.item)}
/>
}
/>
)}
</For>
</div>
</section>
)}
</For>
</Show>
</div>
<Show when={state.mode() === "edit" && state.selected()}>
{(item) => (
<>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer keybind-drawer" aria-label="Keybind configuration">
<header class="drawer-header">
<div>
<h2>{item().label}</h2>
<span>{item().id}</span>
</div>
<Button variant="ghost" aria-label="Close keybind overlay" onClick={state.close}>
X
</Button>
</header>
<div class="provider-form keybind-form">
<section class="keybind-command-card wide">
<span>{item().group}</span>
<p>{item().description}</p>
<code>{item().id}</code>
</section>
<label class="required-field wide keybind-binding-field">
Binding
<div class="keybind-input-row">
<input
value={state.binding()}
placeholder="ctrl+x,shift+return,none"
spellcheck={false}
onInput={(event) => state.setBinding(event.currentTarget.value)}
/>
<Button
variant={state.capture() ? "primary" : "secondary"}
aria-pressed={state.capture()}
onClick={() => state.setCapture(!state.capture())}
>
{state.capture() ? "Listening..." : "Capture"}
</Button>
</div>
</label>
<p class="keybind-help wide">
Press Capture, then type a shortcut. Enter comma-separated bindings manually to define alternatives.
</p>
<div class="keybind-default-card wide">
<span>Default binding</span>
<code>{item().default}</code>
</div>
<Show when={state.conflicts().length}>
<p class="keybind-warning wide">Duplicate binding with: {state.conflicts().join(", ")}</p>
</Show>
</div>
<footer class="drawer-footer keybind-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.none}>
Set none
</Button>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving()) || state.defaulted()}
onClick={state.reset}
>
Use default
</Button>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || !state.binding()}
onClick={state.save}
>
Save keybind
</Button>
</footer>
</aside>
</>
)}
</Show>
</ConfigPage>
)
}
@@ -0,0 +1,477 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { StatusTag } from "@kilocode/kilo-web-ui/status-tag"
import { ConfirmDialog } from "../../components/ConfirmDialog"
import { CustomSelect, type SelectOption } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { useMcpSettings } from "./state/mcp"
type StatusFilter = "all" | "installed" | "notInstalled"
const statusOptions = [
{ value: "all", label: "All" },
{ value: "installed", label: "Installed" },
{ value: "notInstalled", label: "Not installed" },
] satisfies SelectOption<StatusFilter>[]
const typeOptions = [
{ value: "local", label: "Local command" },
{ value: "remote", label: "Remote URL" },
] satisfies SelectOption<"local" | "remote">[]
const stateOptions = [
{ value: "enabled", label: "Enabled" },
{ value: "disabled", label: "Disabled" },
] satisfies SelectOption<"enabled" | "disabled">[]
export function McpRoute() {
const state = useMcpSettings()
return (
<Show when={state.snap()}>
{(_data) => (
<ConfigPage
title={
<span class="config-title-count">
MCP Servers
<CountTag>{state.rows().length}</CountTag>
</span>
}
description="Install, configure, and manage Model Context Protocol servers available to Kilo agents."
actions={
<>
<Button icon="plus" variant="primary" disabled={Boolean(state.ctx.saving())} onClick={state.openMarket}>
Install MCP
</Button>
<Button variant="secondary" disabled={Boolean(state.ctx.saving())} onClick={state.openManual}>
Manual server
</Button>
</>
}
>
<SearchField
label="Filter MCP servers"
value={state.search()}
placeholder="Filter by name, ID, command, URL, or status..."
onValue={state.setSearch}
/>
<div class="providers mcp-servers">
<Show
when={state.visible().length}
fallback={
<p class="empty">
{state.rows().length
? "No MCP servers match this filter."
: "No MCP servers configured. Install one from the Marketplace or add a manual server."}
</p>
}
>
<For each={state.visible()}>
{(row) => (
<article class="provider configured-provider mcp-server" classList={{ inherited: row.inherited }}>
<div class="provider-title mcp-title">
<div>
<strong>{row.name}</strong>
<span>{row.id}</span>
<em>{row.summary}</em>
</div>
</div>
<div class="tags mcp-server-tags">
<SourceBadge source={row.source} inherited={row.inherited} overridden={row.overridden} />
<Tag>{row.kind}</Tag>
<StatusTag status={row.status?.status ?? (row.enabled ? "configured" : "disabled")} />
</div>
<div class="provider-actions mcp-server-actions">
<Show
when={row.status?.status === "needs_auth" || row.status?.status === "needs_client_registration"}
>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.authenticate(row)}
>
Authenticate
</Button>
</Show>
<Show when={row.status?.status === "connected"}>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.disconnect(row)}
>
Disconnect
</Button>
</Show>
<Show when={row.status?.status === "disabled" && row.enabled}>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.connect(row)}
>
Connect
</Button>
</Show>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.toggle(row)}
>
{row.enabled ? "Disable" : "Enable"}
</Button>
<IconButton
icon="edit"
variant="ghost"
aria-label={`Configure ${row.name}`}
disabled={Boolean(state.ctx.saving()) || row.editable === false || !row.config}
onClick={() => state.edit(row)}
/>
<IconButton
icon="trash"
variant="ghost"
aria-label={`${row.revert ? "Revert" : "Delete"} ${row.name}`}
disabled={Boolean(state.ctx.saving()) || !row.path || (row.inherited && !row.overridden)}
onClick={() => state.ask(row)}
/>
</div>
<Show when={row.status?.status === "failed" || row.status?.status === "needs_client_registration"}>
<p class="mcp-server-error">
{row.status && "error" in row.status ? row.status.error : "Authentication setup is required."}
</p>
</Show>
</article>
)}
</For>
</Show>
</div>
<Show when={state.mode() === "market"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer mcp-drawer" aria-label="Install MCP server">
<header class="drawer-header">
<div>
<h2>Install MCP server</h2>
<span>Browse the Kilo Marketplace MCP catalog and install into the current settings scope.</span>
</div>
<Button variant="ghost" aria-label="Close MCP marketplace overlay" onClick={state.close}>
X
</Button>
</header>
<div class="mcp-market-body">
<div class="mcp-market-filters">
<SearchField
class="drawer-search mcp-market-search"
label="Filter marketplace MCP servers"
value={state.filter()}
variant="drawer"
placeholder="Search marketplace by name, ID, author, or description..."
onValue={state.setFilter}
/>
<div class="mcp-status-filter">
<span>Status</span>
<CustomSelect
label="Status filter"
value={state.status()}
options={statusOptions}
class="mcp-status-select"
onSelect={state.setStatus}
/>
</div>
</div>
<Show when={state.tags().length}>
<div class="mcp-tag-filters">
<For each={state.tags()}>
{(tag) => (
<button
class="mcp-tag-filter"
classList={{ active: state.picked().includes(tag) }}
type="button"
onClick={() => state.toggleTag(tag)}
>
<Tag>{tag}</Tag>
</button>
)}
</For>
</div>
</Show>
<Show when={state.marketError()}>
{(message) => (
<Card class="banner" variant="info">
Marketplace catalog could not be loaded: {message()}
</Card>
)}
</Show>
<Show when={!state.catalog.loading} fallback={<p class="empty">Loading marketplace MCP servers...</p>}>
<Show
when={state.marketVisible().length}
fallback={<p class="empty">No marketplace MCP servers match this filter.</p>}
>
<div class="mcp-market-grid">
<For each={state.marketVisible()}>
{(item) => (
<article class="mcp-market-card">
<div class="mcp-market-card-head">
<div>
<strong>{item.name}</strong>
<Show when={item.author}>
{(author) => <span class="mcp-market-author">by {author()}</span>}
</Show>
<span class="mcp-market-id">{item.id}</span>
</div>
<Show when={state.installed().has(item.id)}>
<Tag tone="success">Installed</Tag>
</Show>
</div>
<p>{item.description}</p>
<div class="tags mcp-market-card-tags">
<For each={item.tags ?? []}>{(tag) => <Tag>{tag}</Tag>}</For>
</div>
<div class="mcp-market-card-footer">
<div>
<Show when={item.url}>
{(url) => (
<a href={url()} target="_blank" rel="noreferrer">
Source
</a>
)}
</Show>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || state.installed().has(item.id)}
onClick={() => state.openInstall(item)}
>
Install
</Button>
</div>
</div>
</article>
)}
</For>
</div>
</Show>
</Show>
</div>
</aside>
</Show>
<Show when={state.mode() === "install" && state.choice()}>
{(item) => (
<>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer mcp-drawer" aria-label="MCP install parameters">
<header class="drawer-header">
<div>
<h2>{item().name}</h2>
<span>{item().id}</span>
</div>
<Button variant="ghost" aria-label="Close MCP install overlay" onClick={state.close}>
X
</Button>
</header>
<div class="provider-auth mcp-install-form">
<p>{item().description}</p>
<Show when={state.methods().length > 1}>
<label>
Installation method
<CustomSelect
label="Installation method"
value={state.methodName()}
options={state.methods().map((method) => ({ value: method.name, label: method.name }))}
onSelect={state.setMethodName}
/>
</label>
</Show>
<Show when={state.prerequisites().length}>
<section class="mcp-install-section">
<span>Prerequisites</span>
<ul>
<For each={state.prerequisites()}>{(item) => <li>{item}</li>}</For>
</ul>
</section>
</Show>
<Show
when={state.parameters().length}
fallback={<p>No parameters are required for this server.</p>}
>
<section class="mcp-install-section">
<span>Parameters</span>
<For each={state.parameters()}>
{(param) => (
<label classList={{ "required-field": !param.optional }}>
{param.name}
{param.optional ? " (optional)" : ""}
<input
value={state.params()[param.key] ?? ""}
placeholder={param.placeholder ?? param.key}
spellcheck={false}
onInput={(event) => state.setParam(param.key, event.currentTarget.value)}
/>
</label>
)}
</For>
</section>
</Show>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.openMarket}>
Back
</Button>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving()) || !state.valid()}
onClick={state.install}
>
Install MCP
</Button>
</footer>
</aside>
</>
)}
</Show>
<Show when={state.mode() === "config"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer mcp-drawer" aria-label="MCP server configuration">
<header class="drawer-header">
<div>
<h2>{state.editing() ? "Configure MCP server" : "Add manual MCP server"}</h2>
<span>{state.editing() ?? "New MCP server"}</span>
</div>
<Button variant="ghost" aria-label="Close MCP configuration overlay" onClick={state.close}>
X
</Button>
</header>
<div class="provider-form mcp-config-form">
<label class="required-field">
Server ID
<input
value={state.id()}
disabled={Boolean(state.editing())}
spellcheck={false}
placeholder="github"
onInput={(event) => state.setId(event.currentTarget.value)}
/>
</label>
<label class="required-field">
Type
<CustomSelect
label="MCP server type"
value={state.type()}
options={typeOptions}
class="mcp-config-select"
onSelect={state.setType}
/>
</label>
<label class="optional-field">
State
<CustomSelect
label="MCP server state"
value={state.enabled() ? "enabled" : "disabled"}
options={stateOptions}
class="mcp-config-select"
onSelect={(value) => state.setEnabled(value === "enabled")}
/>
</label>
<label class="optional-field">
Timeout ms
<input
value={state.limit()}
inputmode="numeric"
placeholder="30000"
onInput={(event) => state.setLimit(event.currentTarget.value)}
/>
</label>
<Show
when={state.type() === "remote"}
fallback={
<>
<label class="wide required-field">
Command
<input
value={state.command()}
spellcheck={false}
placeholder="npx -y @modelcontextprotocol/server-filesystem ."
onInput={(event) => state.setCommand(event.currentTarget.value)}
/>
</label>
<label class="wide optional-field">
Environment JSON
<textarea
value={state.env()}
spellcheck={false}
placeholder={'{\n "GITHUB_TOKEN": "{env:GITHUB_TOKEN}"\n}'}
onInput={(event) => state.setEnv(event.currentTarget.value)}
/>
</label>
</>
}
>
<>
<label class="wide required-field">
URL
<input
value={state.url()}
spellcheck={false}
placeholder="https://mcp.example.com"
onInput={(event) => state.setUrl(event.currentTarget.value)}
/>
</label>
<label class="wide optional-field">
Headers JSON
<textarea
value={state.headers()}
spellcheck={false}
placeholder={'{\n "Authorization": "Bearer {env:MCP_TOKEN}"\n}'}
onInput={(event) => state.setHeaders(event.currentTarget.value)}
/>
</label>
<label class="wide optional-field">
OAuth JSON or false
<textarea
value={state.auth()}
spellcheck={false}
placeholder={'{\n "clientId": "...",\n "scope": "read"\n}'}
onInput={(event) => state.setAuth(event.currentTarget.value)}
/>
</label>
</>
</Show>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button variant="primary" disabled={Boolean(state.ctx.saving())} onClick={state.save}>
Save MCP
</Button>
</footer>
</aside>
</Show>
<ConfirmDialog
open={Boolean(state.pending())}
title={`${state.pending()?.revert ? "Revert" : "Delete"} MCP server ${state.pending()?.name ?? ""}?`}
message={
state.pending()?.revert
? `This removes the current-scope override for ${state.pending()?.id ?? "the MCP server"}.`
: `This removes ${state.pending()?.id ?? "the MCP server"} from the current configuration.`
}
confirm={state.pending()?.revert ? "Revert" : "Delete"}
busy={Boolean(state.ctx.saving())}
onCancel={state.cancel}
onConfirm={state.confirm}
/>
</ConfigPage>
)}
</Show>
)
}
@@ -0,0 +1,449 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import type { Model } from "@kilocode/sdk/v2/client"
import { SearchField } from "../../components/SearchField"
import { text } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { type Capability, type ModelField, useModelSettings } from "./state/models"
function money(n: number) {
if (n === 0) return "$0.00"
if (n < 0.01) return `$${n.toFixed(4)}`
return `$${n.toFixed(2)}`
}
function fmtContext(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`
return String(n)
}
function fmtDate(s: string) {
const d = new Date(s)
if (isNaN(d.getTime())) return s
return d.toLocaleDateString(undefined, { year: "numeric", month: "short" })
}
function title(input: string) {
return input.charAt(0).toUpperCase() + input.slice(1)
}
function desc(model: Model) {
const value = model.options?.description
if (typeof value === "string" && value.trim()) return value
return null
}
function chips(model: Model) {
const list: string[] = []
if (model.capabilities.toolcall) list.push("toolcall")
if (model.capabilities.attachment) list.push("attachment")
if (model.capabilities.input.image) list.push("vision")
if (model.capabilities.input.audio || model.capabilities.output.audio) list.push("audio")
return list
}
function step(max: number) {
if (max >= 1_000_000) return 50_000
if (max >= 100_000) return 10_000
return 1_000
}
const slots = [
{ key: "model", label: "Default model" },
{ key: "small_model", label: "Small model" },
] satisfies { key: ModelField; label: string }[]
function pct(value: number, max: number) {
if (max <= 0) return 0
return Math.max(0, Math.min(100, (value / max) * 100))
}
function snap(value: number, max: number) {
const size = step(max)
return Math.max(0, Math.min(max, Math.round(value / size) * size))
}
function point(event: PointerEvent, root: HTMLElement, max: number) {
const rect = root.getBoundingClientRect()
const ratio = (event.clientX - rect.left) / rect.width
return snap(ratio * max, max)
}
function Stat(props: { label: string; value: string; sub?: string; mono?: boolean }) {
return (
<div class="model-stat">
<span>{props.label}</span>
<strong classList={{ mono: props.mono }}>
{props.value}
<Show when={props.sub}>{(sub) => <small>{sub()}</small>}</Show>
</strong>
</div>
)
}
const caps = [
{ key: "toolcall", label: "toolcall" },
{ key: "attachment", label: "attachment" },
{ key: "input:image", label: "vision" },
{ key: "input:audio", label: "audio" },
] satisfies { key: Capability; label: string }[]
type Option = { value: string; label: string }
function FilterSelect(props: { label: string; value: string; options: Option[]; onSelect: (value: string) => void }) {
const current = () => props.options.find((option) => option.value === props.value)?.label ?? props.value
function choose(value: string, event: MouseEvent & { currentTarget: HTMLButtonElement }) {
props.onSelect(value)
event.currentTarget.closest("details")?.removeAttribute("open")
}
function toggle(event: Event & { currentTarget: HTMLDetailsElement }) {
if (!event.currentTarget.open) return
event.currentTarget.parentElement?.querySelectorAll(".models-select[open]").forEach((node) => {
if (node !== event.currentTarget) node.removeAttribute("open")
})
}
return (
<details class="models-select" onToggle={toggle}>
<summary aria-label={props.label}>{current()}</summary>
<div class="models-select-menu" role="listbox" aria-label={props.label}>
<For each={props.options}>
{(option) => (
<button
class="models-select-option"
classList={{ selected: option.value === props.value }}
type="button"
role="option"
aria-selected={option.value === props.value}
onClick={(event) => choose(option.value, event)}
>
{option.label}
</button>
)}
</For>
</div>
</details>
)
}
export function ModelsRoute() {
return <ModelsDefaultRoute />
}
export function ModelsDefaultRoute() {
const state = useModelSettings()
return (
<Show when={state.snap()}>
<ConfigPage
title="Model defaults"
description="The models Kilo uses when an agent or command doesn't pin its own."
>
<div class="resolved-grid model-defaults model-default-fields">
<For each={slots}>
{(item) => {
const field = () => state.snap()?.overlay.fields[item.key]
const model = () => state.item(field()?.value)
return (
<article class="resolved-card default-model-card" classList={{ inherited: field()?.inherited }}>
<header class="default-model-header">
<span>{item.label}</span>
<div class="tags default-model-actions">
<SourceBadge
source={field()?.source}
inherited={field()?.inherited}
overridden={field()?.overridden}
/>
<Show when={state.ctx.query()?.scope === "project" && field()?.overridden}>
<Button
variant="secondary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.ctx.unset([[item.key]])}
>
Revert
</Button>
</Show>
<IconButton
icon="edit"
variant="ghost"
aria-label={`Edit ${item.label}`}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.edit(item.key)}
/>
</div>
</header>
<div class="default-model-value">
<Show when={model()} fallback={<strong>{text(field()?.value)}</strong>}>
{(selected) => (
<>
<strong>{`${selected().provider.name} / ${selected().model.name}`}</strong>
<span class="default-model-id">{selected().id}</span>
</>
)}
</Show>
</div>
</article>
)
}}
</For>
</div>
<Show when={state.mode() !== "closed"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer" aria-label={`${state.label()} selector`}>
<header class="drawer-header">
<div>
<h2>{`Choose ${state.label()}`}</h2>
<span>Favorites are listed first, then models are sorted alphabetically.</span>
</div>
<Button variant="ghost" aria-label="Close model selector" onClick={state.close}>
X
</Button>
</header>
<SearchField
class="drawer-search"
hideLabel={false}
label="Filter models"
value={state.picker()}
variant="drawer"
placeholder="Search by name, provider, or ID"
onValue={state.setPicker}
/>
<div class="provider-picker model-picker">
<Show when={state.options().length} fallback={<p class="empty">No models match this filter.</p>}>
<For each={state.options()}>
{(item) => (
<button
class="provider-option model-option"
classList={{ selected: state.choice() === item.id }}
type="button"
onClick={() => state.select(item)}
>
<span class="model-star" classList={{ active: state.fav(item) }} aria-hidden="true" />
<div>
<strong>{item.model.name}</strong>
<span>{item.id}</span>
</div>
<div class="tags">
<Tag>{item.provider.name}</Tag>
<Tag>{item.model.isFree ? "free" : "paid"}</Tag>
</div>
</button>
)}
</For>
</Show>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button variant="primary" disabled={Boolean(state.ctx.saving()) || !state.choice()} onClick={state.save}>
Save
</Button>
</footer>
</aside>
</Show>
</ConfigPage>
</Show>
)
}
export function ModelsAvailableRoute() {
const state = useModelSettings()
function drag(kind: "min" | "max", event: PointerEvent & { currentTarget: HTMLButtonElement }) {
const root = event.currentTarget.closest(".context-range")
if (!(root instanceof HTMLElement)) return
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
const move = (next: PointerEvent) => {
const value = point(next, root, state.max())
if (kind === "min") {
state.setMin(value)
return
}
state.setMax(value)
}
const up = () => {
window.removeEventListener("pointermove", move)
window.removeEventListener("pointerup", up)
}
move(event)
window.addEventListener("pointermove", move)
window.addEventListener("pointerup", up, { once: true })
}
function key(kind: "min" | "max", event: KeyboardEvent) {
const delta = event.key === "ArrowLeft" ? -step(state.max()) : event.key === "ArrowRight" ? step(state.max()) : 0
if (delta === 0) return
event.preventDefault()
if (kind === "min") {
state.setMin(state.low() + delta)
return
}
state.setMax(state.top() + delta)
}
return (
<Show when={state.snap()}>
<ConfigPage
title={
<span class="config-title-count">
Explore models
<Tag>{state.models().length}</Tag>
</span>
}
description="All models exposed by configured providers. Set defaults under Models > Defaults."
>
<section class="models-filter-card">
<div class="models-filter-row models-filter-primary">
<label class="models-filter-field models-search-field">
<span>Search</span>
<input
value={state.search()}
placeholder="Search by name or ID..."
onInput={(event) => state.setSearch(event.currentTarget.value)}
/>
</label>
<FilterSelect
label="Provider"
value={state.filter()}
options={[
{ value: "all", label: "All providers" },
...state.providers().map((provider) => ({ value: provider.id, label: provider.name })),
]}
onSelect={state.setFilter}
/>
<FilterSelect
label="Cost"
value={state.price()}
options={[
{ value: "all", label: "Any cost" },
{ value: "free", label: "Free" },
{ value: "paid", label: "Paid" },
]}
onSelect={state.setPrice}
/>
<FilterSelect
label="Reasoning"
value={state.reason()}
options={[
{ value: "all", label: "Reasoning · any" },
{ value: "reasoning", label: "Reasoning · yes" },
{ value: "standard", label: "Reasoning · no" },
]}
onSelect={state.setReason}
/>
<button
class="models-favorite-filter"
classList={{ selected: state.starred() }}
type="button"
aria-pressed={state.starred()}
onClick={() => state.setStarred(!state.starred())}
>
<span class="model-star" classList={{ active: state.starred() }} aria-hidden="true" />
Favorites
</button>
</div>
<div class="models-filter-row models-filter-secondary">
<div class="models-context-filter">
<span>Context max</span>
<div class="context-range" style={`--context-min: 0%; --context-max: ${pct(state.top(), state.max())}%;`}>
<span class="context-track" aria-hidden="true" />
<span class="context-fill" aria-hidden="true" />
<button
class="context-max"
aria-label="Maximum context"
aria-valuemin="0"
aria-valuemax={state.max()}
aria-valuenow={state.top()}
disabled={state.max() === 0}
role="slider"
type="button"
onKeyDown={(event) => key("max", event)}
onPointerDown={(event) => drag("max", event)}
/>
</div>
<strong class="models-context-value mono">{fmtContext(state.top())}</strong>
</div>
<div class="models-capabilities-filter">
<span>Capabilities</span>
<div class="models-capabilities-list">
<For each={caps}>
{(cap) => (
<button
class="models-capability-toggle"
classList={{ selected: state.caps().includes(cap.key) }}
type="button"
aria-pressed={state.caps().includes(cap.key)}
onClick={() => state.toggle(cap.key)}
>
{cap.label}
</button>
)}
</For>
</div>
</div>
</div>
</section>
<div class="models explore-models">
<Show when={state.models().length} fallback={<p class="empty">No models match the current filters.</p>}>
<For each={state.models()}>
{(item) => (
<article class="model explore-model-card">
<div class="model-main">
<div class="model-title">
<button
aria-label={state.fav(item) ? "Remove favorite" : "Add favorite"}
aria-pressed={state.fav(item)}
class="model-star"
classList={{ active: state.fav(item) }}
onClick={() => state.favorite(item)}
type="button"
/>
<div>
<strong>{item.model.name}</strong>
<span>{item.id}</span>
</div>
</div>
<div class="tags model-tags">
<Tag tone="brand">{item.provider.id}</Tag>
<Tag tone={item.model.isFree ? "success" : "neutral"}>{item.model.isFree ? "free" : "paid"}</Tag>
</div>
</div>
<Show when={desc(item.model)}>{(value) => <p class="model-description">{value()}</p>}</Show>
<div class="model-info-grid">
<Stat label="Family" value={item.model.family ? title(item.model.family) : "Unknown"} />
<Stat
label="Released"
value={item.model.release_date ? fmtDate(item.model.release_date) : "Unknown"}
/>
<Stat label="Context" value={fmtContext(item.model.limit.context)} mono />
<Stat label="Input" value={money(item.model.cost.input)} sub="/ 1M tok" mono />
<Stat label="Output" value={money(item.model.cost.output)} sub="/ 1M tok" mono />
<Stat label="Reasoning" value={item.model.capabilities.reasoning ? "Yes" : "No"} />
</div>
<Show when={chips(item.model).length}>
<div class="tags model-capabilities">
<For each={chips(item.model)}>{(cap) => <Tag>{cap}</Tag>}</For>
</div>
</Show>
</article>
)}
</For>
</Show>
</div>
</ConfigPage>
</Show>
)
}
@@ -0,0 +1,132 @@
import { For, Show } from "solid-js"
import { useParams } from "@solidjs/router"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Banner, StatusDot, StatusTag } from "@kilocode/kilo-web-ui/console"
import { useConfig } from "../../context/config"
import { friendly, size, text } from "../../shared/utils"
import { ConfigPage } from "./ConfigPage"
function repo(input: string) {
const parts = input.split(/[\\/]/).filter(Boolean)
return parts.at(-1) ?? "Project"
}
export function OverviewRoute() {
const ctx = useConfig()
const params = useParams()
return (
<Show when={ctx.data()}>
{(snap) => (
<Show when={ctx.query()}>
{(q) => {
const cfg = () => snap().effective
const project = () => q().scope === "project"
const label = () => {
if (!project()) return "Global"
const dir = q().dir
if (dir) return friendly(repo(dir))
return friendly(decodeURIComponent(params.project ?? "Project"))
}
const desc = () => {
if (project()) return `Effective configuration for ${label()}. Values inherited from global are marked.`
return "Effective global configuration. Applies to all projects unless overridden."
}
const state = () => (snap().health.healthy ? "healthy" : "unhealthy")
const rows = () => [
{ label: "Default model", value: text(cfg().model), mono: true },
{ label: "Small model", value: text(cfg().small_model), mono: true },
{ label: "Default agent", value: text(cfg().default_agent), mono: true },
{ label: "Providers", value: String(snap().providers.all.length) },
{ label: "Agents", value: String(size(cfg().agent)) },
{ label: "MCP servers", value: String(size(cfg().mcp)) },
]
const facts = () => [
{ term: "Scope", value: project() ? `Project / ${label()}` : "Global" },
{ term: "Server URL", value: q().url, mono: true },
{ term: "Server version", value: snap().health.version, mono: true },
{ term: "Config directory", value: q().dir || "Server default", mono: !!q().dir },
{ term: "Active project", value: project() ? label() : "None", mono: project() },
{ term: "Default model", value: text(cfg().model), mono: true },
]
return (
<ConfigPage
title={
<span class="overview-heading">
Overview
<StatusTag status={state()} />
</span>
}
description={desc()}
>
<section class="server-strip" aria-label="Server status">
<div class="server-strip-info">
<span>Server</span>
<strong class="mono" title={q().url}>
{q().url}
</strong>
<span class="server-strip-separator" aria-hidden="true" />
<span>Version</span>
<strong class="mono" title={snap().health.version}>
{snap().health.version}
</strong>
</div>
<span class="server-strip-state">
<StatusDot tone={snap().health.healthy ? "success" : "critical"} />
{snap().health.healthy ? "Online" : "Unavailable"}
</span>
</section>
<section class="metrics" aria-label="Configuration metrics">
<For each={rows()}>
{(row) => (
<Card class="metric" padding={12}>
<span>{row.label}</span>
<strong classList={{ mono: row.mono }} title={row.value}>
{row.value}
</strong>
</Card>
)}
</For>
</section>
<section class="overview-details">
<Card class="panel" padding={0}>
<div class="title">
<div>
<h2>Diagnostics</h2>
<p>Where the dashboard is talking to.</p>
</div>
</div>
<dl class="facts">
<For each={facts()}>
{(row) => (
<div>
<dt>{row.term}</dt>
<dd classList={{ mono: row.mono }} title={row.value}>
{row.value}
</dd>
</div>
)}
</For>
</dl>
</Card>
</section>
<Show when={project()}>
<div class="overview-banner">
<Banner tone="info">
Values shown here are <em>effective</em> for this project. Use the section pages to see where each
value comes from.
</Banner>
</div>
</Show>
</ConfigPage>
)
}}
</Show>
)}
</Show>
)
}
@@ -0,0 +1,277 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
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 { toolName } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { actions, usePermissionSettings, type PermissionAction, type PermissionRule } from "./state/permissions"
export function tone(action: PermissionAction) {
if (action === "allow") return "success"
if (action === "deny") return "critical"
return "warning"
}
export function label(action: PermissionAction) {
return actions.find((item) => item.value === action)?.label ?? action
}
export function RuleMeta(props: { rule: PermissionRule }) {
return (
<div class="permission-row-meta">
<Tag tone={tone(props.rule.action)}>{label(props.rule.action)}</Tag>
<SourceBadge source={props.rule.source} inherited={props.rule.inherited} overridden={props.rule.overridden} />
</div>
)
}
export 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 (
<details class="models-select permission-select" classList={{ disabled: props.disabled }} onToggle={toggle}>
<summary aria-label={props.label} aria-disabled={props.disabled}>
{current()}
</summary>
<div class="models-select-menu" role="listbox" aria-label={props.label}>
<For each={actions}>
{(item) => (
<button
class="models-select-option"
classList={{ selected: item.value === props.value }}
type="button"
role="option"
aria-selected={item.value === props.value}
onClick={(event) => choose(item.value, event)}
>
{item.label}
</button>
)}
</For>
</div>
</details>
)
}
export function PermissionsRoute() {
const state = usePermissionSettings()
return (
<ConfigPage
title={
<span class="config-title-count">
Permissions
<CountTag>{state.groups().length + state.settings().length}</CountTag>
</span>
}
description="Control what tools agents can use by default and add pattern-specific allow, ask, or deny rules."
>
<div class="permissions">
<For each={state.groups()}>
{(group) => (
<section class="permission-group">
<header class="permission-group-header">
<div class="permission-group-copy">
<h2>{group.title}</h2>
<span>{group.id}</span>
<p>{group.description}</p>
</div>
<div class="permission-section-actions">
<CountTag>{group.rules.length}</CountTag>
<Button
icon="plus"
variant="primary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.open(group.id)}
>
Add rule
</Button>
</div>
</header>
<ConfigRow
title="Default method"
subtitle={`Used for ${group.noun}s that do not match a specific rule.`}
status={
<div class="permission-row-meta">
<SourceBadge source={group.source} inherited={group.inherited} overridden={group.overridden} />
</div>
}
actions={
<ActionSelect
label={`Default method for ${group.title}`}
value={group.action}
disabled={Boolean(state.ctx.saving())}
onSelect={(action) => state.setDefault(group.id, action)}
/>
}
/>
<div class="permission-rules">
<Show
when={group.rules.length}
fallback={<p class="permission-empty">No specific {group.noun} rules.</p>}
>
<For each={group.rules}>
{(rule) => (
<ConfigRow
title={<span class="permission-pattern">{rule.pattern}</span>}
subtitle={`${group.title} ${group.noun} rule`}
status={<RuleMeta rule={rule} />}
actions={
<IconButton
icon="trash"
variant="ghost"
aria-label={`Delete ${group.title} rule ${rule.pattern}`}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.remove(rule)}
/>
}
/>
)}
</For>
</Show>
</div>
</section>
)}
</For>
<section class="permission-group">
<SectionTitle
trailing={<CountTag>{state.settings().length}</CountTag>}
description="Default methods for additional built-in tool permissions."
>
Tool Defaults
</SectionTitle>
<div class="permission-rules">
<For each={state.settings()}>
{(item) => (
<ConfigRow
title={item.title}
subtitle={`${item.id} · ${item.description}`}
status={
<div class="permission-row-meta">
<SourceBadge source={item.source} inherited={item.inherited} overridden={item.overridden} />
</div>
}
actions={
<ActionSelect
label={`Default method for ${item.title}`}
value={item.action}
disabled={Boolean(state.ctx.saving())}
onSelect={(action) => state.setDefault(item.id, action)}
/>
}
/>
)}
</For>
</div>
</section>
<Show when={state.other().length}>
<section class="permission-group">
<SectionTitle
trailing={<CountTag>{state.other().length}</CountTag>}
description="Additional tool permission rules from config."
>
Other Permissions
</SectionTitle>
<div class="permission-rules">
<For each={state.other()}>
{(rule) => (
<ConfigRow
title={toolName(rule.tool)}
subtitle={
<span class="permission-subtitle">
<span>{rule.tool}</span>
<span class="permission-pattern">{rule.pattern}</span>
</span>
}
status={<RuleMeta rule={rule} />}
actions={
<IconButton
icon="trash"
variant="ghost"
aria-label={`Delete ${rule.tool} rule ${rule.pattern}`}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.remove(rule)}
/>
}
/>
)}
</For>
</div>
</section>
</Show>
</div>
<Show when={state.mode() === "rule"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer permission-drawer" aria-label="Permission rule configuration">
<header class="drawer-header">
<div>
<h2>{`Add ${state.selected().title} Rule`}</h2>
<span>{`${state.selected().id} · ${state.selected().description}`}</span>
</div>
<Button variant="ghost" aria-label="Close permission rule overlay" onClick={state.close}>
X
</Button>
</header>
<div class="provider-form permission-form">
<label class="required-field wide">
{state.selected().noun === "command" ? "Command pattern" : "Path pattern"}
<input
value={state.pattern()}
placeholder={state.selected().placeholder}
spellcheck={false}
onInput={(event) => state.setPattern(event.currentTarget.value)}
/>
</label>
<label class="required-field wide">
Method
<ActionSelect
label="Rule method"
value={state.action()}
disabled={Boolean(state.ctx.saving())}
onSelect={state.setAction}
/>
</label>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button variant="primary" disabled={Boolean(state.ctx.saving())} onClick={state.add}>
Save Rule
</Button>
</footer>
</aside>
</Show>
</ConfigPage>
)
}
@@ -0,0 +1,436 @@
import { For, Show } from "solid-js"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { ProviderIcon } from "@kilocode/kilo-web-ui/provider-icon"
import { CountTag } from "@kilocode/kilo-web-ui/tag"
import { StatusTag } from "@kilocode/kilo-web-ui/status-tag"
import { ConfirmDialog } from "../../components/ConfirmDialog"
import { CustomSelect } from "../../components/CustomSelect"
import { SearchField } from "../../components/SearchField"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { useProviderSettings } from "./state/providers"
export function ProvidersRoute() {
const state = useProviderSettings()
const project = () => state.ctx.query()?.scope === "project"
return (
<Show when={state.snap()}>
{(data) => (
<ConfigPage
title={
<span class="config-title-count">
Providers
<CountTag>{state.configured().length}</CountTag>
</span>
}
description="Credentials and endpoints for upstream model providers. Each provider exposes one or more models."
actions={
<>
<Button
icon="plus"
variant="primary"
disabled={Boolean(state.ctx.saving()) || project()}
onClick={state.add}
>
Add provider
</Button>
</>
}
>
<Show when={project()}>
<Card class="banner" variant="info">
Providers are global credentials. Project settings show inherited providers as read-only.
</Card>
</Show>
<SearchField
label="Filter providers"
value={state.search()}
placeholder="Filter by name or ID..."
onValue={state.setSearch}
/>
<div class="providers">
<Show when={state.visible().length} fallback={<p class="empty">No providers match this filter.</p>}>
<For each={state.visible()}>
{(provider) => (
<article class="provider configured-provider" classList={{ inherited: provider.inherited }}>
<div class="provider-title">
<ProviderIcon id={provider.id} class="provider-icon" />
<div>
<strong>{provider.name}</strong>
<span>{provider.id}</span>
</div>
</div>
<div class="tags">
<SourceBadge
source={provider.source}
inherited={provider.inherited}
overridden={provider.overridden}
/>
<span class="provider-model-count">{`${provider.models} ${provider.models === 1 ? "model" : "models"}`}</span>
<Show when={data().providers.connected.includes(provider.id)}>
<StatusTag status="connected" />
</Show>
<Show when={data().providers.failed.includes(provider.id)}>
<StatusTag status="failed" />
</Show>
</div>
<div class="provider-actions">
<IconButton
icon="edit"
variant="ghost"
aria-label={`Edit ${provider.name}`}
disabled={Boolean(state.ctx.saving()) || project() || provider.editable === false}
onClick={() => state.edit(provider)}
/>
<IconButton
icon="trash"
variant="ghost"
aria-label={`Delete ${provider.name}`}
disabled={Boolean(state.ctx.saving()) || project() || provider.editable === false}
onClick={() => state.ask(provider)}
/>
</div>
</article>
)}
</For>
</Show>
</div>
<Show when={state.mode() !== "closed"}>
<div class="drawer-scrim" onClick={state.close} />
<aside class="provider-drawer" aria-label="Provider configuration">
<Show
when={state.mode() === "form"}
fallback={
<>
<header class="drawer-header">
<div>
<h2>Add provider</h2>
<span>Choose a provider, then enter credentials.</span>
</div>
<Button variant="ghost" aria-label="Close provider overlay" onClick={state.close}>
X
</Button>
</header>
<div class="add-provider-body">
<section class="add-provider-field">
<h3>Choose provider</h3>
<SearchField
class="drawer-search add-provider-search"
label="Filter providers"
value={state.filter()}
variant="drawer"
placeholder="Search by name or ID"
onValue={state.setFilter}
/>
<div class="provider-picker add-provider-picker">
<Show
when={state.available().length}
fallback={<p class="empty">No available providers match.</p>}
>
<For each={state.available()}>
{(provider) => (
<button
class="provider-option add-provider-option"
classList={{ selected: state.target()?.id === provider.id }}
type="button"
aria-pressed={state.target()?.id === provider.id}
onClick={() => state.choose(provider)}
onDblClick={() => state.pick(provider)}
>
<ProviderIcon id={provider.id} class="provider-icon" />
<div>
<strong>{provider.name}</strong>
<span>{provider.id}</span>
</div>
</button>
)}
</For>
</Show>
</div>
</section>
<Card class="add-provider-step" padding={16}>
<Show when={state.target()} fallback={<span>Select a provider to enter credentials.</span>}>
{(provider) => (
<span>
Step 2 · enter credentials for <strong>{provider().name}</strong>
</span>
)}
</Show>
</Card>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button variant="primary" disabled={!state.target()} onClick={state.next}>
Add provider
</Button>
</footer>
</>
}
>
<header class="drawer-header provider-config-header">
<div class="provider-title provider-drawer-title">
<ProviderIcon id={state.id() || state.selected()?.id || "synthetic"} class="provider-icon" />
<div>
<h2>{state.name() || state.selected()?.name || state.id() || "Provider"}</h2>
<span>{state.id() || "New provider"}</span>
</div>
</div>
<Button variant="ghost" aria-label="Close provider overlay" onClick={state.close}>
X
</Button>
</header>
<Show
when={state.auth()}
fallback={
<>
<div class="provider-form">
<label class="required-field">
Provider ID
<input
value={state.id()}
spellcheck={false}
onInput={(event) => state.setId(event.currentTarget.value)}
/>
</label>
<label class="required-field">
Display name
<input value={state.name()} onInput={(event) => state.setName(event.currentTarget.value)} />
</label>
<label class="optional-field">
Environment variables
<input
value={state.env()}
placeholder="ANTHROPIC_API_KEY, OPENAI_API_KEY"
spellcheck={false}
onInput={(event) => state.setEnv(event.currentTarget.value)}
/>
</label>
<label class="optional-field">
API key
<input
value={state.apiKey()}
placeholder="sk-... or {env:PROVIDER_API_KEY}"
spellcheck={false}
onInput={(event) => state.setApiKey(event.currentTarget.value)}
/>
</label>
<label class="optional-field">
Base URL
<input
value={state.baseURL()}
placeholder="https://api.example.com/v1"
spellcheck={false}
onInput={(event) => state.setBaseURL(event.currentTarget.value)}
/>
</label>
<label class="optional-field">
NPM package
<input
value={state.npm()}
placeholder="@ai-sdk/openai-compatible"
spellcheck={false}
onInput={(event) => state.setNpm(event.currentTarget.value)}
/>
</label>
<label class="optional-field">
API identifier
<input
value={state.api()}
placeholder="openai-compatible"
spellcheck={false}
onInput={(event) => state.setApi(event.currentTarget.value)}
/>
</label>
<label class="optional-field">
Model whitelist
<input
value={state.whitelist()}
placeholder="model-a, model-b"
spellcheck={false}
onInput={(event) => state.setWhitelist(event.currentTarget.value)}
/>
</label>
<label class="optional-field">
Model blacklist
<input
value={state.blacklist()}
placeholder="model-a, model-b"
spellcheck={false}
onInput={(event) => state.setBlacklist(event.currentTarget.value)}
/>
</label>
<label class="wide optional-field">
Extra options JSON
<textarea
value={state.options()}
spellcheck={false}
placeholder={'{\n "timeout": 300000\n}'}
onInput={(event) => state.setOptions(event.currentTarget.value)}
/>
</label>
<label class="wide optional-field">
Model overrides JSON
<textarea
value={state.models()}
spellcheck={false}
placeholder={'{\n "model-id": { "name": "Model Name" }\n}'}
onInput={(event) => state.setModels(event.currentTarget.value)}
/>
</label>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Button variant="primary" disabled={Boolean(state.ctx.saving())} onClick={state.save}>
Save Provider
</Button>
</footer>
</>
}
>
<div class="provider-auth">
<Show when={state.methods().length > 1 && state.methodIndex() === undefined}>
<p>Select how to connect {state.name() || state.id()}.</p>
<div class="auth-methods">
<For each={state.methods()}>
{(method, index) => (
<Button variant="secondary" onClick={() => state.selectMethod(index())}>
{method.label}
</Button>
)}
</For>
</div>
</Show>
<Show when={state.phase() === "authorizing"}>
<p>Preparing authorization...</p>
</Show>
<Show when={state.method()?.type === "api"}>
<p>
Connect {state.name() || state.id()} with an API key. Extra fields are provided by the server.
</p>
<label>
API key
<input
type="password"
value={state.authKey()}
placeholder="Paste API key"
autocomplete="off"
spellcheck={false}
classList={{ invalid: state.authField() === "apiKey" }}
onInput={(event) => state.setAuthKey(event.currentTarget.value)}
/>
</label>
<For each={state.prompts()}>
{(prompt) => (
<label>
{prompt.message}
<Show
when={prompt.type === "select"}
fallback={
<input
value={state.fields()[prompt.key] ?? ""}
placeholder={prompt.type === "text" ? prompt.placeholder : undefined}
classList={{ invalid: state.authField() === prompt.key }}
onInput={(event) => state.setField(prompt.key, event.currentTarget.value)}
/>
}
>
<CustomSelect
label={prompt.message}
value={state.fields()[prompt.key] ?? ""}
options={
prompt.type === "select"
? [
{ value: "", label: "Select an option" },
...prompt.options.map((option) => ({
value: option.value,
label: option.hint ? `${option.label} (${option.hint})` : option.label,
})),
]
: []
}
invalid={state.authField() === prompt.key}
onSelect={(value) => state.setField(prompt.key, value)}
/>
</Show>
</label>
)}
</For>
</Show>
<Show when={state.authorization()?.method === "code"}>
<p>Open the authorization page and paste the code returned by {state.name() || state.id()}.</p>
<a href={state.authorization()?.url ?? "#"} target="_blank" rel="noreferrer">
Open authorization page
</a>
<label>
Authorization code
<input
value={state.authCode()}
placeholder="Paste authorization code"
classList={{ invalid: state.authField() === "code" }}
onInput={(event) => state.setAuthCode(event.currentTarget.value)}
/>
</label>
</Show>
<Show when={state.authorization()?.method === "auto"}>
<p>
Complete authorization in the browser window. This page will refresh once the provider is
connected.
</p>
<Show when={state.authorization()?.instructions}>{(text) => <code>{text()}</code>}</Show>
</Show>
<Show when={state.authError()}>{(message) => <p class="form-error">{message()}</p>}</Show>
</div>
<footer class="drawer-footer">
<Button variant="ghost" onClick={state.close}>
Cancel
</Button>
<Show when={state.method()?.type === "api"}>
<Button variant="primary" disabled={Boolean(state.ctx.saving())} onClick={state.connectAuth}>
Save Provider
</Button>
</Show>
<Show when={state.authorization()?.method === "code"}>
<Button
variant="primary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.completeOAuth()}
>
Save Provider
</Button>
</Show>
</footer>
</Show>
</Show>
</aside>
</Show>
<ConfirmDialog
open={Boolean(state.pending())}
title={`Delete provider ${state.pending()?.name ?? ""}?`}
message={`This removes ${state.pending()?.id ?? "the provider"} from the current configuration.`}
confirm="Delete"
busy={Boolean(state.ctx.saving())}
onCancel={state.cancel}
onConfirm={state.confirm}
/>
</ConfigPage>
)}
</Show>
)
}
@@ -0,0 +1,128 @@
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { For, Show, createMemo, createSignal } from "solid-js"
import { useConfig } from "../../context/config"
import { ConfigPage, ConfigToolbar } from "./ConfigPage"
type Server = {
id: string
name: string
url: string
dir: string
status: "connected" | "pending"
apply: boolean
}
export function ServersRoute() {
const ctx = useConfig()
const [items, setItems] = createSignal<Server[]>([])
const [show, setShow] = createSignal(false)
const [name, setName] = createSignal("Production")
const [url, setUrl] = createSignal("http://127.0.0.1:4097")
const rows = createMemo(() => {
const q = ctx.query()
const health = ctx.data()?.health
if (!q) return items()
const seed: Server = {
id: q.url,
name: "Local Kilo",
url: q.url,
dir: q.dir || "Server default",
status: health?.healthy ? "connected" : "pending",
apply: true,
}
return [seed, ...items().filter((item) => item.url !== q.url)]
})
function add() {
const server: Server = {
id: `${url()}-${Date.now()}`,
name: name().trim() || "Kilo Server",
url: url().trim(),
dir: "Server default",
status: "pending",
apply: true,
}
if (!server.url) return
setItems((prev) => [server, ...prev.filter((item) => item.url !== server.url)])
setShow(false)
}
function toggle(id: string) {
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, apply: !item.apply } : item)))
}
return (
<ConfigPage
title="Connected Servers"
actions={
<Button variant="primary" onClick={() => setShow((v) => !v)}>
Add New Server
</Button>
}
>
<ConfigToolbar
title="Server Sync"
description="Register global Kilo server endpoints and choose which ones receive configuration updates."
meta={<Tag>{`${rows().filter((item) => item.apply).length} selected`}</Tag>}
/>
<Show when={show()}>
<Card class="server-draft">
<label>
Name
<input value={name()} onInput={(event) => setName(event.currentTarget.value)} />
</label>
<label>
Server URL
<input value={url()} spellcheck={false} onInput={(event) => setUrl(event.currentTarget.value)} />
</label>
<Button variant="secondary" onClick={add}>
Connect Server
</Button>
</Card>
</Show>
<Card class="server-table-card">
<div class="server-toolbar">
<strong>{rows().length} servers</strong>
<span>{rows().filter((item) => item.apply).length} selected for config sync</span>
</div>
<div class="server-table" role="table" aria-label="Connected Kilo servers">
<div class="server-row server-head" role="row">
<span>Apply</span>
<span>Name</span>
<span>URL</span>
<span>Directory</span>
<span>Version</span>
<span>Status</span>
</div>
<For each={rows()}>
{(item) => (
<div class="server-row" role="row">
<span>
<input
type="checkbox"
checked={item.apply}
disabled={item.url === ctx.query()?.url}
aria-label={`Apply configuration to ${item.name}`}
onChange={() => toggle(item.id)}
/>
</span>
<strong>{item.name}</strong>
<span title={item.url}>{item.url}</span>
<span title={item.dir}>{item.dir}</span>
<span>{item.url === ctx.query()?.url ? (ctx.data()?.health.version ?? "Unknown") : "Pending"}</span>
<span>
<Tag>{item.url === ctx.query()?.url && !ctx.data.loading ? item.status : "pending"}</Tag>
</span>
</div>
)}
</For>
</div>
</Card>
</ConfigPage>
)
}
@@ -0,0 +1,50 @@
import { For, Show } from "solid-js"
import { Tag } from "@kilocode/kilo-web-ui/tag"
import { useConfig } from "../../context/config"
import { ConfigPage, ConfigToolbar } from "./ConfigPage"
export function SourcesRoute() {
const ctx = useConfig()
const snap = () => ctx.data()
return (
<Show when={snap()}>
{(data) => (
<ConfigPage title="Config Sources" actions={<Tag>{data().sources.sources.length}</Tag>}>
<ConfigToolbar
title="Load Order"
description="Load order and editability without exposing secret values."
meta={<Tag>{data().overlay.targets.active ?? "Read only"}</Tag>}
/>
<div class="table" role="table" aria-label="Config sources">
<div class="thead" role="rowgroup">
<div class="tr" role="row">
<span role="columnheader">Order</span>
<span role="columnheader">Scope</span>
<span role="columnheader">Kind</span>
<span role="columnheader">Source</span>
<span role="columnheader">Editable</span>
</div>
</div>
<div role="rowgroup">
<For each={data().sources.sources}>
{(source) => (
<div class="tr" role="row">
<span role="cell">{source.order}</span>
<span role="cell">{source.scope}</span>
<span role="cell">{source.kind}</span>
<span role="cell" title={source.path ?? source.source}>
{source.label}
</span>
<span role="cell">{source.editable ? "Yes" : "No"}</span>
</div>
)}
</For>
</div>
</div>
</ConfigPage>
)}
</Show>
)
}
@@ -0,0 +1,102 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { ConfigRow, SectionTitle, StatusTag } from "@kilocode/kilo-web-ui/console"
import { CountTag } from "@kilocode/kilo-web-ui/tag"
import { SearchField } from "../../components/SearchField"
import { useConfig } from "../../context/config"
import { toolCapabilities, toolName } from "../../shared/utils"
import { ConfigPage } from "./ConfigPage"
export function ToolsRoute() {
const ctx = useConfig()
const [search, setSearch] = createSignal("")
const snap = () => ctx.data()
const rows = createMemo(() => {
const data = snap()
if (!data) return []
const details = new Map(data.toolDetails.map((item) => [item.id, item]))
return data.tools
.map((id) => {
const item = { id, detail: details.get(id) }
return { ...item, caps: toolCapabilities(item) }
})
.sort((a, b) => toolName(a.id).localeCompare(toolName(b.id)))
})
const visible = createMemo(() => {
const q = search().trim().toLowerCase()
if (!q) return rows()
return rows().filter((tool) => `${toolName(tool.id)} ${tool.id} ${tool.caps.join(" ")}`.toLowerCase().includes(q))
})
const servers = createMemo(() => {
const data = snap()
if (!data) return []
return Object.entries(data.mcp).sort(([a], [b]) => a.localeCompare(b))
})
const visibleServers = createMemo(() => {
const q = search().trim().toLowerCase()
if (!q) return servers()
return servers().filter(([name, status]) => `${name} ${status.status}`.toLowerCase().includes(q))
})
const empty = createMemo(() => {
if (!rows().length) return "No tools registered."
return "No tools match this filter."
})
return (
<Show when={snap()}>
{(data) => (
<ConfigPage
title={
<span class="config-title-count">
Tools
<CountTag>{data().tools.length}</CountTag>
</span>
}
description="Built-in tools available to agents, including file access, terminal execution, search, fetch, and orchestration tools."
>
<SearchField
label="Filter tools"
value={search()}
placeholder="Filter by name, ID, or description..."
onValue={setSearch}
/>
<div class="tools tool-grid">
<Show when={visible().length} fallback={<p class="empty">{empty()}</p>}>
<For each={visible()}>
{(tool) => (
<ConfigRow
title={toolName(tool.id)}
subtitle={
<span class="tool-subtitle">
<span class="tool-id">{tool.id}</span>
<span class="tool-description">{tool.caps.join(" ")}</span>
</span>
}
/>
)}
</For>
</Show>
</div>
<Show when={visibleServers().length}>
<section class="tool-section">
<SectionTitle
trailing={<CountTag>{visibleServers().length}</CountTag>}
description="MCP server connection status available to agents."
>
MCP Servers
</SectionTitle>
<div class="tools">
<For each={visibleServers()}>
{([name, status]) => (
<ConfigRow title={name} subtitle="MCP server" status={<StatusTag status={status.status} />} />
)}
</For>
</div>
</section>
</Show>
</ConfigPage>
)}
</Show>
)
}
@@ -0,0 +1,144 @@
import type { Component } from "solid-js"
import type { IconProps } from "@kilocode/kilo-web-ui/icon"
import { AgentBuilderRoute, AgentsRoute } from "./AgentsRoute"
import { CliNotificationsRoute } from "./CliNotificationsRoute"
import { CliUiRoute } from "./CliUiRoute"
import { FormattersRoute, LspRoute } from "./FormattersRoute"
import { KeybindsRoute } from "./KeybindsRoute"
import { McpRoute } from "./McpRoute"
import { ModelsAvailableRoute, ModelsDefaultRoute, ModelsRoute } from "./ModelsRoute"
import { OverviewRoute } from "./OverviewRoute"
import { PermissionsRoute } from "./PermissionsRoute"
import { ProvidersRoute } from "./ProvidersRoute"
import { ServersRoute } from "./ServersRoute"
import { SourcesRoute } from "./SourcesRoute"
import { ToolsRoute } from "./ToolsRoute"
export type ConfigSection = {
path: string
href: string
icon: IconProps["name"]
label: string
component: Component
}
export type ConfigGroup = {
id: string
label: string
items: ConfigSection[]
}
export type ConfigNode = ConfigSection | ConfigGroup
const providers = {
path: "/providers",
href: "/settings/providers",
icon: "providers",
label: "Providers",
component: ProvidersRoute,
}
const agents = { path: "/agents", href: "/settings/agents", icon: "task", label: "Agents", component: AgentsRoute }
const tools = { path: "/tools", href: "/settings/tools", icon: "code", label: "Tools", component: ToolsRoute }
const mcp = { path: "/mcp", href: "/settings/mcp", icon: "mcp", label: "MCP", component: McpRoute }
const permissions = {
path: "/permissions",
href: "/settings/permissions",
icon: "key",
label: "Permissions",
component: PermissionsRoute,
}
const formatters = {
path: "/formatters",
href: "/settings/formatters",
icon: "sliders",
label: "Formatters",
component: FormattersRoute,
}
const lsp = {
path: "/lsp",
href: "/settings/lsp",
icon: "server",
label: "LSP Servers",
component: LspRoute,
}
export const configNav: ConfigNode[] = [
{
id: "general",
label: "General",
items: [{ path: "/", href: "/settings", icon: "home", label: "Overview", component: OverviewRoute }],
},
providers,
{
id: "models",
label: "Models",
items: [
{
path: "/models/default",
href: "/settings/models/default",
icon: "models",
label: "Defaults",
component: ModelsDefaultRoute,
},
{
path: "/models/explore",
href: "/settings/models/explore",
icon: "models",
label: "Explore",
component: ModelsAvailableRoute,
},
],
},
{
id: "behaviour",
label: "Behaviour",
items: [agents, tools, permissions, mcp, formatters, lsp],
},
{
id: "cli",
label: "CLI",
items: [
{ path: "/cli/ui", href: "/settings/cli/ui", icon: "sliders", label: "UI", component: CliUiRoute },
{
path: "/cli/notifications",
href: "/settings/cli/notifications",
icon: "bubble-5",
label: "Notifications",
component: CliNotificationsRoute,
},
{
path: "/cli/keybinds",
href: "/settings/cli/keybinds",
icon: "keyboard",
label: "Keybinds",
component: KeybindsRoute,
},
],
},
{
id: "advanced",
label: "Advanced",
items: [
{ path: "/servers", href: "/settings/servers", icon: "server", label: "Servers", component: ServersRoute },
{ path: "/sources", href: "/settings/sources", icon: "archive", label: "Sources", component: SourcesRoute },
],
},
]
function sections(item: ConfigNode) {
if ("items" in item) return item.items
return [item]
}
export const configSections = [
...configNav.flatMap(sections),
{ path: "/agents/new", href: "/settings/agents/new", icon: "task", label: "New Agent", component: AgentBuilderRoute },
{
path: "/agents/:agentID",
href: "/settings/agents",
icon: "task",
label: "Edit Agent",
component: AgentBuilderRoute,
},
{ path: "/models", href: "/settings/models/default", icon: "models", label: "Models", component: ModelsRoute },
]
@@ -0,0 +1,573 @@
import { createEffect, createMemo, createSignal } from "solid-js"
import type { Accessor } from "solid-js"
import type { AgentBuilderPreviewResponse, Model, Provider } from "@kilocode/sdk/v2/client"
import { previewAgent, saveAgent, type AgentPayload, type Scope, type Snapshot } from "../../../client"
import { useConfig } from "../../../context/config"
import { clean, friendly, sorted, toMode, toolCapabilities, toolName } from "../../../shared/utils"
import {
defaults,
defs,
ruleDefs,
type PermissionAction,
type PermissionDef,
type PermissionTool,
type RuleDef,
} from "./permissions"
type Mode = AgentPayload["mode"]
type Permission = NonNullable<AgentPayload["permission"]>
type Draft = AgentBuilderPreviewResponse
type Panel = "closed" | "model" | "tools" | "permission" | "markdown"
type Favorite = Snapshot["modelState"]["favorite"][number]
type Item = { id: string; provider: Provider; model: Model }
type AgentPermissionRule = {
tool: string
pattern: string
action: PermissionAction
}
type AgentPermissionGroup = RuleDef & {
action: PermissionAction
rules: AgentPermissionRule[]
}
type AgentPermissionDefault = PermissionDef & {
action: PermissionAction
}
export type AgentItem = Snapshot["agents"][number]
export type AgentEntry = Snapshot["overlay"]["collections"][string][number]
export const snippets = [
"Review the current diff and report risks, regressions, and missing tests.",
"Plan the work in small steps, then implement the smallest correct change.",
"Inspect relevant files first, then summarize the root cause before editing.",
"Run the smallest relevant validation checks and report any remaining failures.",
]
function order(a: Provider, b: Provider) {
if (a.id === "kilo") return -1
if (b.id === "kilo") return 1
return a.name.localeCompare(b.name)
}
function same(item: Item, ref: Favorite) {
if (item.provider.id === ref.providerID && item.model.id === ref.modelID) return true
return item.model.providerID === ref.providerID && item.model.id === ref.modelID
}
function maybe(input: string) {
const value = clean(input)
if (value) return value
return undefined
}
function count(input: string) {
const value = Number(input)
if (Number.isInteger(value) && value > 0) return value
return undefined
}
function record(input: unknown): Record<string, unknown> {
if (input && typeof input === "object" && !Array.isArray(input)) return input as Record<string, unknown>
return {}
}
function string(input: unknown) {
if (typeof input === "string") return input
return ""
}
function number(input: unknown) {
if (typeof input === "number" && Number.isFinite(input)) return input
return undefined
}
function act(input: unknown, fallback: PermissionAction = "ask"): PermissionAction {
if (input === "allow" || input === "deny" || input === "ask") return input
return fallback
}
function rule(tool: string, pattern: string, input: unknown): AgentPermissionRule | undefined {
if (input !== "allow" && input !== "deny" && input !== "ask") return undefined
return { tool, pattern, action: input }
}
function rows(tool: string, value: unknown) {
const obj = record(value)
if (Object.keys(obj).length)
return Object.entries(obj).flatMap(([pattern, input]) => rule(tool, pattern, input) ?? [])
const item = rule(tool, "*", value)
return item ? [item] : []
}
const ruleIDs = new Set<string>(ruleDefs.map((item) => item.id))
const knownPermissions = new Set<string>(defs.map((item) => item.id))
function source(item: AgentItem) {
const value = item.options.source
if (typeof value === "string") return value
return undefined
}
function split(input: unknown) {
const entries = Object.entries(perms(input))
return {
tools: sorted(entries.filter(([, value]) => value === "allow").map(([key]) => key)),
permission: Object.fromEntries(entries.filter(([, value]) => value !== "allow")) as Permission,
}
}
function perms(input: unknown): Permission {
if (!Array.isArray(input)) return record(input) as Permission
return input.reduce((result, item) => {
const rule = record(item)
const tool = string(rule.permission)
const action = string(rule.action)
if (!tool || !action) return result
const pattern = string(rule.pattern) || "*"
if (pattern === "*") return { ...result, [tool]: action }
const cur = result[tool]
const map = cur && typeof cur === "object" && !Array.isArray(cur) ? cur : {}
return { ...result, [tool]: { ...map, [pattern]: action } }
}, {} as Permission)
}
export function agentMeta(data: Snapshot, id: string) {
return (data.overlay.collections.agent ?? []).find((entry) => entry.key === id)
}
export function agentTitle(item: AgentItem) {
return item.displayName ?? friendly(item.name)
}
export function agentModel(item: AgentItem) {
if (!item.model) return ""
return `${item.model.providerID}/${item.model.modelID}`
}
export function agentEditable(item: AgentItem, entry?: AgentEntry) {
if (item.native) return false
if (entry?.inherited) return false
if (entry?.editable === false) return false
if (source(item) === "organization") return false
return true
}
export function useAgentBuilder(agent?: Accessor<string | undefined>) {
const ctx = useConfig()
const snap = () => ctx.data()
const scope = createMemo<Scope>(() => ctx.query()?.scope ?? "project")
const [loaded, setLoaded] = createSignal<string>()
const [ready, setReady] = createSignal(true)
const [locked, setLocked] = createSignal(false)
const [id, setId] = createSignal("reviewer")
const [desc, setDesc] = createSignal("")
const [mode, setMode] = createSignal<Mode>("subagent")
const [model, setModel] = createSignal("")
const [color, setColor] = createSignal("")
const [steps, setSteps] = createSignal("")
const [tools, setTools] = createSignal<string[]>([])
const [prompt, setPrompt] = createSignal("")
const [permTool, setPermTool] = createSignal<PermissionTool>("external_directory")
const [permPattern, setPermPattern] = createSignal("")
const [permAction, setPermAction] = createSignal<PermissionAction>("ask")
const [permission, setPermission] = createSignal<Permission>({})
const [draft, setDraft] = createSignal<Draft>()
const [panel, setPanel] = createSignal<Panel>("closed")
const [picker, setPicker] = createSignal("")
const [choice, setChoice] = createSignal("")
const [search, setSearch] = createSignal("")
const [chosen, setChosen] = createSignal<string[]>([])
const providers = createMemo(() => {
const data = snap()
if (!data) return []
const ids = new Set([...Object.keys(data.effective.provider ?? {}), ...data.providers.connected])
return data.providers.all
.filter((provider) => ids.has(provider.id))
.filter((provider) => Object.keys(provider.models).length > 0)
.sort(order)
})
const all = createMemo(() => {
return providers().flatMap((provider) =>
Object.values(provider.models).map((model) => ({
id: `${provider.id}/${model.id}`,
provider,
model,
})),
)
})
const favorites = createMemo(() => snap()?.modelState.favorite ?? [])
const fav = (item: Item) => favorites().some((ref) => same(item, ref))
const models = createMemo(() => {
const term = picker().trim().toLowerCase()
return all()
.filter((item) => {
if (!term) return true
return `${item.id} ${item.model.name} ${item.provider.name}`.toLowerCase().includes(term)
})
.sort((a, b) => {
const ranked = Number(fav(b)) - Number(fav(a))
if (ranked !== 0) return ranked
const named = a.model.name.localeCompare(b.model.name)
if (named !== 0) return named
const provider = a.provider.name.localeCompare(b.provider.name)
if (provider !== 0) return provider
return a.id.localeCompare(b.id)
})
})
const item = (value: unknown) => {
if (typeof value !== "string") return undefined
return all().find((model) => model.id === value || `${model.model.providerID}/${model.model.id}` === value)
}
const selected = createMemo(() => item(model()))
const options = createMemo(() => {
const data = snap()
if (!data) return []
const term = search().trim().toLowerCase()
const details = new Map(data.toolDetails.map((item) => [item.id, item]))
return data.tools
.map((id) => ({ id, detail: details.get(id) }))
.filter((tool) => {
if (!term) return true
return `${tool.id} ${toolName(tool.id)} ${toolCapabilities(tool).join(" ")}`.toLowerCase().includes(term)
})
.sort((a, b) => toolName(a.id).localeCompare(toolName(b.id)))
})
const pickedDraft = createMemo(() => new Set(chosen()))
const rules = createMemo(() => Object.entries(permission()).flatMap(([tool, value]) => rows(tool, value)))
const permissionGroups = createMemo<AgentPermissionGroup[]>(() =>
ruleDefs.map((def) => {
const value = permission()[def.id]
const obj = record(value)
return {
...def,
action: act(typeof value === "string" ? value : obj["*"]),
rules: Object.entries(obj)
.filter(([pattern]) => pattern !== "*")
.flatMap(([pattern, input]) => rule(def.id, pattern, input) ?? []),
}
}),
)
const permissionDefaults = createMemo<AgentPermissionDefault[]>(() =>
defaults.map((def) => {
const value = permission()[def.id]
const obj = record(value)
return {
...def,
action: act(typeof value === "string" ? value : obj["*"]),
}
}),
)
const permissionOther = createMemo(() =>
Object.entries(permission())
.filter(([tool]) => !knownPermissions.has(tool))
.flatMap(([tool, value]) => rows(tool, value))
.sort((a, b) => a.tool.localeCompare(b.tool) || a.pattern.localeCompare(b.pattern)),
)
const selectedPermission = createMemo(() => ruleDefs.find((def) => def.id === permTool()) ?? ruleDefs[0])
function reset(value = "reviewer") {
setId(value)
setDesc("")
setMode("subagent")
setModel("")
setColor("")
setSteps("")
setTools([])
setPrompt("")
setPermission({})
setDraft(undefined)
setLocked(false)
setPanel("closed")
setPermTool("external_directory")
setPermPattern("")
setPermAction("ask")
setPicker("")
setChoice("")
setSearch("")
setChosen([])
}
createEffect(() => {
const value = agent?.()
if (value) return
if (loaded() === "") return
reset()
setReady(true)
setLoaded("")
})
createEffect(() => {
const data = snap()
const key = agent?.()
if (!data || !key) return
if (loaded() === key) return
reset(key)
setReady(false)
setLoaded(key)
const item = data.agents.find((item) => item.name === key)
if (!item) {
ctx.fail(`Agent not found: ${key}`)
return
}
const entry = agentMeta(data, key)
const edit = agentEditable(item, entry)
ctx.fail("")
setReady(true)
setLocked(!edit)
const cfg = record(entry?.value)
const parts = split(cfg.permission ?? (edit ? undefined : item.permission))
const step = number(cfg.steps) ?? item.steps
setId(item.name)
setDesc(string(cfg.description) || item.description || "")
setMode(toMode(string(cfg.mode) || item.mode))
setModel(string(cfg.model) || agentModel(item))
setColor(string(cfg.color) || item.color || "")
setSteps(step ? String(step) : "")
setTools(parts.tools)
setPermission(parts.permission)
setPrompt(string(cfg.prompt) || item.prompt || "")
})
function close() {
setPanel("closed")
}
function guard() {
if (!locked()) return true
ctx.fail("Inspected agents are read-only.")
return false
}
function openMarkdown() {
const payload = build()
if (!payload) return
setDraft(undefined)
setPanel("markdown")
ctx.run("Previewing agent", () => previewAgent(ctx.target(), payload).then(setDraft), { refetch: false })
}
function openModel() {
if (!guard()) return
setPicker("")
setChoice(model())
setPanel("model")
}
function selectModel(item: Item) {
setChoice(item.id)
}
function saveModel() {
if (!guard()) return
const id = choice()
if (!id) {
ctx.fail("Select a model before saving the agent model field.")
return
}
setModel(id)
close()
}
function clearModel() {
if (!guard()) return
setModel("")
}
function openTools() {
if (!guard()) return
setSearch("")
setChosen(tools())
setPanel("tools")
}
function toggleTool(id: string) {
if (!guard()) return
const values = new Set(chosen())
if (values.has(id)) values.delete(id)
else values.add(id)
setChosen(sorted(values))
}
function saveTools() {
if (!guard()) return
setTools(chosen())
close()
}
function clearTools() {
if (!guard()) return
setTools([])
}
function openPermission(tool: PermissionTool) {
if (!guard()) return
setPermTool(tool)
setPermPattern("")
setPermAction("ask")
setPanel("permission")
}
function setPermissionDefault(tool: string, action: PermissionAction) {
if (!guard()) return
const next = { ...permission() }
if (!ruleIDs.has(tool)) {
next[tool] = action
setPermission(next)
return
}
const map = record(next[tool])
next[tool] = { ...map, "*": action }
setPermission(next)
}
function addPermission() {
if (!guard()) return
const pattern = clean(permPattern())
if (!pattern) {
ctx.fail(`Enter a ${selectedPermission().noun} pattern before saving.`)
return
}
const tool = permTool()
const next = { ...permission() }
const cur = next[tool]
const map: Record<string, unknown> = cur && typeof cur === "object" && !Array.isArray(cur) ? { ...cur } : {}
map[pattern] = permAction()
next[tool] = map
setPermission(next)
close()
}
function removePermission(item: AgentPermissionRule) {
if (!guard()) return
const next = { ...permission() }
if (item.pattern === "*") {
const map = { ...record(next[item.tool]) }
if (Object.keys(map).length) {
delete map["*"]
if (Object.keys(map).length) next[item.tool] = map
else delete next[item.tool]
setPermission(next)
return
}
delete next[item.tool]
setPermission(next)
return
}
const map = { ...record(next[item.tool]) }
delete map[item.pattern]
if (Object.keys(map).length) next[item.tool] = map
else delete next[item.tool]
setPermission(next)
}
function insert(value: string) {
if (!guard()) return
const cur = clean(prompt())
setPrompt(cur ? `${cur}\n\n${value}` : value)
}
function build(): AgentPayload | undefined {
if (!ready()) {
ctx.fail("Select an agent before previewing or saving.")
return undefined
}
const name = clean(id())
const body = clean(prompt())
if (!name || !body) {
ctx.fail("Enter an agent id and prompt before previewing or saving.")
return undefined
}
const rules = permission()
return {
scope: scope(),
id: name,
description: maybe(desc()),
mode: mode(),
model: maybe(model()),
color: maybe(color()),
steps: count(steps()),
tools: tools().length ? tools() : undefined,
permission: Object.keys(rules).length ? rules : undefined,
prompt: body,
}
}
function save() {
if (!guard()) return
const payload = build()
if (!payload) return
ctx.run("Saving agent", () => saveAgent(ctx.target(), payload).then(setDraft))
}
return {
ctx,
snap,
scope,
id,
setId,
desc,
setDesc,
mode,
setMode,
model,
setModel,
color,
setColor,
steps,
setSteps,
tools,
prompt,
setPrompt,
permTool,
setPermTool,
permPattern,
setPermPattern,
permAction,
setPermAction,
permissionGroups,
permissionDefaults,
permissionOther,
selectedPermission,
draft,
ready,
locked,
panel,
close,
openMarkdown,
picker,
setPicker,
choice,
models,
selected,
fav,
openModel,
selectModel,
saveModel,
clearModel,
search,
setSearch,
options,
pickedDraft,
openTools,
rules,
openPermission,
setPermissionDefault,
removePermission,
toggleTool,
saveTools,
clearTools,
addPermission,
insert,
save,
}
}
@@ -0,0 +1,494 @@
import { createMemo, createSignal } from "solid-js"
import type { Snapshot } from "../../../client"
import { useConfig } from "../../../context/config"
import { clean, csv, friendly, words } from "../../../shared/utils"
type Coll = Snapshot["overlay"]["collections"][string][number]
type Kind = "formatter" | "lsp"
type RowState = "active" | "connected" | "disabled" | "enabled" | "error" | "idle" | "missing"
type Builtin = {
id: string
title?: string
ext: string[]
req: string
note?: string
}
export type ToolRow = Builtin & {
command: string[]
custom: boolean
disabled: boolean
entry?: Coll
env: Record<string, string>
init: Record<string, unknown>
removable: boolean
state: RowState
}
const fmts = [
{ id: "air", ext: [".R"], req: "air command available" },
{
id: "biome",
ext: [".js", ".jsx", ".ts", ".tsx", ".html", ".css", ".md", ".json", ".yaml"],
req: "biome.json(c) config file",
},
{ id: "cargofmt", ext: [".rs"], req: "cargo fmt command available" },
{ id: "clang-format", ext: [".c", ".cpp", ".h", ".hpp", ".ino"], req: ".clang-format config file" },
{ id: "cljfmt", ext: [".clj", ".cljs", ".cljc", ".edn"], req: "cljfmt command available" },
{ id: "dart", ext: [".dart"], req: "dart command available" },
{ id: "dfmt", ext: [".d"], req: "dfmt command available" },
{ id: "gleam", ext: [".gleam"], req: "gleam command available" },
{ id: "gofmt", ext: [".go"], req: "gofmt command available" },
{ id: "htmlbeautifier", ext: [".erb", ".html.erb"], req: "htmlbeautifier command available" },
{ id: "ktlint", ext: [".kt", ".kts"], req: "ktlint command available" },
{ id: "latexindent", ext: [".tex"], req: "latexindent command available" },
{ id: "mix", ext: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"], req: "mix command available" },
{ id: "nixfmt", ext: [".nix"], req: "nixfmt command available" },
{ id: "ocamlformat", ext: [".ml", ".mli"], req: "ocamlformat command and .ocamlformat config file" },
{ id: "ormolu", ext: [".hs"], req: "ormolu command available" },
{
id: "oxfmt",
ext: [".js", ".jsx", ".ts", ".tsx"],
req: "oxfmt dependency and experimental flag",
note: "Experimental",
},
{ id: "pint", ext: [".php"], req: "laravel/pint dependency in composer.json" },
{
id: "prettier",
ext: [".js", ".jsx", ".ts", ".tsx", ".html", ".css", ".md", ".json", ".yaml"],
req: "prettier dependency in package.json",
},
{ id: "rubocop", ext: [".rb", ".rake", ".gemspec", ".ru"], req: "rubocop command available" },
{ id: "ruff", ext: [".py", ".pyi"], req: "ruff command available with config" },
{ id: "rustfmt", ext: [".rs"], req: "rustfmt command available" },
{ id: "shfmt", ext: [".sh", ".bash"], req: "shfmt command available" },
{ id: "standardrb", ext: [".rb", ".rake", ".gemspec", ".ru"], req: "standardrb command available" },
{ id: "terraform", ext: [".tf", ".tfvars"], req: "terraform command available" },
{ id: "uv", ext: [".py", ".pyi"], req: "uv command available" },
{ id: "zig", ext: [".zig", ".zon"], req: "zig command available" },
] satisfies Builtin[]
const servers = [
{ id: "astro", ext: [".astro"], req: "Auto-installs for Astro projects" },
{ id: "bash", ext: [".sh", ".bash", ".zsh", ".ksh"], req: "Auto-installs bash-language-server" },
{
id: "biome",
ext: [".js", ".jsx", ".ts", ".tsx", ".json", ".jsonc", ".css"],
req: "biome dependency or config available",
},
{
id: "clangd",
ext: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
req: "Auto-installs for C/C++ projects",
},
{ id: "clojure-lsp", ext: [".clj", ".cljs", ".cljc", ".edn"], req: "clojure-lsp command available" },
{ id: "csharp", title: "C#", ext: [".cs", ".csx"], req: ".NET SDK installed" },
{ id: "dart", ext: [".dart"], req: "dart command available" },
{ id: "deno", ext: [".ts", ".tsx", ".js", ".jsx", ".mjs"], req: "deno command available with deno.json/c" },
{
id: "dockerfile",
title: "Dockerfile",
ext: ["Dockerfile", ".dockerfile"],
req: "Docker language server available",
},
{ id: "elixir-ls", ext: [".ex", ".exs"], req: "elixir command available" },
{
id: "eslint",
title: "ESLint",
ext: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
req: "eslint dependency in project",
},
{ id: "fsharp", title: "F#", ext: [".fs", ".fsi", ".fsx", ".fsscript"], req: ".NET SDK installed" },
{ id: "gleam", ext: [".gleam"], req: "gleam command available" },
{ id: "gopls", ext: [".go"], req: "go command available" },
{
id: "haskell-language-server",
title: "HLS",
ext: [".hs", ".lhs"],
req: "haskell-language-server-wrapper command available",
},
{ id: "jdtls", title: "JDTLS", ext: [".java"], req: "Java SDK 21+ installed" },
{ id: "julials", title: "JuliaLS", ext: [".jl"], req: "julia and LanguageServer.jl installed" },
{ id: "kotlin-ls", ext: [".kt", ".kts"], req: "Auto-installs for Kotlin projects" },
{ id: "lua-ls", ext: [".lua"], req: "Auto-installs for Lua projects" },
{ id: "nixd", ext: [".nix"], req: "nixd command available" },
{ id: "ocaml-lsp", ext: [".ml", ".mli"], req: "ocamllsp command available" },
{
id: "oxlint",
ext: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
req: "oxlint dependency in project",
},
{ id: "php intelephense", title: "PHP Intelephense", ext: [".php"], req: "Auto-installs for PHP projects" },
{ id: "prisma", ext: [".prisma"], req: "prisma command available" },
{ id: "pyright", ext: [".py", ".pyi"], req: "pyright dependency installed" },
{ id: "razor", ext: [".razor", ".cshtml"], req: ".NET SDK and VS Code C# extension installed" },
{ id: "ruby-lsp", ext: [".rb", ".rake", ".gemspec", ".ru"], req: "ruby and gem commands available" },
{ id: "rust", ext: [".rs"], req: "rust-analyzer command available" },
{ id: "sourcekit-lsp", ext: [".swift", ".objc", ".objcpp"], req: "swift installed, Xcode on macOS" },
{ id: "svelte", ext: [".svelte"], req: "Auto-installs for Svelte projects" },
{ id: "terraform", ext: [".tf", ".tfvars"], req: "Auto-installs from GitHub releases" },
{ id: "texlab", ext: [".tex"], req: "Auto-installs texlab" },
{ id: "tinymist", ext: [".typ", ".typc"], req: "Auto-installs from GitHub releases" },
{ id: "ty", title: "Ty", ext: [".py", ".pyi"], req: "Experimental Python LSP", note: "Experimental" },
{
id: "typescript",
title: "TypeScript",
ext: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
req: "typescript dependency in project",
},
{ id: "vue", ext: [".vue"], req: "Auto-installs for Vue projects" },
{ id: "yaml-ls", title: "YAML LS", ext: [".yaml", ".yml"], req: "Auto-installs Red Hat yaml-language-server" },
{ id: "zls", title: "ZLS", ext: [".zig", ".zon"], req: "zig command available" },
] satisfies Builtin[]
const fmtIDs = new Set(fmts.map((item) => item.id))
const lspIDs = new Set(servers.map((item) => item.id))
function obj(input: unknown) {
if (!input || typeof input !== "object" || Array.isArray(input)) return {}
return input as Record<string, unknown>
}
function arr(input: unknown) {
if (!Array.isArray(input)) return []
return input.filter((item): item is string => typeof item === "string")
}
function map(input: Coll[]) {
return new Map(input.map((item) => [item.key, item]))
}
function enabled(input: unknown) {
return input === true || Boolean(input && typeof input === "object")
}
function off(input: Coll | undefined) {
return obj(input?.value).disabled === true
}
function cmd(input: Coll | undefined) {
return arr(obj(input?.value).command)
}
function env(input: Coll | undefined, key: "env" | "environment") {
return Object.fromEntries(
Object.entries(obj(obj(input?.value)[key])).filter((item): item is [string, string] => typeof item[1] === "string"),
)
}
function init(input: Coll | undefined) {
return obj(obj(input?.value).initialization)
}
function ext(input: Builtin, entry: Coll | undefined, live: string[]) {
const cfg = arr(obj(entry?.value).extensions)
if (cfg.length) return cfg
if (live.length) return live
return input.ext
}
function lines(input: Record<string, string>) {
return Object.entries(input)
.map((item) => `${item[0]}=${item[1]}`)
.join("\n")
}
function json(input: Record<string, unknown>) {
if (!Object.keys(input).length) return ""
return JSON.stringify(input, null, 2)
}
function parseEnv(input: string) {
const all = input.split("\n").map(clean).filter(Boolean)
const bad = all.find((line) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(line))
if (bad) return { error: `Invalid environment variable: ${bad}` }
return {
value: Object.fromEntries(
all.map((line) => {
const index = line.indexOf("=")
return [line.slice(0, index), line.slice(index + 1)]
}),
),
}
}
function parseInit(input: string) {
const raw = clean(input)
if (!raw) return { value: undefined }
try {
const data = JSON.parse(raw) as unknown
if (!data || typeof data !== "object" || Array.isArray(data))
return { error: "Initialization must be a JSON object." }
return { value: data as Record<string, unknown> }
} catch (err) {
return { error: err instanceof Error ? err.message : "Invalid initialization JSON." }
}
}
function removable(input: Coll | undefined) {
return Boolean(input?.editable && !input.inherited)
}
function label(input: Builtin) {
return input.title ?? friendly(input.id)
}
export function useFormatterSettings() {
const ctx = useConfig()
const snap = () => ctx.data()
const [mode, setMode] = createSignal<Kind | undefined>()
const [edit, setEdit] = createSignal<ToolRow | undefined>()
const [name, setName] = createSignal("")
const [command, setCommand] = createSignal("")
const [extensions, setExtensions] = createSignal("")
const [environment, setEnvironment] = createSignal("")
const [initialization, setInitialization] = createSignal("")
const [disabled, setDisabled] = createSignal(false)
const formatter = createMemo(() => snap()?.overlay.collections.formatter ?? [])
const lsp = createMemo(() => snap()?.overlay.collections.lsp ?? [])
const fmtOn = createMemo(() => enabled(snap()?.effective.formatter))
const lspOn = createMemo(() => enabled(snap()?.effective.lsp))
const fmtEntries = createMemo(() => map(formatter()))
const lspEntries = createMemo(() => map(lsp()))
const fmtStatus = createMemo(() => new Map((snap()?.formatter ?? []).map((item) => [item.name, item])))
const lspStatus = createMemo(() => new Map((snap()?.lsp ?? []).map((item) => [item.id, item])))
const fmtRows = createMemo(() => {
const builtin = fmts.map((item): ToolRow => {
const entry = fmtEntries().get(item.id)
const live = fmtStatus().get(item.id)
const state = !fmtOn() || off(entry) ? "disabled" : live?.enabled ? "active" : live ? "missing" : "enabled"
return {
...item,
command: cmd(entry),
custom: false,
disabled: off(entry),
entry,
env: env(entry, "environment"),
ext: ext(item, entry, live?.extensions ?? []),
init: {},
removable: removable(entry),
state,
title: label(item),
}
})
const custom = formatter()
.filter((item) => !fmtIDs.has(item.key))
.map((entry): ToolRow => {
const value = obj(entry.value)
const live = fmtStatus().get(entry.key)
const state =
!fmtOn() || value.disabled === true ? "disabled" : live?.enabled ? "active" : live ? "missing" : "enabled"
return {
id: entry.key,
title: friendly(entry.key),
command: cmd(entry),
custom: true,
disabled: value.disabled === true,
entry,
env: env(entry, "environment"),
ext: ext({ id: entry.key, ext: [], req: "Custom formatter" }, entry, live?.extensions ?? []),
init: {},
removable: removable(entry),
req: "Custom formatter",
state,
}
})
return [...builtin, ...custom]
})
const lspRows = createMemo(() => {
const builtin = servers.map((item): ToolRow => {
const entry = lspEntries().get(item.id)
const live = lspStatus().get(item.id)
const state =
!lspOn() || off(entry) ? "disabled" : live?.status === "error" ? "error" : live ? "connected" : "idle"
return {
...item,
command: cmd(entry),
custom: false,
disabled: off(entry),
entry,
env: env(entry, "env"),
init: init(entry),
removable: removable(entry),
state,
title: label(item),
}
})
const custom = lsp()
.filter((item) => !lspIDs.has(item.key))
.map((entry): ToolRow => {
const value = obj(entry.value)
const live = lspStatus().get(entry.key)
const state =
!lspOn() || value.disabled === true
? "disabled"
: live?.status === "error"
? "error"
: live
? "connected"
: "idle"
return {
id: entry.key,
title: friendly(entry.key),
command: cmd(entry),
custom: true,
disabled: value.disabled === true,
entry,
env: env(entry, "env"),
ext: ext({ id: entry.key, ext: [], req: "Custom LSP server" }, entry, []),
init: init(entry),
removable: removable(entry),
req: "Custom LSP server",
state,
}
})
return [...builtin, ...custom]
})
const customFmt = createMemo(() => fmtRows().filter((item) => item.custom))
const customLsp = createMemo(() => lspRows().filter((item) => item.custom))
const builtinFmt = createMemo(() => fmtRows().filter((item) => !item.custom))
const builtinLsp = createMemo(() => lspRows().filter((item) => !item.custom))
const locked = createMemo(() => Boolean(edit()))
function open(kind: Kind, row?: ToolRow) {
setMode(kind)
setEdit(row)
setName(row?.id ?? "")
setCommand(row?.command.join(" ") ?? "")
setExtensions(row?.ext.join(", ") ?? "")
setEnvironment(lines(row?.env ?? {}))
setInitialization(json(row?.init ?? {}))
setDisabled(row?.disabled ?? false)
}
function close() {
setMode(undefined)
setEdit(undefined)
setName("")
setCommand("")
setExtensions("")
setEnvironment("")
setInitialization("")
setDisabled(false)
}
function saveFormatter() {
const key = clean(name())
const commandList = words(command())
const extensionList = csv(extensions())
const parsed = parseEnv(environment())
if (!key) {
ctx.fail("Enter a formatter name before saving.")
return
}
if (!edit() && commandList.length === 0) {
ctx.fail("Enter a formatter command before saving.")
return
}
if (!edit() && extensionList.length === 0) {
ctx.fail("Enter at least one formatter extension before saving.")
return
}
if (parsed.error) {
ctx.fail(parsed.error)
return
}
const cfg: { command?: string[]; disabled?: boolean; environment?: Record<string, string>; extensions?: string[] } =
{}
if (disabled()) cfg.disabled = true
if (commandList.length) cfg.command = commandList
if (extensionList.length) cfg.extensions = extensionList
if (parsed.value && Object.keys(parsed.value).length) cfg.environment = parsed.value
ctx.save({ formatter: { [key]: cfg } })
close()
}
function saveLsp() {
const key = clean(name())
const commandList = words(command())
const extensionList = csv(extensions())
const vars = parseEnv(environment())
const opts = parseInit(initialization())
if (!key) {
ctx.fail("Enter an LSP server name before saving.")
return
}
if (vars.error) {
ctx.fail(vars.error)
return
}
if (opts.error) {
ctx.fail(opts.error)
return
}
if (disabled()) {
ctx.save({ lsp: { [key]: { disabled: true as const } } })
close()
return
}
if (commandList.length === 0) {
ctx.fail("Enter an LSP command before saving, or disable the server.")
return
}
if (!edit() && extensionList.length === 0) {
ctx.fail("Enter at least one extension for a custom LSP server.")
return
}
const cfg: {
command: string[]
disabled?: boolean
env?: Record<string, string>
extensions?: string[]
initialization?: Record<string, unknown>
} = { command: commandList }
if (extensionList.length) cfg.extensions = extensionList
if (vars.value && Object.keys(vars.value).length) cfg.env = vars.value
if (opts.value && Object.keys(opts.value).length) cfg.initialization = opts.value
ctx.save({ lsp: { [key]: cfg } })
close()
}
function remove(row: ToolRow) {
const path = row.entry?.path
if (!path) return
ctx.unset([path])
}
return {
builtinFmt,
builtinLsp,
close,
command,
customFmt,
customLsp,
ctx,
disabled,
enableFmt: () => ctx.save({ formatter: {} }),
enableLsp: () => ctx.save({ lsp: {} }),
environment,
extensions,
fmtOn,
initialization,
locked,
lspOn,
mode,
name,
open,
remove,
saveFormatter,
saveLsp,
setCommand,
setDisabled,
setEnvironment,
setExtensions,
setInitialization,
setName,
snap,
disableFmt: () => ctx.save({ formatter: false }),
disableLsp: () => ctx.save({ lsp: false }),
}
}
@@ -0,0 +1,194 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import type { Snapshot, TuiPatch } from "../../../client"
import { useConfig } from "../../../context/config"
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 [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(() => 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<Row[]>(() =>
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<string, Row[]>()),
([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 name = clean(keybind())
const value = clean(binding())
if (!snap() || !name || !value) {
ctx.fail("Enter a TUI keybind name and binding before saving.")
return
}
if (!ids().has(name)) {
ctx.fail(`Unknown TUI keybind: ${name}`)
return
}
ctx.tui({ keybinds: { [name]: value } as NonNullable<TuiPatch["keybinds"]> })
close()
}
return {
ctx,
mode,
close,
open,
search,
setSearch,
capture,
setCapture,
keybinds,
rows,
visible,
groups,
selected,
binding,
setBinding,
conflicts,
defaulted,
reset,
none,
save,
}
}
@@ -0,0 +1,669 @@
import { createMemo, createResource, createSignal } from "solid-js"
import type { McpLocalConfig, McpRemoteConfig, McpStatus } from "@kilocode/sdk/v2/client"
import { parse as parseYaml } from "yaml"
import type { Snapshot } from "../../../client"
import { authenticateMcp, connectMcp, disconnectMcp } from "../../../client"
import { useConfig } from "../../../context/config"
import { clean, errMsg, text, words, type McpMap } from "../../../shared/utils"
const market = "https://api.kilo.ai/api/marketplace/mcps"
const pattern = /^[\w\-@.]+$/
type Resolved = Snapshot["overlay"]["collections"][string][number]
type Dict = Record<string, unknown>
type Config = McpLocalConfig | McpRemoteConfig | { enabled: boolean }
type Mode = "closed" | "market" | "install" | "config"
type Filter = "all" | "installed" | "notInstalled"
export type McpParameter = {
name: string
key: string
placeholder?: string
optional?: boolean
}
export type McpMethod = {
name: string
content: string
parameters?: McpParameter[]
prerequisites?: string[]
}
export type McpMarket = {
type: "mcp"
id: string
name: string
description: string
author?: string
authorUrl?: string
tags?: string[]
prerequisites?: string[]
url?: string
content: string | McpMethod[]
parameters?: McpParameter[]
}
export type McpRow = {
id: string
name: string
summary: string
kind: string
enabled: boolean
config?: Config
status?: McpStatus
source: string
inherited?: boolean
overridden?: boolean
editable?: boolean
revert?: boolean
path?: string[]
market?: McpMarket
}
function record(input: unknown): Dict {
if (input && typeof input === "object" && !Array.isArray(input)) return input as Dict
return {}
}
function str(input: unknown) {
return typeof input === "string" ? input : ""
}
function strs(input: unknown) {
if (!Array.isArray(input)) return []
return input.filter((item): item is string => typeof item === "string")
}
function json(input: unknown) {
if (input === undefined || input === null) return ""
return JSON.stringify(input, null, 2)
}
function strings(input: unknown) {
const data = record(input)
const rows = Object.entries(data).flatMap(([key, value]) =>
typeof value === "string" ? [[key, value] as const] : [],
)
if (!rows.length) return undefined
return Object.fromEntries(rows) as Record<string, string>
}
function object(input: string, label: string, fail: (message: string) => void) {
const raw = clean(input)
if (!raw) return {}
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
fail(`${label} must be a JSON object.`)
return undefined
}
for (const [key, value] of Object.entries(parsed as Dict)) {
if (typeof value === "string") continue
fail(`${label}.${key} must be a string.`)
return undefined
}
return parsed as Record<string, string>
} catch (err) {
fail(`${label}: ${errMsg(err)}`)
return undefined
}
}
function oauth(input: string, fail: (message: string) => void) {
const raw = clean(input)
if (!raw) return undefined
try {
const parsed = JSON.parse(raw)
if (parsed === false) return false
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
fail("OAuth must be a JSON object or false.")
return undefined
}
for (const [key, value] of Object.entries(parsed as Dict)) {
if (typeof value === "string") continue
fail(`OAuth.${key} must be a string.`)
return undefined
}
return parsed as NonNullable<McpRemoteConfig["oauth"]>
} catch (err) {
fail(`OAuth: ${errMsg(err)}`)
return undefined
}
}
function timeout(input: string, fail: (message: string) => void) {
const raw = clean(input)
if (!raw) return undefined
const value = Number(raw)
if (Number.isFinite(value) && value > 0) return value
fail("Timeout must be a positive number of milliseconds.")
return undefined
}
function method(input: unknown) {
const data = record(input)
const name = str(data.name)
const content = str(data.content)
if (!name || !content) return undefined
const params = paramsList(data.parameters)
const prereq = strs(data.prerequisites)
return {
name,
content,
...(params.length ? { parameters: params } : {}),
...(prereq.length ? { prerequisites: prereq } : {}),
}
}
function param(input: unknown) {
const data = record(input)
const key = str(data.key)
const name = str(data.name) || key
if (!key || !name) return undefined
const placeholder = str(data.placeholder)
return {
name,
key,
...(placeholder ? { placeholder } : {}),
...(data.optional === true ? { optional: true } : {}),
}
}
function paramsList(input: unknown) {
if (!Array.isArray(input)) return []
return input.flatMap((item) => {
const next = param(item)
return next ? [next] : []
})
}
function methodsList(input: unknown) {
if (!Array.isArray(input)) return []
return input.flatMap((item) => {
const next = method(item)
return next ? [next] : []
})
}
function item(input: unknown) {
const data = record(input)
const id = str(data.id)
const name = str(data.name) || id
const description = str(data.description)
const methods = methodsList(data.content)
const content = typeof data.content === "string" ? data.content : methods
if (!id || !name || !description) return []
if (Array.isArray(content) && !content.length) return []
const tags = strs(data.tags)
const params = paramsList(data.parameters)
const prereq = strs(data.prerequisites)
const author = str(data.author)
const authorUrl = str(data.authorUrl)
const url = str(data.url)
return [
{
type: "mcp" as const,
id,
name,
description,
content,
...(author ? { author } : {}),
...(authorUrl ? { authorUrl } : {}),
...(url ? { url } : {}),
...(tags.length ? { tags } : {}),
...(params.length ? { parameters: params } : {}),
...(prereq.length ? { prerequisites: prereq } : {}),
},
]
}
async function fetchMarket() {
const response = await fetch(market)
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`)
const body = await response.text()
const parsed = (() => {
try {
return JSON.parse(body) as { items?: unknown[] }
} catch {
return parseYaml(body) as { items?: unknown[] }
}
})()
const items = Array.isArray(parsed.items) ? parsed.items : []
return items.flatMap(item).sort((a, b) => a.name.localeCompare(b.name))
}
function escape(input: string) {
return input
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")
}
function substitute(template: string, values: Record<string, string>) {
return Object.entries(values).reduce((result, [key, value]) => {
const escaped = escape(String(value ?? ""))
return result.replaceAll(`{{${key}}}`, escaped).replaceAll(`\${${key}}`, escaped)
}, template)
}
function normalized(input: Dict): Config {
if (input.type === "local" && Array.isArray(input.command)) {
const command = strs(input.command)
const cfg: McpLocalConfig = { type: "local", command }
const env = strings(input.environment ?? input.env)
if (env) cfg.environment = env
if (input.enabled === false) cfg.enabled = false
if (typeof input.timeout === "number") cfg.timeout = input.timeout
return cfg
}
if (input.type === "remote" && typeof input.url === "string") {
const cfg: McpRemoteConfig = { type: "remote", url: input.url }
const headers = strings(input.headers)
const auth = input.oauth === false || record(input.oauth) === input.oauth ? input.oauth : undefined
if (headers) cfg.headers = headers
if (auth === false || (auth && typeof auth === "object")) cfg.oauth = auth as McpRemoteConfig["oauth"]
if (input.enabled === false) cfg.enabled = false
if (typeof input.timeout === "number") cfg.timeout = input.timeout
return cfg
}
if (typeof input.url === "string") {
const cfg: McpRemoteConfig = { type: "remote", url: input.url }
const headers = strings(input.headers)
const auth = input.oauth === false || record(input.oauth) === input.oauth ? input.oauth : undefined
if (headers) cfg.headers = headers
if (auth === false || (auth && typeof auth === "object")) cfg.oauth = auth as McpRemoteConfig["oauth"]
if (input.enabled === false) cfg.enabled = false
if (typeof input.timeout === "number") cfg.timeout = input.timeout
return cfg
}
if (typeof input.command === "string") {
const cfg: McpLocalConfig = { type: "local", command: [input.command, ...strs(input.args)] }
const env = strings(input.env)
if (env) cfg.environment = env
if (input.enabled === false) cfg.enabled = false
if (typeof input.timeout === "number") cfg.timeout = input.timeout
return cfg
}
return input as Config
}
function build(content: string, values: Record<string, string>, fail: (message: string) => void) {
try {
const parsed = JSON.parse(substitute(content, values))
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
fail("Marketplace MCP content must resolve to a JSON object.")
return undefined
}
return normalized(parsed as Dict)
} catch (err) {
fail(`Invalid MCP marketplace config: ${errMsg(err)}`)
return undefined
}
}
function describe(input: unknown) {
const cfg = record(input)
if (cfg.type === "remote" && typeof cfg.url === "string") return cfg.url
if (Array.isArray(cfg.command)) return strs(cfg.command).join(" ")
if (cfg.enabled === false) return "Disabled override"
return text(input)
}
function kind(input: unknown) {
const cfg = record(input)
if (cfg.type === "remote" || typeof cfg.url === "string") return "remote"
if (cfg.type === "local" || Array.isArray(cfg.command) || typeof cfg.command === "string") return "local"
return "override"
}
function current(input: Config | undefined) {
const cfg = record(input)
return cfg.enabled !== false
}
function config(input: Resolved | undefined, value: Config | undefined) {
return (input?.local ?? value ?? input?.value) as Config | undefined
}
function source(input: Resolved | undefined) {
return input?.source ?? "system"
}
function values(input: Record<string, string>) {
return Object.fromEntries(Object.entries(input).filter(([, value]) => clean(value) !== ""))
}
export function useMcpSettings() {
const ctx = useConfig()
const snap = () => ctx.data()
const [mode, setMode] = createSignal<Mode>("closed")
const [search, setSearch] = createSignal("")
const [filter, setFilter] = createSignal("")
const [status, setStatus] = createSignal<Filter>("all")
const [picked, setPicked] = createSignal<string[]>([])
const [choice, setChoice] = createSignal<McpMarket | undefined>()
const [methodName, setMethodName] = createSignal("")
const [params, setParams] = createSignal<Record<string, string>>({})
const [editing, setEditing] = createSignal<string | undefined>()
const [pending, setPending] = createSignal<McpRow | undefined>()
const [id, setId] = createSignal("")
const [type, setType] = createSignal<"local" | "remote">("local")
const [command, setCommand] = createSignal("")
const [url, setUrl] = createSignal("")
const [env, setEnv] = createSignal("")
const [headers, setHeaders] = createSignal("")
const [auth, setAuth] = createSignal("")
const [limit, setLimit] = createSignal("")
const [enabled, setEnabled] = createSignal(true)
const [catalog, catalogActions] = createResource(fetchMarket)
const rows = createMemo<McpRow[]>(() => {
const data = snap()
if (!data) return []
const meta = new Map(data.overlay.collections.mcp.map((entry) => [entry.key, entry]))
const market = new Map((catalog() ?? []).map((entry) => [entry.id, entry]))
const configs = data.effective.mcp ?? {}
const names = new Set([...Object.keys(configs), ...Object.keys(data.mcp), ...meta.keys()])
return [...names]
.sort((a, b) => a.localeCompare(b))
.map((name) => {
const hit = meta.get(name)
const cfg = config(hit, configs[name])
const item = market.get(name)
return {
id: name,
name: item?.name ?? name,
summary: describe(cfg),
kind: kind(cfg),
enabled: current(cfg),
config: cfg,
status: data.mcp[name],
source: source(hit),
inherited: hit?.inherited,
overridden: hit?.overridden,
editable: hit?.editable,
revert: data.overlay.scope === "project" && hit?.local !== undefined && hit.global !== undefined,
path: hit?.path,
market: item,
}
})
})
const visible = createMemo(() => {
const term = search().trim().toLowerCase()
if (!term) return rows()
return rows().filter((row) =>
`${row.name} ${row.id} ${row.summary} ${row.status?.status ?? ""}`.toLowerCase().includes(term),
)
})
const installed = createMemo(() => new Set(rows().map((row) => row.id)))
const tags = createMemo(() => {
const counts = new Map<string, number>()
for (const item of catalog() ?? []) {
for (const tag of item.tags ?? []) counts.set(tag, (counts.get(tag) ?? 0) + 1)
}
return [...counts.keys()].sort((a, b) => a.localeCompare(b))
})
const marketError = createMemo(() => (catalog.error ? errMsg(catalog.error) : ""))
const marketVisible = createMemo(() => {
const term = filter().trim().toLowerCase()
const selected = picked()
const mode = status()
return (catalog() ?? []).filter((item) => {
const exists = installed().has(item.id)
if (mode === "installed" && !exists) return false
if (mode === "notInstalled" && exists) return false
if (selected.length && !selected.some((tag) => item.tags?.includes(tag))) return false
if (!term) return true
return `${item.name} ${item.id} ${item.description} ${item.author ?? ""}`.toLowerCase().includes(term)
})
})
const methods = createMemo(() => {
const item = choice()
if (!item || typeof item.content === "string") return []
return item.content
})
const method = createMemo(() => methods().find((item) => item.name === methodName()) ?? methods()[0])
const parameters = createMemo(() => method()?.parameters ?? choice()?.parameters ?? [])
const prerequisites = createMemo(() => method()?.prerequisites ?? choice()?.prerequisites ?? [])
const valid = createMemo(() => parameters().every((item) => item.optional || clean(params()[item.key] ?? "")))
function close() {
setMode("closed")
}
function openMarket() {
setMode("market")
}
function openManual() {
setEditing(undefined)
setId("")
setType("local")
setCommand("")
setUrl("")
setEnv("")
setHeaders("")
setAuth("")
setLimit("")
setEnabled(true)
setMode("config")
}
function openInstall(item: McpMarket) {
setChoice(item)
const list = Array.isArray(item.content) ? item.content : []
setMethodName(list[0]?.name ?? "")
setParams({})
setMode("install")
}
function edit(row: McpRow) {
const cfg = record(row.config)
setEditing(row.id)
setId(row.id)
setType(kind(row.config) === "remote" ? "remote" : "local")
setCommand(Array.isArray(cfg.command) ? strs(cfg.command).join(" ") : "")
setUrl(str(cfg.url))
setEnv(json(cfg.environment ?? cfg.env))
setHeaders(json(cfg.headers))
setAuth(json(cfg.oauth))
setLimit(typeof cfg.timeout === "number" ? String(cfg.timeout) : "")
setEnabled(cfg.enabled !== false)
setMode("config")
}
function setParam(key: string, value: string) {
setParams({ ...params(), [key]: value })
}
function toggleTag(tag: string) {
const current = picked()
setPicked(current.includes(tag) ? current.filter((item) => item !== tag) : [...current, tag])
}
function install() {
const item = choice()
if (!item) return
if (installed().has(item.id)) {
ctx.fail("MCP server is already installed. Remove it before installing again.")
return
}
if (!valid()) {
ctx.fail("Fill all required MCP installation parameters before installing.")
return
}
const content = typeof item.content === "string" ? item.content : method()?.content
if (!content) {
ctx.fail("Marketplace item does not include MCP installation content.")
return
}
const cfg = build(content, values(params()), ctx.fail)
if (!cfg) return
ctx.save({ mcp: { [item.id]: cfg } as McpMap })
close()
}
function save() {
const key = clean(id())
if (!key || !pattern.test(key) || key.includes("..") || key.includes("/") || key.includes("\\")) {
ctx.fail("MCP server ID must contain only letters, numbers, underscores, dashes, dots, or @.")
return
}
if (!editing() && installed().has(key)) {
ctx.fail("MCP server ID is already configured.")
return
}
const ms = timeout(limit(), ctx.fail)
if (clean(limit()) && ms === undefined) return
const cfg = (() => {
if (type() === "remote") {
const endpoint = clean(url())
if (!endpoint) {
ctx.fail("Enter the remote MCP server URL before saving.")
return undefined
}
const head = object(headers(), "Headers", ctx.fail)
const oauthCfg = oauth(auth(), ctx.fail)
if (!head || (clean(auth()) && oauthCfg === undefined)) return undefined
const next: Dict = {
type: "remote",
url: endpoint,
enabled: enabled(),
command: null,
environment: null,
env: null,
}
next.headers = Object.keys(head).length ? head : null
next.oauth = oauthCfg !== undefined ? oauthCfg : null
next.timeout = ms !== undefined ? ms : null
return next as McpRemoteConfig
}
const cmd = words(command())
if (!cmd.length) {
ctx.fail("Enter the local MCP command before saving.")
return undefined
}
const vars = object(env(), "Environment", ctx.fail)
if (!vars) return undefined
const next: Dict = {
type: "local",
command: cmd,
enabled: enabled(),
url: null,
headers: null,
oauth: null,
env: null,
}
next.environment = Object.keys(vars).length ? vars : null
next.timeout = ms !== undefined ? ms : null
return next as McpLocalConfig
})()
if (!cfg) return
ctx.save({ mcp: { [key]: cfg } as McpMap })
close()
}
function toggle(row: McpRow) {
const cfg = record(row.config)
const next = { ...cfg, enabled: !row.enabled } as Config
ctx.save({ mcp: { [row.id]: next } as McpMap })
}
function connect(row: McpRow) {
ctx.run("Connecting MCP server", () => connectMcp(ctx.target(), row.id))
}
function disconnect(row: McpRow) {
ctx.run("Disconnecting MCP server", () => disconnectMcp(ctx.target(), row.id))
}
function authenticate(row: McpRow) {
ctx.run("Authenticating MCP server", () => authenticateMcp(ctx.target(), row.id))
}
function ask(row: McpRow) {
setPending(row)
}
function cancel() {
setPending(undefined)
}
function confirm() {
const row = pending()
if (!row?.path) return
ctx.unset([row.path])
setPending(undefined)
}
return {
ctx,
snap,
mode,
search,
setSearch,
filter,
setFilter,
status,
setStatus,
picked,
catalog,
catalogActions,
rows,
visible,
installed,
tags,
marketError,
marketVisible,
choice,
methods,
method,
methodName,
setMethodName,
parameters,
prerequisites,
params,
setParam,
valid,
editing,
pending,
id,
setId,
type,
setType,
command,
setCommand,
url,
setUrl,
env,
setEnv,
headers,
setHeaders,
auth,
setAuth,
limit,
setLimit,
enabled,
setEnabled,
close,
openMarket,
openManual,
openInstall,
edit,
toggleTag,
install,
save,
toggle,
connect,
disconnect,
authenticate,
ask,
cancel,
confirm,
}
}
@@ -0,0 +1,248 @@
import { createMemo, createSignal } from "solid-js"
import type { Model, Provider } from "@kilocode/sdk/v2/client"
import { saveModelState, type ModelRef } from "../../../client"
import { useConfig } from "../../../context/config"
export const capabilities = [
"toolcall",
"attachment",
"temperature",
"interleaved",
"input:audio",
"input:image",
"input:video",
"input:pdf",
"output:audio",
"output:image",
"output:video",
"output:pdf",
] as const
export type Capability = (typeof capabilities)[number]
export type ModelField = "model" | "small_model"
const cap = 1_000_000
function order(a: Provider, b: Provider) {
if (a.id === "kilo") return -1
if (b.id === "kilo") return 1
return a.name.localeCompare(b.name)
}
function has(model: Model, cap: Capability) {
if (cap === "toolcall") return model.capabilities.toolcall
if (cap === "attachment") return model.capabilities.attachment
if (cap === "temperature") return model.capabilities.temperature
if (cap === "interleaved") return Boolean(model.capabilities.interleaved)
if (cap === "input:audio") return model.capabilities.input.audio
if (cap === "input:image") return model.capabilities.input.image
if (cap === "input:video") return model.capabilities.input.video
if (cap === "input:pdf") return model.capabilities.input.pdf
if (cap === "output:audio") return model.capabilities.output.audio
if (cap === "output:image") return model.capabilities.output.image
if (cap === "output:video") return model.capabilities.output.video
return model.capabilities.output.pdf
}
type Item = { id: string; provider: Provider; model: Model }
function same(item: Item, ref: ModelRef) {
if (item.provider.id === ref.providerID && item.model.id === ref.modelID) return true
return item.model.providerID === ref.providerID && item.model.id === ref.modelID
}
export function useModelSettings() {
const ctx = useConfig()
const snap = () => ctx.data()
const [search, setSearch] = createSignal("")
const [filter, setFilter] = createSignal("all")
const [price, setPrice] = createSignal("all")
const [low, setLow] = createSignal(0)
const [high, setHigh] = createSignal(0)
const [reason, setReason] = createSignal("all")
const [caps, setCaps] = createSignal<Capability[]>([])
const [starred, setStarred] = createSignal(false)
const [mode, setMode] = createSignal<"closed" | "select">("closed")
const [field, setField] = createSignal<ModelField>("model")
const [picker, setPicker] = createSignal("")
const [choice, setChoice] = createSignal("")
const providers = createMemo(() => {
const data = snap()
if (!data) return []
const ids = new Set([...Object.keys(data.effective.provider ?? {}), ...data.providers.connected])
return data.providers.all
.filter((provider) => ids.has(provider.id))
.filter((provider) => Object.keys(provider.models).length > 0)
.sort(order)
})
const all = createMemo(() => {
return providers().flatMap((provider) =>
Object.values(provider.models).map((model) => ({
id: `${provider.id}/${model.id}`,
provider,
model,
})),
)
})
const max = createMemo(() => {
const values = all().map((item) => item.model.limit.context)
return values.length ? Math.min(Math.max(...values), cap) : 0
})
const top = createMemo(() => Math.min(high() || max(), max()))
const bottom = createMemo(() => Math.min(low(), top()))
const upper = createMemo(() => (top() >= max() ? undefined : top()))
const favorites = createMemo(() => snap()?.modelState.favorite ?? [])
const fav = (item: Item) => favorites().some((ref) => same(item, ref))
const models = createMemo(() => {
const term = search().toLowerCase()
return all()
.filter((item) => (filter() === "all" ? true : item.provider.id === filter()))
.filter((item) => (price() === "free" ? item.model.isFree : true))
.filter((item) => (price() === "paid" ? !item.model.isFree : true))
.filter((item) => (bottom() > 0 ? item.model.limit.context >= bottom() : true))
.filter((item) => (upper() !== undefined ? item.model.limit.context <= upper()! : true))
.filter((item) => (reason() === "reasoning" ? item.model.capabilities.reasoning : true))
.filter((item) => (reason() === "standard" ? !item.model.capabilities.reasoning : true))
.filter((item) => (starred() ? fav(item) : true))
.filter((item) => caps().every((cap) => has(item.model, cap)))
.filter((item) => {
if (!term) return true
return `${item.id} ${item.model.name} ${item.provider.name}`.toLowerCase().includes(term)
})
.sort((a, b) => {
const ranked = Number(fav(b)) - Number(fav(a))
if (ranked !== 0) return ranked
return a.model.name.localeCompare(b.model.name) || a.provider.name.localeCompare(b.provider.name)
})
})
const options = createMemo(() => {
const term = picker().trim().toLowerCase()
return all()
.filter((item) => {
if (!term) return true
return `${item.id} ${item.model.name} ${item.provider.name}`.toLowerCase().includes(term)
})
.sort((a, b) => {
const ranked = Number(fav(b)) - Number(fav(a))
if (ranked !== 0) return ranked
const named = a.model.name.localeCompare(b.model.name)
if (named !== 0) return named
const provider = a.provider.name.localeCompare(b.provider.name)
if (provider !== 0) return provider
return a.id.localeCompare(b.id)
})
})
const label = createMemo(() => (field() === "model" ? "Default model" : "Small model"))
function item(value: unknown) {
if (typeof value !== "string") return undefined
return all().find((model) => model.id === value || `${model.model.providerID}/${model.model.id}` === value)
}
function toggle(cap: Capability) {
const set = new Set(caps())
if (set.has(cap)) {
set.delete(cap)
setCaps([...set])
return
}
set.add(cap)
setCaps([...set])
}
function setMin(value: number) {
setLow(Math.max(0, Math.min(value, top())))
}
function setMax(value: number) {
setHigh(Math.min(max(), Math.max(value, bottom())))
}
function favorite(item: Item) {
const query = ctx.query()
if (!query) return
const next = fav(item)
? favorites().filter((ref) => !same(item, ref))
: [{ providerID: item.provider.id, modelID: item.model.id }, ...favorites()]
ctx.run("Saving favorite", () => saveModelState(query, next))
}
function edit(next: ModelField) {
const value = snap()?.overlay.fields[next]?.value
setField(next)
setPicker("")
setChoice(typeof value === "string" ? value : "")
setMode("select")
}
function close() {
setMode("closed")
}
function select(item: Item) {
setChoice(item.id)
}
function save() {
const id = choice()
if (!id) {
ctx.fail(`Select a ${label().toLowerCase()} before saving.`)
return
}
if (field() === "model") {
ctx.save({ model: id })
close()
return
}
ctx.save({ small_model: id })
close()
}
return {
ctx,
snap,
search,
setSearch,
filter,
setFilter,
price,
setPrice,
low: bottom,
high,
top,
setMin,
setMax,
reason,
setReason,
caps,
setCaps,
starred,
setStarred,
fav,
favorite,
max,
providers,
capabilities,
toggle,
models,
item,
mode,
field,
label,
picker,
setPicker,
choice,
options,
edit,
close,
select,
save,
}
}
@@ -0,0 +1,286 @@
import { createMemo, createSignal } from "solid-js"
import type { Snapshot } from "../../../client"
import { useConfig } from "../../../context/config"
import { clean, type PermissionMap } from "../../../shared/utils"
type Resolved = Snapshot["overlay"]["collections"][string][number]
export type PermissionAction = "ask" | "allow" | "deny"
export type PermissionTool = "external_directory" | "bash" | "read" | "edit"
export type PermissionDef = {
id: string
title: string
description: string
}
export type RuleDef = PermissionDef & {
id: PermissionTool
noun: string
placeholder: string
}
export type PermissionRule = {
tool: string
pattern: string
action: PermissionAction
source: string
inherited: boolean
overridden: boolean
path: string[]
}
export type PermissionGroup = {
id: PermissionTool
title: string
description: string
noun: string
placeholder: string
action: PermissionAction
source: string
inherited: boolean
overridden: boolean
rules: PermissionRule[]
}
export type PermissionDefault = PermissionDef & {
action: PermissionAction
source: string
inherited: boolean
overridden: boolean
}
export const actions: Array<{ value: PermissionAction; label: string }> = [
{ value: "ask", label: "Ask" },
{ value: "allow", label: "Allow" },
{ value: "deny", label: "Deny" },
]
export const ruleDefs: RuleDef[] = [
{
id: "external_directory",
title: "External Directory",
description: "Access files outside the project directory.",
noun: "path",
placeholder: "e.g. ~/Downloads/**",
},
{
id: "bash",
title: "Bash",
description: "Run shell commands.",
noun: "command",
placeholder: "e.g. git status or npm *",
},
{
id: "read",
title: "Read",
description: "Read files by matching file paths.",
noun: "path",
placeholder: "e.g. **/*.env",
},
{
id: "edit",
title: "Edit",
description: "Modify files, including writes, patches, and multi-edits.",
noun: "path",
placeholder: "e.g. src/**/*.ts",
},
]
export const defaults: PermissionDef[] = [
{ id: "glob", title: "Glob", description: "Match files using glob patterns." },
{ id: "grep", title: "Grep", description: "Search file contents using regular expressions." },
{ id: "list", title: "List", description: "List files within a directory." },
{ id: "task", title: "Task", description: "Launch sub-agents." },
{ id: "skill", title: "Skill", description: "Load a skill by name." },
{ id: "lsp", title: "LSP", description: "Run language server queries." },
{ id: "todowrite", title: "Todo Write", description: "Update the todo list." },
{ id: "question", title: "Question", description: "Ask the user a question." },
{ id: "webfetch", title: "Web Fetch", description: "Fetch content from a URL." },
{ id: "websearch", title: "Web Search", description: "Search the web." },
{ id: "doom_loop", title: "Doom Loop", description: "Detect repeated tool calls with identical input." },
{ id: "agent_manager", title: "Agent Manager", description: "Manage Agent Manager operations." },
]
export const defs = [...ruleDefs, ...defaults]
const ruleIDs = new Set<string>(ruleDefs.map((item) => item.id))
const known = new Set<string>(defs.map((item) => item.id))
function act(input: unknown, fallback: PermissionAction = "ask"): PermissionAction {
if (input === "ask" || input === "allow" || input === "deny") return input
return fallback
}
function record(input: unknown): Record<string, unknown> {
if (input && typeof input === "object" && !Array.isArray(input)) return input as Record<string, unknown>
return {}
}
function cfg(data: Snapshot) {
return record(data.effective.permission)
}
function meta(data: Snapshot, tool: string) {
return data.overlay.collections.permission?.find((item) => item.key === tool)
}
function raw(data: Snapshot, tool: string) {
if (typeof data.effective.permission === "string") return data.effective.permission
return cfg(data)[tool]
}
function fallback(data: Snapshot) {
return act(data.effective.permission)
}
function paths(item: Resolved | undefined, pattern: string) {
if (!item) return ["permission", pattern]
if (pattern === "*" && typeof item.value === "string") return item.path
return [...item.path, pattern]
}
function row(tool: string, pattern: string, action: unknown, item?: Resolved): PermissionRule | undefined {
if (action !== "ask" && action !== "allow" && action !== "deny") return undefined
return {
tool,
pattern,
action,
source: item?.source ?? "default",
inherited: item?.inherited ?? false,
overridden: item?.overridden ?? false,
path: item ? paths(item, pattern) : ["permission", tool, pattern],
}
}
function entries(item: Resolved): PermissionRule[] {
const rule = item.value
if (rule && typeof rule === "object" && !Array.isArray(rule)) {
return Object.entries(rule as Record<string, unknown>).flatMap(
([glob, value]) => row(item.key, glob, value, item) ?? [],
)
}
const value = row(item.key, "*", rule, item)
return value ? [value] : []
}
function listed(tool: string, value: unknown, item?: Resolved) {
const obj = record(value)
if (Object.keys(obj).length) {
return Object.entries(obj).flatMap(([pattern, action]) => row(tool, pattern, action, item) ?? [])
}
const itemRow = row(tool, "*", value, item)
return itemRow ? [itemRow] : []
}
function setting(data: Snapshot, def: PermissionDef): PermissionDefault {
const item = meta(data, def.id)
const value = raw(data, def.id)
const obj = record(value)
const base = fallback(data)
return {
...def,
action: act(typeof value === "string" ? value : obj["*"], base),
source: item?.source ?? "default",
inherited: item?.inherited ?? false,
overridden: item?.overridden ?? false,
}
}
function group(data: Snapshot, def: RuleDef): PermissionGroup {
const item = meta(data, def.id)
const value = raw(data, def.id)
const obj = record(value)
const base = fallback(data)
return {
...def,
action: act(typeof value === "string" ? value : obj["*"], base),
source: item?.source ?? "default",
inherited: item?.inherited ?? false,
overridden: item?.overridden ?? false,
rules: Object.entries(obj)
.filter(([pattern]) => pattern !== "*")
.flatMap(([pattern, action]) => row(def.id, pattern, action, item) ?? []),
}
}
export function usePermissionSettings() {
const ctx = useConfig()
const snap = () => ctx.data()
const [mode, setMode] = createSignal<"closed" | "rule">("closed")
const [kind, setKind] = createSignal<PermissionTool>("external_directory")
const [pattern, setPattern] = createSignal("")
const [action, setAction] = createSignal<PermissionAction>("ask")
const rules = createMemo(() => (snap()?.overlay.collections.permission ?? []).flatMap(entries))
const groups = createMemo(() => {
const data = snap()
if (!data) return []
return ruleDefs.map((def) => group(data, def))
})
const settings = createMemo(() => {
const data = snap()
if (!data) return []
return defaults.map((def) => setting(data, def))
})
const other = createMemo(() => {
const data = snap()
if (!data) return []
return Object.entries(cfg(data))
.filter(([tool]) => !known.has(tool))
.flatMap(([tool, value]) => listed(tool, value, meta(data, tool)))
.sort((a, b) => a.tool.localeCompare(b.tool) || a.pattern.localeCompare(b.pattern))
})
const selected = createMemo(() => ruleDefs.find((def) => def.id === kind()) ?? ruleDefs[0])
function open(tool: PermissionTool) {
setKind(tool)
setPattern("")
setAction("ask")
setMode("rule")
}
function close() {
setMode("closed")
}
function add() {
const data = snap()
const glob = clean(pattern())
if (!data || !glob) {
ctx.fail(`Enter a ${selected().noun} pattern before saving.`)
return
}
const permission = { [kind()]: { [glob]: action() } } as PermissionMap
ctx.save({ permission })
close()
}
function setDefault(tool: string, action: PermissionAction) {
const permission = ruleIDs.has(tool)
? ({ [tool]: { "*": action } } as PermissionMap)
: ({ [tool]: action } as PermissionMap)
ctx.save({ permission })
}
function remove(rule: PermissionRule) {
ctx.unset([rule.path])
}
return {
ctx,
mode,
kind,
pattern,
setPattern,
action,
setAction,
rules,
groups,
settings,
other,
selected,
open,
close,
add,
setDefault,
remove,
}
}
@@ -0,0 +1,432 @@
import { createMemo, createSignal } from "solid-js"
import type { Provider, ProviderAuthAuthorization, ProviderAuthMethod, ProviderConfig } from "@kilocode/sdk/v2/client"
import { useConfig } from "../../../context/config"
import { clean, csv, errMsg } from "../../../shared/utils"
import { authorizeProvider, completeProvider, connectProvider } from "../../../client"
const priority = ["kilo", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
const pattern = /^[a-z0-9][a-z0-9-_]*$/
type Dict = Record<string, unknown>
type Prompt = NonNullable<ProviderAuthMethod["prompts"]>[number]
export type ConfiguredProvider = {
id: string
name: string
source: string
models: number
config: ProviderConfig
provider?: Provider
inherited?: boolean
overridden?: boolean
editable?: boolean
}
function rank(id: string) {
const index = priority.indexOf(id.toLowerCase())
return index >= 0 ? index : priority.length
}
function sort<T extends { id: string; name: string }>(items: T[]) {
return items.slice().sort((a, b) => {
const diff = rank(a.id) - rank(b.id)
if (diff !== 0) return diff
return a.name.localeCompare(b.name)
})
}
function json(input: unknown) {
if (!input || (typeof input === "object" && !Array.isArray(input) && Object.keys(input).length === 0)) return ""
return JSON.stringify(input, null, 2)
}
function object(input: string, label: string, fail: (message: string) => void) {
const raw = clean(input)
if (!raw) return {}
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
fail(`${label} must be a JSON object.`)
return undefined
}
return parsed as Dict
} catch (err) {
fail(`${label}: ${errMsg(err)}`)
return undefined
}
}
function rest(input: ProviderConfig["options"]) {
return Object.fromEntries(Object.entries(input ?? {}).filter(([key]) => key !== "apiKey" && key !== "baseURL"))
}
function shown(prompt: Prompt, values: Record<string, string>) {
const rule = prompt.when
if (!rule) return true
const value = values[rule.key] ?? ""
if (rule.op === "eq") return value === rule.value
return value !== rule.value
}
export function useProviderSettings() {
const ctx = useConfig()
const snap = () => ctx.data()
const [mode, setMode] = createSignal<"closed" | "select" | "form">("closed")
const [filter, setFilter] = createSignal("")
const [search, setSearch] = createSignal("")
const [choice, setChoice] = createSignal("")
const [editing, setEditing] = createSignal<string | undefined>()
const [id, setId] = createSignal("")
const [name, setName] = createSignal("")
const [env, setEnv] = createSignal("")
const [api, setApi] = createSignal("")
const [npm, setNpm] = createSignal("")
const [apiKey, setApiKey] = createSignal("")
const [baseURL, setBaseURL] = createSignal("")
const [whitelist, setWhitelist] = createSignal("")
const [blacklist, setBlacklist] = createSignal("")
const [options, setOptions] = createSignal("")
const [models, setModels] = createSignal("")
const [pending, setPending] = createSignal<ConfiguredProvider | undefined>()
const [methodIndex, setMethodIndex] = createSignal<number | undefined>()
const [authorization, setAuthorization] = createSignal<ProviderAuthAuthorization | undefined>()
const [phase, setPhase] = createSignal<"authorizing" | "connecting" | undefined>()
const [authKey, setAuthKey] = createSignal("")
const [authCode, setAuthCode] = createSignal("")
const [fields, setFields] = createSignal<Record<string, string>>({})
const [authError, setAuthError] = createSignal("")
const [authField, setAuthField] = createSignal("")
const configured = createMemo<ConfiguredProvider[]>(() => {
const data = snap()
if (!data) return []
const configs = data.effective.provider ?? {}
const meta = new Map(data.overlay.collections.provider.map((item) => [item.key, item]))
const rows = Object.entries(configs).flatMap(([key, cfg]) => {
if (!cfg) return []
const provider = data.providers.all.find((item) => item.id === key)
const hit = meta.get(key)
const model = Object.keys(provider?.models ?? cfg.models ?? {}).length
return [
{
id: key,
name: cfg.name ?? provider?.name ?? key,
source: hit?.source ?? provider?.source ?? "config",
models: model,
config: cfg,
provider,
inherited: hit?.inherited,
overridden: hit?.overridden,
editable: hit?.editable,
},
]
})
const ids = new Set(rows.map((item) => item.id))
const loaded = data.providers.all.flatMap((provider) => {
if (ids.has(provider.id)) return []
if (!data.providers.connected.includes(provider.id)) return []
return [
{
id: provider.id,
name: provider.name,
source: provider.source,
models: Object.keys(provider.models).length,
config: {},
provider,
editable: true,
},
]
})
return sort([...rows, ...loaded])
})
const visible = createMemo(() => {
const term = search().trim().toLowerCase()
if (!term) return configured()
return configured().filter((provider) => `${provider.name} ${provider.id}`.toLowerCase().includes(term))
})
const available = createMemo(() => {
const data = snap()
if (!data) return []
const ids = new Set(configured().map((item) => item.id))
const term = filter().trim().toLowerCase()
return sort(data.providers.all)
.filter((provider) => !ids.has(provider.id))
.filter((provider) => {
if (!term) return true
return `${provider.name} ${provider.id}`.toLowerCase().includes(term)
})
})
const selected = createMemo(() => {
const key = id()
return snap()?.providers.all.find((provider) => provider.id === key)
})
const target = createMemo(() => available().find((provider) => provider.id === choice()) ?? available()[0])
const methods = createMemo(() => snap()?.authMethods[id()] ?? [])
const method = createMemo(() => {
const index = methodIndex()
return index === undefined ? undefined : methods()[index]
})
const prompts = createMemo(() => method()?.prompts?.filter((prompt) => shown(prompt, fields())) ?? [])
const auth = createMemo(() => methods().length > 0)
function resetAuth() {
setMethodIndex(undefined)
setAuthorization(undefined)
setPhase(undefined)
setAuthKey("")
setAuthCode("")
setFields({})
setAuthError("")
setAuthField("")
}
function fill(provider: Provider | undefined, cfg: ProviderConfig | undefined, key: string) {
const opts = cfg?.options ?? {}
setId(key || provider?.id || cfg?.id || "")
setName(cfg?.name ?? provider?.name ?? "")
setEnv((cfg?.env ?? provider?.env ?? []).join(", "))
setApi(cfg?.api ?? "")
setNpm(cfg?.npm ?? "")
setApiKey(typeof opts.apiKey === "string" ? opts.apiKey : "")
setBaseURL(typeof opts.baseURL === "string" ? opts.baseURL : "")
setWhitelist((cfg?.whitelist ?? []).join(", "))
setBlacklist((cfg?.blacklist ?? []).join(", "))
setOptions(json(rest(opts)))
setModels(json(cfg?.models))
resetAuth()
}
function selectMethod(index: number, providerID = id()) {
setMethodIndex(index)
setAuthorization(undefined)
setAuthError("")
setAuthField("")
const current = (snap()?.authMethods[providerID] ?? [])[index]
if (current?.type !== "oauth") return
setPhase("authorizing")
ctx.run(
"Authorizing provider",
async () => {
const data = await authorizeProvider(ctx.target(), providerID, index, fields())
setAuthorization(data)
setPhase(undefined)
if (data.url) window.open(data.url, "_blank", "noopener,noreferrer")
if (data.method === "auto") completeOAuth(index)
},
{ refetch: false },
)
}
function setField(key: string, value: string) {
setFields({ ...fields(), [key]: value })
}
function prepare(providerID: string) {
const list = snap()?.authMethods[providerID] ?? []
if (list.length === 1) selectMethod(0, providerID)
}
function add() {
setEditing(undefined)
setFilter("")
setChoice("")
setMode("select")
}
function close() {
setMode("closed")
}
function pick(provider: Provider) {
setEditing(undefined)
fill(provider, undefined, provider.id)
setMode("form")
prepare(provider.id)
}
function choose(provider: Provider) {
setChoice(provider.id)
}
function next() {
const provider = target()
if (!provider) return
pick(provider)
}
function edit(item: ConfiguredProvider) {
setEditing(item.id)
fill(item.provider, item.config, item.id)
setMode("form")
prepare(item.id)
}
function connectAuth() {
const key = clean(authKey())
if (!key) {
setAuthError("Enter an API key before saving.")
setAuthField("apiKey")
return
}
const meta: Record<string, string> = {}
for (const prompt of prompts()) {
const value = clean(fields()[prompt.key] ?? "")
if (!value) {
setAuthError(`${prompt.message} is required.`)
setAuthField(prompt.key)
return
}
meta[prompt.key] = value
}
setPhase("connecting")
ctx.run("Connecting provider", async () =>
connectProvider(ctx.target(), id(), key, Object.keys(meta).length ? meta : undefined),
)
close()
}
function completeOAuth(index = methodIndex()) {
if (index === undefined) return
const code = clean(authCode())
if (authorization()?.method === "code" && !code) {
setAuthError("Enter the authorization code before saving.")
setAuthField("code")
return
}
setPhase("connecting")
ctx.run("Connecting provider", async () =>
completeProvider(ctx.target(), id(), index, authorization()?.method === "code" ? code : undefined),
)
if (authorization()?.method === "code") close()
}
function save() {
const key = clean(id())
if (!key || !pattern.test(key)) {
ctx.fail(
"Provider ID must start with a lowercase letter or number and contain only lowercase letters, numbers, dashes, or underscores.",
)
return
}
const opts = object(options(), "Extra options", ctx.fail)
const model = object(models(), "Model overrides", ctx.fail)
if (!opts || !model) return
const token = clean(apiKey())
const url = clean(baseURL())
if (token) opts.apiKey = token
if (url) opts.baseURL = url
const cfg: ProviderConfig = {}
const label = clean(name())
const vars = csv(env())
const allow = csv(whitelist())
const deny = csv(blacklist())
const apiID = clean(api())
const pkg = clean(npm())
if (label) cfg.name = label
if (vars.length) cfg.env = vars
if (apiID) cfg.api = apiID
if (pkg) cfg.npm = pkg
if (allow.length) cfg.whitelist = allow
if (deny.length) cfg.blacklist = deny
if (Object.keys(opts).length) cfg.options = opts as ProviderConfig["options"]
if (Object.keys(model).length) cfg.models = model as ProviderConfig["models"]
const data = snap()
if (!data) return
const configs = { ...(data.effective.provider ?? {}) }
const current = editing()
if (current && current !== key) configs[current] = null
configs[key] = cfg
ctx.save({
provider: configs,
disabled_providers: (data.effective.disabled_providers ?? []).filter((item) => item !== key),
})
close()
}
function ask(item: ConfiguredProvider) {
setPending(item)
}
function cancel() {
setPending(undefined)
}
function confirm() {
const item = pending()
if (!item) return
const data = snap()
if (!data) return
ctx.save({ provider: { ...(data.effective.provider ?? {}), [item.id]: null } })
setPending(undefined)
}
return {
ctx,
snap,
mode,
filter,
setFilter,
search,
setSearch,
configured,
visible,
available,
selected,
target,
auth,
methods,
method,
prompts,
methodIndex,
authorization,
phase,
authKey,
setAuthKey,
authCode,
setAuthCode,
fields,
setField,
authError,
authField,
id,
setId,
name,
setName,
env,
setEnv,
api,
setApi,
npm,
setNpm,
apiKey,
setApiKey,
baseURL,
setBaseURL,
whitelist,
setWhitelist,
blacklist,
setBlacklist,
options,
setOptions,
models,
setModels,
pending,
add,
close,
pick,
choose,
next,
edit,
selectMethod,
connectAuth,
completeOAuth,
save,
ask,
cancel,
confirm,
}
}
@@ -0,0 +1,254 @@
import { createEffect, createMemo, createSignal } from "solid-js"
import type { TuiPatch } from "../../../client"
import { useConfig } from "../../../context/config"
import { clean } from "../../../shared/utils"
export type Theme = {
id: string
swatches: string[]
custom?: boolean
}
type Diff = "auto" | "stacked"
const fallback = ["#0c0a09", "#fafaf9", "#f9f76f", "#a6a09b", "#3794ff", "#44403b"]
export const themes: Theme[] = [
{ id: "aura", swatches: ["#0f0f0f", "#edecee", "#a277ff", "#f694ff", "#a277ff", "#2d2d2d"] },
{ id: "ayu", swatches: ["#0B0E14", "#BFBDB6", "#59C2FF", "#D2A6FF", "#E6B450", "#6C7380"] },
{ id: "carbonfox", swatches: ["#161616", "#f2f4f8", "#33b1ff", "#78a9ff", "#ff7eb6", "#303030"] },
{ id: "catppuccin", swatches: ["#1e1e2e", "#cdd6f4", "#89b4fa", "#cba6f7", "#f5c2e7", "#313244"] },
{
id: "catppuccin-frappe",
swatches: ["#303446", "#c6d0f5", "#8da4e2", "#ca9ee6", "#f4b8e4", "#414559"],
},
{
id: "catppuccin-macchiato",
swatches: ["#24273a", "#cad3f5", "#8aadf4", "#c6a0f6", "#f5bde6", "#363a4f"],
},
{ id: "cobalt2", swatches: ["#193549", "#ffffff", "#0088ff", "#9a5feb", "#2affdf", "#1f4662"] },
{ id: "colorblind", swatches: ["#1A1A2E", "#E8E8E8", "#0077BB", "#EE7733", "#33BBEE", "#1F4068"] },
{ id: "cursor", swatches: ["#181818", "#e4e4e4", "#88c0d0", "#81a1c1", "#88c0d0", "#e4e4e413"] },
{ id: "dracula", swatches: ["#282a36", "#f8f8f2", "#bd93f9", "#ff79c6", "#8be9fd", "#44475a"] },
{ id: "everforest", swatches: ["#2d353b", "#d3c6aa", "#a7c080", "#7fbbb3", "#d699b6", "#859289"] },
{ id: "flexoki", swatches: ["#100F0F", "#CECDC3", "#DA702C", "#4385BE", "#8B7EC8", "#575653"] },
{ id: "github", swatches: ["#0d1117", "#c9d1d9", "#58a6ff", "#bc8cff", "#39c5cf", "#30363d"] },
{ id: "gruvbox", swatches: ["#282828", "#ebdbb2", "#83a598", "#d3869b", "#8ec07c", "#665c54"] },
{ id: "kanagawa", swatches: ["#1F1F28", "#DCD7BA", "#7E9CD8", "#957FB8", "#D27E99", "#54546D"] },
{ id: "kilo", swatches: ["#0c0a09", "#fafaf9", "#f9f76f", "#a6a09b", "#f9f76f", "#44403b"] },
{ id: "kilo-v1", swatches: ["#1e1e1e", "#cccccc", "#faf74f", "#007acc", "#007fd4", "#3c3c3c"] },
{ id: "lucent-orng", swatches: ["#000000", "#eeeeee", "#EC5B2B", "#EE7948", "#FFF7F1", "#EC5B2B"] },
{ id: "material", swatches: ["#263238", "#eeffff", "#82aaff", "#c792ea", "#89ddff", "#37474f"] },
{ id: "matrix", swatches: ["#0a0e0a", "#62ff94", "#2eff6a", "#00efff", "#c770ff", "#1e2a1b"] },
{ id: "mercury", swatches: ["#171721", "#dddde5", "#8da4f5", "#a7b6f8", "#8da4f5", "#b4b7c81f"] },
{ id: "monokai", swatches: ["#272822", "#f8f8f2", "#66d9ef", "#ae81ff", "#a6e22e", "#3e3d32"] },
{ id: "nightowl", swatches: ["#011627", "#d6deeb", "#82AAFF", "#7fdbca", "#c792ea", "#5f7e97"] },
{ id: "nord", swatches: ["#2E3440", "#ECEFF4", "#88C0D0", "#81A1C1", "#8FBCBB", "#434C5E"] },
{ id: "one-dark", swatches: ["#282c34", "#abb2bf", "#61afef", "#c678dd", "#56b6c2", "#393f4a"] },
{ id: "opencode", swatches: ["#0a0a0a", "#eeeeee", "#fab283", "#5c9cf5", "#9d7cd8", "#484848"] },
{ id: "orng", swatches: ["#0a0a0a", "#eeeeee", "#EC5B2B", "#EE7948", "#FFF7F1", "#EC5B2B"] },
{ id: "osaka-jade", swatches: ["#111c18", "#C1C497", "#2DD5B7", "#D2689C", "#549e6a", "#3d4a44"] },
{ id: "palenight", swatches: ["#292d3e", "#a6accd", "#82aaff", "#c792ea", "#89ddff", "#32364a"] },
{ id: "rosepine", swatches: ["#191724", "#e0def4", "#9ccfd8", "#c4a7e7", "#ebbcba", "#403d52"] },
{ id: "solarized", swatches: ["#002b36", "#839496", "#268bd2", "#6c71c4", "#2aa198", "#073642"] },
{ id: "synthwave84", swatches: ["#262335", "#ffffff", "#36f9f6", "#ff7edb", "#b084eb", "#495495"] },
{ id: "tokyonight", swatches: ["#1a1b26", "#c8d3f5", "#82aaff", "#c099ff", "#ff966c", "#737aa2"] },
{ id: "vercel", swatches: ["#000000", "#EDEDED", "#0070F3", "#52A8FF", "#8E4EC6", "#1F1F1F"] },
{ id: "vesper", swatches: ["#101010", "#FFF", "#FFC799", "#99FFE4", "#FFC799", "#282828"] },
{ id: "zenburn", swatches: ["#3f3f3f", "#dcdccc", "#8cd0d3", "#dc8cc3", "#93e0e3", "#5f5f5f"] },
]
export function themeTitle(id: string) {
return id
.split("-")
.filter(Boolean)
.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
.join(" ")
}
function custom(id: string): Theme {
return { id, swatches: fallback, custom: true }
}
function pick(value: unknown): Diff {
if (value === "stacked") return "stacked"
return "auto"
}
function bool(value: unknown, fallback: boolean) {
if (typeof value === "boolean") return value
return fallback
}
export function useTuiUiSettings() {
const ctx = useConfig()
const current = createMemo(() => ctx.data()?.tui.theme ?? "kilo")
const [theme, setTheme] = createSignal("")
const [mode, setMode] = createSignal<"closed" | "theme">("closed")
const [picker, setPicker] = createSignal("")
const [choice, setChoice] = createSignal("")
const [speed, setSpeed] = createSignal("3")
const [accel, setAccel] = createSignal(false)
const [diff, setDiff] = createSignal<Diff>("auto")
const [mouse, setMouse] = createSignal(true)
const [dirty, setDirty] = createSignal(false)
const options = createMemo(() => {
const term = clean(picker()).toLowerCase()
const list = themes.filter((item) => {
if (!term) return true
return `${item.id} ${themeTitle(item.id)}`.toLowerCase().includes(term)
})
const raw = clean(picker())
if (!raw || themes.some((item) => item.id === raw)) return list
if (list.length) return list
return [custom(raw)]
})
function item(id: string) {
return themes.find((row) => row.id === id) ?? custom(id || "kilo")
}
const active = createMemo(() => item(theme()))
const selected = createMemo(() => item(choice() || theme()))
createEffect(() => {
const tui = ctx.data()?.tui
setTheme(current())
if (dirty()) return
setSpeed(String(tui?.scroll_speed ?? 3))
setAccel(bool(tui?.scroll_acceleration?.enabled, false))
setDiff(pick(tui?.diff_style))
setMouse(bool(tui?.mouse, true))
})
function edit() {
setChoice(theme())
setPicker("")
setMode("theme")
}
function close() {
setMode("closed")
}
function select(value: Theme) {
setChoice(value.id)
}
function change(run: () => void) {
setDirty(true)
run()
}
function saveTheme() {
const id = clean(choice())
if (!id) {
ctx.fail("Select a TUI theme before saving.")
return
}
ctx.tui({ theme: id })
setTheme(id)
close()
}
function save() {
const scroll = Number(speed())
if (!Number.isFinite(scroll) || scroll < 0.001) {
ctx.fail("Enter a scroll speed of at least 0.001.")
return
}
const patch: TuiPatch = {
scroll_speed: scroll,
scroll_acceleration: { enabled: accel() },
diff_style: diff(),
mouse: mouse(),
}
ctx.tui(patch)
setDirty(false)
}
return {
ctx,
current,
theme,
active,
selected,
mode,
picker,
setPicker,
choice,
options,
edit,
close,
select,
saveTheme,
speed,
setSpeed: (value: string) => change(() => setSpeed(value)),
accel,
setAccel: (value: boolean) => change(() => setAccel(value)),
diff,
setDiff: (value: Diff) => change(() => setDiff(value)),
mouse,
setMouse: (value: boolean) => change(() => setMouse(value)),
dirty,
save,
}
}
export function useTuiNotificationSettings() {
const ctx = useConfig()
const [alert, setAlert] = createSignal(false)
const [notify, setNotify] = createSignal(true)
const [sound, setSound] = createSignal(true)
const [volume, setVolume] = createSignal("0.4")
const [dirty, setDirty] = createSignal(false)
createEffect(() => {
if (dirty()) return
const cfg = ctx.data()?.tui.attention
setAlert(bool(cfg?.enabled, false))
setNotify(bool(cfg?.notifications, true))
setSound(bool(cfg?.sound, true))
setVolume(String(cfg?.volume ?? 0.4))
})
function change(run: () => void) {
setDirty(true)
run()
}
function save() {
const vol = Number(volume())
if (!Number.isFinite(vol) || vol < 0 || vol > 1) {
ctx.fail("Enter an attention volume between 0 and 1.")
return
}
ctx.tui({
attention: {
enabled: alert(),
notifications: notify(),
sound: sound(),
volume: vol,
},
})
setDirty(false)
}
return {
ctx,
alert,
setAlert: (value: boolean) => change(() => setAlert(value)),
notify,
setNotify: (value: boolean) => change(() => setNotify(value)),
sound,
setSound: (value: boolean) => change(() => setSound(value)),
volume,
setVolume: (value: string) => change(() => setVolume(value)),
dirty,
save,
}
}
@@ -0,0 +1,9 @@
export function ProfileRoute() {
return (
<section class="route-empty">
<p class="eyebrow">Profile</p>
<h1>Profile Settings</h1>
<p>Profile identity, account preferences, and workspace defaults will live here.</p>
</section>
)
}
@@ -0,0 +1,788 @@
import { A, useLocation, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Icon } from "@kilocode/kilo-web-ui/icon"
import {
createProjectPty,
createProjectWorktree,
discover,
forgetCached,
loadCached,
loadProjectConsole,
loadProjectDiff,
loadProjectDiffFile,
removeProjectPty,
removeProjectWorktree,
resetProjectWorktree,
resolveServer,
saveCached,
subscribeProjectEvents,
viewProjectSessions,
type ProjectConsoleEvent,
type ProjectConsoleQuery,
type ProjectTerminalItem,
type Query,
} from "../../client"
import { clean, errMsg, friendly } from "../../shared/utils"
import {
markUnread as storeMarkUnread,
clearUnread as storeClearUnread,
sessionHasUnread,
} from "../../shared/terminal-status"
import { GhosttyTerminal } from "./terminal/GhosttyTerminal"
const ui = new Set(["3017", "3018"])
type Context = {
id: string
dir: string
label: string
kind: "local" | "worktree"
}
function discoverable(search: URLSearchParams) {
if (search.get("server")) return false
return ui.has(window.location.port)
}
function base(search: URLSearchParams) {
const param = search.get("server")
if (param) return param
const cached = discoverable(search) ? loadCached() : ""
if (cached) return cached
if (discoverable(search)) return ""
return window.location.origin
}
function repo(input: string) {
const parts = input.split(/[\\/]/).filter(Boolean)
return parts.at(-1) ?? "Project"
}
function title(input: string) {
return friendly(repo(input))
}
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null
}
function nested(input: unknown, key: string) {
if (!record(input)) return undefined
const value = input[key]
if (!record(value)) return undefined
return value
}
function eventSession(event: ProjectConsoleEvent) {
const payload = event.payload
const props: unknown = "properties" in payload ? payload.properties : "data" in payload ? payload.data : undefined
if (!record(props)) return undefined
const id = props["sessionID"]
if (typeof id === "string") return id
const info = nested(props, "info")
if (typeof info?.["sessionID"] === "string") return info["sessionID"]
if (typeof info?.["id"] === "string") return info["id"]
const part = nested(props, "part")
if (typeof part?.["sessionID"] === "string") return part["sessionID"]
return undefined
}
function eventType(event: ProjectConsoleEvent) {
const payload = event.payload as { type?: string; name?: unknown; syncEvent?: { type?: unknown } }
if (!payload.type) return ""
if (payload.type !== "sync") return payload.type
if (typeof payload.name === "string") return payload.name
if (typeof payload.syncEvent?.type === "string") return payload.syncEvent.type
return ""
}
function messageEvent(event: ProjectConsoleEvent) {
return eventType(event).startsWith("message.")
}
function refreshEvent(event: ProjectConsoleEvent) {
const type = eventType(event)
if (type.startsWith("pty.")) return true
if (type.startsWith("session.")) return true
if (type.startsWith("permission.")) return true
if (type.startsWith("question.")) return true
if (type.startsWith("message.")) return true
return false
}
function terminalKey(url: string, item: ProjectTerminalItem) {
return `${url}\n${item.directory}\n${item.id}`
}
export function ProjectConsoleRoute() {
const loc = useLocation()
const params = useParams()
const search = createMemo(() => new URLSearchParams(loc.search))
const fallback = () => base(search())
const [url, setUrl] = createSignal(fallback())
const [selected, setSelected] = createSignal(window.localStorage.getItem(`kilo.console.${params.project}.dir`) ?? "")
const [active, setActive] = createSignal(window.localStorage.getItem(`kilo.console.${params.project}.pty`) ?? "")
const [local, setLocal] = createSignal<ProjectTerminalItem[]>([])
const [file, setFile] = createSignal<string | undefined>()
const [saving, setSaving] = createSignal<string | undefined>()
const [failure, setFailure] = createSignal<string | undefined>()
const [unread, setUnread] = createSignal(new Set<string>())
const [closing, setClosing] = createSignal(new Set<string>())
const [labelRev, setLabelRev] = createSignal(0)
const events = { timer: undefined as number | undefined }
const project = () => params.project ?? ""
const query = createMemo<ProjectConsoleQuery | undefined>(() => {
const target = clean(url()) || fallback()
if (!target || !project()) return undefined
return { url: target, dir: "", project: project() }
})
const [snap, { refetch }] = createResource(query, loadProjectConsole)
const contexts = createMemo<Context[]>(() => {
const data = snap()
if (!data) return []
return [
{ id: "local", dir: data.project.worktree, label: "Local", kind: "local" },
...data.worktrees.map((dir) => ({ id: dir, dir, label: title(dir), kind: "worktree" as const })),
]
})
const terminals = createMemo(() => {
const items = new Map<string, ProjectTerminalItem>()
const closed = closing()
for (const item of local()) {
if (closed.has(item.id)) continue
if (item.status === "running") items.set(item.id, item)
}
for (const item of snap()?.terminals ?? []) {
if (closed.has(item.id)) continue
if (item.status === "running") items.set(item.id, item)
}
return Array.from(items.values())
})
const grouped = createMemo(() => {
const items = new Map<string, ProjectTerminalItem[]>()
for (const item of terminals()) {
const list = items.get(item.directory) ?? []
list.push(item)
items.set(item.directory, list)
}
return items
})
const current = createMemo(() => contexts().find((item) => item.dir === selected()) ?? contexts()[0])
const activeTerminal = createMemo(() => terminals().find((item) => item.id === active()))
const target = createMemo<Query | undefined>(() => {
const data = snap()
const item = current()
const base = query()
if (!data || !item || !base) return undefined
return { url: base.url, dir: item.dir, scope: "project" }
})
const diffKey = createMemo(() => {
const item = target()
if (!item) return undefined
return { input: item, dir: item.dir }
})
const [diffs] = createResource(diffKey, (item) => loadProjectDiff(item.input, item.dir))
const detailKey = createMemo(() => {
const item = target()
const path = file()
if (!item || !path) return undefined
return { input: item, dir: item.dir, file: path }
})
const terminal = createMemo(() => {
const item = activeTerminal()
const base = query()
if (!item || !base) return undefined
return terminalKey(base.url, item)
})
const terminalMap = createMemo(() => {
const items = new Map<string, ProjectTerminalItem>()
const base = query()
if (!base) return items
for (const item of terminals()) items.set(terminalKey(base.url, item), item)
return items
})
const terminalKeys = createMemo(() => Array.from(terminalMap().keys()))
const [detail] = createResource(detailKey, (item) => loadProjectDiffFile(item.input, item.dir, item.file))
const settings = createMemo(() => {
const q = search().toString()
return `/projects/${encodeURIComponent(project())}/settings${q ? `?${q}` : ""}`
})
function projectInput(): Query | undefined {
const base = query()
const data = snap()
if (!base || !data) return undefined
return { url: base.url, dir: data.project.worktree, scope: "project" }
}
function labelKey(dir: string) {
return `kilo.console.${project()}.worktree.${encodeURIComponent(dir)}.label`
}
function displayLabel(item: Context) {
labelRev()
return window.localStorage.getItem(labelKey(item.dir))?.trim() || item.label
}
function currentLabel() {
const item = current()
if (!item) return "Project"
return displayLabel(item)
}
function sessionID(item: ProjectTerminalItem | undefined) {
return item?.sessionID ?? item?.session?.id
}
function activeSessionID() {
return sessionID(activeTerminal())
}
function terminalName(item: ProjectTerminalItem) {
return item.session?.title || item.title || `Terminal ${item.id.slice(-4)}`
}
function terminalState(item: ProjectTerminalItem) {
if (item.attention) return "attention"
if (item.sessionStatus && item.sessionStatus.type !== "idle") return "busy"
const id = sessionID(item)
if (id && unread().has(id)) return "unread"
return "idle"
}
function clearUnread(item: ProjectTerminalItem) {
const id = sessionID(item)
if (!id || !unread().has(id)) return
setUnread((old) => {
const next = new Set(old)
next.delete(id)
return next
})
const pid = snap()?.project.id
if (pid && id) storeClearUnread(pid, id)
}
function markUnread(id: string) {
if (id === activeSessionID()) return
setUnread((old) => {
if (old.has(id)) return old
return new Set([...old, id])
})
const pid = snap()?.project.id
if (pid) storeMarkUnread(pid, id)
}
function scheduleRefetch() {
if (events.timer) return
events.timer = window.setTimeout(() => {
events.timer = undefined
void refetch()
}, 150)
}
function terminalsFor(dir: string) {
return grouped().get(dir) ?? []
}
function remember(dir: string, pty?: string) {
window.localStorage.setItem(`kilo.console.${project()}.dir`, dir)
if (pty) {
window.localStorage.setItem(`kilo.console.${project()}.pty`, pty)
return
}
window.localStorage.removeItem(`kilo.console.${project()}.pty`)
}
function select(item: Context) {
setSelected(item.dir)
const pty = terminalsFor(item.dir)[0]
setActive(pty?.id ?? "")
setFile(undefined)
remember(item.dir, pty?.id)
}
function selectTerminal(item: ProjectTerminalItem) {
setSelected(item.directory)
setActive(item.id)
setFile(undefined)
clearUnread(item)
remember(item.directory, item.id)
}
function run(label: string, job: () => Promise<unknown>) {
setSaving(label)
setFailure(undefined)
void job()
.then(() => refetch())
.catch((err) => setFailure(errMsg(err)))
.finally(() => setSaving(undefined))
}
function addWorktree() {
const input = projectInput()
const data = snap()
if (!input || !data) return
const name = window.prompt("Worktree name") ?? undefined
run("Creating worktree", async () => {
const next = await createProjectWorktree(input, name)
setSelected(next.directory)
window.localStorage.setItem(`kilo.console.${project()}.dir`, next.directory)
})
}
function addSession(item = current()) {
const base = query()
if (!base || !item) return
const input = { url: base.url, dir: item.dir, scope: "project" as const }
const label = `Kilo ${terminalsFor(item.dir).length + 1}`
setSaving("Creating session")
setFailure(undefined)
void createProjectPty(input, item.dir, label)
.then((pty) => {
const next = { ...pty, directory: item.dir }
setLocal((rows) => [...rows.filter((row) => row.id !== next.id), next])
setSelected(item.dir)
setActive(next.id)
remember(item.dir, next.id)
return refetch()
})
.catch((err) => setFailure(errMsg(err)))
.finally(() => setSaving(undefined))
}
function forgetTerminal(id: string) {
setLocal((rows) => rows.filter((row) => row.id !== id))
if (active() === id) {
setActive("")
window.localStorage.removeItem(`kilo.console.${project()}.pty`)
}
}
function dropTerminal(id: string) {
forgetTerminal(id)
void refetch()
}
function closeTerminal(item: ProjectTerminalItem) {
const input = { url: query()?.url ?? "", dir: item.directory, scope: "project" as const }
if (!input.url) return
setClosing((old) => new Set([...old, item.id]))
forgetTerminal(item.id)
setSaving("Closing terminal")
setFailure(undefined)
void removeProjectPty(input, item.id)
.then(() => refetch())
.then(() => {
setClosing((old) => {
const next = new Set(old)
next.delete(item.id)
return next
})
})
.catch((err) => {
setClosing((old) => {
const next = new Set(old)
next.delete(item.id)
return next
})
setFailure(errMsg(err))
})
.finally(() => setSaving(undefined))
}
function renameWorktree(item: Context) {
if (item.kind === "local") return
const input = window.prompt("Worktree label", displayLabel(item))
if (input === null) return
const next = input.trim()
if (next) window.localStorage.setItem(labelKey(item.dir), next)
else window.localStorage.removeItem(labelKey(item.dir))
setLabelRev((value) => value + 1)
}
function removeWorktree(item: Context) {
const input = projectInput()
if (!input || item.kind === "local") return
if (!window.confirm(`Remove worktree ${displayLabel(item)}?`)) return
run("Removing worktree", async () => {
await removeProjectWorktree(input, item.dir)
window.localStorage.removeItem(labelKey(item.dir))
setLabelRev((value) => value + 1)
if (selected() === item.dir) {
setSelected(input.dir)
remember(input.dir)
}
})
}
function removeSelected() {
const item = current()
if (!item) return
removeWorktree(item)
}
function resetSelected() {
const input = projectInput()
const item = current()
if (!input || !item || item.kind === "local") return
if (!window.confirm(`Reset worktree ${displayLabel(item)}?`)) return
run("Resetting worktree", async () => resetProjectWorktree(input, item.dir))
}
createEffect(() => {
const next = search().get("server")
if (next && next !== url()) setUrl(next)
})
createEffect(() => {
if (!discoverable(search())) return
void resolveServer().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
createEffect(() => {
const data = snap()
if (!data) return
const hit = contexts().some((item) => item.dir === selected())
if (hit) return
setSelected(data.project.worktree)
remember(data.project.worktree)
})
createEffect(() => {
const ids = new Set((snap()?.terminals ?? []).map((item) => item.id))
if (ids.size === 0) return
const rows = local()
if (!rows.some((item) => ids.has(item.id))) return
setLocal(rows.filter((item) => !ids.has(item.id)))
})
createEffect(() => {
const item = current()
if (!item) return
const hit = activeTerminal()
if (hit?.directory === item.dir) return
const pty = terminalsFor(item.dir)[0]
if ((pty?.id ?? "") === active()) return
setActive(pty?.id ?? "")
remember(item.dir, pty?.id)
})
createEffect(() => {
const item = activeTerminal()
if (item) clearUnread(item)
})
createEffect(() => {
const base = query()
const data = snap()
if (!base || !data) return
const focused = activeSessionID()
const open = terminals().flatMap((item) => {
const id = sessionID(item)
return id ? [id] : []
})
void viewProjectSessions({ url: base.url, dir: data.project.worktree }, focused ? [focused] : [], open).catch(
() => {},
)
})
createEffect(() => {
const base = query()
const data = snap()
if (!base || !data) return
const dirs = new Set([data.project.worktree, ...data.worktrees])
const stop = subscribeProjectEvents({ url: base.url, dir: data.project.worktree }, (event) => {
if (event.directory !== "global" && !dirs.has(event.directory)) return
const id = eventSession(event)
if (id && messageEvent(event)) markUnread(id)
if (refreshEvent(event)) scheduleRefetch()
})
onCleanup(stop)
})
onCleanup(() => {
if (events.timer) window.clearTimeout(events.timer)
})
createEffect(() => {
if (!snap.error || !discoverable(search())) return
const cached = loadCached()
if (!cached || cached !== url()) return
forgetCached()
setUrl("")
void discover().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
return (
<section class="project-console">
<aside class="project-console-sidebar" aria-label="Project console sections">
<div class="project-console-title">
<span class="project-console-heading">
<span>Project</span>
<A class="project-title-settings" href={settings()} title="Project Settings" aria-label="Project Settings">
<Icon name="settings-gear" size="small" />
</A>
</span>
<span class="project-console-scope">
<span class="project-console-name">
{snap()?.project.name
? friendly(snap()?.project.name ?? "")
: title(snap()?.project.worktree ?? project())}
</span>
</span>
<small>{snap()?.vcs.branch ?? "No branch"}</small>
</div>
<div class="project-console-scroll">
<section class="project-sidebar-group">
<div class="project-panel-heading project-panel-heading-row">
<span>Worktrees</span>
<button
type="button"
class="project-heading-action"
onClick={addWorktree}
disabled={!projectInput() || !!saving()}
title="Create worktree"
aria-label="Create worktree"
>
+
</button>
</div>
<nav class="project-contexts" aria-label="Worktrees">
<For each={contexts()}>
{(item) => (
<div class="project-worktree-block">
<div
class="project-context-row"
classList={{ active: current()?.dir === item.dir && !activeTerminal() }}
>
<button
type="button"
class="project-context"
classList={{ active: current()?.dir === item.dir && !activeTerminal() }}
onClick={() => select(item)}
title={item.dir}
>
<span>{displayLabel(item)}</span>
<small>{item.kind === "local" ? "project" : repo(item.dir)}</small>
</button>
<div class="project-row-actions">
<button
type="button"
class="project-inline-action"
onClick={(event) => {
event.stopPropagation()
addSession(item)
}}
disabled={!query() || !!saving()}
title={`New session in ${displayLabel(item)}`}
aria-label={`New session in ${displayLabel(item)}`}
>
+
</button>
<Show when={item.kind === "worktree"}>
<button
type="button"
class="project-inline-action"
onClick={(event) => {
event.stopPropagation()
renameWorktree(item)
}}
disabled={!!saving()}
title={`Rename ${displayLabel(item)}`}
aria-label={`Rename ${displayLabel(item)}`}
>
<Icon name="edit" size="small" />
</button>
<button
type="button"
class="project-inline-action danger"
onClick={(event) => {
event.stopPropagation()
removeWorktree(item)
}}
disabled={!!saving()}
title={`Delete ${displayLabel(item)}`}
aria-label={`Delete ${displayLabel(item)}`}
>
<Icon name="trash" size="small" />
</button>
</Show>
</div>
</div>
<Show when={terminalsFor(item.dir).length > 0}>
<div class="project-terminal-list">
<For each={terminalsFor(item.dir)}>
{(pty) => (
<div class="project-terminal-item" classList={{ active: active() === pty.id }}>
<button
type="button"
class="project-terminal-row"
classList={{ active: active() === pty.id }}
onClick={() => selectTerminal(pty)}
title={`${terminalName(pty)} (${pty.pid})`}
>
<span
class="project-console-dot"
classList={{
"project-console-dot-idle": terminalState(pty) === "idle",
"project-console-dot-busy": terminalState(pty) === "busy",
"project-console-dot-attention": terminalState(pty) === "attention",
"project-console-dot-unread": terminalState(pty) === "unread",
}}
aria-hidden="true"
/>
<span>{terminalName(pty)}</span>
</button>
<button
type="button"
class="project-terminal-close"
onClick={(event) => {
event.stopPropagation()
closeTerminal(pty)
}}
disabled={!!saving()}
title={`Close ${terminalName(pty)}`}
aria-label={`Close ${terminalName(pty)}`}
>
x
</button>
</div>
)}
</For>
</div>
</Show>
</div>
)}
</For>
</nav>
</section>
</div>
<div class="project-sidebar-bottom">
<A class="project-settings-link" href={settings()}>
<span>Project Settings</span>
<small>Project configuration</small>
</A>
</div>
</aside>
<main class="project-console-main">
<Show when={!query() && discoverable(search())}>
<Card class="banner" variant="info">
Discovering Kilo server...
</Card>
</Show>
<Show when={snap.loading && !snap()}>
<Card class="banner" variant="info">
Loading project console...
</Card>
</Show>
<Show when={snap.error || failure()}>
<Card class="banner" variant="error">
<strong>Project console failed</strong>
<span>{failure() ?? errMsg(snap.error)}</span>
</Card>
</Show>
<For each={terminalKeys()}>
{(key) => {
const pty = createMemo(() => terminalMap().get(key))
const input = createMemo<Query | undefined>(() => {
const item = pty()
const base = query()
if (!item || !base) return undefined
return { url: base.url, dir: item.directory, scope: "project" }
})
return (
<Show when={input()}>
{(target) => {
const item = pty()
if (!item) return null
return (
<div
class="project-terminal-frame"
classList={{ active: terminal() === key }}
aria-hidden={terminal() !== key}
>
<GhosttyTerminal
query={target()}
pty={item.id}
active={terminal() === key}
onExit={() => {
const next = pty()
if (next) dropTerminal(next.id)
}}
/>
</div>
)
}}
</Show>
)
}}
</For>
<Show when={!terminal() && !snap.loading && !snap.error && !failure()}>
<div class="project-terminal-empty">
<strong>No terminal session selected</strong>
<span>Use + next to a worktree to start Kilo CLI.</span>
</div>
</Show>
</main>
<aside class="project-console-info" aria-label="Project details">
<div class="project-info-card">
<div class="project-panel-heading">Context</div>
<strong>{currentLabel()}</strong>
<code>{current()?.dir ?? snap()?.project.worktree ?? project()}</code>
<Show when={current()?.kind === "worktree"}>
<div class="project-info-actions">
<button type="button" onClick={resetSelected} disabled={!!saving()}>
Reset
</button>
<button type="button" onClick={removeSelected} disabled={!!saving()}>
Remove
</button>
</div>
</Show>
</div>
<div class="project-info-card grow">
<div class="project-panel-heading">Changes</div>
<Show when={diffs.loading && !diffs()}>
<p class="empty">Loading diff...</p>
</Show>
<Show when={diffs.error}>
<p class="empty">{errMsg(diffs.error)}</p>
</Show>
<Show when={!diffs.loading && (diffs() ?? []).length === 0 && !diffs.error}>
<p class="empty">No changes detected.</p>
</Show>
<div class="project-diff-list">
<For each={diffs() ?? []}>
{(item) => (
<button
type="button"
class="project-diff-row"
classList={{ active: file() === item.file }}
onClick={() => setFile(item.file)}
>
<span>{item.file}</span>
<small>
+{item.additions} -{item.deletions}
</small>
</button>
)}
</For>
</div>
<Show when={detail()}>{(item) => <pre class="project-diff-detail">{item()?.patch ?? ""}</pre>}</Show>
</div>
</aside>
</section>
)
}
@@ -0,0 +1,202 @@
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { A } from "@solidjs/router"
import { Card } from "@kilocode/kilo-web-ui/card"
import { SearchField } from "../../components/SearchField"
import {
discover,
forgetCached,
loadCached,
loadVisibleProjects,
resolveServer,
saveCached,
type ProjectItem,
type ProjectQuery,
} from "../../client"
import { clean, errMsg, friendly } from "../../shared/utils"
const search = new URLSearchParams(window.location.search)
const ui = new Set(["3017", "3018"])
function discoverable() {
if (search.get("server")) return false
return ui.has(window.location.port)
}
function base() {
const param = search.get("server")
if (param) return param
const cached = discoverable() ? loadCached() : ""
if (cached) return cached
if (discoverable()) return ""
return window.location.origin
}
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 mark(item: ProjectItem) {
const parts = name(item)
.split(/[\s._/-]+/)
.filter(Boolean)
const text = `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? parts[0]?.[1] ?? ""}`
return text.toUpperCase() || "KG"
}
function href(item: ProjectItem) {
const params = new URLSearchParams()
const server = search.get("server")
if (server) params.set("server", server)
const query = params.toString()
return `/projects/${encodeURIComponent(item.id)}${query ? `?${query}` : ""}`
}
function short(input: string) {
if (input.length <= 8) return input
return input.slice(0, 8)
}
function updated(input: number) {
const diff = Math.max(0, Date.now() - input)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) return "just now"
if (diff < hour) return `${Math.floor(diff / minute)} minutes ago`
if (diff < day) return `${Math.floor(diff / hour)} hours ago`
const days = Math.floor(diff / day)
if (days === 1) return "yesterday"
return `${days} days ago`
}
function matches(item: ProjectItem, input: string) {
const needle = input.trim().toLowerCase()
if (!needle) return true
return `${name(item)} ${item.id} ${item.worktree}`.toLowerCase().includes(needle)
}
export function ProjectsRoute() {
const [url, setUrl] = createSignal(base())
const [filter, setFilter] = createSignal("")
const query = createMemo<ProjectQuery | undefined>(() => {
const target = clean(url()) || base()
if (!target) return undefined
return { url: target, dir: "" }
})
const [items] = createResource(query, loadVisibleProjects)
const rows = createMemo(() => [...(items() ?? [])].sort((a, b) => b.time.updated - a.time.updated))
const filtered = createMemo(() => rows().filter((item) => matches(item, filter())))
createEffect(() => {
if (!discoverable()) return
void resolveServer().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)
})
})
return (
<section class="route-empty">
<div class="projects-page">
<header class="projects-header">
<h1>
Projects <span class="count-tag">{rows().length}</span>
</h1>
<p>Projects opened with this Kilo server. Selecting a project opens its console.</p>
</header>
<Show when={!query() && discoverable()}>
<Card class="banner" variant="info">
Discovering Kilo server...
</Card>
</Show>
<Show when={items.loading && !items()}>
<Card class="banner" variant="info">
Loading projects...
</Card>
</Show>
<Show when={items.error}>
<Card class="banner" variant="error">
<strong>Project request failed</strong>
<span>{errMsg(items.error)}</span>
</Card>
</Show>
<SearchField label="Filter projects" value={filter()} placeholder="Filter projects..." onValue={setFilter} />
<Show when={query() && !items.loading && rows().length === 0 && !items.error}>
<Card class="empty">No projects have been opened with this Kilo server yet.</Card>
</Show>
<Show when={query() && !items.loading && rows().length > 0 && filtered().length === 0 && !items.error}>
<Card class="empty">No matches. Clear the filter or open a project from the CLI.</Card>
</Show>
<ul class="project-list" aria-label="Kilo projects">
<For each={filtered()}>
{(item) => (
<li>
<A class="project-row" href={href(item)}>
<span class="project-icon" aria-hidden="true">
{mark(item)}
</span>
<span class="project-body">
<span class="project-title">
<strong>{name(item)}</strong>
<span>{short(item.id)}</span>
</span>
<span class="project-meta">
<span class="project-git" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M6 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M18 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M6 8v4a3 3 0 0 0 3 3h6" />
<path d="M14 12l3 3l-3 3" />
</svg>
</span>
<span class="project-folder">{item.worktree}</span>
<span aria-hidden="true">·</span>
<span>{updated(item.time.updated)}</span>
</span>
</span>
<span class="project-arrow" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M9 6l6 6l-6 6" />
</svg>
</span>
</A>
</li>
)}
</For>
</ul>
</div>
</section>
)
}
@@ -0,0 +1,364 @@
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
import { CellFlags, FitAddon, init, Terminal, type GhosttyCell } from "ghostty-web"
import { ptyWsUrl, resizeProjectPty, type Query } from "../../../client"
let boot: Promise<void> | undefined
function ready() {
boot ??= init()
return boot
}
function css(el: Element, name: string, fallback: string) {
const value = getComputedStyle(el).getPropertyValue(name).trim()
return value || fallback
}
function px(el: Element, name: string, fallback: number) {
const value = css(el, name, "")
const size = Number.parseFloat(value)
if (!Number.isFinite(size) || size <= 0) return fallback
if (value.endsWith("rem") || value.endsWith("em")) {
const root = Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
return Number.isFinite(root) ? size * root : fallback
}
return size
}
function theme(el: Element) {
const background = css(el, "--project-terminal-background", "#0f1015")
return {
background,
foreground: css(el, "--foreground", "#d4d4d8"),
cursor: css(el, "--foreground", "#d4d4d8"),
selectionBackground: css(el, "--accent", "#334155"),
black: "#0f1015",
red: "#f87171",
green: "#34d399",
yellow: "#fbbf24",
blue: "#60a5fa",
magenta: "#c084fc",
cyan: "#22d3ee",
white: "#e5e7eb",
brightBlack: "#71717a",
brightRed: "#fca5a5",
brightGreen: "#86efac",
brightYellow: "#fde68a",
brightBlue: "#93c5fd",
brightMagenta: "#d8b4fe",
brightCyan: "#67e8f9",
brightWhite: "#fafafa",
}
}
type View = {
ctx: CanvasRenderingContext2D
devicePixelRatio: number
metrics: { width: number; height: number }
theme: { selectionForeground: string }
renderCellText: (cell: GhosttyCell, x: number, y: number, over?: string) => void
rgbToCSS: (r: number, g: number, b: number) => string
isInSelection: (x: number, y: number) => boolean
}
function rect(view: View, x: number, y: number, w: number, h: number) {
const ratio = view.devicePixelRatio || window.devicePixelRatio || 1
const left = Math.floor(x * ratio) / ratio
const top = Math.floor(y * ratio) / ratio
const right = Math.ceil((x + w) * ratio) / ratio
const bottom = Math.ceil((y + h) * ratio) / ratio
view.ctx.fillRect(left, top, right - left, bottom - top)
}
function sextant(view: View, mask: number, x: number, y: number, w: number, h: number) {
const sw = w / 2
const sh = h / 3
for (const n of [1, 2, 3, 4, 5, 6]) {
if (!(mask & (1 << (n - 1)))) continue
const dx = n % 2 === 1 ? 0 : sw
const dy = Math.floor((n - 1) / 2) * sh
rect(view, x + dx, y + dy, sw, sh)
}
}
function fill(view: View, cp: number, x: number, y: number, w: number, h: number) {
const ew = w / 8
const eh = h / 8
const hw = w / 2
const hh = h / 2
if (cp === 0x1fb01) {
sextant(view, 0b000010, x, y, w, h)
return true
}
if (cp === 0x1fb0f) {
sextant(view, 0b010000, x, y, w, h)
return true
}
if (cp === 0x1fb2c) {
sextant(view, 0b101111, x, y, w, h)
return true
}
if (cp === 0x1fb3a) {
sextant(view, 0b111101, x, y, w, h)
return true
}
if (cp >= 0x2581 && cp <= 0x2587) {
const n = cp - 0x2580
rect(view, x, y + h - eh * n, w, eh * n)
return true
}
if (cp >= 0x2589 && cp <= 0x258f) {
const n = 8 - (cp - 0x2588)
rect(view, x, y, ew * n, h)
return true
}
if (cp === 0x2580) {
rect(view, x, y, w, hh)
return true
}
if (cp === 0x2588) {
rect(view, x, y, w, h)
return true
}
if (cp === 0x2590) {
rect(view, x + hw, y, hw, h)
return true
}
if (cp === 0x2594) {
rect(view, x, y, w, eh)
return true
}
if (cp === 0x2595) {
rect(view, x + w - ew, y, ew, h)
return true
}
if (cp === 0x2596) {
rect(view, x, y + hh, hw, hh)
return true
}
if (cp === 0x2597) {
rect(view, x + hw, y + hh, hw, hh)
return true
}
if (cp === 0x2598) {
rect(view, x, y, hw, hh)
return true
}
if (cp === 0x2599) {
rect(view, x, y, hw, h)
rect(view, x + hw, y + hh, hw, hh)
return true
}
if (cp === 0x259a) {
rect(view, x, y, hw, hh)
rect(view, x + hw, y + hh, hw, hh)
return true
}
if (cp === 0x259b) {
rect(view, x, y, w, hh)
rect(view, x, y + hh, hw, hh)
return true
}
if (cp === 0x259c) {
rect(view, x, y, w, hh)
rect(view, x + hw, y + hh, hw, hh)
return true
}
if (cp === 0x259d) {
rect(view, x + hw, y, hw, hh)
return true
}
if (cp === 0x259e) {
rect(view, x + hw, y, hw, hh)
rect(view, x, y + hh, hw, hh)
return true
}
if (cp === 0x259f) {
rect(view, x + hw, y, hw, h)
rect(view, x, y + hh, hw, hh)
return true
}
return false
}
function color(view: View, cell: GhosttyCell, x: number, y: number, over?: string) {
if (over) return over
if (view.isInSelection(x, y)) return view.theme.selectionForeground
if (cell.flags & CellFlags.INVERSE) return view.rgbToCSS(cell.bg_r, cell.bg_g, cell.bg_b)
return view.rgbToCSS(cell.fg_r, cell.fg_g, cell.fg_b)
}
function patch(renderer: Terminal["renderer"]) {
if (!renderer) return
const view = renderer as unknown as View
const original = view.renderCellText.bind(renderer)
view.renderCellText = (cell, x, y, over) => {
if (
!(cell.flags & CellFlags.INVISIBLE) &&
((cell.codepoint >= 0x2580 && cell.codepoint <= 0x259f) ||
cell.codepoint === 0x1fb01 ||
cell.codepoint === 0x1fb0f ||
cell.codepoint === 0x1fb2c ||
cell.codepoint === 0x1fb3a)
) {
const w = view.metrics.width * cell.width
const h = view.metrics.height
view.ctx.fillStyle = color(view, cell, x, y, over)
if (cell.flags & CellFlags.FAINT) view.ctx.globalAlpha = 0.5
const drawn = fill(view, cell.codepoint, x * view.metrics.width, y * h, w, h)
if (cell.flags & CellFlags.FAINT) view.ctx.globalAlpha = 1
if (drawn) return
}
original(cell, x, y, over)
}
}
export function GhosttyTerminal(props: { query: Query; pty: string; active?: boolean; onExit?: () => void }) {
let host!: HTMLDivElement
let term: Terminal | undefined
let fit: FitAddon | undefined
const [failure, setFailure] = createSignal<string | undefined>()
const [shown, setShown] = createSignal(false)
createEffect(() => {
if (!props.active || !shown()) return
requestAnimationFrame(() => {
fit?.fit()
term?.focus()
})
})
onMount(() => {
let disposed = false
let ws: WebSocket | undefined
let data: { dispose: () => void } | undefined
let size: { dispose: () => void } | undefined
let replay = false
const done = (input: Uint8Array) => {
if (input[0] !== 0) return false
replay = true
requestAnimationFrame(() => {
if (!disposed) setShown(true)
})
return true
}
const run = async () => {
host.replaceChildren()
await ready()
if (disposed) return
term = new Terminal({
cols: 100,
rows: 30,
cursorBlink: true,
fontFamily:
"'FiraCode Nerd Font', 'FiraCode Nerd Font Mono', 'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: Math.max(14, px(host, "--font-size-base", 14)),
scrollback: 5000,
theme: theme(host),
})
fit = new FitAddon()
term.loadAddon(fit)
host.replaceChildren()
term.open(host)
patch(term.renderer)
term.reset()
term.clear()
data = term.onData((input) => {
if (replay && ws?.readyState === WebSocket.OPEN) ws.send(input)
})
size = term.onResize((next) => {
void resizeProjectPty(props.query, props.pty, next.cols, next.rows).catch((err) =>
console.warn("Terminal resize failed", err),
)
})
fit.fit()
fit.observeResize()
requestAnimationFrame(() => {
fit?.fit()
if (props.active) term?.focus()
})
ws = new WebSocket(ptyWsUrl(props.query, props.pty))
ws.binaryType = "arraybuffer"
ws.onmessage = (event) => {
if (disposed || !term) return
if (typeof event.data === "string") {
term.write(event.data)
return
}
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (done(bytes)) return
term.write(bytes)
return
}
if (event.data instanceof Blob) {
void event.data.arrayBuffer().then((buffer) => {
if (disposed || !term) return
const bytes = new Uint8Array(buffer)
if (done(bytes)) return
term.write(bytes)
})
}
}
ws.onerror = () => setFailure("Terminal WebSocket connection failed")
ws.onclose = () => {
if (disposed) return
setFailure("Terminal disconnected")
props.onExit?.()
}
}
void run().catch((err) => {
const msg = err instanceof Error ? err.message : String(err)
setFailure(msg)
})
onCleanup(() => {
disposed = true
data?.dispose()
size?.dispose()
fit?.dispose()
ws?.close()
term?.dispose()
host.replaceChildren()
fit = undefined
term = undefined
})
})
return (
<div class="project-terminal" classList={{ shown: shown() }}>
<div ref={host} class="project-terminal-host" />
<Show when={failure()}>{(msg) => <div class="project-terminal-error">{msg()}</div>}</Show>
</div>
)
}
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { path, settings, strip } from "./navigation"
test("strips the deployed console base from route paths", () => {
expect(strip("/console/projects/demo/settings/agents", "/console")).toBe("/projects/demo/settings/agents")
expect(strip("/console", "/console")).toBe("/")
expect(strip("/consoleish/projects", "/console")).toBe("/consoleish/projects")
})
test("classifies routes after stripping the console base", () => {
expect(path("/console/projects/demo/settings/agents", "/console")).toBe("/project")
expect(path("/console/settings/agents", "/console")).toBe("/settings")
expect(path("/console/profile", "/console")).toBe("/profile")
})
test("builds settings roots without preserving the console base", () => {
expect(settings("/console/projects/demo/settings/agents", "/console")).toBe("/projects/demo/settings")
expect(settings("/console/settings/agents", "/console")).toBe("/settings")
})
@@ -0,0 +1,28 @@
export type Path = "/projects" | "/project" | "/profile" | "/settings"
function base() {
return (import.meta.env?.BASE_URL ?? "/").replace(/\/$/, "")
}
export function strip(input: string, prefix = base()) {
if (!prefix || prefix === "/") return input
if (input === prefix) return "/"
if (input.startsWith(`${prefix}/`)) return input.slice(prefix.length)
return input
}
export function settings(input: string, prefix = base()) {
const route = strip(input, prefix)
const index = route.indexOf("/settings")
if (index > 0) return `${route.slice(0, index)}/settings`
if (route.startsWith("/config")) return "/config"
return "/settings"
}
export function path(input: string, prefix = base()): Path {
const route = strip(input, prefix)
if (route === "/profile") return "/profile"
if (route.startsWith("/settings") || route.startsWith("/config")) return "/settings"
if (route.startsWith("/projects/")) return "/project"
return "/projects"
}
@@ -0,0 +1,164 @@
/**
* Global reactive store for per-project terminal status.
*
* State is organized as Map<projectId, Set<sessionId>> for each category.
* Any component can read or write to this store — it is the single source of
* truth for the status indicators shown in the rail and in the terminal list.
*
* Population sources:
* - SSE events wired up once in AppSidebar (cross-project + initial hydration)
* - ProjectConsoleRoute (same-project events + message-level unread tracking)
*/
import { createSignal } from "solid-js"
import type { ProjectItem } from "../client"
// ─── types ────────────────────────────────────────────────────────────────────
export type ProjectStatus = "error" | "attention" | "unread" | "busy" | "idle"
const PRIORITY: ProjectStatus[] = ["error", "attention", "unread", "busy", "idle"]
// ─── internal signals (Map<projectId, Set<sessionId>>) ────────────────────────
const [_errors, setErrors] = createSignal(new Map<string, Set<string>>())
const [_attention, setAttention] = createSignal(new Map<string, Set<string>>())
const [_unread, setUnread] = createSignal(new Map<string, Set<string>>())
const [_busy, setBusy] = createSignal(new Map<string, Set<string>>())
// ─── helpers ──────────────────────────────────────────────────────────────────
function addSession(
setter: (fn: (m: Map<string, Set<string>>) => Map<string, Set<string>>) => void,
projectId: string,
sessionId: string,
) {
setter((m) => {
if (m.get(projectId)?.has(sessionId)) return m
const next = new Map(m)
next.set(projectId, new Set([...(m.get(projectId) ?? []), sessionId]))
return next
})
}
function removeSession(
setter: (fn: (m: Map<string, Set<string>>) => Map<string, Set<string>>) => void,
projectId: string,
sessionId: string,
) {
setter((m) => {
const sessions = m.get(projectId)
if (!sessions?.has(sessionId)) return m
const next = new Map(m)
const s = new Set(sessions)
s.delete(sessionId)
if (s.size === 0) next.delete(projectId)
else next.set(projectId, s)
return next
})
}
function removeProject(
setter: (fn: (m: Map<string, Set<string>>) => Map<string, Set<string>>) => void,
projectId: string,
) {
setter((m) => {
if (!m.has(projectId)) return m
const next = new Map(m)
next.delete(projectId)
return next
})
}
// ─── public API ───────────────────────────────────────────────────────────────
// error
export const markError = (p: string, s: string) => addSession(setErrors, p, s)
export const clearError = (p: string, s: string) => removeSession(setErrors, p, s)
export const clearErrorProject = (p: string) => removeProject(setErrors, p)
// attention
export const markAttention = (p: string, s: string) => addSession(setAttention, p, s)
export const clearAttention = (p: string, s: string) => removeSession(setAttention, p, s)
export const clearAttentionProject = (p: string) => removeProject(setAttention, p)
// unread
export const markUnread = (p: string, s: string) => addSession(setUnread, p, s)
export const clearUnread = (p: string, s: string) => removeSession(setUnread, p, s)
export const clearUnreadProject = (p: string) => removeProject(setUnread, p)
// busy
export const markBusy = (p: string, s: string) => addSession(setBusy, p, s)
export const clearBusy = (p: string, s: string) => removeSession(setBusy, p, s)
export const clearBusyProject = (p: string) => removeProject(setBusy, p)
// ─── derived per-project status (reactive) ────────────────────────────────────
export function projectStatus(projectId: string): ProjectStatus {
if ((_errors().get(projectId)?.size ?? 0) > 0) return "error"
if ((_attention().get(projectId)?.size ?? 0) > 0) return "attention"
if ((_unread().get(projectId)?.size ?? 0) > 0) return "unread"
if ((_busy().get(projectId)?.size ?? 0) > 0) return "busy"
return "idle"
}
export function sessionHasUnread(projectId: string, sessionId: string) {
return _unread().get(projectId)?.has(sessionId) ?? false
}
// ─── path helpers (reused from client.ts pattern) ─────────────────────────────
function norm(input: string) {
return input.replace(/\\/g, "/").replace(/\/+$/, "") || "/"
}
function inside(root: string, dir: string) {
const r = norm(root)
const d = norm(dir)
return d === r || d.startsWith(`${r}/`)
}
export function projectForDir(items: ProjectItem[], dir: string): ProjectItem | undefined {
let best: ProjectItem | undefined
let bestLen = -1
for (const item of items) {
for (const root of [item.worktree, ...item.sandboxes]) {
if (inside(root, dir)) {
const len = norm(root).length
if (len > bestLen) {
bestLen = len
best = item
}
}
}
}
return best
}
// ─── event parsing ────────────────────────────────────────────────────────────
export type GlobalEvent = {
directory: string
project?: string
payload: unknown
}
export function eventTypeName(event: GlobalEvent): string {
const payload = event.payload as { type?: string; name?: unknown; syncEvent?: { type?: unknown } }
if (!payload.type) return ""
if (payload.type !== "sync") return payload.type
if (typeof payload.name === "string") return payload.name
if (typeof (payload.syncEvent as Record<string, unknown> | undefined)?.type === "string")
return (payload.syncEvent as { type: string }).type
return ""
}
export function eventSessionId(event: GlobalEvent): string | undefined {
const payload = event.payload as Record<string, unknown>
const props = (payload.properties ?? payload.data) as Record<string, unknown> | undefined
const id = props?.sessionID
return typeof id === "string" ? id : undefined
}
// priority helpers for composing status
export { PRIORITY }
+223
View File
@@ -0,0 +1,223 @@
import type { Provider, ToolListItem } from "@kilocode/sdk/v2/client"
import type { Snapshot } from "../client"
export type McpMap = NonNullable<Snapshot["effective"]["mcp"]>
export type FormatterMap = Exclude<NonNullable<Snapshot["effective"]["formatter"]>, boolean>
export type LspMap = Exclude<NonNullable<Snapshot["effective"]["lsp"]>, boolean>
export type PermissionMap = Exclude<NonNullable<Snapshot["effective"]["permission"]>, string | null>
export type ToolItem = { id: string; detail: ToolListItem | undefined }
const labels: Record<string, string> = {
apply_patch: "Apply Patch",
bash: "Bash",
edit: "Edit File",
glob: "Find Files",
grep: "Search Content",
lsp: "Language Server",
question: "Ask User",
read: "Read File",
recall: "Local Recall",
skill: "Load Skill",
task: "Subagent Task",
todowrite: "Todo List",
webfetch: "Fetch URL",
websearch: "Web Search",
write: "Write File",
}
const copy: Record<string, string[]> = {
apply_patch: [
"Apply structured patches to add, update, move, or delete files.",
"Keeps edits explicit and easy to review.",
],
bash: [
"Run shell commands in the current workspace.",
"Supports timeouts, working directories, and command descriptions.",
],
edit: ["Replace exact text inside an existing file.", "Can update one match or all matches when needed."],
glob: ["Find files by path patterns.", "Useful for locating files before reading or editing them."],
grep: [
"Search file contents with regular expressions.",
"Can limit results to a file pattern such as TypeScript or CSS files.",
],
lsp: [
"Use language-server features for code navigation.",
"Supports operations such as definitions, references, and diagnostics.",
],
question: ["Ask the user for a decision during execution.", "Supports single-choice and multi-choice prompts."],
read: [
"Read files, directories, images, and PDFs from the workspace.",
"Supports line offsets and limits for large files.",
],
recall: [
"Search and read previous project conversations.",
"Useful for recovering prior decisions or implementation context.",
],
skill: [
"Load task-specific instructions and workflows.",
"Applies specialized repo guidance when a matching skill exists.",
],
task: [
"Delegate focused exploration or implementation work to a subagent.",
"Useful for parallel codebase research and complex subtasks.",
],
todowrite: [
"Track multi-step implementation work.",
"Shows progress with pending, in-progress, and completed states.",
],
webfetch: ["Fetch and convert web pages for analysis.", "Supports markdown, text, and HTML output formats."],
websearch: ["Search the web for external information.", "Supports fast, automatic, and deep search modes."],
write: ["Create or overwrite files with complete contents.", "Best for new files or full-file replacements."],
}
const acronyms = new Set(["api", "html", "id", "json", "lsp", "mcp", "pdf", "url"])
export function clean(input: string) {
return input.trim()
}
export function text(input: unknown): string {
if (input === undefined || input === null || input === "") return "Not set"
if (typeof input === "string") return input
if (typeof input === "number" || typeof input === "boolean") return String(input)
return JSON.stringify(input) ?? "Unserializable value"
}
export function sorted(input: Iterable<string>) {
return [...input].sort((a, b) => a.localeCompare(b))
}
export function words(input: string) {
return input
.split(/\s+/)
.map((w) => w.trim())
.filter(Boolean)
}
export function friendly(input: string) {
return input
.trim()
.split(/[\s._/-]+/)
.filter(Boolean)
.map((part) => {
if (/^[A-Z0-9]+$/.test(part) && part.length <= 4) return part
return `${part[0]?.toUpperCase() ?? ""}${part.slice(1).toLowerCase()}`
})
.join(" ")
}
function word(input: string) {
const lower = input.toLowerCase()
if (acronyms.has(lower)) return lower.toUpperCase()
return input.charAt(0).toUpperCase() + input.slice(1)
}
function clip(input: string) {
if (input.length <= 180) return input
return `${input.slice(0, 177)}...`
}
function summary(input: string) {
const value = input
.replace(/```[\s\S]*?```/g, " ")
.replace(/[*`#>_-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
const parts = value
.split(/(?:\.|\?|!)\s+/)
.map((part) => part.trim())
.filter(Boolean)
.slice(0, 2)
if (parts.length) return parts.map(clip)
return value ? [clip(value)] : []
}
export function toolName(id: string) {
const known = labels[id]
if (known) return known
const value = id
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[._:/-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
if (!value) return id
return value.split(" ").map(word).join(" ")
}
export function toolCapabilities(item: ToolItem) {
const known = copy[item.id]
if (known) return known
const desc = item.detail?.description.trim()
if (desc) return summary(desc)
return ["No capability description is available for this tool."]
}
export function csv(input: string) {
return input
.split(",")
.map((w) => w.trim())
.filter(Boolean)
}
export function size(input: unknown) {
if (!input || typeof input !== "object" || Array.isArray(input)) return 0
return Object.keys(input).length
}
export function catalog(data: Snapshot) {
return data.providers.all.flatMap((provider) =>
Object.values(provider.models).map((model) => ({
id: `${provider.id}/${model.id}`,
provider,
model,
})),
)
}
export function providerState(provider: Provider, data: Snapshot) {
if (data.effective.disabled_providers?.includes(provider.id)) return "disabled"
if (data.providers.failed.includes(provider.id)) return "failed"
if (data.providers.connected.includes(provider.id)) return "connected"
if (provider.source === "env" || provider.source === "config" || provider.source === "custom") return "configured"
return "available"
}
export function fmtRecord(input: Snapshot["effective"]["formatter"]): FormatterMap {
if (input && typeof input === "object") return input
return {}
}
export function lspRecord(input: Snapshot["effective"]["lsp"]): LspMap {
if (input && typeof input === "object") return input
return {}
}
export function dupBindings(data: Snapshot, key: string, value: string) {
if (!value) return []
return Object.entries(data.tui.keybinds ?? {})
.filter(([name, binding]) => name !== key && binding === value)
.map(([name]) => name)
}
export function errMsg(input: unknown) {
if (input instanceof Error) return input.message
if (typeof input === "string") return input
return text(input)
}
export function toScope(input: string | null): "global" | "project" {
if (input === "global") return "global"
return "project"
}
export function toMode(input: string): "primary" | "subagent" | "all" {
if (input === "subagent") return "subagent"
if (input === "all") return "all"
return "primary"
}
export function toAction(input: string): "ask" | "allow" | "deny" {
if (input === "allow") return "allow"
if (input === "deny") return "deny"
return "ask"
}
+17
View File
@@ -0,0 +1,17 @@
@import "./styles/base.css";
@import "./styles/config.css";
@import "./styles/cli-ui.css";
@import "./styles/resolved.css";
@import "./styles/overview.css";
@import "./styles/providers.css";
@import "./styles/mcp.css";
@import "./styles/permissions.css";
@import "./styles/formatters.css";
@import "./styles/keybinds.css";
@import "./styles/models.css";
@import "./styles/agents-tools.css";
@import "./styles/projects.css";
@import "./styles/project-console.css";
@import "./styles/servers.css";
@import "./styles/sources.css";
@import "./styles/responsive.css";
@@ -0,0 +1,394 @@
.kilo-console .agents {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kilo-console .tools {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kilo-console .tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.kilo-console .tools [data-component="config-row"] {
align-items: flex-start;
}
.kilo-console .agent-tags {
flex-wrap: wrap;
gap: 0.5rem;
}
.kilo-console .agents [data-component="config-row"] {
align-items: flex-start;
}
.kilo-console .agents [data-slot="config-row-subtitle"] {
overflow: visible;
white-space: normal;
}
.kilo-console .agent-subtitle {
display: grid;
gap: 0.125rem;
min-width: 0;
}
.kilo-console .agent-id {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .agent-description {
overflow-wrap: anywhere;
white-space: normal;
}
.kilo-console .agent-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kilo-console .agent-section [data-component="section-title"] {
margin: 0.75rem 0 0.25rem;
}
.kilo-console .agent-mode-dot {
margin-top: 0.45rem;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--muted-foreground);
}
.kilo-console .agent-mode-dot[data-mode="primary"] {
background: var(--primary);
}
.kilo-console .agent-mode-dot[data-mode="all"] {
background: var(--info);
}
.kilo-console .tools [data-slot="config-row-subtitle"] {
overflow: visible;
white-space: normal;
}
.kilo-console .tool-subtitle {
display: grid;
gap: 0.125rem;
min-width: 0;
}
.kilo-console .tool-id {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .tool-description {
overflow-wrap: anywhere;
white-space: normal;
}
.kilo-console .tool-section {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.kilo-console .provider-drawer[aria-label="Agent tool access selector"] .drawer-header span,
.kilo-console .provider-drawer[aria-label="Agent tool access selector"] .tool-capabilities {
color: var(--muted-foreground);
font-size: 0.6875rem;
line-height: 1.45;
}
.kilo-console .provider-drawer[aria-label="Agent tool access selector"] .tool-capabilities {
margin: 0;
padding-left: 0;
}
@media (max-width: 980px) {
.kilo-console .tool-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.kilo-console .advanced {
display: grid;
gap: 1.25rem;
}
.kilo-console .advanced section {
display: grid;
gap: 0.75rem;
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-lg);
padding: 1rem;
}
.kilo-console .advanced h3 {
margin: 0;
color: var(--text-strong);
}
.kilo-console .builder {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
}
.kilo-console .agent-builder-stack {
display: grid;
gap: 0.75rem;
}
.kilo-console .agent-builder-card {
border-color: var(--border);
background: var(--card);
}
.kilo-console .agent-builder-form {
grid-template-columns: 1fr;
gap: 0.625rem;
}
.kilo-console .agent-builder-field {
background: color-mix(in oklab, var(--card) 72%, transparent);
}
.kilo-console .agent-builder-field-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
min-width: 0;
}
.kilo-console .agent-builder-field-head > div:not(.agent-builder-field-actions) {
display: grid;
gap: 0.125rem;
min-width: 0;
}
.kilo-console .agent-builder-field-head span,
.kilo-console .agent-builder-field > span {
color: var(--foreground);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.45;
}
.kilo-console .agent-builder-field-head small {
color: var(--muted-foreground);
font-size: 0.6875rem;
line-height: 1.45;
}
.kilo-console .agent-builder-field-head > .agent-builder-field-actions {
display: flex;
gap: 0.375rem;
align-items: center;
flex: 0 0 auto;
flex-direction: row;
flex-wrap: nowrap;
}
.kilo-console .agent-builder-control {
display: grid;
gap: 0.375rem;
min-width: 0;
}
.kilo-console .agent-builder-control input,
.kilo-console .agent-builder-control select,
.kilo-console .agent-builder-control textarea,
.kilo-console .agent-builder-field input,
.kilo-console .agent-builder-field select {
width: 100%;
}
.kilo-console .agent-builder-control > strong,
.kilo-console .agent-builder-control > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .agent-builder-control > strong {
color: var(--foreground);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.45;
}
.kilo-console .agent-builder-control > span {
color: var(--muted-foreground);
font-size: 0.6875rem;
line-height: 1.45;
}
.kilo-console .agent-prompt-input {
min-height: 16rem;
resize: vertical;
}
.kilo-console .agent-builder-field input[readonly],
.kilo-console .agent-builder-field textarea[readonly] {
border-color: var(--border);
background: color-mix(in oklab, var(--muted) 45%, transparent);
color: var(--muted-foreground);
cursor: default;
}
.kilo-console .agent-builder-control .tag-cloud {
justify-content: flex-start;
}
.kilo-console .agent-tool-summary {
margin-top: 0.15rem;
}
.kilo-console .wide {
grid-column: 1 / -1;
}
.kilo-console .agent-color-control {
display: grid;
gap: 0.5rem;
}
.kilo-console .agent-color-swatches {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.kilo-console .agent-color-swatch {
width: 1.375rem;
height: 1.375rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.kilo-console .agent-color-swatch.selected {
outline: 2px solid var(--foreground);
outline-offset: 1px;
}
.kilo-console .agent-color-swatch:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.kilo-console .agent-snippets {
display: grid;
gap: 0.625rem;
border-top: 1px solid var(--border);
padding-top: 0.75rem;
}
.kilo-console .block-title {
display: grid;
gap: 0.125rem;
}
.kilo-console .block-title strong {
color: var(--foreground);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.45;
}
.kilo-console .block-title span {
overflow: hidden;
color: var(--muted-foreground);
font-size: 0.6875rem;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .snippet-list {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.kilo-console .snippet-list button {
max-width: 100%;
white-space: normal;
}
.kilo-console .markdown-preview {
margin: 0;
overflow: auto;
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-lg);
background: var(--background-stronger);
color: var(--text-strong);
font-size: var(--font-size-small);
line-height: 1.5;
padding: 1rem;
white-space: pre-wrap;
}
.kilo-console .markdown-picker .markdown-preview {
min-height: 100%;
}
.kilo-console .compact {
grid-template-columns: repeat(3, minmax(8rem, 1fr)) auto;
margin-bottom: 0;
}
.kilo-console .mini-list {
display: grid;
gap: 0.5rem;
}
.kilo-console .mini-list.columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.kilo-console .mini-list.spaced {
margin-top: 0.75rem;
}
.kilo-console .mini-item {
display: grid;
grid-template-columns: minmax(12rem, 1fr) auto auto;
gap: 0.75rem;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0.65rem;
}
.kilo-console .mini-item.simple {
grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 1.4fr) auto;
}
.kilo-console .mini-item span {
overflow: hidden;
color: var(--text-weaker);
font-size: var(--font-size-small);
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .tag-cloud {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}

Some files were not shown because too many files have changed in this diff Show More