Merge branch 'main' into fix/queue-messages

Resolve conflicts in question/index.ts, session/prompt.ts, and KiloProvider.ts to pull in upstream's flattened namespace refactor while preserving the queued-messages feature: Question.dismissAll, KiloQuestion.guardFollowup on ask, and the mid-turn 'break out when a follow-up is queued' check in runLoop. VS Code abortSession keeps its queue-preserving signature (no queuedMessageIDs delete); new autocomplete settings helpers from main are wired in unchanged.
This commit is contained in:
Alex Alecu
2026-04-23 16:44:16 +03:00
231 changed files with 18797 additions and 16509 deletions
-1
View File
@@ -594,7 +594,6 @@ KILO_DISABLE_CLAUDE_CODE
KILO_DISABLE_CLAUDE_CODE_PROMPT
KILO_DISABLE_CLAUDE_CODE_SKILLS
KILO_DISABLE_DEFAULT_PLUGINS
KILO_DISABLE_FILETIME_CHECK
KILO_DISABLE_LSP_DOWNLOAD
KILO_DISABLE_MODELS_FETCH
KILO_DISABLE_PRUNE
+9 -7
View File
@@ -422,8 +422,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -525,16 +525,16 @@
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.99",
"@opentui/solid": ">=0.1.99",
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100",
},
"optionalPeers": [
"@opentui/core",
@@ -569,7 +569,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "7.2.14",
"version": "7.2.20",
"bin": {
"opencode": "./bin/opencode",
},
@@ -703,6 +703,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
+2
View File
@@ -33,6 +33,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/shared/util/path"
import { createEffect, createMemo, For, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
@@ -134,6 +135,7 @@ export function SessionHeader() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
@@ -151,6 +153,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_KILO_CHANNEL === "beta"
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -262,12 +269,16 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err))
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
setRightMount(document.getElementById("opencode-titlebar-right"))
})
return (
<>
<Show when={centerMount()}>
<Show when={search() && centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<Button
@@ -415,24 +426,28 @@ export function SessionHeader() {
</div>
</Show>
<div class="flex items-center gap-1">
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
@@ -451,30 +466,32 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>
@@ -106,6 +106,7 @@ export const SettingsGeneral: Component = () => {
permission.disableAutoAccept(params.id, value)
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
@@ -279,6 +280,74 @@ export const SettingsGeneral: Component = () => {
</div>
)
const AdvancedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
@@ -527,6 +596,7 @@ export const SettingsGeneral: Component = () => {
</div>
)
console.log(import.meta.env)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -609,6 +679,10 @@ export const SettingsGeneral: Component = () => {
)
}}
</Show>
<Show when={desktop() && import.meta.env.VITE_KILO_CHANNEL === "beta"}>
<AdvancedSection />
</Show>
</div>
</div>
)
+6 -4
View File
@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
type TauriDesktopWindow = {
@@ -40,6 +41,7 @@ export function Titlebar() {
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const theme = useTheme()
const navigate = useNavigate()
const location = useLocation()
@@ -78,6 +80,7 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => import.meta.env.VITE_KILO_CHANNEL !== "beta" || settings.general.showNavigation())
const back = () => {
const next = backPath(history)
@@ -255,13 +258,12 @@ export function Titlebar() {
<div
class="flex items-center shrink-0"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects()}>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
@@ -286,7 +288,7 @@ export function Titlebar() {
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{import.meta.env.VITE_KILO_CHANNEL && ["beta", "dev"].includes(import.meta.env.VITE_KILO_CHANNEL) && (
{import.meta.env.VITE_KILO_CHANNEL && ["beta", "dev"].includes(import.meta.env.VITE_KILO_CHANNEL) && ( // kilocode_change
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_KILO_CHANNEL.toUpperCase()}
</div>
+1 -2
View File
@@ -204,7 +204,7 @@ function createGlobalSync() {
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
.ensureQueryData({
.fetchQuery({
...loadSessionsQuery(directory),
queryFn: () =>
loadRootSessionsWithFallback({
@@ -264,7 +264,6 @@ function createGlobalSync() {
children.pin(directory)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
child[1]("bootstrapPromise", promise!)
const cache = children.vcsCache.get(directory)
if (!cache) return
const sdk = sdkFor(directory)
@@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
limit: 5,
message: {},
part: {},
bootstrapPromise: Promise.resolve(),
})
children[directory] = child
disposers.set(directory, dispose)
@@ -72,7 +72,6 @@ export type State = {
part: {
[messageID: string]: Part[]
}
bootstrapPromise: Promise<void>
}
export type VcsCache = {
+30
View File
@@ -23,6 +23,11 @@ export interface Settings {
autoSave: boolean
releaseNotes: boolean
followup: "queue" | "steer"
showFileTree: boolean
showNavigation: boolean
showSearch: boolean
showStatus: boolean
showTerminal: boolean
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
@@ -89,6 +94,11 @@ const defaultSettings: Settings = {
autoSave: true,
releaseNotes: true,
followup: "steer",
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showTerminal: false,
showReasoningSummaries: false,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
@@ -162,6 +172,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
},
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
},
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
setShowNavigation(value: boolean) {
setStore("general", "showNavigation", value)
},
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,
+1 -3
View File
@@ -1,5 +1,3 @@
import "solid-js"
interface ImportMetaEnv {
readonly VITE_KILO_SERVER_HOST: string
readonly VITE_KILO_SERVER_PORT: string
@@ -10,7 +8,7 @@ interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "solid-js" {
export declare module "solid-js" {
namespace JSX {
interface Directives {
sortable: true
+11
View File
@@ -719,6 +719,7 @@ export const dict = {
"settings.desktop.wsl.description": "Run the Kilo server inside WSL on Windows.",
"settings.general.section.appearance": "Appearance",
"settings.general.section.advanced": "Advanced",
"settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
@@ -741,6 +742,16 @@ export const dict = {
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
+179 -181
View File
@@ -13,7 +13,7 @@ import {
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
const theme = useTheme()
const language = useLanguage()
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
@@ -2102,196 +2103,198 @@ export default function Layout(props: ParentProps) {
</Show>
}
>
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
void renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
{(project) => (
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
void renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Tooltip
placement="bottom"
gutter={2}
value={worktree()}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")}
</span>
</Tooltip>
<Tooltip
placement="bottom"
gutter={2}
value={worktree()}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}}
>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
icon="plus-small"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
const item = project()
if (!item) return
void createWorkspace(item)
}}
>
{language.t("command.session.new")}
{language.t("workspace.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
void createWorkspace(item)
}}
>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
</Show>
</div>
</>
)}
</Show>
<div
@@ -2355,14 +2358,9 @@ export default function Layout(props: ParentProps) {
/>
)
const [loading] = createResource(
() => route()?.store?.[0]?.bootstrapPromise,
(p) => p,
)
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{(autoselecting(), loading()) ?? ""}
{autoselecting() ?? ""}
<Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
})
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false)
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
const loading = () => query.isLoading
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={showNew}
loading={loading}
loading={() => query.isLoading && count() === 0}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const loading = createMemo(() => query.isPending && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => query.isLoading && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree)
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={() => false}
loading={() => query.isLoading}
loading={loading}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
+45 -133
View File
@@ -1,6 +1,6 @@
import type { Project, UserMessage, VcsFileDiff } from "@kilocode/sdk/v2"
import type { Project, UserMessage } from "@kilocode/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
batch,
onCleanup,
@@ -324,6 +324,7 @@ export default function Page() {
const local = useLocal()
const file = useFile()
const sync = useSync()
const queryClient = useQueryClient()
const dialog = useDialog()
const language = useLanguage()
const sdk = useSDK()
@@ -518,26 +519,6 @@ export default function Page() {
deferRender: false,
})
const [vcs, setVcs] = createStore<{
diff: {
git: VcsFileDiff[]
branch: VcsFileDiff[]
}
ready: {
git: boolean
branch: boolean
}
}>({
diff: {
git: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
},
ready: {
git: false,
branch: false,
},
})
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
createStore<{
@@ -571,68 +552,6 @@ export default function Page() {
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const bumpVcs = (mode: VcsMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}
const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}
const loadVcs = (mode: VcsMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()
if (force) {
if (vcsTask.has(mode)) bumpVcs(mode)
vcsTask.delete(mode)
setVcs("ready", mode, false)
}
const current = vcsTask.get(mode)
if (current) return current
const run = bumpVcs(mode)
const task = sdk.client.vcs
.diff({ mode })
.then((result) => {
if (vcsRun.get(mode) !== run) return
setVcs("diff", mode, list(result.data))
setVcs("ready", mode, true)
})
.catch((error) => {
if (vcsRun.get(mode) !== run) return
console.debug("[session-review] failed to load vcs diff", { mode, error })
setVcs("diff", mode, [])
setVcs("ready", mode, true)
})
.finally(() => {
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
})
vcsTask.set(mode, task)
return task
}
const refreshVcs = () => {
resetVcs()
const mode = untrack(vcsMode)
if (!mode) return
if (!untrack(wantsReview)) return
void loadVcs(mode, true)
}
createComputed((prev) => {
const open = desktopReviewOpen()
@@ -663,21 +582,52 @@ export default function Page() {
list.push("turn")
return list
})
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return list(vcs.diff.git)
if (store.changes === "branch") return list(vcs.diff.branch)
const vcsKey = createMemo(
() => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const,
)
const vcsQuery = createQuery(() => {
const mode = vcsMode()
const enabled = wantsReview() && sync.project?.vcs === "git"
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
staleTime: Number.POSITIVE_INFINITY,
gcTime: 60 * 1000,
queryFn: mode
? () =>
sdk.client.vcs
.diff({ mode })
.then((result) => list(result.data))
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
})
: skipToken,
}
})
const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() })
const reviewDiffs = () => {
if (store.changes === "git" || store.changes === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return turnDiffs()
})
const reviewCount = createMemo(() => reviewDiffs().length)
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
}
const reviewCount = () => reviewDiffs().length
const hasReview = () => reviewCount() > 0
const reviewReady = () => {
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
return true
})
}
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
@@ -897,27 +847,6 @@ export default function Page() {
),
)
createEffect(
on(
() => sdk.directory,
() => {
resetVcs()
},
{ defer: true },
),
)
createEffect(
on(
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
(next, prev) => {
if (prev === undefined || same(next, prev)) return
refreshVcs()
},
{ defer: true },
),
)
const stopVcs = sdk.event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
const props =
@@ -1051,13 +980,6 @@ export default function Page() {
}
}
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const wantsReview = createMemo(() =>
isDesktop()
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
: store.mobileTab === "changes",
)
createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
@@ -1066,22 +988,12 @@ export default function Page() {
setStore("changes", next)
})
createEffect(() => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
void loadVcs(mode)
})
createEffect(
on(
() => sync.data.session_status[params.id ?? ""]?.type,
(next, prev) => {
const mode = vcsMode()
if (!mode) return
if (!wantsReview()) return
if (next !== "idle" || prev === undefined || prev === "idle") return
void loadVcs(mode, true)
refreshVcs()
},
{ defer: true },
),
@@ -19,6 +19,9 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
@@ -39,6 +42,9 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const platform = usePlatform()
const settings = useSettings()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -46,9 +52,15 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(
() =>
platform.platform !== "desktop" ||
import.meta.env.VITE_KILO_CHANNEL !== "beta" ||
settings.general.showFileTree(),
)
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
@@ -341,98 +353,99 @@ export function SessionSidePanel(props: {
</div>
</div>
<div
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<Show when={shown()}>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
modified={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Show>
</Match>
<Match when={true}>{empty(props.empty())}</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
</Show>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</Show>
</div>
</Show>
</div>
</aside>
</Show>
@@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@opencode-ai/ui/toast"
@@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const platform = usePlatform()
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
@@ -66,6 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = () =>
platform.platform !== "desktop" ||
import.meta.env.VITE_KILO_CHANNEL !== "beta" ||
settings.general.showFileTree()
const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
@@ -457,12 +465,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
...(shown()
? [
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
]
: []),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
+1 -1
View File
@@ -469,7 +469,7 @@ export function persisted<T>(
state,
setState,
init,
Object.assign(() => ready() === true, {
Object.assign(() => (ready.loading ? false : ready.latest === true), {
promise: init instanceof Promise ? init : undefined,
}),
]
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "1.4.7"
version = "1.4.9"
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/v1.4.7/kilo-darwin-arm64.zip"
cmd = "./kilo"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.7/kilo-darwin-x64.zip"
cmd = "./kilo"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.7/kilo-linux-arm64.tar.gz"
cmd = "./kilo"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/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/v1.4.7/kilo-linux-x64.tar.gz"
cmd = "./kilo"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/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/v1.4.7/kilo-windows-x64.zip"
cmd = "./kilo.exe"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.9/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+11 -33
View File
@@ -55,6 +55,11 @@ import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-pa
import { childID } from "./kilo-provider/task-session"
import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network"
import { abortSession } from "./kilo-provider/abort"
import {
buildAutocompleteSettingsMessage,
routeAutocompleteMessage,
watchAutocompleteConfig,
} from "./services/autocomplete/settings"
import * as ModelState from "./kilo-provider/model-state"
import { handleForkSession } from "./kilo-provider/fork-session"
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
@@ -201,6 +206,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private unsubscribeDirectoryProvider: (() => void) | null = null
private initConnectionPromise: Promise<void> | null = null
private webviewMessageDisposable: vscode.Disposable | null = null
private autocompleteConfigDisposable: vscode.Disposable | null = null
private viewStateDisposable: vscode.Disposable | null = null
private visibilityDisposable: vscode.Disposable | null = null
@@ -556,6 +562,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private setupWebviewMessageHandler(webview: vscode.Webview): void {
this.webviewMessageDisposable?.dispose()
this.autocompleteConfigDisposable?.dispose()
this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg))
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
// Run interceptor if attached (e.g., AgentManagerProvider worktree logic)
if (this.onBeforeMessage) {
@@ -571,6 +579,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await routeSuggestionWebviewMessage(this.questionCtx, message)
if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return
if (await routeAutocompleteMessage(message, (msg) => this.postMessage(msg))) return
switch (message.type) {
case "webviewReady":
console.log("[Kilo New] KiloProvider: ✅ webviewReady received")
@@ -803,23 +812,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
.update("language", message.locale || undefined, vscode.ConfigurationTarget.Global)
this.connectionService.notifyLanguageChanged(message.locale as string)
break
case "requestAutocompleteSettings":
this.sendAutocompleteSettings()
break
case "updateAutocompleteSetting": {
const allowedKeys = new Set([
"enableAutoTrigger",
"enableSmartInlineTaskKeybinding",
"enableChatAutocomplete",
])
if (allowedKeys.has(message.key)) {
await vscode.workspace
.getConfiguration("kilo-code.new.autocomplete")
.update(message.key, message.value, vscode.ConfigurationTarget.Global)
this.sendAutocompleteSettings()
}
break
}
case "requestChatCompletion": {
if (!this.chatAutocomplete) {
this.chatAutocomplete = new ChatTextAreaAutocomplete(this.connectionService)
@@ -2833,7 +2825,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined)
// Re-send all settings to the webview so the UI reflects the reset
this.sendAutocompleteSettings()
this.postMessage(buildAutocompleteSettingsMessage())
this.sendBrowserSettings()
this.sendNotificationSettings()
this.sendTimelineSetting()
@@ -3003,21 +2995,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage(msg)
}
/**
* Read autocomplete settings from VS Code configuration and push to the webview.
*/
private sendAutocompleteSettings(): void {
const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete")
this.postMessage({
type: "autocompleteSettingsLoaded",
settings: {
enableAutoTrigger: config.get<boolean>("enableAutoTrigger", true),
enableSmartInlineTaskKeybinding: config.get<boolean>("enableSmartInlineTaskKeybinding", false),
enableChatAutocomplete: config.get<boolean>("enableChatAutocomplete", false),
},
})
}
/** Wait until the webview has sent "webviewReady". Resolves immediately when already ready. */
public waitForReady(): Promise<void> {
return this.isWebviewReady && this.webview ? Promise.resolve() : new Promise((r) => this.readyResolvers.push(r))
@@ -3334,6 +3311,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.viewStateDisposable?.dispose()
this.visibilityDisposable?.dispose()
this.webviewMessageDisposable?.dispose()
this.autocompleteConfigDisposable?.dispose()
this.streams.dispose()
this.isWebviewReady = false
this.promptRecoveryQueued = false
@@ -0,0 +1,59 @@
import * as vscode from "vscode"
const keys = new Set(["enableAutoTrigger", "enableSmartInlineTaskKeybinding", "enableChatAutocomplete"])
type Message = {
type: string
key?: unknown
value?: unknown
}
type Post = (msg: unknown) => void
export async function routeAutocompleteMessage(message: Message, post: Post): Promise<boolean> {
if (message.type === "requestAutocompleteSettings") {
post(buildAutocompleteSettingsMessage())
return true
}
if (message.type === "updateAutocompleteSetting") {
if (await update(message.key, message.value)) {
post(buildAutocompleteSettingsMessage())
}
return true
}
return false
}
export function buildAutocompleteSettingsMessage() {
const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete")
return {
type: "autocompleteSettingsLoaded" as const,
settings: {
enableAutoTrigger: config.get<boolean>("enableAutoTrigger", true),
enableSmartInlineTaskKeybinding: config.get<boolean>("enableSmartInlineTaskKeybinding", false),
enableChatAutocomplete: config.get<boolean>("enableChatAutocomplete", false),
},
}
}
/** Push autocomplete settings to the webview whenever VS Code config changes. */
export function watchAutocompleteConfig(post: Post): vscode.Disposable {
return vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("kilo-code.new.autocomplete")) {
post(buildAutocompleteSettingsMessage())
}
})
}
async function update(key: unknown, value: unknown) {
if (typeof key !== "string") return false
if (!keys.has(key)) return false
await vscode.workspace
.getConfiguration("kilo-code.new.autocomplete")
.update(key, value, vscode.ConfigurationTarget.Global)
return true
}
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"
import { fmtPrice } from "../../webview-ui/src/components/shared/model-preview-utils"
import { avgPrice, fmtCachedPrice, fmtPrice } from "../../webview-ui/src/components/shared/model-preview-utils"
// Prices arriving at fmtPrice are already in $/M tokens (converted by parseApiPrice).
// This test suite guards against the double-multiplication bug where fmtPrice was
@@ -34,3 +34,35 @@ describe("fmtPrice", () => {
expect(fmtPrice(75)).toBe("$75.00/1M")
})
})
describe("fmtCachedPrice", () => {
it("formats supported cached prices using the same $/1M formatter", () => {
expect(fmtCachedPrice({ input: 3, cache: { read: 0.3 } })).toBe("$0.30/1M")
})
it("shows free cached pricing when input is free and cache_read is zero", () => {
expect(fmtCachedPrice({ input: 0, cache: { read: 0 } })).toBe("Free")
})
it("returns null when cache_read is zero but input is paid", () => {
expect(fmtCachedPrice({ input: 3, cache: { read: 0 } })).toBeNull()
})
it("returns null when cache_read is missing but input is paid", () => {
expect(fmtCachedPrice({ input: 3 })).toBeNull()
})
})
describe("avgPrice", () => {
it("uses the cache-heavy ratio when cache_read is supported", () => {
expect(avgPrice({ input: 3, output: 15, cache: { read: 0.3 } })).toBe(2.31)
})
it("uses the fallback ratio when cache_read is zero", () => {
expect(avgPrice({ input: 3, output: 15, cache: { read: 0 } })).toBe(4.2)
})
it("uses the fallback ratio when cache_read is missing", () => {
expect(avgPrice({ input: 3, output: 15 })).toBe(4.2)
})
})
@@ -1,11 +1,12 @@
import { Component, Show, For, useContext } from "solid-js"
import { Show, For, useContext, type Component } from "solid-js"
import type { EnrichedModel } from "../../context/provider"
import { SessionContext } from "../../context/session"
import { Markdown } from "@kilocode/kilo-ui/markdown"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../../context/language"
import { sanitizeName } from "./model-selector-utils"
import { fmtPrice } from "./model-preview-utils"
import { avgPrice, fmtCachedPrice, fmtPrice } from "./model-preview-utils"
interface Props {
model: EnrichedModel | null
@@ -22,12 +23,12 @@ function fmtDate(s: string): string {
return d.toLocaleDateString(undefined, { year: "numeric", month: "short" })
}
const MODALITY_LABELS: Record<string, string> = {
text: "Text",
image: "Images",
audio: "Audio",
video: "Video",
pdf: "PDF",
const MODALITY_KEYS: Record<string, string> = {
text: "model.preview.modality.text",
image: "model.preview.modality.image",
audio: "model.preview.modality.audio",
video: "model.preview.modality.video",
pdf: "model.preview.modality.pdf",
}
export const ModelPreview: Component<Props> = (props) => {
@@ -40,6 +41,11 @@ export const ModelPreview: Component<Props> = (props) => {
<Show when={m()}>
{(model) => {
const cost = () => model().cost
const cachedText = () => {
if (!cost()) return ""
return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported")
}
const avg = () => (cost() ? avgPrice(cost()!) : undefined)
const ctx = () => model().limit?.context ?? model().contextLength
const caps = () => model().capabilities
const inputs = () => caps()?.input
@@ -47,13 +53,18 @@ export const ModelPreview: Component<Props> = (props) => {
inputs()
? (Object.entries(inputs()!) as [string, boolean][])
.filter(([, v]) => v)
.map(([k]) => MODALITY_LABELS[k] ?? k)
.map(([k]) => {
const key = MODALITY_KEYS[k]
return key ? language.t(key) : k
})
: []
return (
<>
<Show when={model().isFree}>
<span class="model-preview-badge model-preview-badge--free model-preview-badge--top-right">Free</span>
<span class="model-preview-badge model-preview-badge--free model-preview-badge--top-right">
{language.t("model.tag.free")}
</span>
</Show>
{/* Header — name + provider + star */}
<div class="model-preview-header">
@@ -88,21 +99,29 @@ export const ModelPreview: Component<Props> = (props) => {
<div class="model-preview-grid">
{/* Release date */}
<Show when={model().releaseDate}>
<span class="model-preview-label">Released</span>
<span class="model-preview-label">{language.t("model.preview.label.released")}</span>
<span class="model-preview-value">{fmtDate(model().releaseDate!)}</span>
</Show>
{/* Pricing — hidden for free models */}
<Show when={cost() && !model().isFree}>
<span class="model-preview-label">Input</span>
<span class="model-preview-label">{language.t("model.preview.label.input")}</span>
<span class="model-preview-value">{fmtPrice(cost()!.input)}</span>
<span class="model-preview-label">Output</span>
<span class="model-preview-label">{language.t("model.preview.label.output")}</span>
<span class="model-preview-value">{fmtPrice(cost()!.output)}</span>
<span class="model-preview-label">{language.t("model.preview.label.cached")}</span>
<span class="model-preview-value">{cachedText()}</span>
<Tooltip value={language.t("model.preview.tooltip.average")} placement="top">
<span class="model-preview-label model-preview-label--hint" tabIndex={0}>
{language.t("model.preview.label.average")}
</span>
</Tooltip>
<span class="model-preview-value">{fmtPrice(avg()!)}</span>
</Show>
{/* Context window */}
<Show when={ctx()}>
<span class="model-preview-label">Context</span>
<span class="model-preview-label">{language.t("model.preview.label.context")}</span>
<span class="model-preview-value">{fmtContext(ctx()!)}</span>
</Show>
</div>
@@ -111,7 +130,9 @@ export const ModelPreview: Component<Props> = (props) => {
<Show when={caps()?.reasoning || activeModalities().length > 0}>
<div class="model-preview-caps">
<Show when={caps()?.reasoning}>
<span class="model-preview-badge model-preview-badge--reasoning">Reasoning</span>
<span class="model-preview-badge model-preview-badge--reasoning">
{language.t("model.preview.badge.reasoning")}
</span>
</Show>
<For each={activeModalities()}>{(label) => <span class="model-preview-badge">{label}</span>}</For>
</div>
@@ -7,3 +7,18 @@ export function fmtPrice(n: number): string {
if (n < 0.01) return `$${n.toFixed(4)}/1M`
return `$${n.toFixed(2)}/1M`
}
export function fmtCachedPrice(cost: { input: number; cache?: { read: number } }): string | null {
const read = cost.cache?.read
if (read !== undefined && read > 0) return fmtPrice(read)
if (cost.input === 0) return fmtPrice(0)
return null
}
export function avgPrice(cost: { input: number; output: number; cache?: { read: number } }): number {
const read = cost.cache?.read
if (read !== undefined && read > 0) {
return read * 0.7 + cost.input * 0.2 + cost.output * 0.1
}
return cost.input * 0.9 + cost.output * 0.1
}
+15
View File
@@ -181,6 +181,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "يسمح بالاستنتاج",
"model.tooltip.reasoning.none": "بدون استنتاج",
"model.tooltip.context": "حد السياق {{limit}}",
"model.preview.label.released": "الإصدار",
"model.preview.label.input": "الإدخال",
"model.preview.label.output": "الإخراج",
"model.preview.label.cached": "مخزن مؤقتاً",
"model.preview.label.average": "متوسط التكلفة التقديرية",
"model.preview.label.context": "السياق",
"model.preview.value.notSupported": "غير مدعوم",
"model.preview.tooltip.average":
"يعتمد متوسط التكلفة التقديرية على نسبة نموذجية لرموز الإدخال والإخراج والقراءة المخزنة مؤقتاً.",
"model.preview.badge.reasoning": "التفكير",
"model.preview.modality.text": "نص",
"model.preview.modality.image": "صور",
"model.preview.modality.audio": "صوت",
"model.preview.modality.video": "فيديو",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "بحث",
"common.goBack": "رجوع",
+15
View File
@@ -182,6 +182,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Permite raciocínio",
"model.tooltip.reasoning.none": "Sem raciocínio",
"model.tooltip.context": "Limite de contexto {{limit}}",
"model.preview.label.released": "Lançado",
"model.preview.label.input": "Entrada",
"model.preview.label.output": "Saída",
"model.preview.label.cached": "Em cache",
"model.preview.label.average": "Custo Médio Est.",
"model.preview.label.context": "Contexto",
"model.preview.value.notSupported": "Não suportado",
"model.preview.tooltip.average":
"O custo médio estimado é baseado em uma proporção típica de tokens de entrada, saída e leitura de cache.",
"model.preview.badge.reasoning": "Raciocínio",
"model.preview.modality.text": "Texto",
"model.preview.modality.image": "Imagens",
"model.preview.modality.audio": "Áudio",
"model.preview.modality.video": "Vídeo",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Buscar",
"common.goBack": "Voltar",
+15
View File
@@ -183,6 +183,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Podržava rasuđivanje",
"model.tooltip.reasoning.none": "Bez rasuđivanja",
"model.tooltip.context": "Limit konteksta {{limit}}",
"model.preview.label.released": "Objavljeno",
"model.preview.label.input": "Ulaz",
"model.preview.label.output": "Izlaz",
"model.preview.label.cached": "Keširano",
"model.preview.label.average": "Procj. prosječni trošak",
"model.preview.label.context": "Kontekst",
"model.preview.value.notSupported": "Nije podržano",
"model.preview.tooltip.average":
"Procijenjeni prosječni trošak zasniva se na tipičnom omjeru ulaznih i izlaznih tokena, te tokena pročitanih iz keša.",
"model.preview.badge.reasoning": "Zaključivanje",
"model.preview.modality.text": "Tekst",
"model.preview.modality.image": "Slike",
"model.preview.modality.audio": "Audio",
"model.preview.modality.video": "Video",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Pretraži",
"common.goBack": "Nazad",
+15
View File
@@ -183,6 +183,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Tillader tænkning",
"model.tooltip.reasoning.none": "Ingen tænkning",
"model.tooltip.context": "Kontekstgrænse {{limit}}",
"model.preview.label.released": "Udgivet",
"model.preview.label.input": "Input",
"model.preview.label.output": "Output",
"model.preview.label.cached": "Cached",
"model.preview.label.average": "Est. gennemsnitspris",
"model.preview.label.context": "Kontekst",
"model.preview.value.notSupported": "Ikke understøttet",
"model.preview.tooltip.average":
"Den estimerede gennemsnitspris er baseret på et typisk forhold mellem input-, output- og cache-læsetokens.",
"model.preview.badge.reasoning": "Ræsonnering",
"model.preview.modality.text": "Tekst",
"model.preview.modality.image": "Billeder",
"model.preview.modality.audio": "Lyd",
"model.preview.modality.video": "Video",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Søg",
"common.goBack": "Gå tilbage",
"common.goForward": "Fremad",
+15
View File
@@ -187,6 +187,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Erlaubt Reasoning",
"model.tooltip.reasoning.none": "Kein Reasoning",
"model.tooltip.context": "Kontextlimit {{limit}}",
"model.preview.label.released": "Veröffentlicht",
"model.preview.label.input": "Eingabe",
"model.preview.label.output": "Ausgabe",
"model.preview.label.cached": "Zwischengespeichert",
"model.preview.label.average": "Geschätzte Ø-Kosten",
"model.preview.label.context": "Kontext",
"model.preview.value.notSupported": "Nicht unterstützt",
"model.preview.tooltip.average":
"Die geschätzten Durchschnittskosten basieren auf einem typischen Verhältnis von Eingabe-, Ausgabe- und Cache-Lese-Token.",
"model.preview.badge.reasoning": "Reasoning",
"model.preview.modality.text": "Text",
"model.preview.modality.image": "Bilder",
"model.preview.modality.audio": "Audio",
"model.preview.modality.video": "Video",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Suchen",
"common.goBack": "Zurück",
"common.goForward": "Vorwärts",
@@ -183,6 +183,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Allows reasoning",
"model.tooltip.reasoning.none": "No reasoning",
"model.tooltip.context": "Context limit {{limit}}",
"model.preview.label.released": "Released",
"model.preview.label.input": "Input",
"model.preview.label.output": "Output",
"model.preview.label.cached": "Cached",
"model.preview.label.average": "Est. Average Cost",
"model.preview.label.context": "Context",
"model.preview.value.notSupported": "Not supported",
"model.preview.tooltip.average":
"The estimated average cost is based on a typical ratio of input, output, and cache read tokens.",
"model.preview.badge.reasoning": "Reasoning",
"model.preview.modality.text": "Text",
"model.preview.modality.image": "Images",
"model.preview.modality.audio": "Audio",
"model.preview.modality.video": "Video",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Search",
"common.goBack": "Back",
+15
View File
@@ -183,6 +183,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Permite razonamiento",
"model.tooltip.reasoning.none": "Sin razonamiento",
"model.tooltip.context": "Límite de contexto {{limit}}",
"model.preview.label.released": "Lanzamiento",
"model.preview.label.input": "Entrada",
"model.preview.label.output": "Salida",
"model.preview.label.cached": "En caché",
"model.preview.label.average": "Costo prom. est.",
"model.preview.label.context": "Contexto",
"model.preview.value.notSupported": "No admitido",
"model.preview.tooltip.average":
"El costo promedio estimado se basa en una proporción típica de tokens de entrada, salida y lectura en caché.",
"model.preview.badge.reasoning": "Razonamiento",
"model.preview.modality.text": "Texto",
"model.preview.modality.image": "Imágenes",
"model.preview.modality.audio": "Audio",
"model.preview.modality.video": "Video",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Buscar",
"common.goBack": "Volver",
"common.goForward": "Adelante",
+15
View File
@@ -184,6 +184,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Autorise le raisonnement",
"model.tooltip.reasoning.none": "Sans raisonnement",
"model.tooltip.context": "Limite de contexte {{limit}}",
"model.preview.label.released": "Sortie",
"model.preview.label.input": "Entrée",
"model.preview.label.output": "Sortie",
"model.preview.label.cached": "En cache",
"model.preview.label.average": "Coût moyen est.",
"model.preview.label.context": "Contexte",
"model.preview.value.notSupported": "Non pris en charge",
"model.preview.tooltip.average":
"Le coût moyen estimé est basé sur un ratio typique de jetons d'entrée, de sortie et de lecture en cache.",
"model.preview.badge.reasoning": "Raisonnement",
"model.preview.modality.text": "Texte",
"model.preview.modality.image": "Images",
"model.preview.modality.audio": "Audio",
"model.preview.modality.video": "Vidéo",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Rechercher",
"common.goBack": "Retour",
"common.goForward": "Suivant",
+15
View File
@@ -182,6 +182,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "推論を許可",
"model.tooltip.reasoning.none": "推論なし",
"model.tooltip.context": "コンテキスト上限 {{limit}}",
"model.preview.label.released": "リリース",
"model.preview.label.input": "入力",
"model.preview.label.output": "出力",
"model.preview.label.cached": "キャッシュ",
"model.preview.label.average": "推定平均コスト",
"model.preview.label.context": "コンテキスト",
"model.preview.value.notSupported": "未対応",
"model.preview.tooltip.average":
"推定平均コストは、入力、出力、およびキャッシュ読み取りトークンの一般的な比率に基づいています。",
"model.preview.badge.reasoning": "推論",
"model.preview.modality.text": "テキスト",
"model.preview.modality.image": "画像",
"model.preview.modality.audio": "音声",
"model.preview.modality.video": "動画",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "検索",
"common.goBack": "戻る",
"common.goForward": "進む",
+14
View File
@@ -186,6 +186,20 @@ export const dict = {
"model.tooltip.reasoning.allowed": "추론 허용",
"model.tooltip.reasoning.none": "추론 없음",
"model.tooltip.context": "컨텍스트 제한 {{limit}}",
"model.preview.label.released": "출시",
"model.preview.label.input": "입력",
"model.preview.label.output": "출력",
"model.preview.label.cached": "캐시됨",
"model.preview.label.average": "예상 평균 비용",
"model.preview.label.context": "컨텍스트",
"model.preview.value.notSupported": "미지원",
"model.preview.tooltip.average": "예상 평균 비용은 입력, 출력 및 캐시 읽기 토큰의 일반적인 비율을 기반으로 합니다.",
"model.preview.badge.reasoning": "추론",
"model.preview.modality.text": "텍스트",
"model.preview.modality.image": "이미지",
"model.preview.modality.audio": "오디오",
"model.preview.modality.video": "비디오",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "검색",
"common.goBack": "뒤로 가기",
"common.goForward": "앞으로 가기",
+15
View File
@@ -183,6 +183,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Staat redeneren toe",
"model.tooltip.reasoning.none": "Geen redenering",
"model.tooltip.context": "Contextlimiet {{limit}}",
"model.preview.label.released": "Uitgebracht",
"model.preview.label.input": "Input",
"model.preview.label.output": "Output",
"model.preview.label.cached": "Gecachet",
"model.preview.label.average": "Gesch. gem. kosten",
"model.preview.label.context": "Context",
"model.preview.value.notSupported": "Niet ondersteund",
"model.preview.tooltip.average":
"De geschatte gemiddelde kosten zijn gebaseerd op een typische verhouding van input-, output- en cache-leestokens.",
"model.preview.badge.reasoning": "Redeneren",
"model.preview.modality.text": "Tekst",
"model.preview.modality.image": "Afbeeldingen",
"model.preview.modality.audio": "Audio",
"model.preview.modality.video": "Video",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Zoeken",
"common.goBack": "Terug",
+15
View File
@@ -185,6 +185,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Tillater resonnering",
"model.tooltip.reasoning.none": "Ingen resonnering",
"model.tooltip.context": "Kontekstgrense {{limit}}",
"model.preview.label.released": "Utgitt",
"model.preview.label.input": "Inndata",
"model.preview.label.output": "Utdata",
"model.preview.label.cached": "Bufret",
"model.preview.label.average": "Est. snittkostnad",
"model.preview.label.context": "Kontekst",
"model.preview.value.notSupported": "Ikke støttet",
"model.preview.tooltip.average":
"Den estimerte snittkostnaden er basert på et typisk forhold mellom inndata-, utdata- og leste buffer-tokens.",
"model.preview.badge.reasoning": "Resonnering",
"model.preview.modality.text": "Tekst",
"model.preview.modality.image": "Bilder",
"model.preview.modality.audio": "Lyd",
"model.preview.modality.video": "Video",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Søk",
"common.goBack": "Gå tilbake",
+15
View File
@@ -182,6 +182,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Obsługuje wnioskowanie",
"model.tooltip.reasoning.none": "Brak wnioskowania",
"model.tooltip.context": "Limit kontekstu {{limit}}",
"model.preview.label.released": "Wydano",
"model.preview.label.input": "Wejście",
"model.preview.label.output": "Wyjście",
"model.preview.label.cached": "W pamięci podręcznej",
"model.preview.label.average": "Szac. średni koszt",
"model.preview.label.context": "Kontekst",
"model.preview.value.notSupported": "Nieobsługiwane",
"model.preview.tooltip.average":
"Szacowany średni koszt opiera się na typowym stosunku tokenów wejściowych, wyjściowych i pobranych z pamięci podręcznej.",
"model.preview.badge.reasoning": "Rozumowanie",
"model.preview.modality.text": "Tekst",
"model.preview.modality.image": "Obrazy",
"model.preview.modality.audio": "Audio",
"model.preview.modality.video": "Wideo",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Szukaj",
"common.goBack": "Wstecz",
+15 -1
View File
@@ -182,7 +182,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Разрешает рассуждение",
"model.tooltip.reasoning.none": "Без рассуждения",
"model.tooltip.context": "Лимит контекста {{limit}}",
"model.preview.label.released": "Выпущена",
"model.preview.label.input": "Ввод",
"model.preview.label.output": "Вывод",
"model.preview.label.cached": "В кэше",
"model.preview.label.average": "Средняя стоимость",
"model.preview.label.context": "Контекст",
"model.preview.value.notSupported": "Не поддерживается",
"model.preview.tooltip.average":
"Примерная средняя стоимость основана на типичном соотношении токенов ввода, вывода и чтения из кэша.",
"model.preview.badge.reasoning": "Рассуждение",
"model.preview.modality.text": "Текст",
"model.preview.modality.image": "Изображения",
"model.preview.modality.audio": "Аудио",
"model.preview.modality.video": "Видео",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Поиск",
"common.goBack": "Назад",
"common.goForward": "Вперёд",
+15
View File
@@ -182,6 +182,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "อนุญาตการใช้เหตุผล",
"model.tooltip.reasoning.none": "ไม่มีการใช้เหตุผล",
"model.tooltip.context": "ขีดจำกัดบริบท {{limit}}",
"model.preview.label.released": "เปิดตัว",
"model.preview.label.input": "อินพุต",
"model.preview.label.output": "เอาต์พุต",
"model.preview.label.cached": "แคช",
"model.preview.label.average": "ต้นทุนเฉลี่ยโดยประมาณ",
"model.preview.label.context": "บริบท",
"model.preview.value.notSupported": "ไม่รองรับ",
"model.preview.tooltip.average":
"ต้นทุนเฉลี่ยโดยประมาณนี้คำนวณจากอัตราส่วนทั่วไปของโทเค็นอินพุต เอาต์พุต และการอ่านแคช",
"model.preview.badge.reasoning": "การให้เหตุผล",
"model.preview.modality.text": "ข้อความ",
"model.preview.modality.image": "รูปภาพ",
"model.preview.modality.audio": "เสียง",
"model.preview.modality.video": "วิดีโอ",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "ค้นหา",
"common.goBack": "ย้อนกลับ",
+15
View File
@@ -182,6 +182,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Akıl yürütme destekler",
"model.tooltip.reasoning.none": "Akıl yürütme yok",
"model.tooltip.context": "Bağlam limiti {{limit}}",
"model.preview.label.released": "Yayınlanma",
"model.preview.label.input": "Girdi",
"model.preview.label.output": "Çıktı",
"model.preview.label.cached": "Önbellek",
"model.preview.label.average": "Tahmini Ort. Maliyet",
"model.preview.label.context": "Bağlam",
"model.preview.value.notSupported": "Desteklenmiyor",
"model.preview.tooltip.average":
"Tahmini ortalama maliyet; girdi, çıktı ve önbellek okuma tokenlerinin tipik bir oranına dayanmaktadır.",
"model.preview.badge.reasoning": "Akıl Yürütme",
"model.preview.modality.text": "Metin",
"model.preview.modality.image": "Görseller",
"model.preview.modality.audio": "Ses",
"model.preview.modality.video": "Video",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Ara",
"common.goBack": "Geri git",
+15
View File
@@ -183,6 +183,21 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Підтримує міркування",
"model.tooltip.reasoning.none": "Без міркування",
"model.tooltip.context": "Ліміт контексту {{limit}}",
"model.preview.label.released": "Випущено",
"model.preview.label.input": "Вхідні",
"model.preview.label.output": "Вихідні",
"model.preview.label.cached": "Кешовані",
"model.preview.label.average": "Орієнт. сер. вартість",
"model.preview.label.context": "Контекст",
"model.preview.value.notSupported": "Не підтримується",
"model.preview.tooltip.average":
"Орієнтовна середня вартість базується на типовому співвідношенні вхідних, вихідних токенів та читання кешу.",
"model.preview.badge.reasoning": "Міркування",
"model.preview.modality.text": "Текст",
"model.preview.modality.image": "Зображення",
"model.preview.modality.audio": "Аудіо",
"model.preview.modality.video": "Відео",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "Пошук",
"common.goBack": "Назад",
+14
View File
@@ -183,6 +183,20 @@ export const dict = {
"model.tooltip.reasoning.allowed": "支持推理",
"model.tooltip.reasoning.none": "不支持推理",
"model.tooltip.context": "上下文上限 {{limit}}",
"model.preview.label.released": "发布",
"model.preview.label.input": "输入",
"model.preview.label.output": "输出",
"model.preview.label.cached": "缓存",
"model.preview.label.average": "预估平均成本",
"model.preview.label.context": "上下文",
"model.preview.value.notSupported": "不支持",
"model.preview.tooltip.average": "预估平均成本基于输入、输出和缓存读取 token 的典型比例计算。",
"model.preview.badge.reasoning": "推理",
"model.preview.modality.text": "文本",
"model.preview.modality.image": "图像",
"model.preview.modality.audio": "音频",
"model.preview.modality.video": "视频",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "搜索",
"common.goBack": "返回",
"common.goForward": "前进",
+14
View File
@@ -183,6 +183,20 @@ export const dict = {
"model.tooltip.reasoning.allowed": "支援推理",
"model.tooltip.reasoning.none": "不支援推理",
"model.tooltip.context": "上下文上限 {{limit}}",
"model.preview.label.released": "發布",
"model.preview.label.input": "輸入",
"model.preview.label.output": "輸出",
"model.preview.label.cached": "快取",
"model.preview.label.average": "預估平均成本",
"model.preview.label.context": "上下文",
"model.preview.value.notSupported": "不支援",
"model.preview.tooltip.average": "預估平均成本是基於輸入、輸出和快取讀取 token 的典型比例計算而得。",
"model.preview.badge.reasoning": "推理",
"model.preview.modality.text": "文字",
"model.preview.modality.image": "圖片",
"model.preview.modality.audio": "音訊",
"model.preview.modality.video": "影片",
"model.preview.modality.pdf": "PDF",
"common.search.placeholder": "搜尋",
"common.goBack": "上一頁",
"common.goForward": "下一頁",
@@ -494,8 +494,13 @@
}
.mode-switcher-item-desc {
display: block;
font-size: 11px;
color: var(--text-weak, var(--vscode-descriptionForeground));
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mode-switcher-item.selected .mode-switcher-item-name {
@@ -355,7 +355,14 @@ export interface ProviderModel {
options?: { description?: string }
recommendedIndex?: number
isFree?: boolean
cost?: { input: number; output: number }
cost?: {
input: number
output: number
cache?: {
read: number
write: number
}
}
}
export interface Provider {
+2 -3
View File
@@ -15,7 +15,6 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
"dev:local": "XDG_DATA_HOME=/tmp/kilo-local/data XDG_CONFIG_HOME=/tmp/kilo-local/config XDG_STATE_HOME=/tmp/kilo-local/state XDG_CACHE_HOME=/tmp/kilo-local/cache KILO_API_URL=http://localhost:3000 KILO_SESSION_INGEST_URL=http://localhost:8800 bun run --conditions=browser ./src/index.ts",
"db": "bun drizzle-kit"
},
"bin": {
@@ -128,8 +127,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env bun
/**
* Collapse a single-namespace barrel directory into a dir/index.ts module.
*
* Given a directory `src/foo/` that contains:
*
* - `index.ts` (exactly `export * as Foo from "./foo"`)
* - `foo.ts` (the real implementation)
* - zero or more sibling files
*
* this script:
*
* 1. Deletes the old `index.ts` barrel.
* 2. `git mv`s `foo.ts` `index.ts` so the implementation IS the directory entry.
* 3. Appends `export * as Foo from "."` to the new `index.ts`.
* 4. Rewrites any same-directory sibling `*.ts` files that imported
* `./foo` (with or without the namespace name) to import `"."` instead.
*
* Consumer files outside the directory keep importing from the directory
* (`"@/foo"` / `"../foo"` / etc.) and continue to work, because
* `dir/index.ts` now provides the `Foo` named export directly.
*
* Usage:
*
* bun script/collapse-barrel.ts src/bus
* bun script/collapse-barrel.ts src/bus --dry-run
*
* Notes:
*
* - Only works on directories whose barrel is a single
* `export * as Name from "./file"` line. Refuses otherwise.
* - Refuses if the implementation file name already conflicts with
* `index.ts`.
* - Safe to run repeatedly: a second run on an already-collapsed dir
* will exit with a clear message.
*/
import fs from "node:fs"
import path from "node:path"
import { spawnSync } from "node:child_process"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const targetArg = args.find((a) => !a.startsWith("--"))
if (!targetArg) {
console.error("Usage: bun script/collapse-barrel.ts <dir> [--dry-run]")
process.exit(1)
}
const dir = path.resolve(targetArg)
const indexPath = path.join(dir, "index.ts")
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
console.error(`Not a directory: ${dir}`)
process.exit(1)
}
if (!fs.existsSync(indexPath)) {
console.error(`No index.ts in ${dir}`)
process.exit(1)
}
// Validate barrel shape.
const indexContent = fs.readFileSync(indexPath, "utf-8").trim()
const match = indexContent.match(/^export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']\s*;?\s*$/)
if (!match) {
console.error(`Not a simple single-namespace barrel:\n${indexContent}`)
process.exit(1)
}
const namespaceName = match[1]
const implRel = match[2].replace(/\.ts$/, "")
const implPath = path.join(dir, `${implRel}.ts`)
if (!fs.existsSync(implPath)) {
console.error(`Implementation file not found: ${implPath}`)
process.exit(1)
}
if (implRel === "index") {
console.error(`Nothing to do — impl file is already index.ts`)
process.exit(0)
}
console.log(`Collapsing ${path.relative(process.cwd(), dir)}`)
console.log(` namespace: ${namespaceName}`)
console.log(` impl file: ${implRel}.ts → index.ts`)
// Figure out which sibling files need rewriting.
const siblings = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))
.filter((f) => f !== "index.ts" && f !== `${implRel}.ts`)
.map((f) => path.join(dir, f))
type SiblingEdit = { file: string; content: string }
const siblingEdits: SiblingEdit[] = []
for (const sibling of siblings) {
const content = fs.readFileSync(sibling, "utf-8")
// Match any import or re-export referring to "./<implRel>" inside this directory.
const siblingRegex = new RegExp(`(from\\s*["'])\\.\\/${implRel.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")}(["'])`, "g")
if (!siblingRegex.test(content)) continue
const updated = content.replace(siblingRegex, `$1.$2`)
siblingEdits.push({ file: sibling, content: updated })
}
if (siblingEdits.length > 0) {
console.log(` sibling rewrites: ${siblingEdits.length}`)
for (const edit of siblingEdits) {
console.log(` ${path.relative(process.cwd(), edit.file)}`)
}
} else {
console.log(` sibling rewrites: none`)
}
if (dryRun) {
console.log(`\n(dry run) would:`)
console.log(` - delete ${path.relative(process.cwd(), indexPath)}`)
console.log(` - git mv ${path.relative(process.cwd(), implPath)} ${path.relative(process.cwd(), indexPath)}`)
console.log(` - append \`export * as ${namespaceName} from "."\` to the new index.ts`)
for (const edit of siblingEdits) {
console.log(` - rewrite sibling: ${path.relative(process.cwd(), edit.file)}`)
}
process.exit(0)
}
// Apply: remove the old barrel, git-mv the impl onto it, then rewrite content.
// We can't git-mv on top of an existing tracked file, so we remove the barrel first.
function runGit(...cmd: string[]) {
const res = spawnSync("git", cmd, { stdio: "inherit" })
if (res.status !== 0) {
console.error(`git ${cmd.join(" ")} failed`)
process.exit(res.status ?? 1)
}
}
// Step 1: remove the barrel
runGit("rm", "-f", indexPath)
// Step 2: rename the impl file into index.ts
runGit("mv", implPath, indexPath)
// Step 3: append the self-reexport to the new index.ts
const newContent = fs.readFileSync(indexPath, "utf-8")
const trimmed = newContent.endsWith("\n") ? newContent : newContent + "\n"
fs.writeFileSync(indexPath, `${trimmed}\nexport * as ${namespaceName} from "."\n`)
console.log(` appended: export * as ${namespaceName} from "."`)
// Step 4: rewrite siblings
for (const edit of siblingEdits) {
fs.writeFileSync(edit.file, edit.content)
}
if (siblingEdits.length > 0) {
console.log(` rewrote ${siblingEdits.length} sibling file(s)`)
}
console.log(`\nDone. Verify with:`)
console.log(` cd packages/opencode`)
console.log(` bunx --bun tsgo --noEmit`)
console.log(` bun run --conditions=browser ./src/index.ts generate`)
console.log(` bun run test`)
@@ -1,305 +0,0 @@
#!/usr/bin/env bun
/**
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
*
* Usage:
* bun script/unwrap-namespace.ts src/bus/index.ts
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
* bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
*
* What it does:
* 1. Reads the file and finds the `export namespace Foo { ... }` block
* (uses ast-grep for accurate AST-based boundary detection)
* 2. Removes the namespace wrapper and dedents the body
* 3. Fixes self-references (e.g. Config.PermissionAction PermissionAction)
* 4. If the file is index.ts, renames it to <lowercase-name>.ts
* 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
* 6. Rewrites import paths across src/, test/, and script/
* 7. Fixes sibling imports within the same directory
*
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
*/
import path from "path"
import fs from "fs"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const nameFlag = args.find((a, i) => args[i - 1] === "--name")
const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
if (!filePath) {
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
process.exit(1)
}
const absPath = path.resolve(filePath)
if (!fs.existsSync(absPath)) {
console.error(`File not found: ${absPath}`)
process.exit(1)
}
const src = fs.readFileSync(absPath, "utf-8")
const lines = src.split("\n")
// Use ast-grep to find the namespace boundaries accurately.
// This avoids false matches from braces in strings, templates, comments, etc.
const astResult = Bun.spawnSync(
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
{ stdout: "pipe", stderr: "pipe" },
)
if (astResult.exitCode !== 0) {
console.error("ast-grep failed:", astResult.stderr.toString())
process.exit(1)
}
const matches = JSON.parse(astResult.stdout.toString()) as Array<{
text: string
range: { start: { line: number; column: number }; end: { line: number; column: number } }
metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
}>
if (matches.length === 0) {
console.error("No `export namespace Foo { ... }` found in file")
process.exit(1)
}
if (matches.length > 1) {
console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
console.error("Namespaces found:")
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
process.exit(1)
}
const match = matches[0]
const nsName = match.metaVariables.single.NAME.text
const nsLine = match.range.start.line // 0-indexed
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
console.log(`Found: export namespace ${nsName} { ... }`)
console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
// Build the new file content:
// 1. Everything before the namespace declaration (imports, etc.)
// 2. The namespace body, dedented by one level (2 spaces)
// 3. Everything after the closing brace (rare, but possible)
const before = lines.slice(0, nsLine)
const body = lines.slice(nsLine + 1, closeLine)
const after = lines.slice(closeLine + 1)
// Dedent: remove exactly 2 leading spaces from each line
const dedented = body.map((line) => {
if (line === "") return ""
if (line.startsWith(" ")) return line.slice(2)
return line
})
let newContent = [...before, ...dedented, ...after].join("\n")
// --- Fix self-references ---
// After unwrapping, references like `Config.PermissionAction` inside the same file
// need to become just `PermissionAction`. Only fix code positions, not strings.
const exportedNames = new Set<string>()
const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
for (const line of dedented) {
for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
}
const reExportRegex = /export\s*\{\s*([^}]+)\}/g
for (const line of dedented) {
for (const m of line.matchAll(reExportRegex)) {
for (const name of m[1].split(",")) {
const trimmed = name
.trim()
.split(/\s+as\s+/)
.pop()!
.trim()
if (trimmed) exportedNames.add(trimmed)
}
}
}
let selfRefCount = 0
if (exportedNames.size > 0) {
const fixedLines = newContent.split("\n").map((line) => {
// Split line into string-literal and code segments to avoid replacing inside strings
const segments: Array<{ text: string; isString: boolean }> = []
let i = 0
let current = ""
let inString: string | null = null
while (i < line.length) {
const ch = line[i]
if (inString) {
current += ch
if (ch === "\\" && i + 1 < line.length) {
current += line[i + 1]
i += 2
continue
}
if (ch === inString) {
segments.push({ text: current, isString: true })
current = ""
inString = null
}
i++
continue
}
if (ch === '"' || ch === "'" || ch === "`") {
if (current) segments.push({ text: current, isString: false })
current = ch
inString = ch
i++
continue
}
if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
current += line.slice(i)
segments.push({ text: current, isString: true })
current = ""
i = line.length
continue
}
current += ch
i++
}
if (current) segments.push({ text: current, isString: !!inString })
return segments
.map((seg) => {
if (seg.isString) return seg.text
let result = seg.text
for (const name of exportedNames) {
const pattern = `${nsName}.${name}`
while (result.includes(pattern)) {
const idx = result.indexOf(pattern)
const charBefore = idx > 0 ? result[idx - 1] : " "
const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
selfRefCount++
}
}
return result
})
.join("")
})
newContent = fixedLines.join("\n")
}
// Figure out file naming
const dir = path.dirname(absPath)
const basename = path.basename(absPath, ".ts")
const isIndex = basename === "index"
const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
const implFile = path.join(dir, `${implName}.ts`)
const indexFile = path.join(dir, "index.ts")
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
console.log("")
if (isIndex) {
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
} else {
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
}
if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
console.log("")
if (dryRun) {
console.log("--- DRY RUN ---")
console.log("")
console.log(`=== ${implName}.ts (first 30 lines) ===`)
newContent
.split("\n")
.slice(0, 30)
.forEach((l, i) => console.log(` ${i + 1}: ${l}`))
console.log(" ...")
console.log("")
console.log(`=== index.ts ===`)
console.log(` ${barrelLine.trim()}`)
console.log("")
if (!isIndex) {
const relDir = path.relative(path.resolve("src"), dir)
console.log(`=== Import rewrites (would apply) ===`)
console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
} else {
console.log("No import rewrites needed (was index.ts)")
}
} else {
if (isIndex) {
fs.writeFileSync(implFile, newContent)
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
console.log(`Wrote index.ts (barrel)`)
} else {
fs.writeFileSync(absPath, newContent)
if (fs.existsSync(indexFile)) {
const existing = fs.readFileSync(indexFile, "utf-8")
if (!existing.includes(`export * as ${nsName}`)) {
fs.appendFileSync(indexFile, barrelLine)
console.log(`Appended to existing index.ts`)
} else {
console.log(`index.ts already has ${nsName} export`)
}
} else {
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote index.ts (barrel)`)
}
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
}
// --- Rewrite import paths across src/, test/, script/ ---
const relDir = path.relative(path.resolve("src"), dir)
if (!isIndex) {
const oldTail = `${relDir}/${basename}`
const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
stdout: "pipe",
stderr: "pipe",
})
const filesToRewrite = rgResult.stdout
.toString()
.trim()
.split("\n")
.filter((f) => f.length > 0)
if (filesToRewrite.length > 0) {
console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
for (const file of filesToRewrite) {
const content = fs.readFileSync(file, "utf-8")
fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
}
console.log(` Done: ${oldTail}" → ${relDir}"`)
} else {
console.log("\nNo import rewrites needed")
}
} else {
console.log("\nNo import rewrites needed (was index.ts)")
}
// --- Fix sibling imports within the same directory ---
const siblingFiles = fs.readdirSync(dir).filter((f) => {
if (!f.endsWith(".ts")) return false
if (f === "index.ts" || f === `${implName}.ts`) return false
return true
})
let siblingFixCount = 0
for (const sibFile of siblingFiles) {
const sibPath = path.join(dir, sibFile)
const content = fs.readFileSync(sibPath, "utf-8")
const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
if (pattern.test(content)) {
fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
siblingFixCount++
}
}
if (siblingFixCount > 0) {
console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
}
}
console.log("")
console.log("=== Verify ===")
console.log("")
console.log("bunx --bun tsgo --noEmit # typecheck")
console.log("bun run test # run tests")
+1 -3
View File
@@ -9,7 +9,7 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
@@ -195,7 +195,6 @@ This checklist is only about the service shape migration. Many of these services
- [x] `Config``config/config.ts`
- [x] `Discovery``skill/discovery.ts` (dependency-only layer, no standalone runtime)
- [x] `File``file/index.ts`
- [x] `FileTime``file/time.ts`
- [x] `FileWatcher``file/watcher.ts`
- [x] `Format``format/index.ts`
- [x] `Installation``installation/index.ts`
@@ -301,7 +300,6 @@ For each service, the migration is roughly:
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
@@ -1,256 +0,0 @@
# Namespace → self-reexport migration
Migrate every `export namespace Foo { ... }` to flat top-level exports plus a
single self-reexport line at the bottom of the same file:
```ts
export * as Foo from "./foo"
```
No barrel `index.ts` files. No cross-directory indirection. Consumers keep the
exact same `import { Foo } from "../foo/foo"` ergonomics.
## Why this pattern
We tested three options against Bun, esbuild, Rollup (what Vite uses under the
hood), Bun's runtime, and Node's native TypeScript runner.
```
heavy.ts loaded?
A. namespace B. barrel C. self-reexport
Bun bundler YES YES no
esbuild YES YES no
Rollup (Vite) YES YES no
Bun runtime YES YES no
Node --experimental-strip-types SYNTAX ERROR YES no
```
- **`export namespace`** compiles to an IIFE. Bundlers see one opaque function
call and can't analyze what's used. Node's native TS runner rejects the
syntax outright: `SyntaxError: TypeScript namespace declaration is not
supported in strip-only mode`.
- **Barrel `index.ts`** files (`export * as Foo from "./foo"` in a separate
file) force every re-exported sibling to evaluate when you import one name.
Siblings with side effects (top-level imports of SDKs, etc.) always load.
- **Self-reexport** keeps the file as plain ESM. Bundlers see static named
exports. The module is only pulled in when something actually imports from
it. There is no barrel hop, so no sibling contamination and no circular
import hazard.
Bundle overhead for the self-reexport wrapper is roughly 240 bytes per module
(`Object.defineProperty` namespace proxy). At ~100 modules that's ~24KB —
negligible for a CLI binary.
## The pattern
### Before
```ts
// src/permission/arity.ts
export namespace BashArity {
export function prefix(tokens: string[]) { ... }
}
```
### After
```ts
// src/permission/arity.ts
export function prefix(tokens: string[]) { ... }
export * as BashArity from "./arity"
```
Consumers don't change at all:
```ts
import { BashArity } from "@/permission/arity"
BashArity.prefix(...) // still works
```
Editors still auto-import `BashArity` like any named export, because the file
does have a named `BashArity` export at the module top level.
### Odd but harmless
`BashArity.BashArity.BashArity.prefix(...)` compiles and runs because the
namespace contains a re-export of itself. Nobody would write that. Not a
problem.
## Why this is different from what we tried first
An earlier pass used sibling barrel files (`index.ts` with `export * as ...`).
That turned out to be wrong for our constraints:
1. The barrel file always loads all its sibling modules when you import
through it, even if you only need one. For our CLI this is exactly the
cost we're trying to avoid.
2. Barrel + sibling imports made it very easy to accidentally create circular
imports that only surface as `ReferenceError` at runtime, not at
typecheck.
The self-reexport has none of those issues. There is no indirection. The
file and the namespace are the same unit.
## Why this matters for startup
The worst import chain in the codebase looks like:
```
src/index.ts
└── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (~1700 lines)
│ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers
│ ├── google-auth-library
│ └── more
├── { Config } from config/config.ts (~1600 lines)
└── { MCP } from mcp/mcp.ts (~900 lines)
```
All of that currently gets pulled in just to do `.isInstance()` on a handful
of error classes. The namespace IIFE shape is the main reason bundlers cannot
strip the unused parts. Self-reexport + flat ESM fixes it.
## Automation
From `packages/opencode`:
```bash
bun script/unwrap-namespace.ts <file> [--dry-run]
```
The script:
1. Uses ast-grep to locate the `export namespace Foo { ... }` block accurately.
2. Removes the `export namespace Foo {` line and the matching closing `}`.
3. Dedents the body by one indent level (2 spaces).
4. Rewrites `Foo.Bar` self-references inside the file to just `Bar`.
5. Appends `export * as Foo from "./<basename>"` at the bottom of the file.
6. Never creates a barrel `index.ts`.
### Typical flow for one file
```bash
# 1. Preview
bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run
# 2. Apply
bun script/unwrap-namespace.ts src/permission/arity.ts
# 3. Verify
cd packages/opencode
bunx --bun tsgo --noEmit
bun run --conditions=browser ./src/index.ts generate
bun run test <affected test files>
```
### Consumer imports usually don't need to change
Most consumers already import straight from the file, e.g.:
```ts
import { BashArity } from "@/permission/arity"
import { Config } from "@/config/config"
```
Because the file itself now does `export * as Foo from "./foo"`, those imports
keep working with zero edits.
The only edits needed are when a consumer was importing through a previous
barrel (`"@/config"` or `"../config"` resolving to `config/index.ts`). In
that case, repoint it at the file:
```ts
// before
import { Config } from "@/config"
// after
import { Config } from "@/config/config"
```
### Dynamic imports in tests
If a test did `const { Foo } = await import("../../src/x/y")`, the destructure
still works because of the self-reexport. No change required.
## Verification checklist (per PR)
Run all of these locally before pushing:
```bash
cd packages/opencode
bunx --bun tsgo --noEmit
bun run --conditions=browser ./src/index.ts generate
bun run test <affected test files>
```
Also do a quick grep in `src/`, `test/`, and `script/` to make sure no
consumer is still importing the namespace from an old barrel path that no
longer exports it.
The SDK build step (`bun run --conditions=browser ./src/index.ts generate`)
evaluates every module eagerly and is the most reliable way to catch circular
import regressions at runtime — the typechecker does not catch these.
## Rules for new code
- No new `export namespace`.
- Every module directory has a single canonical file — typically
`dir/index.ts` — with flat top-level exports and a self-reexport at the
bottom:
`export * as Foo from "."`
- Consumers import from the directory:
`import { Foo } from "@/dir"` or `import { Foo } from "../dir"`.
- No sibling barrel files. If a directory has multiple independent
namespaces, they each get their own file (e.g. `config/config.ts`,
`config/plugin.ts`) and their own self-reexport; the `index.ts` in that
directory stays minimal or does not exist.
- If a file needs a sibling, import the sibling file directly:
`import * as Sibling from "./sibling"`, not `from "."`.
### Why `dir/index.ts` + `"."` is fine for us
A single-file module (e.g. `pty/`) can live entirely in `dir/index.ts`
with `export * as Foo from "."` at the bottom. Consumers write the
short form:
```ts
import { Pty } from "@/pty"
```
This works in Bun runtime, Bun build, esbuild, and Rollup. It does NOT
work under Node's `--experimental-strip-types` runner:
```
node --experimental-strip-types entry.ts
ERR_UNSUPPORTED_DIR_IMPORT: Directory import '/.../pty' is not supported
```
Node requires an explicit file or a `package.json#exports` map for ESM.
We don't care about that target right now because the opencode CLI is
built with Bun and the web apps are built with Vite/Rollup. If we ever
want to run raw `.ts` through Node, we'll need to either use explicit
`.ts` extensions everywhere or add per-directory `package.json` exports
maps.
### When NOT to collapse to `index.ts`
Some directories contain multiple independent namespaces where
`dir/index.ts` would be misleading. Examples:
- `config/` has `Config`, `ConfigPaths`, `ConfigMarkdown`, `ConfigPlugin`,
`ConfigKeybinds`. Each lives in its own file with its own self-reexport
(`config/config.ts`, `config/plugin.ts`, etc.). Consumers import the
specific one: `import { ConfigPlugin } from "@/config/plugin"`.
- Same shape for `session/`, `server/`, etc.
Collapsing one of those into `index.ts` would mean picking a single
"canonical" namespace for the directory, which breaks the symmetry and
hides the other files.
## Scope
There are still dozens of `export namespace` files left across the codebase.
Each one is its own small PR. Do them one at a time, verified locally, rather
than batching by directory.
+4 -2
View File
@@ -181,10 +181,10 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const repo = yield* AccountRepo.Service
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
@@ -452,3 +452,5 @@ export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpCli
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export * as Account from "./account"
-24
View File
@@ -1,24 +0,0 @@
export * as Account from "./account"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccountTransportError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
type PollResult,
} from "./schema"
export type { AccountOrgs, ActiveOrg } from "./account"
+132 -132
View File
@@ -13,154 +13,154 @@ type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
const ACCOUNT_STATE_ID = 1
export namespace AccountRepo {
export interface Service {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
readonly persistToken: (input: {
accountID: AccountID
accessToken: AccessToken
refreshToken: RefreshToken
expiry: Option.Option<number>
}) => Effect.Effect<void, AccountRepoError>
readonly persistAccount: (input: {
id: AccountID
email: string
url: string
accessToken: AccessToken
refreshToken: RefreshToken
expiry: number
orgID: Option.Option<OrgID>
}) => Effect.Effect<void, AccountRepoError>
}
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
readonly persistToken: (input: {
accountID: AccountID
accessToken: AccessToken
refreshToken: RefreshToken
expiry: Option.Option<number>
}) => Effect.Effect<void, AccountRepoError>
readonly persistAccount: (input: {
id: AccountID
email: string
url: string
accessToken: AccessToken
refreshToken: RefreshToken
expiry: number
orgID: Option.Option<OrgID>
}) => Effect.Effect<void, AccountRepoError>
}
export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo,
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info)
export class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {}
const query = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
export const layer: Layer.Layer<Service> = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const tx = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
const id = Option.getOrNull(orgID)
return db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: id },
})
.run()
}
const tx = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const active = Effect.fn("AccountRepo.active")(() =>
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
)
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
const list = Effect.fn("AccountRepo.list")(() =>
query((db) =>
db
.select()
.from(AccountTable)
.all()
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
),
)
const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
const id = Option.getOrNull(orgID)
return db
.insert(AccountStateTable)
.values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id })
.onConflictDoUpdate({
target: AccountStateTable.id,
set: { active_account_id: accountID, active_org_id: id },
})
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
tx((db) => {
db.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
}
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}).pipe(Effect.asVoid),
)
const active = Effect.fn("AccountRepo.active")(() =>
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
)
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
)
const list = Effect.fn("AccountRepo.list")(() =>
query((db) =>
db
.select()
.from(AccountTable)
.all()
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
),
)
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
)
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
tx((db) => {
db.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}).pipe(Effect.asVoid),
)
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
query((db) =>
db
.update(AccountTable)
.set({
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: Option.getOrNull(input.expiry),
})
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
)
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
)
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
tx((db) => {
const url = normalizeServerUrl(input.url)
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
)
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
query((db) =>
db
.update(AccountTable)
.set({
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: Option.getOrNull(input.expiry),
})
.where(eq(AccountTable.id, input.accountID))
.run(),
).pipe(Effect.asVoid),
)
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
tx((db) => {
const url = normalizeServerUrl(input.url)
db.insert(AccountTable)
.values({
id: input.id,
db.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
void state(db, input.id, input.orgID)
}).pipe(Effect.asVoid),
)
},
})
.run()
void state(db, input.id, input.orgID)
}).pipe(Effect.asVoid),
)
return AccountRepo.of({
active,
list,
remove,
use,
getRow,
persistToken,
persistAccount,
})
}),
)
}
return Service.of({
active,
list,
remove,
use,
getRow,
persistToken,
persistAccount,
})
}),
)
export * as AccountRepo from "./repo"
File diff suppressed because it is too large Load Diff
+419 -420
View File
@@ -25,426 +25,425 @@ import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect"
import * as KiloAgent from "@/kilocode/agent" // kilocode_change
export namespace Agent {
export const Info = z
.object({
name: z.string(),
displayName: z.string().optional(), // kilocode_change - human-readable name for org modes
description: z.string().optional(),
deprecated: z.boolean().optional(), // kilocode_change
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset.zod,
model: z
.object({
modelID: ModelID.zod,
providerID: ProviderID.zod,
})
.optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Agent.Info>
readonly list: () => Effect.Effect<Agent.Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
// kilocode_change start - include global config dirs so agents can read them without prompting
const whitelistedDirs = [
Truncate.GLOB,
...skillDirs.map((dir) => path.join(dir, "*")),
path.join(Global.Path.config, "*"),
...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")),
]
// kilocode_change end
const baseDefaults = Permission.fromConfig({
// kilocode_change: renamed from defaults
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
suggest: "deny", // kilocode_change
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
// kilocode_change start - patch defaults with bash allowlist and recall permission
const kilo = KiloAgent.prepare(cfg)
const defaults = Permission.merge(baseDefaults, kilo.defaultsPatch)
// kilocode_change end
const user = Permission.fromConfig(cfg.permission ?? {})
const agents: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
suggest: "allow", // kilocode_change
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
"allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
// kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore
KiloAgent.patchAgents(agents, defaults, user, cfg, kilo)
// kilocode_change end
// kilocode_change start - preprocess config to remap "build" key → "code"
const agentConfigs = KiloAgent.preprocessConfig(cfg.agent ?? {})
for (const [key, value] of Object.entries(agentConfigs)) {
// kilocode_change end
if (value.disable) {
delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[KiloAgent.resolveKey(agent)] // kilocode_change - treat "build" as "code"
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config.get()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "code"), "desc"], // kilocode_change - renamed from "build" to "code"
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config.get()
if (c.default_agent) {
const effective = KiloAgent.resolveKey(c.default_agent) // kilocode_change - treat "build" as "code"
const agent = agents[effective] // kilocode_change
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
}
// kilocode_change start - prefer "code" as default agent (key order changes after rename from "build")
const code = agents.code
if (code && code.mode !== "subagent" && code.hidden !== true) return code.name
// kilocode_change end
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
})
return {
get,
list,
defaultAgent,
} satisfies State
}),
)
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())
const resolved = yield* provider.getModel(model.providerID, model.modelID)
const language = yield* provider.getLanguage(resolved)
const system = [PROMPT_GENERATE]
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = {
// kilocode_change start - enable telemetry with custom PostHog tracer
experimental_telemetry: KiloAgent.telemetryOptions(cfg),
// kilocode_change end
temperature: 0.3,
messages: [
...(isOpenaiOauth
? []
: system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
)),
{
role: "user",
content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
if (isOpenaiOauth) {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
}),
export const Info = z
.object({
name: z.string(),
displayName: z.string().optional(), // kilocode_change - human-readable name for org modes
description: z.string().optional(),
deprecated: z.boolean().optional(), // kilocode_change
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset.zod,
model: z
.object({
modelID: ModelID.zod,
providerID: ProviderID.zod,
})
}),
)
.optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
export const defaultLayer = layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
// kilocode_change start - agent removal (delegated to kilocode module)
export const RemoveError = KiloAgent.RemoveError
export async function remove(name: string) {
return KiloAgent.remove(name)
}
// kilocode_change end
// kilocode_change start - legacy promise helpers for Kilo callsites
const { runPromise } = makeRuntime(Service, defaultLayer)
export const get = (agent: string) => runPromise((svc) => svc.get(agent))
export const list = () => runPromise((svc) => svc.list())
export const defaultAgent = () => runPromise((svc) => svc.defaultAgent())
// kilocode_change end
export interface Interface {
readonly get: (agent: string) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const auth = yield* Auth.Service
const plugin = yield* Plugin.Service
const skill = yield* Skill.Service
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
// kilocode_change start - include global config dirs so agents can read them without prompting
const whitelistedDirs = [
Truncate.GLOB,
...skillDirs.map((dir) => path.join(dir, "*")),
path.join(Global.Path.config, "*"),
...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")),
]
// kilocode_change end
const baseDefaults = Permission.fromConfig({
// kilocode_change: renamed from defaults
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
suggest: "deny", // kilocode_change
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
// kilocode_change start - patch defaults with bash allowlist and recall permission
const kilo = KiloAgent.prepare(cfg)
const defaults = Permission.merge(baseDefaults, kilo.defaultsPatch)
// kilocode_change end
const user = Permission.fromConfig(cfg.permission ?? {})
const agents: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
suggest: "allow", // kilocode_change
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
// kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore
KiloAgent.patchAgents(agents, defaults, user, cfg, kilo)
// kilocode_change end
// kilocode_change start - preprocess config to remap "build" key → "code"
const agentConfigs = KiloAgent.preprocessConfig(cfg.agent ?? {})
for (const [key, value] of Object.entries(agentConfigs)) {
// kilocode_change end
if (value.disable) {
delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
KiloAgent.processConfigItem(item) // kilocode_change - populate displayName from options
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[KiloAgent.resolveKey(agent)] // kilocode_change - treat "build" as "code"
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config.get()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "code"), "desc"], // kilocode_change - renamed from "build" to "code"
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config.get()
if (c.default_agent) {
const effective = KiloAgent.resolveKey(c.default_agent) // kilocode_change - treat "build" as "code"
const agent = agents[effective] // kilocode_change
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
}
// kilocode_change start - prefer "code" as default agent (key order changes after rename from "build")
const code = agents.code
if (code && code.mode !== "subagent" && code.hidden !== true) return code.name
// kilocode_change end
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
})
return {
get,
list,
defaultAgent,
} satisfies State
}),
)
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())
const resolved = yield* provider.getModel(model.providerID, model.modelID)
const language = yield* provider.getLanguage(resolved)
const system = [PROMPT_GENERATE]
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = {
// kilocode_change start - enable telemetry with custom PostHog tracer
experimental_telemetry: KiloAgent.telemetryOptions(cfg),
// kilocode_change end
temperature: 0.3,
messages: [
...(isOpenaiOauth
? []
: system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
)),
{
role: "user",
content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
if (isOpenaiOauth) {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Plugin.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
// kilocode_change start - agent removal (delegated to kilocode module)
export const RemoveError = KiloAgent.RemoveError
export async function remove(name: string) {
return KiloAgent.remove(name)
}
// kilocode_change end
// kilocode_change start - legacy promise helpers for Kilo callsites
const { runPromise } = makeRuntime(Service, defaultLayer)
export const get = (agent: string) => runPromise((svc) => svc.get(agent))
export const list = () => runPromise((svc) => svc.list())
export const defaultAgent = () => runPromise((svc) => svc.defaultAgent())
// kilocode_change end
export * as Agent from "./agent"
-112
View File
@@ -1,112 +0,0 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
import { makeRuntime } from "@/effect/run-service" // kilocode_change
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
if (process.env.KILO_AUTH_CONTENT) {
try {
return JSON.parse(process.env.KILO_AUTH_CONTENT)
} catch (err) {}
}
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
// kilocode_change start - Track logout and reset telemetry identity for Kilo
if (key === "kilo") {
yield* Effect.promise(() => Telemetry.updateIdentity(null))
}
Telemetry.trackAuthLogout(key)
// kilocode_change end
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
// kilocode_change start - legacy promise helpers for Kilo callsites
const { runPromise } = makeRuntime(Service, defaultLayer)
export const get = (providerID: string) => runPromise((svc) => svc.get(providerID))
export const all = () => runPromise((svc) => svc.all())
export const set = (key: string, info: Info) => runPromise((svc) => svc.set(key, info))
export const remove = (key: string) => runPromise((svc) => svc.remove(key))
// kilocode_change end
+114 -2
View File
@@ -1,2 +1,114 @@
export * as Auth from "./auth"
export { OAUTH_DUMMY_KEY } from "./auth"
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
import { makeRuntime } from "@/effect/run-service" // kilocode_change
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
if (process.env.KILO_AUTH_CONTENT) {
try {
return JSON.parse(process.env.KILO_AUTH_CONTENT)
} catch (err) {}
}
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
// kilocode_change start - Track logout and reset telemetry identity for Kilo
if (key === "kilo") {
yield* Effect.promise(() => Telemetry.updateIdentity(null))
}
Telemetry.trackAuthLogout(key)
// kilocode_change end
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
// kilocode_change start - legacy promise helpers for Kilo callsites
const { runPromise } = makeRuntime(Service, defaultLayer)
export const get = (providerID: string) => runPromise((svc) => svc.get(providerID))
export const all = () => runPromise((svc) => svc.all())
export const set = (key: string, info: Info) => runPromise((svc) => svc.set(key, info))
export const remove = (key: string) => runPromise((svc) => svc.remove(key))
// kilocode_change end
export * as Auth from "."
+26 -26
View File
@@ -1,33 +1,33 @@
import z from "zod"
import type { ZodType } from "zod"
export namespace BusEvent {
export type Definition = ReturnType<typeof define>
export type Definition = ReturnType<typeof define>
const registry = new Map<string, Definition>()
const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
}
registry.set(type, result)
return result
}
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
const result = {
type,
properties,
}
registry.set(type, result)
return result
}
export function payloads() {
return registry
.entries()
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: `Event.${def.type}`,
})
})
.toArray()
}
export * as BusEvent from "./bus-event"
+2 -2
View File
@@ -1,8 +1,8 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { type AccountError } from "@/account/schema"
import { Account } from "@/account/account"
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime"
import * as Prompt from "../effect/prompt"
import open from "open"
+10 -6
View File
@@ -157,7 +157,16 @@ export function tui(input: {
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
@@ -351,7 +360,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
local.model.set({ providerID, modelID }, { recent: true })
}
// Handle --session without --fork immediately (fork is handled in createEffect below)
if (args.sessionID && !args.fork) {
route.navigate({
type: "session",
@@ -438,12 +446,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
aliases: ["clear"],
},
onSelect: () => {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
})
dialog.clear()
},
@@ -0,0 +1,130 @@
import { BoxRenderable, RGBA } from "@opentui/core"
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
import { tint, useTheme } from "@tui/context/theme"
const PERIOD = 4600
const RINGS = 3
const WIDTH = 3.8
const TAIL = 9.5
const AMP = 0.55
const TAIL_AMP = 0.16
const BREATH_AMP = 0.05
const BREATH_SPEED = 0.0008
// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
const PHASE_OFFSET = 0.29
export type BgPulseMask = {
x: number
y: number
width: number
height: number
pad?: number
strength?: number
}
export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
const { theme } = useTheme()
const [now, setNow] = createSignal(performance.now())
const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
let box: BoxRenderable | undefined
const timer = setInterval(() => setNow(performance.now()), 50)
onCleanup(() => clearInterval(timer))
const sync = () => {
if (!box) return
setSize({ width: box.width, height: box.height })
}
onMount(() => {
sync()
box?.on("resize", sync)
})
onCleanup(() => {
box?.off("resize", sync)
})
const grid = createMemo(() => {
const t = now()
const w = size().width
const h = size().height
if (w === 0 || h === 0) return [] as RGBA[][]
const cxv = props.centerX ?? w / 2
const cyv = props.centerY ?? h / 2
const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
const ringStates = Array.from({ length: RINGS }, (_, i) => {
const offset = i / RINGS
const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
const envelope = Math.sin(phase * Math.PI)
const eased = envelope * envelope * (3 - 2 * envelope)
return {
head: phase * reach,
eased,
}
})
const normalizedMasks = props.masks?.map((m) => {
const pad = m.pad ?? 2
return {
left: m.x - pad,
right: m.x + m.width + pad,
top: m.y - pad,
bottom: m.y + m.height + pad,
pad,
strength: m.strength ?? 0.85,
}
})
const rows = [] as RGBA[][]
for (let y = 0; y < h; y++) {
const row = [] as RGBA[]
for (let x = 0; x < w; x++) {
const dx = x + 0.5 - cxv
const dy = (y + 0.5 - cyv) * 2
const dist = Math.hypot(dx, dy)
let level = 0
for (const ring of ringStates) {
const delta = dist - ring.head
const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
level += (crest * AMP + tail * TAIL_AMP) * ring.eased
}
const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
let maskAtten = 1
if (normalizedMasks) {
for (const m of normalizedMasks) {
if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
const inX = Math.min(x - m.left, m.right - x)
const inY = Math.min(y - m.top, m.bottom - y)
const edge = Math.min(inX / m.pad, inY / m.pad, 1)
const eased = edge * edge * (3 - 2 * edge)
const reduce = 1 - m.strength * eased
if (reduce < maskAtten) maskAtten = reduce
}
}
const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
}
rows.push(row)
}
return rows
})
return (
<box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
<For each={grid()}>
{(row) => (
<box flexDirection="row">
<For each={row}>
{(color) => (
<text bg={color} fg={color} selectable={false}>
{" "}
</text>
)}
</For>
</box>
)}
</For>
</box>
)
}
@@ -1,12 +1,16 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open"
import { createSignal } from "solid-js"
import { createSignal, onCleanup, onMount } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
import { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
const PAD_TOP_OUTER = 1
export type DialogGoUpsellProps = {
onClose?: (dontShowAgain?: boolean) => void
@@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
const dialog = useDialog()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const [selected, setSelected] = createSignal(0)
const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
let content: BoxRenderable | undefined
let logoBox: BoxRenderable | undefined
let headingBox: BoxRenderable | undefined
let descBox: BoxRenderable | undefined
let buttonsBox: BoxRenderable | undefined
const sync = () => {
if (!content || !logoBox) return
setCenter({
x: logoBox.x - content.x + logoBox.width / 2,
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
})
const next: BgPulseMask[] = []
const baseY = PAD_TOP_OUTER
for (const b of [headingBox, descBox, buttonsBox]) {
if (!b) continue
next.push({
x: b.x - content.x,
y: b.y - content.y + baseY,
width: b.width,
height: b.height,
pad: 2,
strength: 0.78,
})
}
setMasks(next)
}
onMount(() => {
sync()
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
})
onCleanup(() => {
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === 0 ? 1 : 0))
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
return
}
if (evt.name !== "return") return
if (selected() === 0) subscribe(props, dialog)
else dismiss(props, dialog)
if (evt.name === "return") {
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box ref={(item: BoxRenderable) => (content = item)}>
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
</box>
<box gap={1} paddingBottom={1}>
<text fg={theme.textMuted}>
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
$5/month.
</text>
<box flexDirection="row" gap={1}>
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
<box flexDirection="row">
<text fg={theme.textMuted}>Subscribe to </text>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
OpenCode Go
</text>
<text fg={theme.textMuted}> for reliable access to the</text>
</box>
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
</box>
<box alignItems="center" gap={1} paddingBottom={1}>
<box ref={(item: BoxRenderable) => (logoBox = item)}>
<GoLogo />
</box>
<Link href={GO_URL} fg={theme.primary} />
</box>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(0)}
onMouseUp={() => subscribe(props, dialog)}
>
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
subscribe
</text>
</box>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(1)}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === 1 ? fg : theme.textMuted}
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
don't show again
</text>
<text
fg={selected() === "dismiss" ? fg : theme.textMuted}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("subscribe")}
onMouseUp={() => subscribe(props, dialog)}
>
<text
fg={selected() === "subscribe" ? fg : theme.text}
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
>
subscribe
</text>
</box>
</box>
</box>
</box>
@@ -137,7 +137,11 @@ export function DialogSessionList() {
const all = global() // kilocode_change
return sessions()
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.toSorted((a, b) => {
const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
if (updatedDay !== 0) return updatedDay
return b.time.created - a.time.created
})
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
@@ -229,6 +229,10 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
log.error("workspace create request failed", {
type,
error: errorData(err),
@@ -1,9 +1,62 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import { KiloLogo } from "./kilo-logo" // kilocode_change
import * as Sound from "@tui/util/sound"
import { logo } from "@/cli/logo"
import { go, logo } from "@/cli/logo"
export type LogoShape = {
left: string[]
right: string[]
}
type ShimmerConfig = {
period: number
rings: number
sweepFraction: number
coreWidth: number
coreAmp: number
softWidth: number
softAmp: number
tail: number
tailAmp: number
haloWidth: number
haloOffset: number
haloAmp: number
breathBase: number
noise: number
ambientAmp: number
ambientCenter: number
ambientWidth: number
shadowMix: number
primaryMix: number
originX: number
originY: number
}
const shimmerConfig: ShimmerConfig = {
period: 4600,
rings: 2,
sweepFraction: 1,
coreWidth: 1.2,
coreAmp: 1.9,
softWidth: 10,
softAmp: 1.6,
tail: 5,
tailAmp: 0.64,
haloWidth: 4.3,
haloOffset: 0.6,
haloAmp: 0.16,
breathBase: 0.04,
noise: 0.1,
ambientAmp: 0.36,
ambientCenter: 0.5,
ambientWidth: 0.34,
shadowMix: 0.1,
primaryMix: 0.3,
originX: 4.5,
originY: 13.5,
}
// Shadow markers (rendered chars in parens):
// _ = full shadow cell (space with bg=shadow)
@@ -75,9 +128,6 @@ type Frame = {
spark: number
}
const LEFT = logo.left[0]?.length ?? 0
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
const NEAR = [
[1, 0],
[1, 1],
@@ -141,7 +191,7 @@ function noise(x: number, y: number, t: number) {
}
function lit(char: string) {
return char !== " " && char !== "_" && char !== "~"
return char !== " " && char !== "_" && char !== "~" && char !== ","
}
function key(x: number, y: number) {
@@ -189,12 +239,12 @@ function route(list: Array<{ x: number; y: number }>) {
return path
}
function mapGlyphs() {
function mapGlyphs(full: string[]) {
const cells = [] as Array<{ x: number; y: number }>
for (let y = 0; y < FULL.length; y++) {
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
for (let y = 0; y < full.length; y++) {
for (let x = 0; x < (full[y]?.length ?? 0); x++) {
if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
}
}
@@ -238,9 +288,25 @@ function mapGlyphs() {
return { glyph, trace, center }
}
const MAP = mapGlyphs()
type LogoContext = {
LEFT: number
FULL: string[]
SPAN: number
MAP: ReturnType<typeof mapGlyphs>
shape: LogoShape
}
function shimmer(x: number, y: number, frame: Frame) {
function build(shape: LogoShape): LogoContext {
const LEFT = shape.left[0]?.length ?? 0
const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
}
const DEFAULT = build(logo)
const GO = build(go)
function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
return frame.list.reduce((best, item) => {
const age = frame.t - item.at
if (age < SHIMMER_IN || age > LIFE) return best
@@ -248,7 +314,7 @@ function shimmer(x: number, y: number, frame: Frame) {
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const p = age / LIFE
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
const lag = r - dist
if (lag < 0.18 || lag > SHIMMER_OUT) return best
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
@@ -259,19 +325,19 @@ function shimmer(x: number, y: number, frame: Frame) {
}, 0)
}
function remain(x: number, y: number, item: Release, t: number) {
function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
const age = t - item.at
if (age < 0 || age > LIFE) return 0
const p = age / LIFE
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
if (dist > r) return 1
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
}
function wave(x: number, y: number, frame: Frame, live: boolean) {
function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
return frame.list.reduce((sum, item) => {
const age = frame.t - item.at
if (age < 0 || age > LIFE) return sum
@@ -279,7 +345,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
const fade = (1 - p) ** 1.32
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
@@ -293,7 +359,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
}, 0)
}
function field(x: number, y: number, frame: Frame) {
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
@@ -327,11 +393,11 @@ function field(x: number, y: number, frame: Frame) {
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
Math.exp(-(dist * dist) / 0.15) *
lerp(0.08, 0.42, body)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
}
function pick(x: number, y: number, frame: Frame) {
function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
@@ -340,26 +406,26 @@ function pick(x: number, y: number, frame: Frame) {
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
}
function select(x: number, y: number) {
const direct = MAP.glyph.get(key(x, y))
function select(x: number, y: number, ctx: LogoContext) {
const direct = ctx.MAP.glyph.get(key(x, y))
if (direct !== undefined) return direct
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
(item): item is number => item !== undefined,
)
return near
}
function trace(x: number, y: number, frame: Frame) {
function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item || item.glyph === undefined) return 0
const step = MAP.trace.get(key(x, y))
const step = ctx.MAP.trace.get(key(x, y))
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
const age = frame.t - item.at
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
@@ -369,269 +435,131 @@ function trace(x: number, y: number, frame: Frame) {
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
const tail = (head - TAIL + step.l) % step.l
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
return (core + glow + trail) * appear * fade
}
function bloom(x: number, y: number, frame: Frame) {
function idle(
x: number,
pixelY: number,
frame: Frame,
ctx: LogoContext,
state: IdleState,
): { glow: number; peak: number; primary: number } {
const cfg = state.cfg
const dx = x + 0.5 - cfg.originX
const dy = pixelY - cfg.originY
const dist = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
const traveled = dist + jitter
let glow = 0
let peak = 0
let halo = 0
let primary = 0
let ambient = 0
for (const active of state.active) {
const head = active.head
const eased = active.eased
const delta = traveled - head
// Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
// so adjacent pixels have smaller brightness deltas
const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
const tailRange = cfg.tail * 2.6
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
const haloDelta = delta + cfg.haloOffset
const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
peak += core * cfg.coreAmp * eased
halo += haloBand * cfg.haloAmp * eased
// Primary-tinted fringe follows the halo (which trails behind the core) and the tail
primary += (haloBand + tail * 0.6) * eased
ambient += active.ambient
}
ambient /= state.rings
return {
glow: glow / state.rings,
peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
primary: (primary / state.rings) * cfg.primaryMix,
}
}
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
const item = frame.glow
if (!item) return 0
const glyph = MAP.glyph.get(key(x, y))
const glyph = ctx.MAP.glyph.get(key(x, y))
if (glyph !== item.glyph) return 0
const age = frame.t - item.at
if (age < 0 || age > GLOW_OUT) return 0
const p = age / GLOW_OUT
const flash = (1 - p) ** 2
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
}
export function Logo() {
// kilocode_change start
return <KiloLogo />
// kilocode_change end
const { theme } = useTheme()
const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>()
const [release, setRelease] = createSignal<Release>()
const [glow, setGlow] = createSignal<Glow>()
const [now, setNow] = createSignal(0)
let box: BoxRenderable | undefined
let timer: ReturnType<typeof setInterval> | undefined
let hum = false
const stop = () => {
if (!timer) return
clearInterval(timer)
timer = undefined
}
const tick = () => {
const t = performance.now()
setNow(t)
const item = hold()
if (item && !hum && t - item.at >= HOLD) {
hum = true
Sound.start()
}
if (item && t - item.at >= CHARGE) {
burst(item.x, item.y)
}
let live = false
setRings((list) => {
const next = list.filter((item) => t - item.at < LIFE)
live = next.length > 0
return next
})
const flash = glow()
if (flash && t - flash.at >= GLOW_OUT) {
setGlow(undefined)
}
if (!live) setRelease(undefined)
if (live || hold() || release() || glow()) return
stop()
}
const start = () => {
if (timer) return
timer = setInterval(tick, 16)
}
const hit = (x: number, y: number) => {
const char = FULL[y]?.[x]
return char !== undefined && char !== " "
}
const press = (x: number, y: number, t: number) => {
const last = hold()
if (last) burst(last.x, last.y)
setNow(t)
if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y) })
hum = false
start()
}
const burst = (x: number, y: number) => {
const item = hold()
if (!item) return
hum = false
const t = performance.now()
const age = t - item.at
const rise = ramp(age, HOLD, CHARGE)
const level = push(rise)
setHold(undefined)
setRelease({ x, y, at: t, glyph: item.glyph, level, rise })
if (item.glyph !== undefined) {
setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) })
}
setRings((list) => [
...list,
{
x: x + 0.5,
y: y * 2 + 1,
at: t,
force: lerp(0.82, 2.55, level),
kick: lerp(0.32, 0.32 + KICK, level),
},
])
setNow(t)
start()
Sound.pulse(lerp(0.8, 1, level))
}
const frame = createMemo(() => {
const t = now()
const item = hold()
return {
t,
list: rings(),
hold: item,
release: release(),
glow: glow(),
spark: item ? noise(item.x, item.y, t) : 0,
}
})
const dusk = createMemo(() => {
const base = frame()
const t = base.t - LAG
const item = base.hold
return {
t,
list: base.list,
hold: item,
release: base.release,
glow: base.glow,
spark: item ? noise(item.x, item.y, t) : 0,
}
})
const renderLine = (
line: string,
y: number,
ink: RGBA,
bold: boolean,
off: number,
frame: Frame,
dusk: Frame,
): JSX.Element[] => {
const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char, i) => {
const h = field(off + i, y, frame)
const n = wave(off + i, y, frame, lit(char)) + h
const s = wave(off + i, y, dusk, false) + h
const p = lit(char) ? pick(off + i, y, frame) : 0
const e = lit(char) ? trace(off + i, y, frame) : 0
const b = lit(char) ? bloom(off + i, y, frame) : 0
const q = shimmer(off + i, y, frame)
if (char === "_") {
return (
<text
fg={shade(ink, theme, s * 0.08)}
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
attributes={attrs}
selectable={false}
>
{" "}
</text>
)
}
if (char === "^") {
return (
<text
fg={shade(ink, theme, n + p + e + b)}
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
attributes={attrs}
selectable={false}
>
</text>
)
}
if (char === "~") {
return (
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text>
)
}
if (char === " ") {
return (
<text fg={ink} attributes={attrs} selectable={false}>
{char}
</text>
)
}
return (
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
{char}
</text>
)
})
}
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
const mouse = (evt: MouseEvent) => {
if (!box) return
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
const x = evt.x - box.x
const y = evt.y - box.y
if (!hit(x, y)) return
if (evt.type === "drag" && hold()) return
evt.preventDefault()
evt.stopPropagation()
const t = performance.now()
press(x, y, t)
return
}
if (!hold()) return
if (evt.type === "up") {
const item = hold()
if (!item) return
burst(item.x, item.y)
}
}
return (
<box ref={(item: BoxRenderable) => (box = item)}>
<box
position="absolute"
top={0}
left={0}
width={FULL[0]?.length ?? 0}
height={FULL.length}
zIndex={1}
onMouse={mouse}
/>
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
<box flexDirection="row">
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
</box>
</box>
)}
</For>
</box>
)
type IdleState = {
cfg: ShimmerConfig
reach: number
rings: number
active: Array<{
head: number
eased: number
ambient: number
}>
}
function buildIdleState(t: number, ctx: LogoContext): IdleState {
const cfg = shimmerConfig
const w = ctx.FULL[0]?.length ?? 1
const h = ctx.FULL.length * 2
const corners: [number, number][] = [
[0, 0],
[w, 0],
[0, h],
[w, h],
]
let maxCorner = 0
for (const [cx, cy] of corners) {
const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
if (d > maxCorner) maxCorner = d
}
const reach = maxCorner + cfg.tail * 2
const rings = Math.max(1, Math.floor(cfg.rings))
const active = [] as IdleState["active"]
for (let i = 0; i < rings; i++) {
const offset = i / rings
const cyclePhase = (t / cfg.period + offset) % 1
if (cyclePhase >= cfg.sweepFraction) continue
const phase = cyclePhase / cfg.sweepFraction
const envelope = Math.sin(phase * Math.PI)
const eased = envelope * envelope * (3 - 2 * envelope)
const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
active.push({
head: phase * reach,
eased,
ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
})
}
return { cfg, reach, rings, active }
}
// kilocode_change start
export function Logo(_props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
return <KiloLogo />
}
// kilocode_change end
export function GoLogo() {
const { theme } = useTheme()
const base = tint(theme.background, theme.text, 0.62)
return <Logo shape={go} ink={base} idle />
}
@@ -1,4 +1,4 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
@@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { assign } from "./part"
@@ -35,6 +35,7 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "@tui/context/args"
@@ -75,6 +76,12 @@ function randomIndex(count: number) {
return Math.floor(Math.random() * count)
}
function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
@@ -95,6 +102,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
@@ -435,7 +443,22 @@ export function Prompt(props: PromptProps) {
},
}
onMount(() => {
const saved = stashed
stashed = undefined
if (store.prompt.input) return
if (saved && saved.prompt.input) {
input.setText(saved.prompt.input)
setStore("prompt", saved.prompt)
restoreExtmarksFromParts(saved.prompt.parts)
input.cursorOffset = saved.cursor
}
})
onCleanup(() => {
if (store.prompt.input) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
props.ref?.(undefined)
})
@@ -844,6 +867,13 @@ export function Prompt(props: PromptProps) {
return !!current
})
const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
const variantMetaAlpha = createFadeIn(
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
if (store.mode === "shell") {
@@ -1139,7 +1169,7 @@ export function Prompt(props: PromptProps) {
<Show when={local.agent.current()} fallback={<box height={1} />}>
{(agent) => (
<>
<text fg={highlight()}>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
{/* kilocode_change start */}
{store.mode === "shell"
? "Shell"
@@ -1149,14 +1179,19 @@ export function Prompt(props: PromptProps) {
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
{local.model.variant.current()}
</span>
</text>
</Show>
</box>
@@ -26,7 +26,6 @@ const TuiLegacy = z
interface MigrateInput {
cwd: string
directories: string[]
custom?: string
}
/**
@@ -134,8 +133,11 @@ async function backupAndStripLegacy(file: string, source: string) {
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
// kilocode_change start: use kilo directory everywhere
const project = Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("kilo", input.cwd)
const project = Flag.KILO_DISABLE_PROJECT_CONFIG
? []
: await Filesystem.findUp(["kilo.json", "kilo.jsonc"], input.cwd, undefined, { rootFirst: true })
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "kilo")]
// kilocode_change end
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "kilo"))
}
@@ -149,4 +151,3 @@ async function opencodeFiles(input: { directories: string[]; cwd: string }) {
)
return existing.filter((file): file is string => !!file)
}
// kilocode_change end
+187 -184
View File
@@ -1,3 +1,5 @@
export * as TuiConfig from "./tui"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
@@ -16,201 +18,202 @@ import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export const Info = TuiInfo
type Acc = {
result: Info
}
type Acc = {
result: Info
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[]
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[]
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void>
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly waitForDependencies: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope {
if (Filesystem.contains(ctx.directory, file)) return "local"
// if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
return "global"
}
function customPath() {
return Flag.KILO_TUI_CONFIG
}
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return data
}
const tui = data.tui
function normalize(raw: Record<string, unknown>) {
const data = { ...raw }
if (!("tui" in data)) return data
if (!isRecord(data.tui)) {
delete data.tui
return {
...tui,
...data,
}
return data
}
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string }) {
let projectFiles = Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const directories = await ConfigPaths.directories(ctx.directory)
const custom = customPath()
await migrateTuiConfig({ directories, custom, cwd: ctx.directory })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const acc: Acc = {
result: {},
}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
}
if (custom) {
await mergeFile(acc, custom, ctx)
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
}
// kilocode_change - also load tui.json from .kilo/.kilocode
const dirs = unique(directories).filter(
(dir) =>
dir.endsWith(".kilo") || dir.endsWith(".kilocode") || dir.endsWith(".opencode") || dir === Flag.KILO_CONFIG_DIR,
)
for (const dir of dirs) {
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
}
}
const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* Effect.promise(() => loadState({ directory }))
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigParse.load(Info, text, {
type: "path",
path: configFilepath,
missing: "empty",
normalize: (data) => {
if (!isRecord(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
return normalize(data)
},
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
return {}
})
const tui = data.tui
delete data.tui
return {
...tui,
...data,
}
}
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and KILO_CONFIG_DIR.
const directories = yield* ConfigPaths.directories(ctx.directory)
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
const projectFiles = Flag.KILO_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
const acc: Acc = {
result: {},
}
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 2. Explicit KILO_TUI_CONFIG override, if set.
if (Flag.KILO_TUI_CONFIG) {
const configFile = Flag.KILO_TUI_CONFIG
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
log.debug("loaded custom tui config", { path: configFile })
}
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 4. `.opencode` directories (and KILO_CONFIG_DIR) discovered while
// walking up the tree. Also returned below so callers can install plugin
// dependencies from each location.
// kilocode_change start - also load tui.json from .kilo/.kilocode
const dirs = unique(directories).filter(
(dir) =>
dir.endsWith(".kilo") || dir.endsWith(".kilocode") || dir.endsWith(".opencode") || dir === Flag.KILO_CONFIG_DIR,
)
// kilocode_change end
for (const dir of dirs) {
// if (!dir.endsWith(".opencode") && dir !== Flag.KILO_CONFIG_DIR) continue // kilocode_change
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
}
const keybinds = { ...(acc.result.keybinds ?? {}) }
if (process.platform === "win32") {
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
keybinds.terminal_suspend = "none"
keybinds.input_undo ??= unique([
"ctrl+z",
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* loadState({ directory })
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(Effect.forkScoped),
{
concurrency: "unbounded",
},
)
const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
)
return Service.of({ get, waitForDependencies })
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
.then((data) => {
if (!isRecord(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
return ConfigParse.schema(Info, normalize(data), configFilepath)
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
return {}
})
}
@@ -12,7 +12,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [store, setStore] = createStore<Record<string, any>>()
const filePath = path.join(Global.Path.state, "kv.json")
Filesystem.readJson(filePath)
Filesystem.readJson<Record<string, any>>(filePath)
.then((x) => {
setStore(x)
})
@@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
name: "Project",
init: () => {
const sdk = useSDK()
const defaultPath = {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
path: {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
path: defaultPath,
},
workspace: {
current: undefined as string | undefined,
@@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
])
batch(() => {
setStore("instance", "path", reconcile(path.data!))
setStore("instance", "path", reconcile(path.data || defaultPath))
setStore("project", "id", project.data?.id)
})
}
@@ -1,16 +1,16 @@
import { createStore, unwrap } from "solid-js/store"
import { createStore, reconcile, unwrap } from "solid-js/store" // kilocode_change
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
initialPrompt?: PromptInfo
prompt?: PromptInfo
}
export type SessionRoute = {
type: "session"
sessionID: string
initialPrompt?: PromptInfo
prompt?: PromptInfo
}
// kilocode_change start
@@ -29,13 +29,14 @@ export type Route = HomeRoute | SessionRoute | PluginRoute | KiloClawRoute // ki
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: () => {
init: (props: { initialRoute?: Route }) => {
const [store, setStore] = createStore<Route>(
process.env["KILO_ROUTE"]
? JSON.parse(process.env["KILO_ROUTE"])
: {
type: "home",
},
props.initialRoute ??
(process.env["KILO_ROUTE"]
? JSON.parse(process.env["KILO_ROUTE"])
: {
type: "home",
}),
)
// kilocode_change start
@@ -48,7 +49,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
},
navigate(route: Route) {
previous = structuredClone(unwrap(store)) // kilocode_change
setStore(route)
setStore(reconcile(route))
},
// kilocode_change start
back() {
@@ -29,7 +29,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js"
import { batch, createEffect, on, onMount } from "solid-js" // kilocode_change - add createEffect/on for workspace re-bootstrap
import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kilocode_change
import { useToast } from "@tui/ui/toast" // kilocode_change
import { Log } from "@/util"
@@ -125,8 +125,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK()
const toast = useToast() // kilocode_change
const fullSyncedSessions = new Set<string>() // kilocode_change
// kilocode_change start
function evict(sessionID: string) {
// Collect child session IDs so we can evict them too.
@@ -159,6 +157,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
// kilocode_change end
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@@ -475,9 +476,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const exit = useExit()
const args = useArgs()
async function bootstrap() {
console.log("bootstrapping")
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
if (workspace !== syncedWorkspace) {
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
@@ -593,10 +598,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
await exit(e)
if (fatal) {
await exit(e)
} else {
throw e
}
})
}
onMount(() => {
void bootstrap()
})
// kilocode_change start - re-bootstrap when workspace changes (Agent Manager)
createEffect(
on(
() => project.workspace.current(),
@@ -604,8 +618,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.clear()
void bootstrap()
},
{ defer: true },
),
)
// kilocode_change end
const result = {
data: store,
@@ -614,6 +630,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.status
},
get ready() {
return true
if (process.env.KILO_FAST_BOOT) return true
return store.status !== "loading"
},
get path() {
@@ -410,7 +410,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (store.lock) return
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
// renderer.on(CliRenderEvents.THEME_MODE, handle)
const refresh = () => {
renderer.clearPaletteCache()
@@ -91,7 +91,7 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
name: "session",
params: {
sessionID: route.data.sessionID,
initialPrompt: route.data.initialPrompt,
prompt: route.data.prompt,
},
}
}
@@ -918,113 +918,113 @@ async function installPluginBySpec(
}
}
export namespace TuiPluginRuntime {
let dir = ""
let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
let dir = ""
let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
}
return loaded
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
}
dir = cwd
loaded = load(input)
return loaded
}
export function list() {
if (!runtime) return []
return listPluginStatus(runtime)
}
dir = cwd
loaded = load(input)
return loaded
}
export async function activatePlugin(id: string) {
return activatePluginById(runtime, id, true)
}
export function list() {
if (!runtime) return []
return listPluginStatus(runtime)
}
export async function deactivatePlugin(id: string) {
return deactivatePluginById(runtime, id, true)
}
export async function activatePlugin(id: string) {
return activatePluginById(runtime, id, true)
}
export async function addPlugin(spec: string) {
return addPluginBySpec(runtime, spec)
}
export async function deactivatePlugin(id: string) {
return deactivatePluginById(runtime, id, true)
}
export async function installPlugin(spec: string, options?: { global?: boolean }) {
return installPluginBySpec(runtime, spec, options?.global)
}
export async function addPlugin(spec: string) {
return addPluginBySpec(runtime, spec)
}
export async function dispose() {
const task = loaded
loaded = undefined
dir = ""
if (task) await task
const state = runtime
runtime = undefined
if (!state) return
const queue = [...state.plugins].reverse()
for (const plugin of queue) {
await deactivatePluginEntry(state, plugin, false)
}
}
export async function installPlugin(spec: string, options?: { global?: boolean }) {
return installPluginBySpec(runtime, spec, options?.global)
}
async function load(input: { api: Api; config: TuiConfig.Info }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
const next: RuntimeState = {
directory: cwd,
api,
slots,
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
}
runtime = next
try {
await Instance.provide({
directory: cwd,
fn: async () => {
const records = Flag.KILO_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.KILO_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) {
if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
},
})
} catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
}
export async function dispose() {
const task = loaded
loaded = undefined
dir = ""
if (task) await task
const state = runtime
runtime = undefined
if (!state) return
const queue = [...state.plugins].reverse()
for (const plugin of queue) {
await deactivatePluginEntry(state, plugin, false)
}
}
async function load(input: { api: Api; config: TuiConfig.Info }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
const next: RuntimeState = {
directory: cwd,
api,
slots,
plugins: [],
plugins_by_id: new Map(),
pending: new Map(),
}
runtime = next
try {
await Instance.provide({
directory: cwd,
fn: async () => {
const records = Flag.KILO_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.KILO_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
id: entry.id,
load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
}
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) {
if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false)
}
},
})
} catch (error) {
fail("failed to load tui plugins", { directory: cwd, error })
}
}
export * as TuiPluginRuntime from "./runtime"
@@ -10,7 +10,6 @@ import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin"
// TODO: what is the best way to do this?
let once = false
const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
@@ -31,8 +30,8 @@ export function Home() {
setRef(r)
promptRef.set(r)
if (once || !r) return
if (route.initialPrompt) {
r.set(route.initialPrompt)
if (route.prompt) {
r.set(route.prompt)
once = true
return
}
@@ -38,7 +38,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
messageID: message.id,
})
const parts = sync.data.part[message.id] ?? []
const initialPrompt = parts.reduce(
const prompt = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
@@ -51,7 +51,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
route.navigate({
sessionID: forked.data!.id,
type: "session",
initialPrompt,
prompt,
})
dialog.clear()
},
@@ -81,25 +81,23 @@ export function DialogMessage(props: {
sessionID: props.sessionID,
messageID: props.messageID,
})
const initialPrompt = (() => {
const msg = message()
if (!msg) return undefined
const parts = sync.data.part[msg.id]
return parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
})()
const msg = message()
const prompt = msg
? sync.data.part[msg.id].reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
: undefined
route.navigate({
sessionID: result.data!.id,
type: "session",
initialPrompt,
prompt,
})
dialog.clear()
},
@@ -48,7 +48,6 @@ import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { parsePatch } from "diff"
import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
@@ -88,6 +87,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
addDefaultParsers(parsers.parsers)
@@ -262,27 +262,32 @@ export function Session() {
const sdk = useSDK()
createEffect(async () => {
await sdk.client.session
.get({ sessionID: route.sessionID }, { throwOnError: true })
.then((x) => {
project.workspace.set(x.data?.workspaceID)
})
.then(() => sync.session.sync(route.sessionID))
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
.catch((e) => {
console.error(e)
toast.show({
message: `Session not found: ${route.sessionID}`,
variant: "error",
})
return navigate({ type: "home" })
const previousWorkspace = project.workspace.current()
const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
if (!result.data) {
toast.show({
message: `Session not found: ${route.sessionID}`,
variant: "error",
})
navigate({ type: "home" })
return
}
if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)
// Sync all the data for this workspace. Note that this
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
try {
await sync.bootstrap({ fatal: false })
} catch (e) {}
}
await sync.session.sync(route.sessionID)
if (scroll) scroll.scrollBy(100_000)
})
// Handle initial prompt from fork
let seeded = false
let lastSwitch: string | undefined = undefined
event.on("message.part.updated", (evt) => {
const part = evt.properties.part
@@ -298,14 +303,15 @@ export function Session() {
}
})
let seeded = false
let scroll: ScrollBoxRenderable
let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => {
prompt = r
promptRef.set(r)
if (seeded || !route.initialPrompt || !r) return
if (seeded || !route.prompt || !r) return
seeded = true
r.set(route.initialPrompt)
r.set(route.prompt)
}
const keybind = useKeybind()
const dialog = useDialog()
@@ -1075,31 +1081,7 @@ export function Session() {
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
const revertDiffFiles = createMemo(() => {
const diffText = revertInfo()?.diff ?? ""
if (!diffText) return []
try {
const patches = parsePatch(diffText)
return patches.map((patch) => {
const filename = patch.newFileName || patch.oldFileName || "unknown"
const cleanFilename = filename.replace(/^[ab]\//, "")
return {
filename: cleanFilename,
additions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length,
0,
),
deletions: patch.hunks.reduce(
(sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length,
0,
),
}
})
} catch {
return []
}
})
const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
const revertRevertedMessages = createMemo(() => {
const messageID = revertMessageID()
@@ -0,0 +1,18 @@
import { parsePatch } from "diff"
export function getRevertDiffFiles(diffText: string) {
if (!diffText) return []
try {
return parsePatch(diffText).map((patch) => {
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
return {
filename: filename.replace(/^[ab]\//, ""),
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
}
})
} catch {
return []
}
}
@@ -1,7 +1,38 @@
import { createSignal, type Accessor } from "solid-js"
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { debounce, type Scheduled } from "@solid-primitives/scheduled"
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
const [get, set] = createSignal(value)
return [get, debounce((v: T) => set(() => v), ms)]
}
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
createEffect(
on([show, enabled], ([visible, animate], previous) => {
if (!visible) {
setAlpha(0)
return
}
if (!animate || !previous) {
setAlpha(1)
return
}
const start = performance.now()
setAlpha(0)
const timer = setInterval(() => {
const progress = Math.min((performance.now() - start) / 160, 1)
setAlpha(progress * progress * (3 - 2 * progress))
if (progress >= 1) clearInterval(timer)
}, 16)
onCleanup(() => clearInterval(timer))
}),
)
return alpha
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const memoMap = Layer.makeMemoMapUnsafe()
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () =>
(rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
(rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
+4 -4
View File
@@ -28,10 +28,10 @@ export function FormatError(input: unknown) {
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
const data = (input as ErrorLike).data
const suggestions = data?.suggestions as string[] | undefined
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
return [
`Model not found: ${data?.providerID}/${data?.modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`kilo models\` to list available models`, // kilocode_change
`Or check your config (opencode.json) provider/model names`,
].join("\n")
@@ -64,10 +64,10 @@ export function FormatError(input: unknown) {
const data = (input as ErrorLike).data
const path = data?.path
const message = data?.message
const issues = data?.issues as Array<{ message: string; path: string[] }> | undefined
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...(issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n")
}
+41 -41
View File
@@ -8,52 +8,52 @@ const log = Log.create({ service: "heap" })
const MINUTE = 60_000
const LIMIT = 2 * 1024 * 1024 * 1024
export namespace Heap {
let timer: Timer | undefined
let lock = false
let armed = true
let timer: Timer | undefined
let lock = false
let armed = true
export function start() {
if (!Flag.KILO_AUTO_HEAP_SNAPSHOT) return
if (timer) return
export function start() {
if (!Flag.KILO_AUTO_HEAP_SNAPSHOT) return
if (timer) return
const run = async () => {
if (lock) return
const run = async () => {
if (lock) return
const stat = process.memoryUsage()
if (stat.rss <= LIMIT) {
armed = true
return
}
if (!armed) return
const stat = process.memoryUsage()
if (stat.rss <= LIMIT) {
armed = true
return
}
if (!armed) return
lock = true
armed = false
const file = path.join(
Global.Path.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
)
log.warn("heap usage exceeded limit", {
rss: stat.rss,
heap: stat.heapUsed,
file,
lock = true
armed = false
const file = path.join(
Global.Path.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
)
log.warn("heap usage exceeded limit", {
rss: stat.rss,
heap: stat.heapUsed,
file,
})
await Promise.resolve()
.then(() => writeHeapSnapshot(file))
.catch((err) => {
log.error("failed to write heap snapshot", {
error: err instanceof Error ? err.message : String(err),
file,
})
})
await Promise.resolve()
.then(() => writeHeapSnapshot(file))
.catch((err) => {
log.error("failed to write heap snapshot", {
error: err instanceof Error ? err.message : String(err),
file,
})
})
lock = false
}
timer = setInterval(() => {
void run()
}, MINUTE)
timer.unref?.()
lock = false
}
timer = setInterval(() => {
void run()
}, MINUTE)
timer.unref?.()
}
export * as Heap from "./heap"
+7 -1
View File
@@ -8,4 +8,10 @@ export const logo = {
],
}
// kilocode_change end
export const marks = "_^~"
export const go = {
left: ["", "", "", ""], // kilocode_change
right: ["", "", "", ""], // kilocode_change
}
export const marks = "_^~,"
+119 -121
View File
@@ -3,135 +3,133 @@ import { EOL } from "os"
import { NamedError } from "@opencode-ai/shared/util/error"
import { logo as glyphs } from "./logo"
export namespace UI {
const wordmark = [
// kilocode_change start
`██ ▄█▀ ██ ██ ▄████▄ ▄█████ ██ ██ `,
`████ ██ ██ ██~~██ ██~~~~ ██ ██ `,
`██ ▀█▄ ██ ██████ ▀████▀ ▀█████ ██████ ██ `,
`~~ ~~ ~~ ~~~~~~ ~~~~ ~~~~~ ~~~~~~ ~~ `,
// kilocode_change end
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
TEXT_DIM: "\x1b[90m",
TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
TEXT_NORMAL: "\x1b[0m",
TEXT_NORMAL_BOLD: "\x1b[1m",
TEXT_WARNING: "\x1b[93m",
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
TEXT_DANGER: "\x1b[91m",
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
TEXT_SUCCESS: "\x1b[92m",
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
TEXT_INFO: "\x1b[94m",
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
}
export function println(...message: string[]) {
print(...message)
process.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
process.stderr.write(message.join(" "))
}
let blank = false
export function empty() {
if (blank) return
println("" + Style.TEXT_NORMAL)
blank = true
}
const wordmark = [
// kilocode_change start
export function logo(pad?: string) {
if (!process.stdout.isTTY && !process.stderr.isTTY) {
const result = []
for (const row of wordmark) {
if (pad) result.push(pad)
result.push(row)
result.push(EOL)
}
return result.join("").trimEnd()
}
`██ ▄█▀ ██ ██ ▄████▄ ▄█████ ██ ██ `,
`████ ██ ██ ██~~██ ██~~~~ ██ ██ `,
`██ ▀█▄ ██ ██████ ▀████▀ ▀█████ ██████ ██ `,
`~~ ~~ ~~ ~~~~~~ ~~~~ ~~~~~ ~~~~~~ ~~ `,
// kilocode_change end
]
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}
const right = {
fg: reset,
shadow: "\x1b[38;5;238m",
bg: "\x1b[48;5;238m",
}
const gap = " "
const draw = (line: string, fg: string, shadow: string, bg: string) => {
const parts: string[] = []
for (const char of line) {
if (char === "_") {
parts.push(bg, " ", reset)
continue
}
if (char === "^") {
parts.push(fg, bg, "▀", reset)
continue
}
if (char === "~") {
parts.push(shadow, "▀", reset)
continue
}
if (char === " ") {
parts.push(" ")
continue
}
parts.push(fg, char, reset)
}
return parts.join("")
}
glyphs.left.forEach((row, index) => {
export const CancelledError = NamedError.create("UICancelledError", z.void())
export const Style = {
TEXT_HIGHLIGHT: "\x1b[96m",
TEXT_HIGHLIGHT_BOLD: "\x1b[96m\x1b[1m",
TEXT_DIM: "\x1b[90m",
TEXT_DIM_BOLD: "\x1b[90m\x1b[1m",
TEXT_NORMAL: "\x1b[0m",
TEXT_NORMAL_BOLD: "\x1b[1m",
TEXT_WARNING: "\x1b[93m",
TEXT_WARNING_BOLD: "\x1b[93m\x1b[1m",
TEXT_DANGER: "\x1b[91m",
TEXT_DANGER_BOLD: "\x1b[91m\x1b[1m",
TEXT_SUCCESS: "\x1b[92m",
TEXT_SUCCESS_BOLD: "\x1b[92m\x1b[1m",
TEXT_INFO: "\x1b[94m",
TEXT_INFO_BOLD: "\x1b[94m\x1b[1m",
}
export function println(...message: string[]) {
print(...message)
process.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
process.stderr.write(message.join(" "))
}
let blank = false
export function empty() {
if (blank) return
println("" + Style.TEXT_NORMAL)
blank = true
}
export function logo(pad?: string) {
if (!process.stdout.isTTY && !process.stderr.isTTY) {
const result = []
for (const row of wordmark) {
if (pad) result.push(pad)
result.push(draw(row, left.fg, left.shadow, left.bg))
result.push(gap)
const other = glyphs.right[index] ?? ""
result.push(draw(other, right.fg, right.shadow, right.bg))
result.push(row)
result.push(EOL)
})
}
return result.join("").trimEnd()
}
// kilocode_change end
export async function input(prompt: string): Promise<string> {
const readline = require("readline")
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise((resolve) => {
rl.question(prompt, (answer: string) => {
rl.close()
resolve(answer.trim())
})
})
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}
export function error(message: string) {
if (message.startsWith("Error: ")) {
message = message.slice("Error: ".length)
const right = {
fg: reset,
shadow: "\x1b[38;5;238m",
bg: "\x1b[48;5;238m",
}
const gap = " "
const draw = (line: string, fg: string, shadow: string, bg: string) => {
const parts: string[] = []
for (const char of line) {
if (char === "_") {
parts.push(bg, " ", reset)
continue
}
if (char === "^") {
parts.push(fg, bg, "▀", reset)
continue
}
if (char === "~") {
parts.push(shadow, "▀", reset)
continue
}
if (char === " ") {
parts.push(" ")
continue
}
parts.push(fg, char, reset)
}
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
export function markdown(text: string): string {
return text
return parts.join("")
}
glyphs.left.forEach((row, index) => {
if (pad) result.push(pad)
result.push(draw(row, left.fg, left.shadow, left.bg))
result.push(gap)
const other = glyphs.right[index] ?? ""
result.push(draw(other, right.fg, right.shadow, right.bg))
result.push(EOL)
})
return result.join("").trimEnd()
}
export async function input(prompt: string): Promise<string> {
const readline = require("readline")
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
return new Promise((resolve) => {
rl.question(prompt, (answer: string) => {
rl.close()
resolve(answer.trim())
})
})
}
export function error(message: string) {
if (message.startsWith("Error: ")) {
message = message.slice("Error: ".length)
}
println(Style.TEXT_DANGER_BOLD + "Error: " + Style.TEXT_NORMAL + message)
}
export function markdown(text: string): string {
return text
}
export * as UI from "./ui"
+336 -384
View File
@@ -12,12 +12,11 @@ import { Auth } from "../auth"
import { Env } from "../env"
import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser" // kilocode_change - parseTree/findNodeAtLocation used in patchJsonc
import { Instance, type InstanceContext } from "../project/instance"
import * as LSPServer from "../lsp/server"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { existsSync } from "fs"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Account } from "@/account"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -42,6 +41,9 @@ import { KilocodeConfig } from "../kilocode/config/config"
import { makeRuntime } from "@/effect/run-service"
import { unique } from "remeda"
// kilocode_change end
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigVariable } from "./variable"
const log = Log.create({ service: "config" })
@@ -220,56 +222,8 @@ export const Info = z
)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
formatter: z
.union([
z.literal(false),
z.record(
z.string(),
z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
}),
),
])
.optional(),
lsp: z
.union([
z.literal(false),
z.record(
z.string(),
z.union([
z.object({
disabled: z.literal(true),
}),
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
]),
),
])
.optional()
.refine(
(data) => {
if (!data) return true
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
),
formatter: ConfigFormatter.Info.optional(),
lsp: ConfigLSP.Info.optional(),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: ConfigPermission.Info.optional(),
@@ -434,24 +388,16 @@ export const layer = Layer.effect(
text: string,
options: { path: string } | { dir: string; source: string },
) {
if (!("path" in options)) {
return yield* Effect.promise(() =>
ConfigParse.load(Info, text, {
type: "virtual",
dir: options.dir,
source: options.source,
normalize: normalizeLoadedConfig,
}),
)
}
const data = yield* Effect.promise(() =>
ConfigParse.load(Info, text, {
type: "path",
path: options.path,
normalize: normalizeLoadedConfig,
}),
const source = "path" in options ? options.path : options.source
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute(
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
if (!("path" in options)) return data
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
if (!data.$schema) {
data.$schema = "https://app.kilo.ai/config.json" // kilocode_change
@@ -542,352 +488,359 @@ export const layer = Layer.effect(
}
})
const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) {
// kilocode_change start - warning accumulator and legacy Kilo config
const warnings: Warning[] = []
const auth = yield* authSvc.all().pipe(Effect.orDie)
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
function* (ctx: InstanceContext) {
// kilocode_change start - warning accumulator and legacy Kilo config
const warnings: Warning[] = []
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const legacy = yield* Effect.promise(() =>
KilocodeConfig.loadLegacyConfigs({
projectDir: ctx.directory,
merge: mergeConfigConcatArrays,
}),
)
result = mergeConfigConcatArrays(result, legacy.config)
warnings.push(...legacy.warnings)
let result: Info = {}
const legacy = yield* Effect.promise(() =>
KilocodeConfig.loadLegacyConfigs({
projectDir: ctx.directory,
merge: mergeConfigConcatArrays,
}),
)
result = mergeConfigConcatArrays(result, legacy.config)
warnings.push(...legacy.warnings)
const orgModes = yield* Effect.promise(() => KilocodeConfig.loadOrganizationModes(auth))
if (Object.keys(orgModes.agents).length > 0) {
result = mergeConfigConcatArrays(result, { agent: orgModes.agents })
}
warnings.push(...orgModes.warnings)
// kilocode_change end
const orgModes = yield* Effect.promise(() => KilocodeConfig.loadOrganizationModes(auth))
if (Object.keys(orgModes.agents).length > 0) {
result = mergeConfigConcatArrays(result, { agent: orgModes.agents })
}
warnings.push(...orgModes.warnings)
// kilocode_change end
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "KILO_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "KILO_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
const mergePluginOrigins = Effect.fnUntraced(function* (
source: string,
// mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
// is attached.
list: ConfigPlugin.Spec[] | undefined,
// Scope can be inferred from the source path, but some callers already know whether the config should
// behave as global or local and can pass that explicitly.
kind?: ConfigPlugin.Scope,
) {
if (!list?.length) return
const hit = kind ?? (yield* pluginScopeForSource(source))
// Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
// keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
const mergePluginOrigins = Effect.fnUntraced(function* (
source: string,
// mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
// is attached.
list: ConfigPlugin.Spec[] | undefined,
// Scope can be inferred from the source path, but some callers already know whether the config should
// behave as global or local and can pass that explicitly.
kind?: ConfigPlugin.Scope,
) {
if (!list?.length) return
const hit = kind ?? (yield* pluginScopeForSource(source))
// Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
// keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return mergePluginOrigins(source, next.plugin, kind)
}
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return mergePluginOrigins(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
const source = `${url}/.well-known/opencode`
process.env[value.key] = value.token
log.debug("fetching remote config", { url: source })
// kilocode_change start - warn instead of fail on wellknown errors
const next = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(source)
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (await response.json()) as { config?: Record<string, unknown> }
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json"
return remoteConfig
},
catch: (err) => err,
}).pipe(
Effect.flatMap((remoteConfig) =>
loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
const source = `${url}/.well-known/opencode`
process.env[value.key] = value.token
log.debug("fetching remote config", { url: source })
// kilocode_change start - warn instead of fail on wellknown errors
const next = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(source)
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (await response.json()) as { config?: Record<string, unknown> }
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json"
return remoteConfig
},
catch: (err) => err,
}).pipe(
Effect.flatMap((remoteConfig) =>
loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
}),
),
Effect.tap(() => Effect.sync(() => log.debug("loaded remote config from well-known", { url }))),
Effect.catch((err: unknown) => {
caughtWarning(warnings, source, err)
log.warn("skipped remote config due to error", { url, err })
return Effect.succeed({} as Info)
}),
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
log.warn("skipped remote config due to error", { url, err })
return Effect.succeed({} as Info)
}),
)
yield* merge(source, next, "global")
// kilocode_change end
}
}
// kilocode_change start - capture global config failures as warnings
const global = yield* getGlobal().pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, "global config", err)
return Effect.succeed({} as Info)
}),
)
// kilocode_change end
yield* merge(Global.Path.config, global, "global")
if (Flag.KILO_CONFIG) {
// kilocode_change start - capture KILO_CONFIG failures as warnings
yield* merge(
Flag.KILO_CONFIG,
yield* loadFile(Flag.KILO_CONFIG).pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, Flag.KILO_CONFIG!, err)
return Effect.succeed({} as Info)
}),
),
Effect.tap(() => Effect.sync(() => log.debug("loaded remote config from well-known", { url }))),
Effect.catch((err: unknown) => {
caughtWarning(warnings, source, err)
log.warn("skipped remote config due to error", { url, err })
return Effect.succeed({} as Info)
}),
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
log.warn("skipped remote config due to error", { url, err })
return Effect.succeed({} as Info)
}),
)
yield* merge(source, next, "global")
// kilocode_change end
log.debug("loaded custom config", { path: Flag.KILO_CONFIG })
}
if (!Flag.KILO_DISABLE_PROJECT_CONFIG) {
// kilocode_change start - also discover kilo.json project files
for (const name of ["kilo", "opencode"] as const) {
for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(
file,
yield* loadFile(file).pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, file, err)
return Effect.succeed({} as Info)
}),
),
"local",
)
}
}
// kilocode_change end
}
}
const global = yield* getGlobal().pipe(
// kilocode_change start - capture global config failures as warnings
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, "global config", err)
return Effect.succeed({} as Info)
}),
// kilocode_change end
)
yield* merge(Global.Path.config, global, "global")
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
if (Flag.KILO_CONFIG) {
// kilocode_change start - capture KILO_CONFIG failures as warnings
yield* merge(
Flag.KILO_CONFIG,
yield* loadFile(Flag.KILO_CONFIG).pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, Flag.KILO_CONFIG!, err)
return Effect.succeed({} as Info)
}),
),
)
// kilocode_change end
log.debug("loaded custom config", { path: Flag.KILO_CONFIG })
}
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
if (!Flag.KILO_DISABLE_PROJECT_CONFIG) {
// kilocode_change start - also discover kilo.json project files
for (const name of ["kilo", "opencode"] as const) {
for (const file of yield* Effect.promise(() => ConfigPaths.projectFiles(name, ctx.directory, ctx.worktree))) {
yield* merge(
file,
yield* loadFile(file).pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, file, err)
return Effect.succeed({} as Info)
}),
),
"local",
)
}
if (Flag.KILO_CONFIG_DIR) {
log.debug("loading config from KILO_CONFIG_DIR", { path: Flag.KILO_CONFIG_DIR })
}
// kilocode_change end
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const deps: Fiber.Fiber<void, never>[] = []
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
if (Flag.KILO_CONFIG_DIR) {
log.debug("loading config from KILO_CONFIG_DIR", { path: Flag.KILO_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of unique(directories)) {
// kilocode_change start - accept .kilo/.kilocode config dirs and all kilo/opencode filenames
if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) {
for (const file of KilocodeConfig.ALL_CONFIG_FILES) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(
source,
yield* loadFile(source).pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
return Effect.succeed({} as Info)
}),
),
)
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
// kilocode_change end
yield* ensureGitignore(dir).pipe(Effect.orDie)
const dep = yield* npmSvc
.install(dir, {
add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
// kilocode_change start - propagate parse errors to the Warning accumulator
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir, warnings)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir, warnings)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings)))
// kilocode_change end
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)
}
if (process.env.KILO_CONFIG_CONTENT) {
// kilocode_change start - capture KILO_CONFIG_CONTENT parse failures as warnings
const source = "KILO_CONFIG_CONTENT"
yield* merge(
source,
yield* loadConfig(process.env.KILO_CONFIG_CONTENT, {
dir: ctx.directory,
source,
}).pipe(
Effect.tap(() => Effect.sync(() => log.debug("loaded custom config from KILO_CONFIG_CONTENT"))),
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
return Effect.succeed({} as Info)
}),
),
"local",
)
// kilocode_change end
}
const activeAccount = Option.getOrUndefined(
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeAccount?.active_org_id) {
const accountID = activeAccount.id
const orgID = activeAccount.active_org_id
const url = activeAccount.url
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["KILO_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("KILO_CONSOLE_TOKEN", tokenOpt.value)
}
if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
for (const dir of unique(directories)) { // kilocode_change
// kilocode_change start
if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) {
for (const file of KilocodeConfig.ALL_CONFIG_FILES) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(
source,
yield* loadFile(source).pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
return Effect.succeed({} as Info)
}),
),
)
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
yield* merge(source, next, "global")
}
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
// kilocode_change end
yield* ensureGitignore(dir).pipe(Effect.orDie)
const dep = yield* npmSvc
.install(dir, {
add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
return Effect.void
}),
)
}
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
const managedDir = ConfigManaged.managedConfigDir()
// kilocode_change start - include kilo.json/kilo.jsonc in managed dir loading
if (existsSync(managedDir)) {
for (const file of KilocodeConfig.ALL_CONFIG_FILES) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
// kilocode_change start - propagate parse errors to the Warning accumulator
result.command = mergeDeep(
result.command ?? {},
yield* Effect.promise(() => ConfigCommand.load(dir, warnings)),
)
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir, warnings)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir, warnings)))
// kilocode_change end
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)
}
}
// kilocode_change end
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
if (process.env.KILO_CONFIG_CONTENT) {
// kilocode_change start - capture KILO_CONFIG_CONTENT parse failures as warnings
const source = "KILO_CONFIG_CONTENT"
yield* merge(
source,
yield* loadConfig(process.env.KILO_CONFIG_CONTENT, {
dir: ctx.directory,
source,
}).pipe(
Effect.tap(() => Effect.sync(() => log.debug("loaded custom config from KILO_CONFIG_CONTENT"))),
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
return Effect.succeed({} as Info)
}),
),
"local",
)
// kilocode_change end
}
const activeAccount = Option.getOrUndefined(
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
}
if (activeAccount?.active_org_id) {
const accountID = activeAccount.id
const orgID = activeAccount.active_org_id
const url = activeAccount.url
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["KILO_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("KILO_CONSOLE_TOKEN", tokenOpt.value)
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
}
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
}
if (Flag.KILO_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.KILO_PERMISSION))
}
if (result.tools) {
const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
const managedDir = ConfigManaged.managedConfigDir()
// kilocode_change start - include kilo.json/kilo.jsonc in managed dir loading
if (existsSync(managedDir)) {
for (const file of KilocodeConfig.ALL_CONFIG_FILES) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
// kilocode_change end
if (!result.username) result.username = os.userInfo().username
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
)
}
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.KILO_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.KILO_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
if (Flag.KILO_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.KILO_PERMISSION))
}
return {
config: result,
directories,
deps,
warnings, // kilocode_change
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
})
if (result.tools) {
const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (Flag.KILO_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.KILO_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
return {
config: result,
directories,
deps,
warnings, // kilocode_change
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
},
Effect.provideService(AppFileSystem.Service, fs),
)
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx)
return yield* loadInstanceState(ctx).pipe(Effect.orDie)
}),
)
@@ -951,17 +904,16 @@ export const layer = Layer.effect(
// kilocode_change end
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.parse(Info, before, file)
const merged = KilocodeConfig.mergeConfig(writable(existing), input) // kilocode_change
const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
const merged = KilocodeConfig.mergeConfig(writable(existing), writable(config)) // kilocode_change
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, input)
next = ConfigParse.parse(Info, updated, file)
const updated = patchJsonc(before, writable(config))
next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
+13
View File
@@ -0,0 +1,13 @@
export * as ConfigFormatter from "./formatter"
import z from "zod"
export const Entry = z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
})
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
export type Info = z.infer<typeof Info>
+2
View File
@@ -2,6 +2,8 @@ export * as Config from "./config"
export * as ConfigAgent from "./agent"
export * as ConfigCommand from "./command"
export * as ConfigError from "./error"
export * as ConfigFormatter from "./formatter"
export * as ConfigLSP from "./lsp"
export * as ConfigVariable from "./variable"
export { ConfigManaged } from "./managed"
export * as ConfigMarkdown from "./markdown"
+12 -2
View File
@@ -106,7 +106,12 @@ export const Keybinds = z
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"),
input_undo: z
.string()
.optional()
// On Windows prepend ctrl+z since terminal_suspend releases the binding.
.default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")
.describe("Undo in input"),
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
input_word_forward: z
.string()
@@ -144,7 +149,12 @@ export const Keybinds = z
session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
session_parent: z.string().optional().default("up").describe("Go to parent session"),
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
terminal_suspend: z
.string()
.optional()
.default("ctrl+z")
.transform((v) => (process.platform === "win32" ? "none" : v))
.describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
news_toggle: z.string().optional().default("none").describe("Toggle news on home screen"), // kilocode_change
+37
View File
@@ -0,0 +1,37 @@
export * as ConfigLSP from "./lsp"
import z from "zod"
import * as LSPServer from "../lsp/server"
export const Disabled = z.object({
disabled: z.literal(true),
})
export const Entry = z.union([
Disabled,
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
])
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
(data) => {
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
)
export type Info = z.infer<typeof Info>
+63 -65
View File
@@ -1,70 +1,68 @@
import z from "zod"
export namespace ConfigMCP {
export const Local = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpLocalConfig",
})
export const Local = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpLocalConfig",
})
export const OAuth = z
.object({
clientId: z
.string()
.optional()
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
ref: "McpOAuthConfig",
})
export type OAuth = z.infer<typeof OAuth>
export const OAuth = z
.object({
clientId: z
.string()
.optional()
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
ref: "McpOAuthConfig",
})
export type OAuth = z.infer<typeof OAuth>
export const Remote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
oauth: z
.union([OAuth, z.literal(false)])
.optional()
.describe(
"OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpRemoteConfig",
})
export const Remote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
oauth: z
.union([OAuth, z.literal(false)])
.optional()
.describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpRemoteConfig",
})
export const Info = z.discriminatedUnion("type", [Local, Remote])
export type Info = z.infer<typeof Info>
}
export const Info = z.discriminatedUnion("type", [Local, Remote])
export type Info = z.infer<typeof Info>
export * as ConfigMCP from "./mcp"
+23 -59
View File
@@ -1,80 +1,44 @@
export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
import z from "zod"
import { ConfigVariable } from "./variable"
import { InvalidError, JsonError } from "./error"
type Schema<T> = z.ZodType<T>
type VariableMode = "error" | "empty"
export type LoadOptions =
| {
type: "path"
path: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
| {
type: "virtual"
dir: string
source: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
function issues(text: string, errors: JsoncParseError[]) {
const lines = text.split("\n")
return errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
}
export function parse<T>(schema: Schema<T>, text: string, filepath: string): T {
export function jsonc(text: string, filepath: string): unknown {
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
const data = parseJsoncImpl(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const issues = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: filepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues(text, errors)}\n--- End ---`,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues}\n--- End ---`,
})
}
return data
}
export function schema<T>(schema: Schema<T>, data: unknown, source: string): T {
const parsed = schema.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({
path: filepath,
path: source,
issues: parsed.error.issues,
})
}
export async function load<T>(schema: Schema<T>, text: string, options: LoadOptions): Promise<T> {
const source = options.type === "path" ? options.path : options.source
const expanded = await ConfigVariable.substitute(
text,
options.type === "path" ? { type: "path", path: options.path } : options,
options.missing,
)
const data = parse(z.unknown(), expanded, source)
const normalized = options.normalize ? options.normalize(data, source) : data
const parsed = schema.safeParse(normalized)
if (!parsed.success) {
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
}
return parsed.data
}
+27 -19
View File
@@ -6,33 +6,41 @@ import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { unique } from "remeda"
import { JsonError } from "./error"
import * as Effect from "effect/Effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export async function projectFiles(name: string, directory: string, worktree?: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
name: string,
directory: string,
worktree?: string,
) {
const afs = yield* AppFileSystem.Service
return (yield* afs.up({
targets: [`${name}.jsonc`, `${name}.json`],
start: directory,
stop: worktree,
})).toReversed()
})
export async function directories(directory: string, worktree?: string) {
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
const afs = yield* AppFileSystem.Service
return unique([
Global.Path.config,
...(!Flag.KILO_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change
start: directory,
stop: worktree,
}),
)
? yield* afs.up({
targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change
start: directory,
stop: worktree,
})
: []),
...(await Array.fromAsync(
Filesystem.up({
targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
...(yield* afs.up({
targets: [".kilocode", ".kilo", ".opencode"], // kilocode_change
start: Global.Path.home,
stop: Global.Path.home,
})),
...(Flag.KILO_CONFIG_DIR ? [Flag.KILO_CONFIG_DIR] : []),
])
}
})
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
+116 -120
View File
@@ -1,125 +1,121 @@
import z from "zod"
export namespace ConfigProvider {
export const Model = z
.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
export const Model = z
.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
variants: z
.record(
z.string(),
// kilocode_change start - allow null values so removed variants can be deleted via stripNulls on save
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any())
.nullable(),
// kilocode_change end
)
.optional()
.describe("Variant-specific configuration"),
})
.partial()
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
variants: z
.record(
z.string(),
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any())
.nullable(), // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save
)
.optional()
.describe("Variant-specific configuration"),
})
.partial()
export const Info = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
// kilocode_change start
timeout: z
.union([
z
.number()
.int()
.positive()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 120000 (2 minutes). Set to false to disable timeout.",
),
z.literal(false).describe("Disable timeout for this provider entirely."),
// kilocode_change end
])
.optional()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 120000 (2 minutes). Set to false to disable timeout.", // kilocode_change
),
chunkTimeout: z
.number()
.int()
.positive()
.optional()
.describe(
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
),
})
.catchall(z.any())
.optional(),
models: z.record(z.string(), Model.nullable()).optional(), // kilocode_change - allow null values so removed models can be deleted via stripNulls on save
})
.partial()
.strict()
.meta({
ref: "ProviderConfig",
})
export const Info = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
timeout: z
.union([
z
.number()
.int()
.positive()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
z.literal(false).describe("Disable timeout for this provider entirely."),
])
.optional()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
chunkTimeout: z
.number()
.int()
.positive()
.optional()
.describe(
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
),
})
.catchall(z.any())
.optional(),
models: z.record(z.string(), Model.nullable()).optional(), // kilocode_change - allow null values so removed models can be deleted via stripNulls on save
})
.partial()
.strict()
.meta({
ref: "ProviderConfig",
})
export type Info = z.infer<typeof Info>
}
export type Info = z.infer<typeof Info>
export * as ConfigProvider from "./provider"
+10 -10
View File
@@ -1,13 +1,13 @@
import z from "zod"
export namespace ConfigSkills {
export const Info = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
.array(z.string())
.optional()
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
})
export const Info = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
.array(z.string())
.optional()
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
})
export type Info = z.infer<typeof Info>
}
export type Info = z.infer<typeof Info>
export * as ConfigSkills from "./skills"
+8 -2
View File
@@ -16,6 +16,11 @@ type ParseSource =
dir: string
}
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
}
function source(input: ParseSource) {
return input.type === "path" ? input.path : input.source
}
@@ -25,8 +30,9 @@ function dir(input: ParseSource) {
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
@@ -2,13 +2,13 @@ import { LocalContext } from "../util"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
workspaceID: WorkspaceID
workspaceID: WorkspaceID | undefined
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, () => input.fn())
},
+425 -416
View File
@@ -26,173 +26,238 @@ import { AppRuntime } from "@/effect/app-runtime"
import { EventSequenceTable } from "@/sync/event.sql"
import { waitEvent } from "./util"
export namespace Workspace {
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
const Restore = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
total: z.number().int().min(0),
step: z.number().int().min(0),
})
const Restore = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
total: z.number().int().min(0),
step: z.number().int().min(0),
})
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
z.object({
name: z.string(),
}),
),
Failed: BusEvent.define(
"workspace.failed",
z.object({
message: z.string(),
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus),
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
z.object({
name: z.string(),
}),
),
Failed: BusEvent.define(
"workspace.failed",
z.object({
message: z.string(),
}),
),
Restore: BusEvent.define("workspace.restore", Restore),
Status: BusEvent.define("workspace.status", ConnectionStatus),
}
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return {
id: row.id,
type: row.type,
branch: row.branch,
name: row.name,
directory: row.directory,
extra: row.extra,
projectID: row.project_id,
}
}
const CreateInput = z.object({
id: WorkspaceID.zod.optional(),
type: Info.shape.type,
branch: Info.shape.branch,
projectID: ProjectID.zod,
extra: Info.shape.extra,
})
export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type)
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return {
id: row.id,
type: row.type,
branch: row.branch,
name: row.name,
directory: row.directory,
extra: row.extra,
projectID: row.project_id,
}
}
const CreateInput = z.object({
id: WorkspaceID.zod.optional(),
type: Info.shape.type,
branch: Info.shape.branch,
projectID: ProjectID.zod,
extra: Info.shape.extra,
Database.use((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
})
export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type)
const env = {
KILO_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
KILO_WORKSPACE_ID: config.id,
KILO_EXPERIMENTAL_WORKSPACES: "true",
}
await adaptor.create(config, env)
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
startSync(info)
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
await waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
},
})
Database.use((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
})
return info
})
const env = {
KILO_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
KILO_WORKSPACE_ID: config.id,
KILO_EXPERIMENTAL_WORKSPACES: "true",
}
await adaptor.create(config, env)
const SessionRestoreInput = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
})
startSync(info)
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
try {
const space = await get(input.workspaceID)
if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`)
await waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
// Need to switch the workspace of the session
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
return info
})
const rows = Database.use((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`)
const SessionRestoreInput = z.object({
workspaceID: WorkspaceID.zod,
sessionID: SessionID.zod,
})
const all = rows
export const sessionRestore = fn(SessionRestoreInput, async (input) => {
log.info("session restore requested", {
const size = 10
const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size))
const total = sets.length
log.info("session restore prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: all.length,
batches: total,
first: all[0]?.seq,
last: all.at(-1)?.seq,
})
try {
const space = await get(input.workspaceID)
if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`)
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
// Need to switch the workspace of the session
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
})
const rows = Database.use((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`)
const all = rows
const size = 10
const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size))
const total = sets.length
log.info("session restore prepared", {
},
})
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: all.length,
batches: total,
first: all[0]?.seq,
last: all.at(-1)?.seq,
})
if (target.type === "local") {
SyncEvent.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const headers = new Headers(target.headers)
headers.set("content-type", "application/json")
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
directory: space.directory ?? "",
events,
}),
})
if (!res.ok) {
const body = await res.text()
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
throw new Error(
`Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
)
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
@@ -202,266 +267,206 @@ export namespace Workspace {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
step: i + 1,
},
},
})
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return {
total,
}
} catch (err) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
}
})
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
}
function lookup(id: WorkspaceID) {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
}
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
startSync(space)
return space
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const sessions = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
for (const session of sessions) {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id)))
}
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
stopSync(id)
const info = fromRow(row)
try {
const adaptor = await getAdaptor(info.projectID, row.type)
await adaptor.remove(info)
} catch {
log.error("adaptor not available when removing workspace", { type: row.type })
}
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
})
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"]) {
const prev = connections.get(id)
if (prev?.status === status) return
const next = { workspaceID: id, status }
connections.set(id, next)
if (status === "error") {
aborts.delete(id)
}
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
function synced(state: Record<string, number>) {
const ids = Object.keys(state)
if (ids.length === 0) return true
const done = Object.fromEntries(
Database.use((db) =>
db
.select({
id: EventSequenceTable.aggregate_id,
seq: EventSequenceTable.seq,
})
if (target.type === "local") {
SyncEvent.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const headers = new Headers(target.headers)
headers.set("content-type", "application/json")
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
directory: space.directory ?? "",
events,
}),
})
if (!res.ok) {
const body = await res.text()
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
throw new Error(
`Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
)
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, ids))
.all(),
).map((row) => [row.id, row.seq]),
) as Record<string, number>
return ids.every((id) => {
return (done[id] ?? -1) >= state[id]
})
}
export async function isSyncing(workspaceID: WorkspaceID) {
return aborts.has(workspaceID)
}
export async function waitForSync(workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) {
if (synced(state)) return
try {
await waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
})
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return {
total,
}
} catch (err) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
}
})
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
}
function lookup(id: WorkspaceID) {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
}
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
startSync(space)
return space
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const sessions = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
for (const session of sessions) {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id)))
}
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
stopSync(id)
const info = fromRow(row)
try {
const adaptor = await getAdaptor(info.projectID, row.type)
await adaptor.remove(info)
} catch {
log.error("adaptor not available when removing workspace", { type: row.type })
}
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
})
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
if (status === "error") {
aborts.delete(id)
}
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
return synced(state)
},
})
} catch {
if (signal?.aborted) throw signal.reason ?? new Error("Request aborted")
throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`)
}
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
const log = Log.create({ service: "workspace-sync" })
function synced(state: Record<string, number>) {
const ids = Object.keys(state)
if (ids.length === 0) return true
function route(url: string | URL, path: string) {
const next = new URL(url)
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
next.search = ""
next.hash = ""
return next
}
const done = Object.fromEntries(
Database.use((db) =>
db
.select({
id: EventSequenceTable.aggregate_id,
seq: EventSequenceTable.seq,
})
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, ids))
.all(),
).map((row) => [row.id, row.seq]),
) as Record<string, number>
async function connectSSE(url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
const res = await fetch(route(url, "/global/event"), {
method: "GET",
headers,
signal,
})
return ids.every((id) => {
return (done[id] ?? -1) >= state[id]
})
}
if (!res.ok) throw new Error(`Workspace sync HTTP failure: ${res.status}`)
if (!res.body) throw new Error("No response body from global sync")
export async function isSyncing(workspaceID: WorkspaceID) {
return aborts.has(workspaceID)
}
return res.body
}
export async function waitForSync(workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) {
if (synced(state)) return
async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return null
let attempt = 0
while (!signal.aborted) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
let stream
try {
await waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
return synced(state)
},
stream = await connectSSE(target.url, target.headers, signal)
} catch (err) {
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
err,
})
} catch {
if (signal?.aborted) throw signal.reason ?? new Error("Request aborted")
throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`)
}
}
const log = Log.create({ service: "workspace-sync" })
function route(url: string | URL, path: string) {
const next = new URL(url)
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
next.search = ""
next.hash = ""
return next
}
async function syncWorkspace(space: Info, signal: AbortSignal) {
while (!signal.aborted) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return
const res = await fetch(route(target.url, "/global/event"), {
method: "GET",
headers: target.headers,
signal,
}).catch((err: unknown) => {
setStatus(space.id, "error", err instanceof Error ? err.message : String(err))
log.info("failed to connect to global sync", {
workspace: space.name,
error: err,
})
return undefined
})
if (!res || !res.ok || !res.body) {
const error = !res ? "No response from global sync" : `Global sync HTTP ${res.status}`
log.info("failed to connect to global sync", { workspace: space.name, error })
setStatus(space.id, "error", error)
await sleep(1000)
continue
}
if (stream) {
attempt = 0
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt: any) => {
await parseSSE(stream, signal, (evt: any) => {
try {
if (!("payload" in evt)) return
@@ -485,46 +490,50 @@ export namespace Workspace {
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
// TODO: Implement exponential backoff
await sleep(1000)
}
}
async function startSync(space: Info) {
if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) return
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return
}
if (aborts.has(space.id)) return true
setStatus(space.id, "disconnected")
const abort = new AbortController()
aborts.set(space.id, abort)
void syncWorkspace(space, abort.signal).catch((error) => {
aborts.delete(space.id)
setStatus(space.id, "error", String(error))
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
})
}
function stopSync(id: WorkspaceID) {
aborts.get(id)?.abort()
aborts.delete(id)
connections.delete(id)
// Back off reconnect attempts up to 2 minutes while the workspace
// stays unavailable.
await sleep(Math.min(120_000, 1_000 * 2 ** attempt))
attempt += 1
}
}
async function startSync(space: Info) {
if (!Flag.KILO_EXPERIMENTAL_WORKSPACES) return
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
setStatus(space.id, exists ? "connected" : "error")
})
return
}
if (aborts.has(space.id)) return true
setStatus(space.id, "disconnected")
const abort = new AbortController()
aborts.set(space.id, abort)
void syncWorkspaceLoop(space, abort.signal).catch((error) => {
aborts.delete(space.id)
setStatus(space.id, "error")
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
})
}
function stopSync(id: WorkspaceID) {
aborts.get(id)?.abort()
aborts.delete(id)
connections.delete(id)
}
export * as Workspace from "./workspace"

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