mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
Merge branch 'main' into generated-flame
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep Kilo Console terminals and worktree changes visible while refreshing diffs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Use the updated favicon in Kilo Console.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep chat auto-scroll stable while edit, write, and patch tools hand off between assistant steps.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve the Changes review scroll position while agents update files.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Restore full-session forks in Agent Manager after the HTTP API migration.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -5,6 +5,7 @@ node_modules
|
||||
.worktrees
|
||||
.sst
|
||||
.env
|
||||
.env.local
|
||||
.idea/*
|
||||
!.idea/gradle.xml
|
||||
!.idea/.run
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
v1.14.41
|
||||
v1.14.42
|
||||
|
||||
@@ -18,4 +18,5 @@
|
||||
"github-triage": false,
|
||||
"github-pr-search": false,
|
||||
},
|
||||
"disabled_providers": [],
|
||||
}
|
||||
|
||||
+367
-285
@@ -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
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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 "."
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
+41
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"]] })
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user