diff --git a/.changeset/calm-agent-variant-clear.md b/.changeset/calm-agent-variant-clear.md deleted file mode 100644 index 9d29ee4c074..00000000000 --- a/.changeset/calm-agent-variant-clear.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Allow clearing agent model and variant overrides from settings. diff --git a/.changeset/calm-background-process-scans.md b/.changeset/calm-background-process-scans.md deleted file mode 100644 index 3b32864b9fa..00000000000 --- a/.changeset/calm-background-process-scans.md +++ /dev/null @@ -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. diff --git a/.changeset/calm-kilos-reconnect.md b/.changeset/calm-kilos-reconnect.md deleted file mode 100644 index 950defda6b8..00000000000 --- a/.changeset/calm-kilos-reconnect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show a retryable connection error and preserve unsent prompts when the VS Code background CLI process exits. diff --git a/.changeset/calm-marketplace-skills-install.md b/.changeset/calm-marketplace-skills-install.md new file mode 100644 index 00000000000..e97b8182c8a --- /dev/null +++ b/.changeset/calm-marketplace-skills-install.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Make Marketplace skill installation resilient to missing project directories and overlapping install attempts. diff --git a/.changeset/console-diff-refresh.md b/.changeset/console-diff-refresh.md new file mode 100644 index 00000000000..55389527fd9 --- /dev/null +++ b/.changeset/console-diff-refresh.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep Kilo Console terminals and worktree changes visible while refreshing diffs. diff --git a/.changeset/console-favicon.md b/.changeset/console-favicon.md new file mode 100644 index 00000000000..6b02af98f89 --- /dev/null +++ b/.changeset/console-favicon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Use the updated favicon in Kilo Console. diff --git a/.changeset/opus-4-8-adaptive-thinking.md b/.changeset/opus-4-8-adaptive-thinking.md deleted file mode 100644 index 30b7026287b..00000000000 --- a/.changeset/opus-4-8-adaptive-thinking.md +++ /dev/null @@ -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. diff --git a/.changeset/quiet-dancers-breathe.md b/.changeset/quiet-dancers-breathe.md deleted file mode 100644 index f432d62cc1b..00000000000 --- a/.changeset/quiet-dancers-breathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Fix queued plan prompts stalling in VS Code after a completed turn. diff --git a/.changeset/shell-permission-descriptions.md b/.changeset/shell-permission-descriptions.md new file mode 100644 index 00000000000..91d3be3d9ff --- /dev/null +++ b/.changeset/shell-permission-descriptions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show shell command descriptions in permission approval prompts. diff --git a/.changeset/stable-tool-scroll.md b/.changeset/stable-tool-scroll.md new file mode 100644 index 00000000000..715e0c06482 --- /dev/null +++ b/.changeset/stable-tool-scroll.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep chat auto-scroll stable while edit, write, and patch tools hand off between assistant steps. diff --git a/.changeset/steady-review-scroll.md b/.changeset/steady-review-scroll.md deleted file mode 100644 index aba17d76729..00000000000 --- a/.changeset/steady-review-scroll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Preserve the Changes review scroll position while agents update files. diff --git a/.changeset/steady-session-forks.md b/.changeset/steady-session-forks.md new file mode 100644 index 00000000000..5e288868734 --- /dev/null +++ b/.changeset/steady-session-forks.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Restore full-session forks in Agent Manager after the HTTP API migration. diff --git a/.changeset/tui-submodule-session-list.md b/.changeset/tui-submodule-session-list.md deleted file mode 100644 index fcb2ecbf3fc..00000000000 --- a/.changeset/tui-submodule-session-list.md +++ /dev/null @@ -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 (`/.git/modules/`) 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. diff --git a/.changeset/vscode-lazy-historical-tool-details.md b/.changeset/vscode-lazy-historical-tool-details.md new file mode 100644 index 00000000000..c431fb3de1f --- /dev/null +++ b/.changeset/vscode-lazy-historical-tool-details.md @@ -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. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7db6f7ac68..a90022ec66e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 6717c0fff59..37fb326f061 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -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: | diff --git a/.gitignore b/.gitignore index 812dece5848..5611c39a766 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .worktrees .sst .env +.env.local .idea/* !.idea/gradle.xml !.idea/.run diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000000..cc01a286fb7 --- /dev/null +++ b/.gitleaksignore @@ -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 diff --git a/.opencode-version b/.opencode-version index 2aa995bb4eb..5ceca955de6 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.14.41 +v1.14.42 diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 30c4d882b90..f126a43c0f9 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -18,4 +18,5 @@ "github-triage": false, "github-pr-search": false, }, + "disabled_providers": [], } diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 63f9f331e04..2d3095a57cf 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -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 + +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 => + !!value && typeof value === "object" && !Array.isArray(value) type Cfg = { label: string route: string vignette: number - keybinds: Record | undefined + keybinds: SmokeBindings | undefined } type Route = { @@ -74,7 +99,7 @@ const cfg = (options: Record | 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 + 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: { {props.input.label} screen plugin route - {props.keys.print("home")} home + {shortcuts().screen_home} home @@ -477,7 +556,7 @@ const Screen = (props: { Counter: {value.count} - {props.keys.print("up")} / {props.keys.print("down")} change value + {shortcuts().screen_up} / {shortcuts().screen_down} change value ) : null} @@ -485,17 +564,16 @@ const Screen = (props: { {value.tab === 2 ? ( - {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 - {props.keys.print("local")} local stack | {props.keys.print("host")} host stack + {shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack - 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 - {props.keys.print("home")} returns home + {shortcuts().screen_home} returns home ) : null} @@ -548,7 +626,7 @@ const Screen = (props: { Plugin-owned stack depth: {value.local} - {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close + {shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close @@ -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: { {props.input.label} modal - {props.keys.print("modal")} modal command - {props.keys.print("screen")} screen command + {shortcuts().modal} modal command + {shortcuts().screen} screen command - {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes + {shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes ({ }, 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, - ) => 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) diff --git a/.opencode/tui.json b/.opencode/tui.json index 1eee01b3022..bb472d8e23b 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -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": {} } diff --git a/bun.lock b/bun.lock index 858138b896f..3938c57547f 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/nix/hashes.json b/nix/hashes.json index 2e1078f68d6..db617d736bd 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/package.json b/package.json index 43c299e393e..2c24470b0cf 100644 --- a/package.json +++ b/package.json @@ -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": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 3875f8a167a..ddfaf9d4af0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 6cbc94e43a9..262d8919cf0 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -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"), diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 223322300fa..9c923f826bd 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -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 { @@ -78,6 +81,7 @@ export function make(input: Partial = {}): Interface { tmp: Path.tmp, bin: Path.bin, log: Path.log, + repos: Path.repos, ...input, } } diff --git a/packages/core/test/effect/cross-spawn-spawner.test.ts b/packages/core/test/effect/cross-spawn-spawner.test.ts index 627db8ef36b..e394d43bf84 100644 --- a/packages/core/test/effect/cross-spawn-spawner.test.ts +++ b/packages/core/test/effect/cross-spawn-spawner.test.ts @@ -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)) }), ) diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts index 4e13e884243..0d236a1309e 100644 --- a/packages/core/test/global.test.ts +++ b/packages/core/test/global.test.ts @@ -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) }) diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 76cee4f8e02..3473ae5b491 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -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, Promise>() + 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) { - if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() +async function stopWorker(proc: ReturnType) { + 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((resolve) => { - const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) - killProc.on("close", () => { - proc.kill() - resolve() - }) + await new Promise((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") diff --git a/packages/core/test/util/flock.test.ts b/packages/core/test/util/flock.test.ts index e1b647b6480..b4de62b1dff 100644 --- a/packages/core/test/util/flock.test.ts +++ b/packages/core/test/util/flock.test.ts @@ -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, Promise>() + 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) { - if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve() +async function stopWorker(proc: ReturnType) { + 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((resolve) => { - const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]) - killProc.on("close", () => { - proc.kill() - resolve() - }) + await new Promise((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(p: string): Promise { 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( diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index f1f65868847..6a2272ebf34 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json new file mode 100644 index 00000000000..6c852c38d35 --- /dev/null +++ b/packages/http-recorder/package.json @@ -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:" + } +} diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts new file mode 100644 index 00000000000..769bcc7c70d --- /dev/null +++ b/packages/http-recorder/src/cassette.ts @@ -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 + readonly write: (name: string, cassette: Cassette) => Effect.Effect + readonly append: ( + name: string, + interaction: Interaction, + metadata: CassetteMetadata | undefined, + ) => Effect.Effect< + { + readonly cassette: Cassette + readonly findings: ReadonlyArray + }, + PlatformError.PlatformError + > + readonly exists: (name: string) => Effect.Effect + readonly list: () => Effect.Effect, PlatformError.PlatformError> + readonly scan: (cassette: Cassette) => ReadonlyArray +} + +export class Service extends Context.Service()("@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>()) + + const pathFor = (name: string) => cassettePath(name, directory) + + const walk = (directory: string): Effect.Effect, 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" diff --git a/packages/http-recorder/src/diff.ts b/packages/http-recorder/src/diff.ts new file mode 100644 index 00000000000..29517befcbd --- /dev/null +++ b/packages/http-recorder/src/diff.ts @@ -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 => { + 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)[key], + (received as Record)[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, received: Record) => + [...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, + ) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts new file mode 100644 index 00000000000..f103e45dc7b --- /dev/null +++ b/packages/http-recorder/src/effect.ts @@ -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 = ["content-type", "accept", "openai-beta"] +const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["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 + readonly query?: ReadonlyArray + readonly url?: (url: string) => string + } + readonly requestHeaders?: ReadonlyArray + readonly responseHeaders?: ReadonlyArray + readonly redactBody?: (body: unknown) => unknown + readonly dispatch?: "match" | "sequential" + readonly match?: RequestMatcher +} + +const responseHeaders = ( + response: HttpClientResponse.HttpClientResponse, + allow: ReadonlyArray, + redact: ReadonlyArray | undefined, +) => { + const merged = redactHeaders(response.headers as Record, allow, redact) + if (!merged["content-type"]) merged["content-type"] = "text/event-stream" + return merged +} + +const BINARY_CONTENT_TYPES: ReadonlyArray = ["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, +) => + 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 = {}, +): Layer.Layer => + 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(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 => + recordingLayer(name, options).pipe( + Layer.provide(CassetteService.layer({ directory: options.directory })), + Layer.provide(FetchHttpClient.layer), + Layer.provide(NodeFileSystem.layer), + ) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts new file mode 100644 index 00000000000..d85e13bf4c0 --- /dev/null +++ b/packages/http-recorder/src/index.ts @@ -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 "." diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts new file mode 100644 index 00000000000..b66c8fd1467 --- /dev/null +++ b/packages/http-recorder/src/matching.ts @@ -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 => + 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) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts new file mode 100644 index 00000000000..3a8b0978397 --- /dev/null +++ b/packages/http-recorder/src/redaction.ts @@ -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 | undefined, defaults: ReadonlyArray) => + new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) + +export type UrlRedactor = (url: string) => string + +export const redactUrl = ( + raw: string, + query: ReadonlyArray = 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, + allow: ReadonlyArray, + redact: ReadonlyArray = 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 => + 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) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts new file mode 100644 index 00000000000..2692b525b4a --- /dev/null +++ b/packages/http-recorder/src/schema.ts @@ -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 + +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 + +export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown) +export type CassetteMetadata = Schema.Schema.Type + +export const HttpInteractionSchema = Schema.Struct({ + transport: Schema.tag("http"), + request: RequestSnapshotSchema, + response: ResponseSnapshotSchema, +}) +export type HttpInteraction = Schema.Schema.Type + +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 + +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 + +export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( + Schema.toTaggedUnion("transport"), +) +export type Interaction = Schema.Schema.Type + +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 + +export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) +export const encodeCassette = Schema.encodeSync(CassetteSchema) diff --git a/packages/http-recorder/src/storage.ts b/packages/http-recorder/src/storage.ts new file mode 100644 index 00000000000..08dadb1bb9a --- /dev/null +++ b/packages/http-recorder/src/storage.ts @@ -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, + 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"))) +} diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts new file mode 100644 index 00000000000..8a854cb62c6 --- /dev/null +++ b/packages/http-recorder/src/websocket.ts @@ -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 = ["content-type", "accept", "openai-beta"] + +export interface WebSocketRequest { + readonly url: string + readonly headers: Headers.Headers +} + +export interface WebSocketConnection { + readonly sendText: (message: string) => Effect.Effect + readonly messages: Stream.Stream + readonly close: Effect.Effect +} + +export interface WebSocketExecutor { + readonly open: (request: WebSocketRequest) => Effect.Effect, E> +} + +export interface WebSocketRecordReplayOptions { + readonly name: string + readonly mode?: "record" | "replay" | "passthrough" + readonly metadata?: CassetteMetadata + readonly cassette: CassetteService.Interface + readonly live: WebSocketExecutor + readonly redact?: { + readonly headers?: ReadonlyArray + readonly query?: ReadonlyArray + readonly url?: (url: string) => string + } + readonly requestHeaders?: ReadonlyArray + readonly compareClientMessagesAsJson?: boolean +} + +const headersRecord = (headers: Headers.Headers) => + Object.fromEntries( + Object.entries(headers as Record) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .toSorted(([a], [b]) => a.localeCompare(b)), + ) + +const openSnapshot = ( + request: WebSocketRequest, + options: Pick, "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) => + 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 = ( + options: WebSocketRecordReplayOptions, +): Effect.Effect, 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 } | 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, + ) + }), + } + }) + }, + } + }) diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json b/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json new file mode 100644 index 00000000000..9953b860cd9 --- /dev/null +++ b/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json @@ -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\"}" + } + } + ] +} diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json b/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json new file mode 100644 index 00000000000..873e5a16c05 --- /dev/null +++ b/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json @@ -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\"}" + } + } + ] +} diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts new file mode 100644 index 00000000000..676422e6a49 --- /dev/null +++ b/packages/http-recorder/test/record-replay.test.ts @@ -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 = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step")))) + +const runWith = ( + name: string, + options: HttpRecorder.RecordReplayOptions, + effect: Effect.Effect, +) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options)))) + +const runRecorder = (effect: Effect.Effect) => + 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) => { + 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 = [] + 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") + }), + ) + }) +}) diff --git a/packages/http-recorder/tsconfig.json b/packages/http-recorder/tsconfig.json new file mode 100644 index 00000000000..81e63123058 --- /dev/null +++ b/packages/http-recorder/tsconfig.json @@ -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/*"] + } + ] + } +} diff --git a/packages/kilo-console/index.html b/packages/kilo-console/index.html new file mode 100644 index 00000000000..d984e41f4d7 --- /dev/null +++ b/packages/kilo-console/index.html @@ -0,0 +1,14 @@ + + + + + + + + Kilo Console + + +
+ + + diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json new file mode 100644 index 00000000000..45539070c38 --- /dev/null +++ b/packages/kilo-console/package.json @@ -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:" + } +} diff --git a/packages/kilo-console/public/favicon.ico b/packages/kilo-console/public/favicon.ico new file mode 100644 index 00000000000..777c633b0b5 Binary files /dev/null and b/packages/kilo-console/public/favicon.ico differ diff --git a/packages/kilo-console/public/favicon.svg b/packages/kilo-console/public/favicon.svg new file mode 100644 index 00000000000..c4b6bb8ba46 --- /dev/null +++ b/packages/kilo-console/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/kilo-console/src/App.tsx b/packages/kilo-console/src/App.tsx new file mode 100644 index 00000000000..0f07de0b232 --- /dev/null +++ b/packages/kilo-console/src/App.tsx @@ -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 ( + + {props.children} + + ) +} diff --git a/packages/kilo-console/src/client.test.ts b/packages/kilo-console/src/client.test.ts new file mode 100644 index 00000000000..5932d3ad98c --- /dev/null +++ b/packages/kilo-console/src/client.test.ts @@ -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"]] }) +}) diff --git a/packages/kilo-console/src/client.ts b/packages/kilo-console/src/client.ts new file mode 100644 index 00000000000..b401c74e11a --- /dev/null +++ b/packages/kilo-console/src/client.ts @@ -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 + +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 + +export type ConfigUnset = string[][] +export type ModelRef = ConfigModelStateResponse["favorite"][number] + +export type TuiPatch = Partial + +export type AgentPayload = { + scope: Scope + id: string + description?: string + mode: "primary" | "subagent" | "all" + model?: string + color?: string + steps?: number + tools?: string[] + permission?: Record + prompt: string +} + +type Result = { + 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 } = {} + +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(label: string, result: Result) { + 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(label: string, result: Promise>) { + 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) { + 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 + 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 { + 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 { + 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 { + 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 { + const items = await loadProjects(input) + return items.filter(visible) +} + +export async function loadRecentProjects(input: ProjectQuery): Promise { + 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() + + 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 { + 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 { + 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 { + 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() + + 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) { + return new Set(items.map((item) => item.sessionID)) +} + +function requested(items: Array, open: Set) { + return items.some((item) => open.has(item.sessionID)) +} + +export type ProjectLiveStatus = { + busy: boolean + attention: boolean +} + +export async function loadProjectLiveStatus(input: ProjectQuery, dir: string): Promise { + 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, questions: Set) { + if (permissions.has(id)) return "permission" + if (questions.has(id)) return "question" + return undefined +} + +export async function loadProjectDiff(input: Query, dir: string): Promise { + 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 { + 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 { + 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) { + 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) { + 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, +): Promise { + 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 { + 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 { + 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) +} diff --git a/packages/kilo-console/src/components/ConfirmDialog.tsx b/packages/kilo-console/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000000..6fba52e63a8 --- /dev/null +++ b/packages/kilo-console/src/components/ConfirmDialog.tsx @@ -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 ( + +
+
+
+ +
+

{props.title}

+ {(text) =>

{text()}

}
+
+
+
+ + +
+
+
+
+ ) +} diff --git a/packages/kilo-console/src/components/CustomSelect.tsx b/packages/kilo-console/src/components/CustomSelect.tsx new file mode 100644 index 00000000000..e440efc7776 --- /dev/null +++ b/packages/kilo-console/src/components/CustomSelect.tsx @@ -0,0 +1,65 @@ +import { For } from "solid-js" + +export type SelectOption = { + value: T + label: string + disabled?: boolean +} + +export function CustomSelect(props: { + label: string + value: T + options: readonly SelectOption[] + 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, 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 ( +
+ + {current()} + +
+ + {(option) => ( + + )} + +
+
+ ) +} diff --git a/packages/kilo-console/src/components/SearchField.tsx b/packages/kilo-console/src/components/SearchField.tsx new file mode 100644 index 00000000000..f6c2b0c3cc9 --- /dev/null +++ b/packages/kilo-console/src/components/SearchField.tsx @@ -0,0 +1,45 @@ +import { splitProps, type ComponentProps } from "solid-js" + +type Props = Omit, "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 ( + + ) +} diff --git a/packages/kilo-console/src/components/app-header/AppHeader.tsx b/packages/kilo-console/src/components/app-header/AppHeader.tsx new file mode 100644 index 00000000000..7721ddc8447 --- /dev/null +++ b/packages/kilo-console/src/components/app-header/AppHeader.tsx @@ -0,0 +1,26 @@ +import { IconButton } from "@kilocode/kilo-web-ui/icon-button" +import { OmniSearch } from "./OmniSearch" + +export function AppHeader() { + return ( +
+ + + + Kilo + Console + + + + + + +
+ ) +} diff --git a/packages/kilo-console/src/components/app-header/OmniSearch.tsx b/packages/kilo-console/src/components/app-header/OmniSearch.tsx new file mode 100644 index 00000000000..5051013fc66 --- /dev/null +++ b/packages/kilo-console/src/components/app-header/OmniSearch.tsx @@ -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(() => { + 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 ( +
(box = node)} + class="omni-search" + classList={{ expanded: open() }} + role="search" + onKeyDown={keys} + onSubmit={submit} + > +
{ + if (event.target instanceof HTMLButtonElement) return + focus() + }} + > + + (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) + }} + /> + +
+ + +
+ 0} fallback={
No matches.
}> + + {(item, index) => ( + + )} + +
+
+ +
+ + ↑ + ↓ + Navigate + + + ↵ + Open + +
+
+
+ ) +} diff --git a/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx b/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx new file mode 100644 index 00000000000..9f25981aa6a --- /dev/null +++ b/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx @@ -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 ( + + ) + } + + if (props.name === "settings") { + return ( + + ) + } + + return ( + + ) +} + +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(() => { + const target = clean(url()) || fallback() + if (!target) return undefined + return { url: target, dir: "" } + }) + const [items, { refetch }] = createResource(query, loadRecentProjects) + const checks = new Map() + + // 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).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).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 ( + + ) +} diff --git a/packages/kilo-console/src/context/ConfigProvider.tsx b/packages/kilo-console/src/context/ConfigProvider.tsx new file mode 100644 index 00000000000..15e0438e1ee --- /dev/null +++ b/packages/kilo-console/src/context/ConfigProvider.tsx @@ -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(() => (route().startsWith("/projects/") ? "project" : "global")) + const [saving, setSaving] = createSignal() + const [failure, setFailure] = createSignal() + 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(() => { + 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, 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) { + 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 {props.children} +} diff --git a/packages/kilo-console/src/context/config.tsx b/packages/kilo-console/src/context/config.tsx new file mode 100644 index 00000000000..5570009a97a --- /dev/null +++ b/packages/kilo-console/src/context/config.tsx @@ -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 + query: Accessor + saving: Accessor + failure: Accessor + + target: () => Query + fail: (message: string) => void + run: (label: string, job: () => Promise, task?: Task) => void + save: (patch: Partial) => void + unset: (paths: ConfigUnset) => void + tui: (patch: TuiPatch) => void +} + +export const ConfigContext = createContext() + +export function useConfig() { + const ctx = useContext(ConfigContext) + if (!ctx) throw new Error("useConfig must be used within ConfigLayout") + return ctx +} diff --git a/packages/kilo-console/src/index.tsx b/packages/kilo-console/src/index.tsx new file mode 100644 index 00000000000..786f9c9cadd --- /dev/null +++ b/packages/kilo-console/src/index.tsx @@ -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) => ) +} + +render( + () => ( + + + + + {routes()} + + + + {routes()} + + + {routes()} + + + + ), + root, +) diff --git a/packages/kilo-console/src/layouts/ConfigLayout.tsx b/packages/kilo-console/src/layouts/ConfigLayout.tsx new file mode 100644 index 00000000000..09ce8141bd8 --- /dev/null +++ b/packages/kilo-console/src/layouts/ConfigLayout.tsx @@ -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 ( + + {props.children} + + ) +} + +function ConfigContent(props: { children?: JSX.Element }) { + const ctx = useConfig() + + return ( +
+ +
+ + {(item) => ( + + )} + + + {(item) => ( + + )} + + + + + + + + {props.children} +
+
+ ) +} diff --git a/packages/kilo-console/src/layouts/ConsoleLayout.tsx b/packages/kilo-console/src/layouts/ConsoleLayout.tsx new file mode 100644 index 00000000000..4ed5148086f --- /dev/null +++ b/packages/kilo-console/src/layouts/ConsoleLayout.tsx @@ -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 ( +
+ +
+ +
{props.children}
+
+
+ ) +} diff --git a/packages/kilo-console/src/routes/config/AgentsRoute.tsx b/packages/kilo-console/src/routes/config/AgentsRoute.tsx new file mode 100644 index 00000000000..bd699e623a7 --- /dev/null +++ b/packages/kilo-console/src/routes/config/AgentsRoute.tsx @@ -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 ( +
+
+
+ {props.label} + {(description) => {description()}} +
+ +
{props.actions}
+
+
+
{props.children}
+
+ ) +} + +function AgentRuleMeta(props: { action: PermissionAction }) { + return ( +
+ {actionLabel(props.action)} +
+ ) +} + +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 ( + + {(_data) => ( + + Agents + {rows().length} + + } + description="Agents are reusable model, prompt, and tool configurations. Primary agents drive sessions; subagents are delegated to." + actions={ + + } + > + + +
+ {empty()}

}> + + {(group) => ( +
+ + {label(group[0]?.rank ?? 1)} + + + {(row) => ( +
+
+ )} +
+
+ )} +
+
+
+
+ )} +
+ ) +} + +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 ( + + {(_data) => ( + + + + + + links.nav(links.href())} + /> + + } + > +
+
+ +
+
+

Identity

+

Name, mode, and display metadata for this agent.

+
+
+
+ + state.setId(event.currentTarget.value)} + /> + + + state.setDesc(event.currentTarget.value)} + /> + + + + + +
+ state.setColor(event.currentTarget.value)} + /> +
+ + {(color) => ( +
+
+
+ + state.setSteps(event.currentTarget.value)} + /> + +
+
+ + +
+
+

Model And Tools

+

Choose the model and default tool access written into the agent frontmatter.

+
+
+
+ + + + + + + } + > + + {state.model() || "Inherit default model"} + {(value) => {value()}} + + } + > + {(model) => ( + <> + {`${model().provider.name} / ${model().model.name}`} + {model().id} + + )} + + + + + + + + + } + > + + No tools selected + No allow permissions will be written. + + } + > + {countTools(state.tools())} +
+ {(tool) => {tool}} +
+
+
+
+
+ + +
+
+

System Prompt

+

Markdown instructions that define how this agent behaves.

+
+
+
+ +