diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml new file mode 100644 index 00000000000..b2fc705daea --- /dev/null +++ b/.github/workflows/check-opencode-annotations.yml @@ -0,0 +1,32 @@ +name: Check opencode annotations + +on: + pull_request: + paths: + - "packages/opencode/**" + - "script/check-opencode-annotations.ts" + - ".github/workflows/check-opencode-annotations.yml" + workflow_dispatch: + +jobs: + check-annotations: + name: Check kilocode_change annotations + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Check kilocode_change annotations in shared opencode files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ -n "$BASE_SHA" ]; then + bun run script/check-opencode-annotations.ts --base "$BASE_SHA" + else + echo "No PR base SHA available (workflow_dispatch without PR context) — skipping." + fi diff --git a/AGENTS.md b/AGENTS.md index a8323860822..1d4c3f77eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang - **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing. - **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale. - **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing. +- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name. ## Products diff --git a/README.md b/README.md index e77e3fbbf7f..2199ffd41ad 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - ⚡ Inline autocomplete suggestions - 🤖 Latest AI models - 🎁 API keys optional -- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.2 +- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 ## Quick Links @@ -38,7 +38,7 @@ ## Get Started in Visual Studio Code 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code). -2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly. +2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 – with transparent pricing that matches provider rates exactly. 3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: Watch the video diff --git a/bun.lock b/bun.lock index 2665ef50623..19f273a6ac1 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -79,7 +79,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -112,7 +112,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -142,7 +142,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -171,7 +171,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/openai": "2.0.101", @@ -206,7 +206,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.1.22", + "version": "7.2.0", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -219,7 +219,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -239,7 +239,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -274,7 +274,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -327,7 +327,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.1.22", + "version": "7.2.0", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -451,7 +451,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -465,14 +465,14 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.1.22", + "version": "7.2.0", "devDependencies": { "@types/bun": "catalog:", }, }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.1.22", + "version": "7.2.0", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -483,7 +483,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.1.22", + "version": "7.2.0", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -506,7 +506,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -553,7 +553,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.1.22", + "version": "7.2.0", "dependencies": { "zod": "catalog:", }, diff --git a/package.json b/package.json index 12e9faefbcf..654595cb6e9 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,6 @@ "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" }, - "version": "7.1.22", + "version": "7.2.0", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 5805d8eaf40..b13141ca050 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.1.22", + "version": "7.2.0", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 0507a2b7b24..37418e6d590 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ece81c56afa..5722e09ba89 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 676bc0335b6..95cc4d7dcfb 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.1.22" +version = "7.2.0" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index fab9fae0df6..c503bf17710 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.1.22", + "version": "7.2.0", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 1eb86997edf..34bc04c675e 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 02faf1a7a8f..780eb6d7052 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index ba725ee81c4..121b2d317a7 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/events.ts b/packages/kilo-telemetry/src/events.ts index ddfde6a9a81..c621da8dc0b 100644 --- a/packages/kilo-telemetry/src/events.ts +++ b/packages/kilo-telemetry/src/events.ts @@ -25,6 +25,9 @@ export enum TelemetryEvent { MCP_SERVER_CONNECTED = "MCP Server Connected", MCP_SERVER_ERROR = "MCP Server Error", + // Remote Events + REMOTE_CONNECTION_OPENED = "Remote Connection Opened", + // Auth Events AUTH_SUCCESS = "Auth Success", AUTH_LOGOUT = "Auth Logout", diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 5b353aadcab..5e4379892da 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -184,6 +184,11 @@ export namespace Telemetry { track(TelemetryEvent.MCP_SERVER_ERROR, { server, error }) } + // Remote + export function trackRemoteConnectionOpened() { + track(TelemetryEvent.REMOTE_CONNECTION_OPENED) + } + // Auth export function trackAuthSuccess(provider: string) { track(TelemetryEvent.AUTH_SUCCESS, { provider }) diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index c09afb6476f..0450b0335f9 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 307ae9fccdd..802bea6c7f9 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -19,6 +19,18 @@ height: 20px; } } + + [data-slot="assistant-copy-wrapper"] { + display: flex; + align-items: center; + justify-content: flex-start; + margin-top: 2px; + + [data-component="icon-button"] { + width: 20px; + height: 20px; + } + } } /* Prevent long title/path from hiding the collapsible expand arrow */ diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 187a6a2df21..663945716bb 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1155,6 +1155,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() + const i18n = useI18n() const part = () => props.part as TextPart const displayText = () => (part().text ?? "").trim() @@ -1166,6 +1167,21 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return props.turnDiffSummary }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return false + if (props.showAssistantCopyPartID === null) return false + return props.showAssistantCopyPartID === part().id + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = displayText() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + const handleMarkdownClick = (e: MouseEvent) => { if (!data.openFile) return const target = e.target @@ -1200,6 +1216,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
+ +
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + +
+
{(render) => ( diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 09cbad788a3..99082d05f0d 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.1.22", + "version": "7.2.0", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts index f9f143f7467..67466a0fa8a 100644 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/DiffViewerProvider.ts @@ -212,6 +212,7 @@ export class DiffViewerProvider implements vscode.Disposable { public dispose(): void { this.stopDiffPolling() + this.gitOps.dispose() this.panel?.dispose() this.outputChannel.dispose() } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 9913759953d..bc7e48b8924 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -38,8 +38,10 @@ import { getWorkspaceRoot } from "./review-utils" import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace" import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, seedSessionStatuses } from "./session-status" +import { retry } from "./services/cli-backend/retry" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" +import { retryable, backoff, MAX_RETRIES } from "./util/retry" // legacy-migration start import { checkAndShowMigrationWizard, @@ -124,6 +126,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedMcpStatusMessage: unknown = null /** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */ private pending = 0 + private configWarningsShown = false /** Cached notificationsLoaded payload */ private cachedNotificationsMessage: unknown = null private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = [] @@ -168,6 +171,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private pendingFollowup: Followup | null = null /** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */ private statsPoller: GitStatsPoller | null = null + private statsGitOps: GitOps | null = null private cachedStats: unknown = null /** Optional interceptor called before the standard message handler. @@ -281,7 +285,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Use fire-and-forget (no throwOnError) to match old getProfile() which returned null on error. if (this.connectionState === "connected" && this.client) { console.log("[Kilo New] KiloProvider: 👤 syncWebviewState fetching profile...") - const profileResult = await this.client.kilo.profile() + const profileResult = await retry(() => this.client!.kilo.profile()) const profileData = profileResult.data ?? null console.log("[Kilo New] KiloProvider: 👤 syncWebviewState profile:", profileData ? "received" : "null") this.postMessage({ @@ -529,6 +533,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break } case "abort": + this.cancelRetry(message.sessionID ?? "") await this.handleAbort(message.sessionID) break case "revertSession": @@ -1048,6 +1053,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "connectionState", state }) if (state === "connected") { + // Fire config warnings independently so a failure in the + // sequential await chain doesn't prevent warnings from being shown + void this.checkConfigWarnings("state") try { // Profile fetch is best-effort — returns 401 when user isn't logged into gateway. const sdkClient = this.client @@ -1123,6 +1131,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } this.postMessage({ type: "connectionState", state: this.connectionState }) + + // connect() can resolve after SSE reaches "connected" but before this + // provider subscribes to onStateChange(). In that case the initial + // connected callback is missed, so run the warning check here too. + if (this.connectionState === "connected") { + void this.checkConfigWarnings("init") + } + await this.syncWebviewState("initializeConnection") await this.flushPendingSessionRefresh("initializeConnection") @@ -1221,9 +1237,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory(sessionID) - const { data: messagesData } = await this.client.session.messages( - { sessionID, directory: workspaceDir }, - { throwOnError: true, signal: abort.signal }, + const { data: messagesData } = await retry(() => + this.client!.session.messages( + { sessionID, directory: workspaceDir }, + { throwOnError: true, signal: abort.signal }, + ), ) // If this request was aborted while awaiting, skip posting stale results @@ -1324,9 +1342,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory(sessionID) - const { data: messagesData } = await this.client.session.messages( - { sessionID, directory: workspaceDir }, - { throwOnError: true }, + const { data: messagesData } = await retry(() => + this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true }), ) const messages = messagesData.map((m) => ({ @@ -1596,7 +1613,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory() - const { data: agents } = await this.client.app.agents({ directory: workspaceDir }, { throwOnError: true }) + const { data: agents } = await retry(() => + this.client!.app.agents({ directory: workspaceDir }, { throwOnError: true }), + ) const { visible, defaultAgent } = filterVisibleAgents(agents) @@ -1630,7 +1649,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory() - const { data: skills } = await this.client.app.skills({ directory: workspaceDir }, { throwOnError: true }) + const { data: skills } = await retry(() => + this.client!.app.skills({ directory: workspaceDir }, { throwOnError: true }), + ) const message = { type: "skillsLoaded", @@ -1653,7 +1674,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const dir = this.getWorkspaceDirectory() - const { data: commands } = await this.client.command.list({ directory: dir }, { throwOnError: true }) + const { data: commands } = await retry(() => + this.client!.command.list({ directory: dir }, { throwOnError: true }), + ) const message = { type: "commandsLoaded", @@ -1675,7 +1698,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client) return undefined try { const dir = this.getWorkspaceDirectory() - const { data } = await this.client.app.skills({ directory: dir }, { throwOnError: true }) + const { data } = await retry(() => this.client!.app.skills({ directory: dir }, { throwOnError: true })) return data } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch CLI skills for marketplace:", error) @@ -1811,7 +1834,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const directory = this.getWorkspaceDirectory() - const { data } = await this.client.mcp.status({ directory }) + const { data } = await retry(() => this.client!.mcp.status({ directory })) if (data) { const message = { type: "mcpStatusLoaded", status: data } this.cachedMcpStatusMessage = message @@ -1934,7 +1957,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const workspaceDir = this.getWorkspaceDirectory() - const { data: config } = await this.client.config.get({ directory: workspaceDir }, { throwOnError: true }) + const { data: config } = await retry(() => + this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }), + ) const message = { type: "configLoaded", @@ -1981,7 +2006,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!this.client || this.connectionState !== "connected") return try { const dir = this.getWorkspaceDirectory() - const { data: config } = await this.client.config.get({ directory: dir }, { throwOnError: true }) + const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) this.cachedConfigMessage = { type: "configLoaded", config } this.postMessage({ type: "configUpdated", config }) } catch (error) { @@ -1989,6 +2014,49 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + /** + * Fetch config warnings from the server and display a single consolidated + * VS Code warning with a "Show Details" action button. + * Only shown once per provider lifecycle (flag resets on dispose/re-create, not on SSE reconnect). + */ + private async checkConfigWarnings(from: string): Promise { + if (this.configWarningsShown) { + console.log("[Kilo New] KiloProvider: config warnings already shown", { from }) + return + } + if (!this.client) { + console.log("[Kilo New] KiloProvider: config warnings skipped (no client)", { from }) + return + } + try { + const dir = this.getWorkspaceDirectory() + console.log("[Kilo New] KiloProvider: checking config warnings", { from, dir }) + const result = await this.client.config.warnings({ directory: dir }) + const list = result?.data ?? [] + console.log("[Kilo New] KiloProvider: config warnings fetched", { from, count: list.length }) + if (list.length === 0) return + this.configWarningsShown = true + + const first = list[0]! + const summary = list.length === 1 ? first.message : `${first.message} (and ${list.length - 1} more)` + console.warn("[Kilo New] KiloProvider: showing config warnings", { from, count: list.length, path: first.path }) + + const action = await vscode.window.showWarningMessage(`Config: ${summary}`, "Show Details") + if (action === "Show Details") { + const lines = list.map((w) => { + const base = `${w.path}\n ${w.message}` + return w.detail ? `${base}\n ${w.detail}` : base + }) + const channel = vscode.window.createOutputChannel("Kilo Config Warnings") + channel.clear() + channel.appendLine(lines.join("\n\n")) + channel.show() + } + } catch (err) { + console.warn("[Kilo New] KiloProvider: checkConfigWarnings failed:", { from, err }) + } + } + /** * Fetch Kilo news/notifications and send to webview. * Uses the cached message pattern so the webview gets data immediately on refresh. @@ -2014,7 +2082,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } try { - const { data: all } = await this.client.kilo.notifications(undefined, { throwOnError: true }) + const { data: all } = await retry(() => this.client!.kilo.notifications(undefined, { throwOnError: true })) const notifications = all.filter((n) => !n.showIn || n.showIn.includes("extension")) const existing = this.extensionContext?.globalState.get("kilo.dismissedNotificationIds", []) ?? [] const active = new Set(notifications.map((n) => n.id)) @@ -2129,7 +2197,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Config.state is reset by updateGlobal (via Instance.resetStateEntry) so // config.get() returns fresh data without a full dispose cycle. const dir = this.getWorkspaceDirectory() - const { data: merged } = await this.client.config.get({ directory: dir }, { throwOnError: true }) + const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) this.cachedConfigMessage = { type: "configLoaded", config: merged } this.postMessage({ type: "configUpdated", config: merged }) @@ -2186,6 +2254,85 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return { sid, dir } } + /** Abort controllers for active retry loops, keyed by session ID */ + private retryAbortControllers = new Map() + + /** + * Execute an SDK call with exponential backoff on HTTP errors. + * Retries on 429, 5xx, and other retryable status codes. + * When the response includes `Retry-After` / `Retry-After-MS` headers, + * the delay honours that value (capped at 5 min). Otherwise uses the + * predefined backoff schedule: 5s -> 10s -> 30s -> 60s -> 300s. + * + * After MAX_RETRIES (5) attempts, automatically throws the error. + * Users can cancel via the cancel button in the UI which sends an abort + * message — this interrupts the backoff delay and stops the retry loop. + * + * The webview receives `sessionStatus` messages with a countdown so the + * user can see that a retry is in progress. + */ + private async withRetry(fn: () => Promise<{ error?: unknown; response: Response }>, sid: string): Promise { + const abortController = new AbortController() + this.retryAbortControllers.set(sid, abortController) + + try { + for (let attempt = 1; ; attempt++) { + if (abortController.signal.aborted) { + // User cancelled — return normally without triggering sendMessageFailed + return + } + + const result = await fn() + if (!result.error) return + + const status = result.response?.status ?? 0 + + // Non-retryable status codes fail immediately without retry + if (!retryable(status)) { + this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) + throw result.error + } + + // Stop retrying after MAX_RETRIES attempts + if (attempt >= MAX_RETRIES) { + this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) + throw result.error + } + + const delay = backoff(attempt, result.response?.headers) + console.log(`[Kilo New] KiloProvider: Retry on ${status}, attempt ${attempt}/${MAX_RETRIES}, delay ${delay}ms`) + + this.postMessage({ + type: "sessionStatus", + sessionID: sid, + status: "retry", + attempt, + message: `Error (${status}). Retrying...`, + next: Date.now() + delay, + }) + + // Wait for delay or until aborted + await new Promise((resolve) => { + const timer = setTimeout(resolve, delay) + abortController.signal.addEventListener("abort", () => { + clearTimeout(timer) + }) + }) + } + } finally { + this.retryAbortControllers.delete(sid) + } + } + + /** Cancel an active retry loop for a session */ + private cancelRetry(sid: string): void { + const controller = this.retryAbortControllers.get(sid) + if (controller) { + controller.abort() + this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) + } + } + private async handleSendMessage( text: string, messageID?: string, @@ -2228,18 +2375,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionService.recordMessageSessionId(messageID, resolved!.sid) } - await this.client.session.promptAsync( - { - sessionID: resolved!.sid, - directory: resolved!.dir, - messageID, - parts, - model: providerID && modelID ? { providerID, modelID } : undefined, - agent, - variant, - editorContext, - }, - { throwOnError: true }, + const sid = resolved!.sid + const dir = resolved!.dir + await this.withRetry( + () => + this.client!.session.promptAsync({ + sessionID: sid, + directory: dir, + messageID, + parts, + model: providerID && modelID ? { providerID, modelID } : undefined, + agent, + variant, + editorContext, + }), + sid, ) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to send message:", error) @@ -2290,19 +2440,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url })) - await this.client.session.command( - { - sessionID: resolved!.sid, - directory: resolved!.dir, - command, - arguments: args, - messageID, - model: providerID && modelID ? `${providerID}/${modelID}` : undefined, - agent, - variant, - parts, - }, - { throwOnError: true }, + const sid = resolved!.sid + const dir = resolved!.dir + await this.withRetry( + () => + this.client!.session.command({ + sessionID: sid, + directory: dir, + command, + arguments: args, + messageID, + model: providerID && modelID ? `${providerID}/${modelID}` : undefined, + agent, + variant, + parts, + }), + sid, ) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to send command:", error) @@ -3032,7 +3185,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private startStatsPolling(): void { this.statsPoller?.stop() + this.statsGitOps?.dispose() const git = new GitOps({ log: () => {} }) + this.statsGitOps = git this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => getWorkspaceRoot(), @@ -3060,6 +3215,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper */ dispose(): void { this.statsPoller?.stop() + this.statsGitOps?.dispose() this.unsubscribeEvent?.() this.unsubscribeState?.() this.unsubscribeNotificationDismiss?.() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 424d0b54b07..bebd61406a2 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -6,8 +6,10 @@ import { getErrorMessage } from "../kilo-provider-utils" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" import { WorktreeStateManager, remoteRef } from "./WorktreeStateManager" +import { handleSection } from "./section-handler" import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller" +import { PRStatusBridge } from "./pr-status-bridge" import { GitOps, type ApplyConflict } from "./GitOps" import { versionedName } from "./branch-name" import { normalizePath, classifyWorktreeError } from "./git-import" @@ -53,6 +55,7 @@ export class AgentManagerProvider implements Disposable { private diffSessionId: string | undefined private lastDiffHash: string | undefined private statsPoller: GitStatsPoller + private prBridge!: PRStatusBridge private gitOps: GitOps private cachedDiffTarget: { sessionId: string; directory: string; baseBranch: string } | undefined private staleWorktreeIds = new Set() @@ -94,6 +97,15 @@ export class AgentManagerProvider implements Disposable { log: (...args) => this.log(...args), git: this.gitOps, }) + this.prBridge = PRStatusBridge.create({ + getWorktrees: () => this.state?.getWorktrees() ?? [], + getWorkspaceRoot: () => this.getRoot(), + postToWebview: (m) => this.postToWebview(m), + updateWorktreePR: (id, n, u, s) => this.state?.updateWorktreePR(id, n, u, s), + hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber, + openExternal: (u) => this.host.openExternal(u), + log: (...a) => this.log(...a), + }) } private log(...args: unknown[]) { @@ -147,13 +159,14 @@ export class AgentManagerProvider implements Disposable { this.stateReady = this.initializeState() void this.sendRepoInfo() this.sendKeybindings() - + this.prBridge.attachPanel(ctx) ctx.onDidDispose(() => { // Only clear if this is still the active panel — a newer panel may // have already replaced us via attachPanel. if (this.panel === ctx) { this.log("Panel disposed") this.statsPoller.stop() + this.prBridge.poller.stop() this.stopDiffPolling() this.panel = undefined } @@ -209,6 +222,7 @@ export class AgentManagerProvider implements Disposable { // --------------------------------------------------------------------------- private async onMessage(msg: Record): Promise | null> { + if (this.prBridge.handleMessage(msg)) return null const m = msg as unknown as AgentManagerInMessage if (m.type === "agentManager.createWorktree") { @@ -292,6 +306,7 @@ export class AgentManagerProvider implements Disposable { // already emitted before the webview was ready to receive messages. if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats) if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats) + this.prBridge.replay() // Refresh sessions after pushState so the webview's sessionsLoaded // handler is guaranteed to be registered (requestState fires from // onMount). Without this, the initial refreshSessions() in @@ -327,6 +342,7 @@ export class AgentManagerProvider implements Disposable { this.state?.setSessionsCollapsed(m.collapsed) return null } + if (this.handleSection(m)) return null if (m.type === "agentManager.setReviewDiffStyle") { this.state?.setReviewDiffStyle(m.style) return null @@ -408,6 +424,7 @@ export class AgentManagerProvider implements Disposable { if (m.type === "loadMessages") { this.activeSessionId = m.sessionID this.terminalManager.syncOnSessionSwitch(m.sessionID) + this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined) } // After clearSession, clear active tracking and re-register worktree sessions @@ -671,6 +688,7 @@ export class AgentManagerProvider implements Disposable { } // Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree. this.statsPoller.skipWorktree(worktreeId) + this.prBridge.remove(worktreeId) const orphaned = state.removeWorktree(worktreeId) if (shouldStopDiffPolling(worktree.path, orphaned, this.cachedDiffTarget, this.diffSessionId)) { this.stopDiffPolling() @@ -679,11 +697,11 @@ export class AgentManagerProvider implements Disposable { this.pushState() // Disk removal after state is clean — pollers no longer reference this worktree. try { - await manager.removeWorktree(worktree.path, worktree.branch) + await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch) } catch (error) { this.log(`Failed to remove worktree from disk: ${error}`) } - this.log(`Deleted worktree ${worktreeId} (${worktree.branch})`) + this.log(`Deleted worktree ${worktreeId} (${worktree.originalBranch ?? worktree.branch})`) return null } @@ -1428,12 +1446,20 @@ export class AgentManagerProvider implements Disposable { const entries = result.worktrees.filter((item) => ids.has(item.worktreeId)) if (entries.length === 0) return + // Sync branches from git worktree list (no extra git calls) + let branchChanged = false + for (const entry of entries) { + if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) { + branchChanged = true + } + } + const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId)) - const changed = + const staleChanged = next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId)) this.staleWorktreeIds = next - if (changed) { + if (staleChanged || branchChanged) { this.pushState() } } @@ -1464,6 +1490,7 @@ export class AgentManagerProvider implements Disposable { type: "agentManager.state", worktrees, sessions: state.getSessions(), + sections: state.getSections(), staleWorktreeIds, tabOrder: state.getTabOrder(), worktreeOrder: state.getWorktreeOrder(), @@ -1474,6 +1501,7 @@ export class AgentManagerProvider implements Disposable { }) this.statsPoller.setEnabled(worktrees.length > 0 || this.panel !== undefined) + this.prBridge.poller.setEnabled(worktrees.length > 0) } /** Push empty state when the folder is not a git repo or has no folder open. */ @@ -1886,6 +1914,10 @@ export class AgentManagerProvider implements Disposable { ) } + private handleSection(m: AgentManagerInMessage): boolean { + return handleSection(this.state, m, () => this.pushState()) + } + public postMessage(message: unknown): void { this.panel?.postMessage(message) } @@ -1893,6 +1925,8 @@ export class AgentManagerProvider implements Disposable { public dispose(): void { this.stopDiffPolling() this.statsPoller.stop() + this.gitOps.dispose() + this.prBridge.poller.stop() this.terminalManager.dispose() this.panel?.dispose() this.outputChannel.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 9aa393513ce..6c43f64d1e4 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -62,19 +62,45 @@ export function nonInteractiveEnv(): NodeJS.ProcessEnv { export class GitOps { private readonly log: (...args: unknown[]) => void private readonly runGit: (args: string[], cwd: string) => Promise + private readonly controller = new AbortController() + + get disposed(): boolean { + return this.controller.signal.aborted + } constructor(options: GitOpsOptions) { this.log = options.log this.runGit = options.runGit ?? ((args, cwd) => - simpleGit(cwd) + simpleGit(cwd, { abort: this.controller.signal }) .raw(args) .then((out) => out.trim())) } + dispose(): void { + if (!this.controller.signal.aborted) { + this.controller.abort() + } + } + private raw(args: string[], cwd: string): Promise { - return this.runGit(args, cwd) + const signal = this.controller.signal + if (signal.aborted) return Promise.reject(new Error("GitOps disposed")) + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("GitOps disposed")) + signal.addEventListener("abort", onAbort, { once: true }) + this.runGit(args, cwd).then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (err) => { + signal.removeEventListener("abort", onAbort) + reject(err) + }, + ) + }) } /** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */ @@ -130,14 +156,14 @@ export class GitOps { } /** Return the set of worktree paths for the repo, excluding bare entries. */ - async listWorktreePaths(cwd: string): Promise> { + async listWorktreePaths(cwd: string): Promise> { const raw = await this.raw(["worktree", "list", "--porcelain"], cwd) - const paths = new Set() + const result = new Map() for (const entry of parseWorktreeList(raw)) { if (entry.bare) continue - paths.add(normalizePath(entry.path)) + result.set(normalizePath(entry.path), entry.branch) } - return paths + return result } /** @@ -335,10 +361,14 @@ export class GitOps { } private exec(args: string[], cwd: string, options?: ExecOptions): Promise { + if (this.controller.signal.aborted) { + return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" }) + } return new Promise((resolve) => { const child = spawn("git", args, { cwd, env: options?.env, + signal: this.controller.signal, stdio: ["pipe", "pipe", "pipe"], }) diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 27260b2f059..301261d137e 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -26,6 +26,8 @@ export interface LocalStats { export interface WorktreePresence { worktreeId: string missing: boolean + /** Current branch from `git worktree list`, if available. */ + branch?: string } export interface WorktreePresenceResult { @@ -231,7 +233,8 @@ export class GitStatsPoller { () => false, ) const missing = !exists || !tracked.has(normalized) - return { worktreeId: wt.id, missing } + const branch = tracked.get(normalized) + return { worktreeId: wt.id, missing, branch } }), ) diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts new file mode 100644 index 00000000000..a1d37297c16 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -0,0 +1,486 @@ +import type { Worktree } from "./WorktreeStateManager" +import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types" +import { execWithShellEnv } from "./shell-env" +import { classifyPRError } from "./git-import" + +interface PRStatusPollerOptions { + getWorktrees: () => Worktree[] + getWorkspaceRoot: () => string | undefined + onStatus: (worktreeId: string, pr: PRStatus | null, error?: "gh_missing" | "gh_auth" | "fetch_failed") => void + log: (...args: unknown[]) => void + intervalMs?: number +} + +const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime +const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors +const BACKOFF_MULTIPLIER = 2 + +export class PRStatusPoller { + private timer: ReturnType | undefined + private active = false + private visible = true + private busy = false + private lastHash = new Map() + private lastError: string | undefined // tracks global error state for de-duplication + private failures = 0 // consecutive failure count for backoff + private ghAvailable: boolean | undefined + private ghProbeTime = 0 + private activeWorktreeId: string | undefined + private cachedRepo: { owner: string; name: string; cwd: string } | undefined + private readonly intervalMs: number + + constructor(private readonly options: PRStatusPollerOptions) { + this.intervalMs = options.intervalMs ?? 15_000 + } + + setEnabled(enabled: boolean): void { + if (enabled) { + if (this.active) return + this.start() + return + } + this.stop() + } + + /** Pause/resume polling based on panel visibility. */ + setVisible(visible: boolean): void { + if (this.visible === visible) return + this.visible = visible + if (!this.active) return + if (visible) { + // Resume — poll immediately then schedule normally + if (this.timer) clearTimeout(this.timer) + this.timer = undefined + void this.poll() + return + } + // Pause — cancel pending timer + if (this.timer) { + clearTimeout(this.timer) + this.timer = undefined + } + } + + stop(): void { + this.active = false + if (this.timer) { + clearTimeout(this.timer) + this.timer = undefined + } + this.busy = false + this.lastHash.clear() + this.lastError = undefined + this.failures = 0 + this.ghAvailable = undefined + this.ghProbeTime = 0 + this.cachedRepo = undefined + } + + /** Force-refresh a specific worktree immediately. */ + refresh(worktreeId: string): void { + if (!this.active) return + void this.fetchOne(worktreeId) + } + + setActiveWorktreeId(id: string | undefined): void { + this.activeWorktreeId = id + } + + private start(): void { + this.stop() + this.active = true + // Don't override this.visible — it may already be set to false by + // setVisible() before setEnabled(true) is called. + void this.poll() + } + + private nextDelay(): number { + if (this.failures === 0) return this.intervalMs + return Math.min(this.intervalMs * Math.pow(BACKOFF_MULTIPLIER, this.failures), MAX_BACKOFF) + } + + private schedule(): void { + if (!this.active || !this.visible) return + const delay = this.nextDelay() + this.timer = setTimeout(() => { + void this.poll() + }, delay) + } + + private poll(): Promise { + if (!this.active || !this.visible) return Promise.resolve() + if (this.busy) return Promise.resolve() + this.busy = true + return this.fetchAll().finally(() => { + this.busy = false + this.schedule() + }) + } + + private async probeGh(): Promise { + const now = Date.now() + if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) { + return this.ghAvailable + } + try { + await execWithShellEnv("gh", ["--version"], { timeout: 5_000 }) + this.ghAvailable = true + } catch { + this.ghAvailable = false + } + this.ghProbeTime = Date.now() + return this.ghAvailable + } + + private async fetchAll(): Promise { + if (!(await this.probeGh())) { + // De-duplicate: only emit gh_missing once, not every poll cycle + if (this.lastError !== "gh_missing") { + this.lastError = "gh_missing" + for (const wt of this.options.getWorktrees()) { + this.options.onStatus(wt.id, null, "gh_missing") + } + } + this.failures++ + return + } + + this.lastError = undefined + const worktrees = this.options.getWorktrees() + const results = await Promise.allSettled(worktrees.map((wt) => this.fetchOne(wt.id))) + const ok = results.every((r) => r.status === "fulfilled") + if (ok) { + this.failures = 0 + return + } + this.failures++ + } + + private async fetchOne(worktreeId: string): Promise { + const worktrees = this.options.getWorktrees() + const wt = worktrees.find((w) => w.id === worktreeId) + if (!wt) return + + if (!this.options.getWorkspaceRoot()) return + + try { + const pr = await this.fetchPRForBranch(wt.branch, wt.path) + if (!pr) { + const hash = `${worktreeId}:none` + if (this.lastHash.get(worktreeId) === hash) return + this.lastHash.set(worktreeId, hash) + this.options.onStatus(worktreeId, null) + return + } + + const [checks, comments] = await Promise.all([ + this.fetchChecks(pr.number, wt.path), + this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined, + ]) + + const status: PRStatus = { + number: pr.number, + title: pr.title, + url: pr.url, + state: pr.state, + review: pr.review, + checks, + ...(comments && { comments }), + additions: pr.additions, + deletions: pr.deletions, + files: pr.files, + } + + const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}` + if (this.lastHash.get(worktreeId) === hash) return + this.lastHash.set(worktreeId, hash) + + this.options.onStatus(worktreeId, status) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const kind = classifyPRError(msg) + this.options.log(`PR fetch failed for ${wt.branch}:`, msg) + + const errKey = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed" + if (kind === "gh_missing") this.ghAvailable = false + + // De-duplicate: only emit if the error state changed for this worktree + const hash = `${worktreeId}:error:${errKey}` + if (this.lastHash.get(worktreeId) !== hash) { + this.lastHash.set(worktreeId, hash) + this.options.onStatus(worktreeId, null, errKey) + } + throw err // propagate so fetchAll can track failures for backoff + } + } + + private static readonly PR_JSON_FIELDS = + "number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid" + + private async fetchPRForBranch(branch: string, cwd: string): Promise { + // Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref. + // Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head). + // Strategy 2: `gh pr view ` — works for same-repo branches pushed to origin. + // Strategy 3: `gh pr list --search ""` — last resort, finds PRs by HEAD commit SHA. + return (await this.ghPRView(cwd)) ?? (await this.ghPRView(cwd, branch)) ?? (await this.ghPRListBySHA(cwd)) + } + + /** Run `gh pr view [branch] --json ...` and parse the result, or return null. */ + private async ghPRView(cwd: string, branch?: string): Promise { + try { + const args = ["pr", "view"] + if (branch) args.push(branch) + args.push("--json", PRStatusPoller.PR_JSON_FIELDS) + + const { stdout } = await execWithShellEnv("gh", args, { cwd, timeout: 15_000 }) + return parsePRResult(stdout) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes("no pull requests found") || msg.includes("Could not resolve")) return null + throw err + } + } + + /** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */ + private async ghPRListBySHA(cwd: string): Promise { + try { + const { stdout: sha } = await execWithShellEnv("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 }) + const head = sha.trim() + if (!head) return null + + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + "--state", + "all", + "--search", + `${head} is:pr`, + "--limit", + "5", + "--json", + PRStatusPoller.PR_JSON_FIELDS, + ], + { cwd, timeout: 15_000 }, + ) + const items = JSON.parse(stdout) as unknown[] + if (!Array.isArray(items) || items.length === 0) return null + + // Only accept PRs where headRefOid matches our HEAD exactly + for (const item of items) { + const data = item as Record + if (data.headRefOid === head) return parsePRResult(JSON.stringify(data)) + } + return null + } catch { + return null + } + } + + private async fetchChecks( + prNumber: number, + cwd: string, + ): Promise<{ + status: AggregateCheckStatus + total: number + passed: number + failed: number + pending: number + items: PRCheck[] + }> { + try { + const { stdout } = await execWithShellEnv( + "gh", + ["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"], + { cwd, timeout: 15_000 }, + ) + const data = JSON.parse(stdout) as Array<{ + name: string + state: string + link?: string + startedAt?: string + completedAt?: string + }> + + const items: PRCheck[] = data.map((c) => ({ + name: c.name, + status: mapCheckStatus(c.state), + url: c.link, + duration: formatCheckDuration(c.startedAt, c.completedAt), + })) + + const total = items.length + const passed = items.filter((c) => c.status === "success").length + const failed = items.filter((c) => c.status === "failure").length + const pending = items.filter((c) => c.status === "pending").length + + const status: AggregateCheckStatus = + total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success" + + return { status, total, passed, failed, pending, items } + } catch { + return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] } + } + } + + private async getRepoInfo(cwd: string): Promise<{ owner: string; name: string }> { + if (this.cachedRepo && this.cachedRepo.cwd === cwd) { + return this.cachedRepo + } + const { stdout } = await execWithShellEnv("gh", ["repo", "view", "--json", "owner,name"], { + cwd, + timeout: 10_000, + }) + const data = JSON.parse(stdout) + const info = { owner: data.owner.login as string, name: data.name as string, cwd } + this.cachedRepo = info + return info + } + + private async fetchComments( + prNumber: number, + cwd: string, + ): Promise<{ total: number; unresolved: number; items: PRComment[] }> { + try { + const repo = await this.getRepoInfo(cwd) + + const query = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + isResolved + comments(first: 1) { + nodes { + id + author { login avatarUrl } + body + path + line + url + createdAt + } + } + } + } + } + } + }` + + const { stdout } = await execWithShellEnv( + "gh", + [ + "api", + "graphql", + "-f", + `query=${query}`, + "-F", + `owner=${repo.owner}`, + "-F", + `repo=${repo.name}`, + "-F", + `number=${prNumber}`, + ], + { cwd, timeout: 15_000 }, + ) + const result = JSON.parse(stdout) + const threads = result?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [] + + const items: PRComment[] = [] + for (const thread of threads) { + const first = thread.comments?.nodes?.[0] + if (!first) continue + items.push({ + id: first.id, + author: first.author?.login ?? "unknown", + avatar: first.author?.avatarUrl, + body: first.body ?? "", + file: first.path, + line: first.line, + url: first.url, + resolved: thread.isResolved ?? false, + createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, + }) + } + + const total = items.length + const unresolved = items.filter((c) => !c.resolved).length + return { total, unresolved, items } + } catch (err) { + this.options.log("Failed to fetch PR comments:", err) + return { total: 0, unresolved: 0, items: [] } + } + } +} + +interface PRResult { + number: number + title: string + url: string + state: PRState + review: ReviewDecision | null + additions: number + deletions: number + files: number +} + +function parsePRResult(json: string): PRResult | null { + const data = JSON.parse(json) + if (!data.number) return null + return { + number: data.number, + title: data.title ?? "", + url: data.url ?? "", + state: parsePRState(data.isDraft, data.state), + review: parseReviewDecision(data.reviewDecision), + additions: data.additions ?? 0, + deletions: data.deletions ?? 0, + files: data.changedFiles ?? 0, + } +} + +function parsePRState(isDraft: boolean, ghState: string): PRState { + if (isDraft) return "draft" + if (ghState === "MERGED") return "merged" + if (ghState === "CLOSED") return "closed" + return "open" +} + +function parseReviewDecision(decision: string | undefined): ReviewDecision | null { + if (decision === "APPROVED") return "approved" + if (decision === "CHANGES_REQUESTED") return "changes_requested" + if (decision === "REVIEW_REQUIRED") return "pending" + return null +} + +function mapCheckStatus(state: string): CheckStatus { + switch (state.toUpperCase()) { + case "SUCCESS": + return "success" + case "FAILURE": + case "ERROR": + return "failure" + case "PENDING": + case "QUEUED": + case "IN_PROGRESS": + case "REQUESTED": + case "WAITING": + return "pending" + case "SKIPPED": + return "skipped" + case "CANCELLED": + case "TIMED_OUT": + case "STALE": + case "STARTUP_FAILURE": + return "cancelled" + default: + return "pending" + } +} + +function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined { + if (!startedAt || !completedAt) return undefined + const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000) + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s` +} diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 1148fbc3f02..8078dad5b2c 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -26,6 +26,27 @@ export interface Worktree { groupId?: string /** User-provided display name for the worktree. */ label?: string + /** Cached PR number for instant badge display on reload. */ + prNumber?: number + /** Cached PR URL for instant badge display on reload. */ + prUrl?: string + /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ + prState?: string + /** Original branch created with the worktree, used for cleanup on deletion. + * Set automatically when `branch` is updated via live sync. */ + originalBranch?: string + /** Section this worktree belongs to, or undefined for ungrouped. */ + sectionId?: string +} + +export interface Section { + id: string + name: string + /** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */ + color: string | null + /** Position among top-level sidebar children (interleaved with ungrouped worktrees). */ + order: number + collapsed: boolean } /** @@ -46,6 +67,7 @@ export interface ManagedSession { interface StateFile { worktrees: Record> sessions: Record> + sections?: Record> tabOrder?: Record worktreeOrder?: string[] sessionsCollapsed?: boolean @@ -67,6 +89,7 @@ export class WorktreeStateManager { private readonly file: string private worktrees = new Map() private sessions = new Map() + private sections = new Map() private tabOrder: Record = {} private worktreeOrder: string[] = [] private collapsed = false @@ -163,6 +186,16 @@ export class WorktreeStateManager { return wt } + updateWorktreeBranch(id: string, branch: string): boolean { + const wt = this.worktrees.get(id) + if (!wt || wt.branch === branch) return false + if (!wt.originalBranch) wt.originalBranch = wt.branch + this.log(`Updated worktree ${id} branch: ${wt.branch} → ${branch}`) + wt.branch = branch + void this.save() + return true + } + updateWorktreeLabel(id: string, label: string): void { const wt = this.worktrees.get(id) if (!wt) return @@ -171,6 +204,16 @@ export class WorktreeStateManager { void this.save() } + updateWorktreePR(id: string, prNumber?: number, prUrl?: string, prState?: string): void { + const wt = this.worktrees.get(id) + if (!wt) return + if (wt.prNumber === prNumber && wt.prUrl === prUrl && wt.prState === prState) return + wt.prNumber = prNumber + wt.prUrl = prUrl + wt.prState = prState + void this.save() + } + removeWorktree(id: string): ManagedSession[] { const removed = this.worktrees.delete(id) if (!removed) return [] @@ -255,7 +298,128 @@ export class WorktreeStateManager { } setWorktreeOrder(order: string[]): void { - this.worktreeOrder = order + const top = new Set() + for (const sec of this.sections.values()) top.add(sec.id) + for (const wt of this.worktrees.values()) { + if (!wt.sectionId) top.add(wt.id) + } + this.worktreeOrder = order.filter((id) => top.has(id)) + void this.save() + } + + // --------------------------------------------------------------------------- + // Sections + // --------------------------------------------------------------------------- + + getSections(): Section[] { + return [...this.sections.values()] + } + + getSection(id: string): Section | undefined { + return this.sections.get(id) + } + + addSection(name: string, color: string | null, worktreeIds?: string[]): Section { + const id = generateId("sec") + const order = this.worktreeOrder.length + const sec: Section = { id, name, color, order, collapsed: false } + this.sections.set(id, sec) + this.worktreeOrder.push(id) + if (worktreeIds) { + for (const wtId of worktreeIds) { + const wt = this.worktrees.get(wtId) + if (wt) { + wt.sectionId = id + // Remove from top-level worktreeOrder since it's now inside a section + const idx = this.worktreeOrder.indexOf(wtId) + if (idx !== -1) this.worktreeOrder.splice(idx, 1) + } + } + } + this.log(`Added section ${id}: "${name}"`) + void this.save() + return sec + } + + renameSection(id: string, name: string): void { + const sec = this.sections.get(id) + if (!sec || !name) return + sec.name = name + this.log(`Renamed section ${id} to "${name}"`) + void this.save() + } + + setSectionColor(id: string, color: string | null): void { + const sec = this.sections.get(id) + if (!sec) return + sec.color = color + void this.save() + } + + toggleSection(id: string): void { + const sec = this.sections.get(id) + if (!sec) return + sec.collapsed = !sec.collapsed + void this.save() + } + + deleteSection(id: string): void { + if (!this.sections.delete(id)) return + // Ungroup all worktrees in this section — do NOT delete them + for (const wt of this.worktrees.values()) { + if (wt.sectionId === id) { + wt.sectionId = undefined + if (!this.worktreeOrder.includes(wt.id)) this.worktreeOrder.push(wt.id) + } + } + // Remove from sidebar order + const idx = this.worktreeOrder.indexOf(id) + if (idx !== -1) this.worktreeOrder.splice(idx, 1) + this.log(`Deleted section ${id}, ungrouped its worktrees`) + void this.save() + } + + moveSection(id: string, dir: -1 | 1): void { + const top = this.worktreeOrder.filter((item) => { + if (this.sections.has(item)) return true + const wt = this.worktrees.get(item) + return !!wt && !wt.sectionId + }) + const idx = top.indexOf(id) + const next = idx + dir + if (idx === -1 || next < 0 || next >= top.length) return + const target = top[next]! + const result = [...this.worktreeOrder] + const fi = result.indexOf(id) + if (fi === -1 || result.indexOf(target) === -1) return + result.splice(fi, 1) + const insertAt = result.indexOf(target) + (dir === 1 ? 1 : 0) + result.splice(insertAt, 0, id) + this.worktreeOrder = result + void this.save() + } + + moveToSection(worktreeIds: string[], sectionId: string | null): void { + // Expand to include all multi-version siblings (same groupId) + const expanded = new Set(worktreeIds) + for (const wtId of worktreeIds) { + const wt = this.worktrees.get(wtId) + if (!wt?.groupId) continue + for (const sibling of this.worktrees.values()) { + if (sibling.groupId === wt.groupId) expanded.add(sibling.id) + } + } + for (const wtId of expanded) { + const wt = this.worktrees.get(wtId) + if (!wt) continue + wt.sectionId = sectionId ?? undefined + if (sectionId) { + const idx = this.worktreeOrder.indexOf(wtId) + if (idx !== -1) this.worktreeOrder.splice(idx, 1) + } else { + if (!this.worktreeOrder.includes(wtId)) this.worktreeOrder.push(wtId) + } + } void this.save() } @@ -314,6 +478,7 @@ export class WorktreeStateManager { const data = JSON.parse(content) as StateFile this.worktrees.clear() this.sessions.clear() + this.sections.clear() this.tabOrder = {} this.worktreeOrder = [] this.reviewDiffStyle = "unified" @@ -329,12 +494,23 @@ export class WorktreeStateManager { for (const [id, s] of Object.entries(data.sessions ?? {})) { this.sessions.set(id, { id, ...s }) } + for (const [id, sec] of Object.entries(data.sections ?? {})) { + this.sections.set(id, { id, ...sec }) + } if (data.tabOrder) { this.tabOrder = data.tabOrder } if (data.worktreeOrder) { this.worktreeOrder = data.worktreeOrder } + // Normalize: ensure all section IDs and ungrouped worktree IDs are in worktreeOrder + const present = new Set(this.worktreeOrder) + for (const id of this.sections.keys()) { + if (!present.has(id)) this.worktreeOrder.push(id) + } + for (const wt of this.worktrees.values()) { + if (!wt.sectionId && !present.has(wt.id)) this.worktreeOrder.push(wt.id) + } this.collapsed = data.sessionsCollapsed ?? false if (data.reviewDiffStyle === "split") { this.reviewDiffStyle = "split" @@ -404,6 +580,13 @@ export class WorktreeStateManager { const { id: _, ...rest } = s data.sessions[id] = rest } + if (this.sections.size > 0) { + data.sections = {} + for (const [id, sec] of this.sections) { + const { id: _, ...rest } = sec + data.sections[id] = rest + } + } if (Object.keys(this.tabOrder).length > 0) { data.tabOrder = this.tabOrder } diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 34bbdd3b7f3..596e32cf88c 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -56,9 +56,15 @@ export interface PanelContext { /** Whether the panel is currently the active tab. */ readonly active: boolean + /** Whether the panel is visible (may be unfocused in a split editor group). */ + readonly visible: boolean + /** Session provider wired to this panel. */ readonly sessions: SessionProvider + /** Register a callback for when panel visibility changes. */ + onDidChangeVisibility(cb: (visible: boolean) => void): Disposable + /** Register a callback for when the panel is disposed. */ onDidDispose(cb: () => void): Disposable @@ -107,6 +113,9 @@ export interface Host { /** Capture a telemetry event. */ capture(event: string, properties?: Record): void + /** Open a URL in the user's default browser. */ + openExternal(url: string): void + /** Ask VS Code's git extension to re-scan repositories (e.g. after worktree ref migration). */ refreshGit(): void diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts new file mode 100644 index 00000000000..15d5d754d7e --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -0,0 +1,110 @@ +/** + * Bridges the PRStatusPoller with the AgentManagerProvider. + * + * Owns the poller instance, the cached PR messages, and all message/panel handling + * so the provider only needs thin delegation calls. + */ +import type { Worktree } from "./WorktreeStateManager" +import type { AgentManagerOutMessage, PRStatus } from "./types" +import type { Disposable } from "./host" +import { PRStatusPoller } from "./PRStatusPoller" + +interface PRBridgeHost { + getWorktrees(): Worktree[] + getWorkspaceRoot(): string | undefined + postToWebview(msg: AgentManagerOutMessage): void + updateWorktreePR(id: string, number?: number, url?: string, state?: string): void + hasPersistedPR(id: string): boolean + openExternal(url: string): void + log(...args: unknown[]): void +} + +/** Minimal panel surface needed by the bridge (subset of PanelContext). */ +interface PanelLike { + readonly visible: boolean + onDidChangeVisibility(cb: (visible: boolean) => void): Disposable +} + +export class PRStatusBridge { + readonly poller: PRStatusPoller + private readonly cache = new Map() + private readonly host: PRBridgeHost + + constructor(host: PRBridgeHost) { + this.host = host + this.poller = new PRStatusPoller(bridgePollerOpts(this, host)) + } + + static create(opts: { + getWorktrees: () => Worktree[] + getWorkspaceRoot: () => string | undefined + postToWebview: (msg: AgentManagerOutMessage) => void + updateWorktreePR: (id: string, n?: number, u?: string, s?: string) => void + hasPersistedPR: (id: string) => boolean + openExternal: (url: string) => void + log: (...args: unknown[]) => void + }): PRStatusBridge { + return new PRStatusBridge(opts) + } + + /** Wire visibility tracking to a panel — pauses polling when hidden. */ + attachPanel(panel: PanelLike): void { + this.poller.setVisible(panel.visible) + panel.onDidChangeVisibility((v) => { + this.poller.setVisible(v) + }) + } + + /** Replay cached PR statuses to a freshly-connected webview. */ + replay(): void { + this.cache.forEach((msg) => this.host.postToWebview(msg)) + } + + /** Handle an incoming webview message. Returns true if handled. */ + handleMessage(m: Record): boolean { + if (m.type === "agentManager.refreshPR") { + this.poller.refresh(m.worktreeId as string) + return true + } + if (m.type === "agentManager.openPR") { + const wt = this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId) + if (wt?.prUrl) this.host.openExternal(wt.prUrl) + return true + } + return false + } + + /** Remove cached status for a deleted worktree. */ + remove(worktreeId: string): void { + this.cache.delete(worktreeId) + } +} + +/** Build PRStatusPoller options that forward events through the bridge cache. */ +function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) { + return { + getWorktrees: () => host.getWorktrees(), + getWorkspaceRoot: () => host.getWorkspaceRoot(), + onStatus: (id: string, pr: PRStatus | null, err?: "gh_missing" | "gh_auth" | "fetch_failed") => { + if (err) { + // Don't forward errors to the webview when we have prior PR data + // (in-memory cache or persisted prNumber) — that would overwrite + // the live badge with pr:null. Only forward when there's truly no + // prior data (first poll failed, nothing persisted). + if (!bridge["cache"].has(id) && !host.hasPersistedPR(id)) + host.postToWebview({ + type: "agentManager.prStatus", + worktreeId: id, + pr: null, + error: err, + } as AgentManagerOutMessage) + return + } + const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage + bridge["cache"].set(id, msg) + host.postToWebview(msg) + host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state) + }, + log: (...args: unknown[]) => host.log(...args), + } +} diff --git a/packages/kilo-vscode/src/agent-manager/section-handler.ts b/packages/kilo-vscode/src/agent-manager/section-handler.ts new file mode 100644 index 00000000000..624a23eb10e --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/section-handler.ts @@ -0,0 +1,21 @@ +import type { WorktreeStateManager } from "./WorktreeStateManager" +import type { AgentManagerInMessage } from "./types" + +/** Handle section CRUD messages. Returns true if handled. */ +export function handleSection( + state: WorktreeStateManager | undefined, + m: AgentManagerInMessage, + push: () => void, +): boolean { + if (!state) return false + if (m.type === "agentManager.createSection") state.addSection(m.name, m.color ?? null, m.worktreeIds) + else if (m.type === "agentManager.renameSection") state.renameSection(m.sectionId, m.name) + else if (m.type === "agentManager.deleteSection") state.deleteSection(m.sectionId) + else if (m.type === "agentManager.setSectionColor") state.setSectionColor(m.sectionId, m.color) + else if (m.type === "agentManager.toggleSectionCollapsed") state.toggleSection(m.sectionId) + else if (m.type === "agentManager.moveToSection") state.moveToSection(m.worktreeIds, m.sectionId) + else if (m.type === "agentManager.moveSection") state.moveSection(m.sectionId, m.dir) + else return false + push() + return true +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index aeffd54bbce..264f1f46107 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -8,7 +8,7 @@ */ import type { FileDiff } from "@kilocode/sdk/v2/client" -import type { Worktree, ManagedSession } from "./WorktreeStateManager" +import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager" import type { WorktreeStats, LocalStats } from "./GitStatsPoller" import type { ApplyConflict } from "./GitOps" import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import" @@ -29,6 +29,58 @@ export type WorktreeDiffEntry = FileDiff & { stamp?: string } +// --------------------------------------------------------------------------- +// PR status types +// --------------------------------------------------------------------------- + +export type PRState = "open" | "draft" | "merged" | "closed" +export type ReviewDecision = "approved" | "changes_requested" | "pending" +export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled" +export type AggregateCheckStatus = "success" | "failure" | "pending" | "none" + +export interface PRCheck { + name: string + status: CheckStatus + url?: string + duration?: string +} + +export interface PRComment { + id: string + author: string + avatar?: string + body: string + file?: string + line?: number + url?: string + resolved: boolean + createdAt?: number +} + +export interface PRStatus { + number: number + title: string + url: string + state: PRState + review: ReviewDecision | null + checks: { + status: AggregateCheckStatus + total: number + passed: number + failed: number + pending: number + items: PRCheck[] + } + comments?: { + total: number + unresolved: number + items: PRComment[] + } + additions: number + deletions: number + files: number +} + // --------------------------------------------------------------------------- // Extension → Webview messages (postToWebview) // --------------------------------------------------------------------------- @@ -66,6 +118,7 @@ interface StateMessage { type: "agentManager.state" worktrees: Worktree[] sessions: ManagedSession[] + sections?: Section[] staleWorktreeIds?: string[] tabOrder?: Record worktreeOrder?: string[] @@ -175,6 +228,13 @@ interface WorktreeDiffFileMessage { diff: WorktreeDiffEntry | null } +interface PRStatusOutMessage { + type: "agentManager.prStatus" + worktreeId: string + pr: PRStatus | null + error?: "gh_missing" | "gh_auth" | "fetch_failed" +} + interface ActionOutMessage { type: "action" action: string @@ -202,6 +262,7 @@ export type AgentManagerOutMessage = | WorktreeDiffLoadingMessage | WorktreeDiffMessage | WorktreeDiffFileMessage + | PRStatusOutMessage | ActionOutMessage // --------------------------------------------------------------------------- @@ -379,6 +440,16 @@ interface StopDiffWatchIn { type: "agentManager.stopDiffWatch" } +interface RefreshPRIn { + type: "agentManager.refreshPR" + worktreeId: string +} + +interface OpenPRIn { + type: "agentManager.openPR" + worktreeId: string +} + interface OpenFileIn { type: "agentManager.openFile" sessionId: string @@ -453,6 +524,47 @@ interface ContinueInWorktreeIn { sessionId: string } +interface CreateSectionIn { + type: "agentManager.createSection" + name: string + color?: string + worktreeIds?: string[] +} + +interface RenameSectionIn { + type: "agentManager.renameSection" + sectionId: string + name: string +} + +interface DeleteSectionIn { + type: "agentManager.deleteSection" + sectionId: string +} + +interface SetSectionColorIn { + type: "agentManager.setSectionColor" + sectionId: string + color: string | null +} + +interface ToggleSectionCollapsedIn { + type: "agentManager.toggleSectionCollapsed" + sectionId: string +} + +interface MoveToSectionIn { + type: "agentManager.moveToSection" + worktreeIds: string[] + sectionId: string | null +} + +interface MoveSectionIn { + type: "agentManager.moveSection" + sectionId: string + dir: -1 | 1 +} + /** All messages the Agent Manager expects from the webview (onMessage input). */ export type AgentManagerInMessage = | CreateWorktreeIn @@ -489,6 +601,8 @@ export type AgentManagerInMessage = | ApplyWorktreeDiffIn | StartDiffWatchIn | StopDiffWatchIn + | RefreshPRIn + | OpenPRIn | OpenFileIn | GenericOpenFileIn | PreviewImageIn @@ -498,3 +612,10 @@ export type AgentManagerInMessage = | ClearSessionIn | AbortIn | ContinueInWorktreeIn + | CreateSectionIn + | RenameSectionIn + | DeleteSectionIn + | SetSectionColorIn + | ToggleSectionCollapsedIn + | MoveToSectionIn + | MoveSectionIn diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index f3f3a4c969f..7ea4403cbc0 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -92,6 +92,9 @@ export class VscodeHost implements Host { get active() { return panel.active }, + get visible() { + return panel.visible + }, postMessage(msg) { void panel.webview.postMessage(msg) }, @@ -99,6 +102,9 @@ export class VscodeHost implements Host { panel.reveal(vscode.ViewColumn.One, preserveFocus ?? false) }, sessions, + onDidChangeVisibility(cb) { + return panel.onDidChangeViewState((e) => cb(e.webviewPanel.visible)) + }, onDidDispose(cb) { return panel.onDidDispose(cb) }, @@ -160,9 +166,11 @@ export class VscodeHost implements Host { TelemetryProxy.capture(event as TelemetryEventName, properties) } + openExternal(url: string): void { + void vscode.env.openExternal(vscode.Uri.parse(url)) + } + refreshGit(): void { - // Trigger VS Code's built-in git extension to re-scan repositories. - // This picks up worktrees whose gitdir refs were just rewritten by migration. void vscode.commands.executeCommand("git.refresh") } diff --git a/packages/kilo-vscode/src/services/cli-backend/retry.ts b/packages/kilo-vscode/src/services/cli-backend/retry.ts new file mode 100644 index 00000000000..1e3cc054af4 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/retry.ts @@ -0,0 +1,34 @@ +// Replicated from packages/util/src/retry.ts to avoid adding @opencode-ai/util +// as a dependency of the extension. Keep in sync with the original. + +const TRANSIENT = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "fetch failed", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", +] + +function transient(error: unknown): boolean { + if (!error) return false + const msg = String(error instanceof Error ? error.message : error).toLowerCase() + return TRANSIENT.some((m) => msg.includes(m)) +} + +export async function retry(fn: () => Promise, attempts = 3, delay = 500): Promise { + let last: unknown + for (let i = 0; i < attempts; i++) { + try { + return await fn() + } catch (error) { + last = error + if (i === attempts - 1 || !transient(error)) throw error + await new Promise((r) => setTimeout(r, delay * 2 ** i)) + } + } + throw last +} diff --git a/packages/kilo-vscode/src/util/retry.ts b/packages/kilo-vscode/src/util/retry.ts new file mode 100644 index 00000000000..5c35542821c --- /dev/null +++ b/packages/kilo-vscode/src/util/retry.ts @@ -0,0 +1,70 @@ +/** + * Exponential backoff retry utilities for rate-limited API calls. + * + * When the CLI backend (or the upstream AI provider it proxies) returns + * HTTP 429, retries are scheduled with exponential backoff. The delay + * respects `Retry-After` / `Retry-After-MS` headers when present. + */ + +/** Backoff delays per attempt: 5s -> 10s -> 30s -> 60s -> 300s */ +const BACKOFF_DELAYS_MS = [5_000, 10_000, 30_000, 60_000, 300_000] + +/** Maximum backoff delay in ms (5 minutes) */ +const MAX_MS = 300_000 + +/** Maximum number of retry attempts */ +const MAX_RETRIES = BACKOFF_DELAYS_MS.length + +/** HTTP status codes that are safe to retry */ +const RETRYABLE = new Set([408, 409, 425, 429, 500, 502, 503, 504]) + +/** + * Whether an HTTP status code is retryable. + */ +export function retryable(status: number): boolean { + if (RETRYABLE.has(status)) return true + return status >= 500 +} + +/** + * Extract a retry delay (in ms) from standard response headers. + * + * Checks `retry-after-ms` first (milliseconds), then `retry-after` + * (seconds or HTTP-date). Returns `null` when no usable header is found. + */ +export function headerDelay(headers: Headers): number | null { + const ms = headers.get("retry-after-ms") + if (ms) { + const parsed = Number.parseFloat(ms) + if (!Number.isNaN(parsed) && parsed > 0) return parsed + } + + const after = headers.get("retry-after") + if (after) { + const seconds = Number.parseFloat(after) + if (!Number.isNaN(seconds) && seconds > 0) return Math.ceil(seconds * 1000) + // Try HTTP-date format + const date = Date.parse(after) - Date.now() + if (!Number.isNaN(date) && date > 0) return Math.ceil(date) + } + + return null +} + +/** + * Calculate backoff delay for a given attempt. + * + * If `headers` are provided and contain a `Retry-After` value, that + * value is used (capped at MAX_MS). Otherwise uses the predefined + * backoff schedule: 5s, 10s, 30s, 60s, 300s. + */ +export function backoff(attempt: number, headers?: Headers): number { + if (headers) { + const fromHeader = headerDelay(headers) + if (fromHeader !== null) return Math.min(fromHeader, MAX_MS) + } + const index = Math.min(attempt - 1, BACKOFF_DELAYS_MS.length - 1) + return BACKOFF_DELAYS_MS[index] ?? MAX_MS +} + +export { MAX_RETRIES, MAX_MS } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index c991cc6f5b6..69c88db63ee 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -31,6 +31,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/BranchSelect.tsx"), path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"), + path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), ] const TSX_FILE = TSX_FILES[0] const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") @@ -532,7 +533,7 @@ const VSCODE_ALLOWED: Record = { */ const MAX_LINES: Record = { "AgentManagerProvider.ts": { - maxLines: 1910, + maxLines: 2000, note: "primary extraction target: break into smaller orchestrators", }, } diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index eff6a667259..64f0b78c85a 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -8,6 +8,10 @@ function ops(handler: (args: string[], cwd: string) => Promise): GitOps return new GitOps({ log: () => undefined, runGit: handler }) } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + function runGit(cwd: string, args: string[]): string { const result = Bun.spawnSync({ cmd: ["git", ...args], @@ -475,4 +479,81 @@ describe("GitOps", () => { }) }) }) + + describe("dispose", () => { + it("aborts in-flight runGit calls quickly", async () => { + let resolved = false + const git = new GitOps({ + log: () => undefined, + runGit: async () => { + await sleep(5000) + resolved = true + return "should not reach" + }, + }) + + const start = Date.now() + const pending = git.currentBranch("/repo") + git.dispose() + await pending + const elapsed = Date.now() - start + expect(elapsed).toBeLessThan(500) + expect(resolved).toBe(false) + }) + + it("causes subsequent runGit calls to fail immediately", async () => { + let called = false + const git = new GitOps({ + log: () => undefined, + runGit: async () => { + called = true + return "ok" + }, + }) + git.dispose() + + // currentBranch swallows errors — should return "" without calling runGit + const result = await git.currentBranch("/repo") + expect(result).toBe("") + expect(called).toBe(false) + }) + + it("reports disposed state", () => { + const git = ops(async () => "ok") + expect(git.disposed).toBe(false) + git.dispose() + expect(git.disposed).toBe(true) + }) + + it("kills in-flight exec (spawn) processes", async () => { + await withRepo(async (cwd) => { + const git = new GitOps({ log: () => undefined }) + await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8") + runGit(cwd, ["add", "-A"]) + runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]) + await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8") + + const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD" + const pending = git.buildWorktreePatch(cwd, branch) + // Give spawn a moment to start, then dispose + await sleep(10) + git.dispose() + + // Should either reject or return (but process should be killed) + try { + await pending + } catch { + // expected — aborted + } + expect(git.disposed).toBe(true) + }) + }) + + it("is safe to call multiple times", () => { + const git = ops(async () => "ok") + git.dispose() + git.dispose() + expect(git.disposed).toBe(true) + }) + }) }) diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index ef9ec16b823..53176ffccb9 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { GitStatsPoller } from "../../src/agent-manager/GitStatsPoller" +import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller" import { GitOps } from "../../src/agent-manager/GitOps" import type { Worktree } from "../../src/agent-manager/WorktreeStateManager" @@ -128,7 +128,7 @@ describe("GitStatsPoller", () => { const wtPath = path.join(root, "wt-a") fs.mkdirSync(wtPath, { recursive: true }) - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], @@ -154,7 +154,10 @@ describe("GitStatsPoller", () => { poller.stop() fs.rmSync(root, { recursive: true, force: true }) - expect(presence[0]).toEqual({ worktrees: [{ worktreeId: "a", missing: false }], degraded: false }) + expect(presence[0]).toEqual({ + worktrees: [{ worktreeId: "a", missing: false, branch: "branch-a" }], + degraded: false, + }) }) it("emits degraded probe when git worktree listing fails", async () => { @@ -162,7 +165,7 @@ describe("GitStatsPoller", () => { const wtPath = path.join(root, "wt-a") fs.mkdirSync(wtPath, { recursive: true }) - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], @@ -199,7 +202,7 @@ describe("GitStatsPoller", () => { const calls: string[] = [] const emitted: Array> = [] - const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = [] + const presence: WorktreePresenceResult[] = [] const client = { worktree: { @@ -240,8 +243,8 @@ describe("GitStatsPoller", () => { expect(calls.some((cwd) => cwd === wtBPath)).toBe(false) expect(presence[0]).toEqual({ worktrees: [ - { worktreeId: "a", missing: false }, - { worktreeId: "b", missing: true }, + { worktreeId: "a", missing: false, branch: "branch-a" }, + { worktreeId: "b", missing: true, branch: undefined }, ], degraded: false, }) diff --git a/packages/kilo-vscode/tests/unit/retry.test.ts b/packages/kilo-vscode/tests/unit/retry.test.ts new file mode 100644 index 00000000000..983d37aa57c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/retry.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "bun:test" +import { retry } from "../../src/services/cli-backend/retry" + +describe("retry", () => { + it("returns on first success", async () => { + const result = await retry(() => Promise.resolve(42)) + expect(result).toBe(42) + }) + + it("retries transient errors and succeeds", async () => { + let calls = 0 + const result = await retry( + () => { + calls++ + if (calls < 3) throw new TypeError("fetch failed") + return Promise.resolve("ok") + }, + 3, + 10, + ) + expect(result).toBe("ok") + expect(calls).toBe(3) + }) + + it("throws immediately on non-transient errors", async () => { + let calls = 0 + await expect( + retry( + () => { + calls++ + throw new Error("404 not found") + }, + 3, + 10, + ), + ).rejects.toThrow("404 not found") + expect(calls).toBe(1) + }) + + it("throws after exhausting attempts on transient errors", async () => { + let calls = 0 + await expect( + retry( + () => { + calls++ + throw new Error("ECONNREFUSED") + }, + 3, + 10, + ), + ).rejects.toThrow("ECONNREFUSED") + expect(calls).toBe(3) + }) + + it("detects all transient error messages", async () => { + const messages = [ + "load failed", + "network connection was lost", + "network request failed", + "failed to fetch", + "fetch failed", + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "socket hang up", + ] + for (const msg of messages) { + let calls = 0 + await retry( + () => { + calls++ + if (calls === 1) throw new Error(msg) + return Promise.resolve(true) + }, + 2, + 10, + ) + expect(calls).toBe(2) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/search-match.test.ts b/packages/kilo-vscode/tests/unit/search-match.test.ts new file mode 100644 index 00000000000..c12cf433abc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/search-match.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "bun:test" +import { acronymMatch, searchMatch } from "../../webview-ui/src/utils/search-match" + +// --------------------------------------------------------------------------- +// acronymMatch — low-level word-boundary matching +// --------------------------------------------------------------------------- + +describe("acronymMatch", () => { + describe("basic word boundary matching", () => { + it("matches at word start", () => { + expect(acronymMatch("fool org", "foo")).toBe(true) + expect(acronymMatch("the fool", "foo")).toBe(true) + }) + + it("does not match arbitrary substrings", () => { + expect(acronymMatch("faoboc", "foo")).toBe(false) + expect(acronymMatch("barfoo", "foo")).toBe(false) + }) + + it("matches prefix of a single word", () => { + expect(acronymMatch("foobar", "foob")).toBe(true) + }) + + it("matches exact word", () => { + expect(acronymMatch("test", "test")).toBe(true) + expect(acronymMatch("testing", "test")).toBe(true) + expect(acronymMatch("the test", "test")).toBe(true) + }) + }) + + describe("word separators", () => { + it("recognizes space", () => { + expect(acronymMatch("hello world", "wor")).toBe(true) + }) + + it("recognizes hyphen", () => { + expect(acronymMatch("hello-world", "wor")).toBe(true) + }) + + it("recognizes underscore", () => { + expect(acronymMatch("hello_world", "wor")).toBe(true) + }) + + it("recognizes slash", () => { + expect(acronymMatch("hello/world", "wor")).toBe(true) + }) + + it("recognizes dot", () => { + expect(acronymMatch("hello.world", "wor")).toBe(true) + }) + + it("recognizes parentheses", () => { + expect(acronymMatch("Grok Code Fast 1 (free)", "free")).toBe(true) + }) + }) + + describe("acronym matching", () => { + it("matches acronyms from word starts", () => { + expect(acronymMatch("Claude Sonnet", "clso")).toBe(true) + }) + + it("matches partial acronyms", () => { + expect(acronymMatch("Claude Sonnet 3.5", "cls")).toBe(true) + }) + + it("matches direct and acronym", () => { + expect(acronymMatch("clso tool", "clso")).toBe(true) + expect(acronymMatch("Claude Sonnet", "clso")).toBe(true) + }) + + it("does not match non-boundary acronym", () => { + expect(acronymMatch("aclbso", "clso")).toBe(false) + }) + }) + + describe("camelCase and PascalCase", () => { + it("recognizes camelCase boundary", () => { + expect(acronymMatch("gitRebase", "gr")).toBe(true) + }) + + it("matches PascalCase acronyms", () => { + expect(acronymMatch("NewFileCreation", "nfc")).toBe(true) + }) + + it("splits camelCase at uppercase transitions", () => { + expect(acronymMatch("parseMarkdownContent", "pmc")).toBe(true) + }) + + it("handles mixed case scenarios", () => { + expect(acronymMatch("gitRebase", "gitr")).toBe(true) + expect(acronymMatch("GitRebase", "gitr")).toBe(true) + }) + }) + + describe("backtracking", () => { + it("matches word that appears later in text", () => { + expect(acronymMatch("google gemini", "gemini")).toBe(true) + expect(acronymMatch("gemini pro", "gemini")).toBe(true) + expect(acronymMatch("google em emini", "gemini")).toBe(true) + }) + + it("matches partial word that appears later", () => { + expect(acronymMatch("Microsoft Copilot", "copilot")).toBe(true) + expect(acronymMatch("GitHub Copilot", "copilot")).toBe(true) + }) + + it("still respects word boundaries with backtracking", () => { + expect(acronymMatch("google gemini", "gemini")).toBe(true) + expect(acronymMatch("googlegemini", "gemini")).toBe(false) + }) + }) + + describe("edge cases", () => { + it("handles empty text", () => { + expect(acronymMatch("", "foo")).toBe(false) + }) + + it("handles empty query", () => { + expect(acronymMatch("foo", "")).toBe(true) + }) + + it("handles special characters in text", () => { + expect(acronymMatch("foo-bar", "foob")).toBe(true) + }) + }) +}) + +// --------------------------------------------------------------------------- +// searchMatch — high-level search with trimming and multi-word support +// --------------------------------------------------------------------------- + +describe("searchMatch", () => { + describe("empty and whitespace queries", () => { + it("returns true for empty query", () => { + expect(searchMatch("", "anything")).toBe(true) + }) + + it("returns true for whitespace-only query", () => { + expect(searchMatch(" ", "anything")).toBe(true) + }) + }) + + describe("case insensitivity", () => { + it("matches case-insensitively", () => { + expect(searchMatch("foo", "Foo Bar")).toBe(true) + expect(searchMatch("foo", "FOO BAZ")).toBe(true) + expect(searchMatch("FoO", "foo qux")).toBe(true) + }) + }) + + describe("trimming", () => { + it("trims leading spaces", () => { + expect(searchMatch(" foo", "foo bar")).toBe(true) + }) + + it("trims trailing spaces", () => { + expect(searchMatch("foo ", "foo bar")).toBe(true) + }) + + it("trims spaces on both sides", () => { + expect(searchMatch(" foo ", "foo bar")).toBe(true) + }) + }) + + describe("multi-word queries", () => { + it("matches when all words present", () => { + expect(searchMatch("claude sonnet", "Claude Sonnet 3.5")).toBe(true) + }) + + it("does not match when any word missing", () => { + expect(searchMatch("claude sonnet", "Claude Opus")).toBe(false) + expect(searchMatch("claude sonnet", "GPT Sonnet")).toBe(false) + }) + }) + + describe("real-world model search", () => { + it("finds model with hyphen in query", () => { + expect(searchMatch("gpt-5", "OpenAI: gpt-5 mini")).toBe(true) + expect(searchMatch("gpt-5", "OpenAI: gpt-4")).toBe(false) + }) + + it("finds model when hyphen omitted from query", () => { + expect(searchMatch("gpt5", "OpenAI: gpt-5 mini")).toBe(true) + }) + + it("finds all models with trailing hyphen", () => { + expect(searchMatch("gpt-", "OpenAI: gpt-5 mini")).toBe(true) + expect(searchMatch("gpt-", "OpenAI: gpt-4")).toBe(true) + expect(searchMatch("gpt-", "Anthropic: claude-3")).toBe(false) + }) + + it("matches file paths", () => { + expect(searchMatch("code", "src/services/code-index/manager.ts")).toBe(true) + }) + + it("matches mode selector options by label+value", () => { + expect(searchMatch("cod", "Code code Write code")).toBe(true) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/section-helpers.test.ts b/packages/kilo-vscode/tests/unit/section-helpers.test.ts new file mode 100644 index 00000000000..8a16ebf78bc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/section-helpers.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "bun:test" +import { buildTopLevelItems, isGrouped, isGroupStart, isGroupEnd } from "../../webview-ui/agent-manager/section-helpers" +import type { WorktreeState, SectionState } from "../../webview-ui/src/types/messages" + +function wt(id: string, opts: Partial = {}): WorktreeState { + return { + id, + branch: `branch-${id}`, + path: `/tmp/${id}`, + parentBranch: "main", + createdAt: "2024-01-01", + ...opts, + } +} + +function sec(id: string, order: number, opts: Partial = {}): SectionState { + return { id, name: `Section ${id}`, color: null, order, collapsed: false, ...opts } +} + +describe("buildTopLevelItems", () => { + it("returns flat worktree list when no sections", () => { + const all = [wt("a"), wt("b"), wt("c")] + const result = buildTopLevelItems([], [], all, []) + expect(result).toHaveLength(3) + expect(result.every((r) => r.kind === "worktree")).toBe(true) + }) + + it("interleaves sections and worktrees per order", () => { + const s1 = sec("s1", 0) + const w1 = wt("w1") + const s2 = sec("s2", 1) + const result = buildTopLevelItems([s1, s2], [w1], [w1], ["s1", "w1", "s2"]) + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ kind: "section", section: s1 }) + expect(result[1]).toEqual({ kind: "worktree", wt: w1 }) + expect(result[2]).toEqual({ kind: "section", section: s2 }) + }) + + it("appends unordered sections and worktrees at the end", () => { + const s1 = sec("s1", 0) + const s2 = sec("s2", 1) + const w1 = wt("w1") + const w2 = wt("w2") + // Only s1 is in the order array + const result = buildTopLevelItems([s1, s2], [w1, w2], [w1, w2], ["s1", "w1"]) + expect(result).toHaveLength(4) + expect(result[0]).toEqual({ kind: "section", section: s1 }) + expect(result[1]).toEqual({ kind: "worktree", wt: w1 }) + // unordered items appended + expect(result[2]).toEqual({ kind: "section", section: s2 }) + expect(result[3]).toEqual({ kind: "worktree", wt: w2 }) + }) + + it("skips duplicate ids in order array", () => { + const s1 = sec("s1", 0) + const w1 = wt("w1") + const result = buildTopLevelItems([s1], [w1], [w1], ["s1", "w1", "s1", "w1"]) + expect(result).toHaveLength(2) + }) +}) + +describe("isGrouped", () => { + it("returns true when groupId is set", () => { + expect(isGrouped(wt("a", { groupId: "g1" }))).toBe(true) + }) + + it("returns false when groupId is undefined", () => { + expect(isGrouped(wt("a"))).toBe(false) + }) + + it("returns false when groupId is empty string", () => { + expect(isGrouped(wt("a", { groupId: "" }))).toBe(false) + }) +}) + +describe("isGroupStart", () => { + const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")] + + it("returns true for first item in group", () => { + expect(isGroupStart(list[0]!, 0, list)).toBe(true) + }) + + it("returns false for middle/end item in same group", () => { + expect(isGroupStart(list[1]!, 1, list)).toBe(false) + }) + + it("returns true when previous item has different groupId", () => { + expect(isGroupStart(list[2]!, 2, list)).toBe(true) + }) + + it("returns false for ungrouped worktree", () => { + expect(isGroupStart(list[3]!, 3, list)).toBe(false) + }) +}) + +describe("isGroupEnd", () => { + const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")] + + it("returns false for start/middle of group", () => { + expect(isGroupEnd(list[0]!, 0, list)).toBe(false) + }) + + it("returns true for last item in group when next has different groupId", () => { + expect(isGroupEnd(list[1]!, 1, list)).toBe(true) + }) + + it("returns true for last item in list with groupId", () => { + expect(isGroupEnd(list[2]!, 2, list)).toBe(true) + }) + + it("returns false for ungrouped worktree", () => { + expect(isGroupEnd(list[3]!, 3, list)).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts new file mode 100644 index 00000000000..e83ea943dc4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import * as fs from "fs" +import * as path from "path" +import * as os from "os" +import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" + +describe("WorktreeStateManager sections", () => { + let root: string + let mgr: WorktreeStateManager + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "wtsm-sec-")) + fs.mkdirSync(path.join(root, ".kilo"), { recursive: true }) + mgr = new WorktreeStateManager(root, () => {}) + }) + + afterEach(async () => { + await mgr.flush() + fs.rmSync(root, { recursive: true, force: true }) + }) + + describe("addSection", () => { + it("creates a section with correct defaults", () => { + const sec = mgr.addSection("Backend", "Blue") + expect(sec.id).toMatch(/^sec-/) + expect(sec.name).toBe("Backend") + expect(sec.color).toBe("Blue") + expect(sec.collapsed).toBe(false) + expect(mgr.getWorktreeOrder()).toContain(sec.id) + }) + + it("adds section id to worktreeOrder", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + const sec = mgr.addSection("Infra", null) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, sec.id]) + }) + + it("moves specified worktrees into the section", () => { + const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" }) + mgr.setWorktreeOrder([wt1.id, wt2.id]) + + const sec = mgr.addSection("Group", "Red", [wt1.id]) + expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id) + expect(mgr.getWorktree(wt2.id)?.sectionId).toBeUndefined() + // wt1 removed from top-level order, wt2 remains + expect(mgr.getWorktreeOrder()).not.toContain(wt1.id) + expect(mgr.getWorktreeOrder()).toContain(wt2.id) + }) + }) + + describe("renameSection", () => { + it("updates the name", () => { + const sec = mgr.addSection("Old", null) + mgr.renameSection(sec.id, "New") + expect(mgr.getSection(sec.id)?.name).toBe("New") + }) + + it("rejects empty string", () => { + const sec = mgr.addSection("Keep", null) + mgr.renameSection(sec.id, "") + expect(mgr.getSection(sec.id)?.name).toBe("Keep") + }) + + it("is a no-op for unknown id", () => { + mgr.renameSection("nonexistent", "Foo") + expect(mgr.getSections()).toHaveLength(0) + }) + }) + + describe("setSectionColor", () => { + it("updates color", () => { + const sec = mgr.addSection("X", null) + mgr.setSectionColor(sec.id, "Green") + expect(mgr.getSection(sec.id)?.color).toBe("Green") + }) + + it("accepts null for default", () => { + const sec = mgr.addSection("X", "Red") + mgr.setSectionColor(sec.id, null) + expect(mgr.getSection(sec.id)?.color).toBeNull() + }) + }) + + describe("toggleSection", () => { + it("flips collapsed state", () => { + const sec = mgr.addSection("T", null) + expect(sec.collapsed).toBe(false) + + mgr.toggleSection(sec.id) + expect(mgr.getSection(sec.id)?.collapsed).toBe(true) + + mgr.toggleSection(sec.id) + expect(mgr.getSection(sec.id)?.collapsed).toBe(false) + }) + }) + + describe("deleteSection", () => { + it("removes section and ungroups its worktrees", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("Del", null, [wt.id]) + expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id) + + mgr.deleteSection(sec.id) + expect(mgr.getSection(sec.id)).toBeUndefined() + // worktree still exists but sectionId cleared + expect(mgr.getWorktree(wt.id)).toBeTruthy() + expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined() + }) + + it("removes section from worktreeOrder", () => { + const sec = mgr.addSection("Gone", null) + expect(mgr.getWorktreeOrder()).toContain(sec.id) + mgr.deleteSection(sec.id) + expect(mgr.getWorktreeOrder()).not.toContain(sec.id) + }) + + it("is a no-op for unknown id", () => { + mgr.deleteSection("nonexistent") + expect(mgr.getSections()).toHaveLength(0) + }) + }) + + describe("moveToSection", () => { + it("sets sectionId and removes from worktreeOrder", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + const sec = mgr.addSection("Target", null) + + mgr.moveToSection([wt.id], sec.id) + expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id) + expect(mgr.getWorktreeOrder()).not.toContain(wt.id) + }) + + it("ungroups worktrees with null sectionId and adds back to order", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("Temp", null, [wt.id]) + expect(mgr.getWorktreeOrder()).not.toContain(wt.id) + + mgr.moveToSection([wt.id], null) + expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined() + expect(mgr.getWorktreeOrder()).toContain(wt.id) + }) + + it("expands to multi-version siblings with same groupId", () => { + const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main", groupId: "g1" }) + const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main", groupId: "g1" }) + const wt3 = mgr.addWorktree({ branch: "c", path: "/tmp/c", parentBranch: "main" }) + mgr.setWorktreeOrder([wt1.id, wt2.id, wt3.id]) + const sec = mgr.addSection("Multi", null) + + // Move only wt1 — wt2 should follow because of shared groupId + mgr.moveToSection([wt1.id], sec.id) + expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id) + expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(sec.id) + expect(mgr.getWorktree(wt3.id)?.sectionId).toBeUndefined() + }) + + it("does not duplicate in worktreeOrder when ungrouping already-present id", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + // Ungroup when already in order + mgr.moveToSection([wt.id], null) + const count = mgr.getWorktreeOrder().filter((id) => id === wt.id).length + expect(count).toBe(1) + }) + }) + + describe("getSections", () => { + it("returns all sections", () => { + mgr.addSection("A", null) + mgr.addSection("B", "Red") + mgr.addSection("C", "Blue") + expect(mgr.getSections()).toHaveLength(3) + }) + + it("returns empty array when no sections", () => { + expect(mgr.getSections()).toEqual([]) + }) + }) + + describe("moveSection", () => { + it("moves a section up within mixed top-level order", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id, b.id]) + mgr.moveSection(b.id, -1) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id]) + }) + + it("moves a section down within mixed top-level order", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + mgr.setWorktreeOrder([wt.id]) + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id, b.id]) + mgr.moveSection(a.id, 1) + expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id]) + }) + + it("is a no-op at boundaries", () => { + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + mgr.moveSection(a.id, -1) + expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id]) + mgr.moveSection(b.id, 1) + expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id]) + }) + + it("does not change section membership", () => { + const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" }) + const a = mgr.addSection("A", null, [wt1.id, wt2.id]) + const b = mgr.addSection("B", null) + mgr.moveSection(b.id, -1) + expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(a.id) + expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(a.id) + }) + + it("persists reordered sections across save/load", async () => { + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + mgr.moveSection(b.id, -1) + await mgr.flush() + await mgr.save() + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktreeOrder()).toEqual([b.id, a.id]) + }) + }) + + describe("persistence", () => { + it("saves and loads sections", async () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("Persist", "Green", [wt.id]) + mgr.toggleSection(sec.id) + await mgr.flush() + await mgr.save() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + + const secs = loaded.getSections() + expect(secs).toHaveLength(1) + const first = secs[0]! + expect(first.name).toBe("Persist") + expect(first.color).toBe("Green") + expect(first.collapsed).toBe(true) + expect(loaded.getWorktree(wt.id)?.sectionId).toBe(sec.id) + }) + + it("normalizes worktreeOrder on load to include missing section IDs", async () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("S", null) + await mgr.flush() + await mgr.save() + // Simulate stale data: manually remove section from worktreeOrder + const file = path.join(root, ".kilo", "agent-manager.json") + const data = JSON.parse(fs.readFileSync(file, "utf-8")) + data.worktreeOrder = [wt.id] // section ID missing + fs.writeFileSync(file, JSON.stringify(data)) + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktreeOrder()).toContain(sec.id) + expect(loaded.getWorktreeOrder()).toContain(wt.id) + }) + + it("normalizes worktreeOrder on load to include missing ungrouped worktrees", async () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + await mgr.flush() + await mgr.save() + const file = path.join(root, ".kilo", "agent-manager.json") + const data = JSON.parse(fs.readFileSync(file, "utf-8")) + data.worktreeOrder = [] // worktree ID missing + fs.writeFileSync(file, JSON.stringify(data)) + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktreeOrder()).toContain(wt.id) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png index 5eea0cc85df..b657e55ee74 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed6e36e191174cd14d61fc3480014d59c9a92bd526f4f889dc71bdbab4b44e66 -size 2106 +oid sha256:270e0dafc2601996e7d048873e55db4680e8803d6800dc44b1fa6ce6239827cc +size 2017 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png index 3138a0847c8..bc4f471b091 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b313dd76197ff089105d6b80ff22bdf570b878f67dcc99e5d050e85573c52d60 -size 1959 +oid sha256:5feb854ee2693b655ab504b8e2ed8794af77a357c3d716e2deb9668a08f3aa0c +size 1847 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png index 853b1606174..b012417eb4f 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e386126977ece9eae41cce0b13677ae4243b985b39a7ee0977e9b5da32415796 -size 5837 +oid sha256:937c4398730fd3e07bc064b4cb370717a5bc280ffaced87fa6f3c626774878a8 +size 5598 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png index 19a85104afe..2ec70d4ede2 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24d02a1a394c5e18e3783b19c8c7145eafbd8cabfb624c53c82137369d2c5784 -size 2570 +oid sha256:1d9935cc07b9eefe0c1eca1852b00ac2b39b76cdf12ee2f01c52e4ac0771b39e +size 2910 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png index 39a8bb71569..9cdd496d4a1 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07039f10de94b6aa2eba14674a9f3370fef2f8b23d6712e91f43ddd77f169c34 -size 1930 +oid sha256:54e7142d591391c6e99631c9bf87b011321e52f17a8ee39e5aeeaa9b1350f2f9 +size 1831 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png index 4d9c9a4f74c..43f94d6b804 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:519310e5cb841302d651f450b4fc281936c331646a4d3759de0c010d1761fe63 -size 2687 +oid sha256:7e4a37b5b7d71e0eb7f043176f410030385a8a61f3913bd6419b1d96c6cad2de +size 2422 diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 49d7728424e..726a3c5c4ee 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -7,7 +7,6 @@ import { createSignal, createMemo, createEffect, - createRoot, on, onMount, onCleanup, @@ -34,7 +33,10 @@ import type { WorktreeGitStats, LocalGitStats, WorktreeState, + PRStatus, + AgentManagerPRStatusMessage, ManagedSessionState, + SectionState, SessionInfo, BranchInfo, } from "../src/types/messages" @@ -45,9 +47,8 @@ import { SortableProvider, closestCenter, createSortable, - useDragDropContext, } from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" import { ThemeProvider } from "@kilocode/kilo-ui/theme" import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog" import { Dialog } from "@kilocode/kilo-ui/dialog" @@ -90,6 +91,11 @@ import { groupApplyConflicts } from "./apply-conflicts" import type { ReviewComment } from "./review-comments" import { BranchSelect } from "./BranchSelect" import { WorktreeItem } from "./WorktreeItem" +import SectionHeader from "./SectionHeader" +import { randomColor } from "./section-colors" +import { buildTopLevelItems, isGrouped, isGroupStart, isGroupEnd, type TopLevelItem } from "./section-helpers" +import { sectionAwareDetector } from "./section-dnd" +import { ConstrainDragXAxis } from "./constrain-drag-x" import { mergeWorktreeDiffs } from "./diff-state" import "./agent-manager.css" import "./agent-manager-review.css" @@ -120,6 +126,8 @@ interface ApplyState { /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null +type SidePanel = "diff" | "pr" | null + const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) // Fallback keybindings before extension sends resolved ones @@ -194,7 +202,6 @@ function useTabScroll(activeTabs: Accessor, activeId: Accessor { const id = activeId() const el = ref() @@ -321,6 +328,7 @@ const AgentManagerContent: Component = () => { const [localSessionIDs, setLocalSessionIDs] = createSignal(persisted?.localSessionIDs ?? []) const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH) const [sessionsCollapsed, setSessionsCollapsed] = createSignal(false) + const [sections, setSections] = createSignal([]) // rAF coalescing for resize handlers — at most one signal write per frame let sidebarRaf: number | undefined @@ -328,8 +336,8 @@ const AgentManagerContent: Component = () => { let diffRaf: number | undefined let pendingDiffWidth: number | undefined - // Diff panel state - const [diffOpen, setDiffOpen] = createSignal(false) + const [sidePanel, setSidePanel] = createSignal(null) + const diffOpen = () => sidePanel() === "diff" const [diffDatas, setDiffDatas] = createSignal>({}) const [diffLoading, setDiffLoading] = createSignal(false) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) @@ -345,6 +353,9 @@ const AgentManagerContent: Component = () => { // Per-worktree git stats (diff additions/deletions, commits missing from origin) const [worktreeStats, setWorktreeStats] = createSignal>({}) + // Per-worktree PR status data + const [prStatuses, setPrStatuses] = createSignal>({}) + // Local repo git stats (branch name, diff additions/deletions, commits) const [localStats, setLocalStats] = createSignal() @@ -609,6 +620,8 @@ const AgentManagerContent: Component = () => { // Sidebar worktree order (persisted to extension state) const [sidebarWorktreeOrder, setSidebarWorktreeOrder] = createSignal([]) const [draggingWorktree, setDraggingWorktree] = createSignal() + const [renamingSection, setRenamingSection] = createSignal(null) + let pendingNewSection = false const addPendingTab = () => { const id = `${PENDING_PREFIX}${++pendingCounter}` @@ -786,6 +799,11 @@ const AgentManagerContent: Component = () => { return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch) } + const worktreeSubtitle = (wt: WorktreeState): string | undefined => { + const label = worktreeLabel(wt) + return label !== wt.branch ? wt.branch : undefined + } + const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId) /** True when any session in the given ID list is actively working (busy/retry and not blocked by permissions/questions). */ @@ -849,23 +867,23 @@ const AgentManagerContent: Component = () => { return result }) - /** Check if this worktree is part of a group. */ - const isGrouped = (wt: WorktreeState) => !!wt.groupId - - /** Check if this is the first item in its group. */ - const isGroupStart = (wt: WorktreeState, idx: number) => { - if (!wt.groupId) return false - const list = sortedWorktrees() - if (idx === 0) return true - return list[idx - 1]?.groupId !== wt.groupId - } - - /** Check if this is the last item in its group. */ - const isGroupEnd = (wt: WorktreeState, idx: number) => { - if (!wt.groupId) return false - const list = sortedWorktrees() - if (idx === list.length - 1) return true - return list[idx + 1]?.groupId !== wt.groupId + const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id) + const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId)) + const topLevelItems = createMemo((): TopLevelItem[] => + buildTopLevelItems(sections(), ungrouped(), sortedWorktrees(), sidebarWorktreeOrder()), + ) + const moveToSection = (ids: string[], sec: string | null) => + vscode.postMessage({ type: "agentManager.moveToSection", worktreeIds: ids, sectionId: sec }) + const moveSection = (sectionId: string, dir: -1 | 1) => + vscode.postMessage({ type: "agentManager.moveSection", sectionId, dir }) + const newSection = (ids?: string[]) => { + pendingNewSection = true + vscode.postMessage({ + type: "agentManager.createSection", + name: t("agentManager.section.defaultName"), + color: randomColor(), + worktreeIds: ids, + }) } const scrollIntoView = (el: HTMLElement) => { @@ -1012,9 +1030,9 @@ const AgentManagerContent: Component = () => { } else if (msg.action === "toggleDiff") { if (reviewActive()) { closeReviewTab() - setDiffOpen(true) + setSidePanel("diff") } else { - setDiffOpen((prev) => !prev) + setSidePanel((prev) => (prev === "diff" ? null : "diff")) } } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() @@ -1153,7 +1171,7 @@ const AgentManagerContent: Component = () => { setSelection(ev.worktreeId) } // Close diff/review panels — nothing to show during setup - setDiffOpen(false) + setSidePanel(null) setReviewActive(false) setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId }) } @@ -1194,6 +1212,14 @@ const AgentManagerContent: Component = () => { // When not a git repo, also mark sessions as loaded since the Kilo // server won't connect to send the sessionsLoaded message. if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) + const prev = new Set(sections().map((s) => s.id)), + incoming = state.sections ?? [] + setSections(incoming) + if (pendingNewSection) { + pendingNewSection = false + const c = incoming.find((s) => !prev.has(s.id)) + if (c) setRenamingSection(c.id) + } if (state.tabOrder) setWorktreeTabOrder(state.tabOrder) if (state.worktreeOrder) setSidebarWorktreeOrder(state.worktreeOrder) if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") { @@ -1383,6 +1409,11 @@ const AgentManagerContent: Component = () => { setLocalStats(ev.stats) setRepoBranch(ev.stats.branch) } + + if (msg.type === "agentManager.prStatus") { + const ev = msg as AgentManagerPRStatusMessage + setPrStatuses((prev) => ({ ...prev, [ev.worktreeId]: ev.pr })) + } }) onCleanup(() => { @@ -1458,7 +1489,7 @@ const AgentManagerContent: Component = () => { const openReviewTab = () => { const sel = selection() if (sel === null) return - setDiffOpen(false) + setSidePanel(null) setReviewOpenForContext(sel, true) setReviewActive(true) } @@ -2128,6 +2159,11 @@ const AgentManagerContent: Component = () => { ))} + + newSection()}> + + {t("agentManager.worktree.newSection")} + @@ -2216,56 +2252,40 @@ const AgentManagerContent: Component = () => { setRenamingWt(null) } + const hasSections = createMemo(() => sections().length > 0) const wtIds = createMemo(() => sortedWorktrees().map((wt) => wt.id)) + const secIds = createMemo(() => new Set(sections().map((s) => s.id))) + const home = () => new Map(sortedWorktrees().map((w) => [w.id, w.sectionId] as const)) + const sectionAware = sectionAwareDetector(secIds, home) const onWtDragStart = (event: DragEvent) => { const id = event.draggable?.id if (typeof id === "string") setDraggingWorktree(id) document.body.classList.add("am-wt-dragging-active") } - const onWtDragOver = (event: DragEvent) => { const from = event.draggable?.id const to = event.droppable?.id if (typeof from !== "string" || typeof to !== "string") return + if (secIds().has(to)) return setSidebarWorktreeOrder((prev) => { - const current = applyTabOrder( - sortedWorktrees().map((wt) => ({ id: wt.id })), + const cur = applyTabOrder( + sortedWorktrees().map((w) => ({ id: w.id })), prev, - ).map((item) => item.id) - return reorderTabs(current, from, to) ?? prev + ).map((i) => i.id) + return reorderTabs(cur, from, to) ?? prev }) } - - const onWtDragEnd = () => { + const onWtDragEnd = (event: DragEvent) => { + const from = event.draggable?.id + const to = event.droppable?.id setDraggingWorktree(undefined) document.body.classList.remove("am-wt-dragging-active") - const order = sortedWorktrees().map((wt) => wt.id) - if (order.length > 0) { - vscode.postMessage({ type: "agentManager.setWorktreeOrder", order }) + if (typeof from === "string" && typeof to === "string" && secIds().has(to)) { + moveToSection([from], to) + return } - } - - /** Lock drag movement to the Y axis (vertical-only worktree dragging). */ - const ConstrainDragXAxis: Component = () => { - const ctx = useDragDropContext() - if (!ctx) return null - const [ - , - { onDragStart: onStart, onDragEnd: onEnd, addTransformer: add, removeTransformer: remove }, - ] = ctx - const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) } - const dispose = createRoot((dispose) => { - onStart(({ draggable }) => { - if (draggable) add("draggables", draggable.id as string, xform) - }) - onEnd(({ draggable }) => { - if (draggable) remove("draggables", draggable.id as string, xform.id) - }) - return dispose - }) - onCleanup(dispose) - return null + vscode.postMessage({ type: "agentManager.setWorktreeOrder", order: sidebarWorktreeOrder() }) } return ( @@ -2273,33 +2293,35 @@ const AgentManagerContent: Component = () => { onDragStart={onWtDragStart} onDragEnd={onWtDragEnd} onDragOver={onWtDragOver} - collisionDetector={closestCenter} + collisionDetector={sectionAware} > - - {(wt, idx) => { - const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) - const navHint = () => { - const flat = [ - LOCAL as string, - ...sortedWorktrees().map((w) => w.id), - ...unassignedSessions().map((s) => s.id), - ] - const active = selection() ?? session.currentSessionID() ?? "" - return adjacentHint( + {(() => { + const renderWt = ( + wt: WorktreeState, + idx: () => number, + inSection?: boolean, + list?: WorktreeState[], + ) => { + const wtSessions = createMemo(() => + managedSessions().filter((ms) => ms.worktreeId === wt.id), + ) + const navHint = () => + adjacentHint( wt.id, - active, - flat, + selection() ?? session.currentSessionID() ?? "", + [ + LOCAL as string, + ...sortedWorktrees().map((w) => w.id), + ...unassignedSessions().map((s) => s.id), + ], kb().previousSession ?? "", kb().nextSession ?? "", ) - } - const groupSize = () => { - if (!wt.groupId) return 0 - return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length - } + const groupSize = () => + !wt.groupId ? 0 : sortedWorktrees().filter((w) => w.groupId === wt.groupId).length const sortable = createSortable(wt.id) void sortable return ( @@ -2315,18 +2337,22 @@ const AgentManagerContent: Component = () => { busy={busyWorktrees().has(wt.id)} working={isAgentBusy(wt.id)} stale={isStaleWorktree(wt.id)} - shortcut={idx() + 2} + shortcut={inSection ? undefined : idx() + 2} stats={worktreeStats()[wt.id]} navHint={navHint()} - sessions={sessions().length} + sessions={wtSessions().length} grouped={isGrouped(wt)} - groupStart={isGroupStart(wt, idx())} - groupEnd={isGroupEnd(wt, idx())} + groupStart={isGroupStart(wt, idx(), list ?? sortedWorktrees())} + groupEnd={isGroupEnd(wt, idx(), list ?? sortedWorktrees())} groupSize={groupSize()} renaming={renamingWt() === wt.id} renameValue={renameValue()} closeKeybind={kb().closeWorktree ?? ""} openKeybind={kb().openWorktree ?? ""} + sections={sections()} + currentSectionId={wt.sectionId} + onMoveToSection={(secId) => moveToSection([wt.id], secId)} + onMoveToNewSection={() => newSection()} onClick={() => { if (pendingDelete() === wt.id) { confirmDeleteWorktree(wt.id) @@ -2340,17 +2366,60 @@ const AgentManagerContent: Component = () => { onCommitRename={() => commitRename(wt.id)} onCancelRename={cancelRename} onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)} - onCopyPath={() => - vscode.postMessage({ type: "agentManager.copyToClipboard", text: wt.path }) - } + onCopyPath={() => navigator.clipboard.writeText(wt.path)} onOpen={() => vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id }) } /> ) - }} - + } + if (hasSections()) { + const post = vscode.postMessage.bind(vscode) + return ( + + {(item, idx) => { + if (item.kind === "section") { + const sec = item.section + const members = createMemo(() => worktreesInSection(sec.id)) + return ( + setRenamingSection(null)} + onToggle={() => + post({ type: "agentManager.toggleSectionCollapsed", sectionId: sec.id }) + } + onRename={(name) => + post({ type: "agentManager.renameSection", sectionId: sec.id, name }) + } + onDelete={() => post({ type: "agentManager.deleteSection", sectionId: sec.id })} + onSetColor={(color) => + post({ type: "agentManager.setSectionColor", sectionId: sec.id, color }) + } + isFirst={idx() === 0} + isLast={idx() === topLevelItems().length - 1} + onMoveUp={() => moveSection(sec.id, -1)} + onMoveDown={() => moveSection(sec.id, 1)} + > + +
+ + {(wt, wtIdx) => renderWt(wt, wtIdx, true, members())} + +
+
+
+ ) + } + return renderWt(item.wt, idx) + }} +
+ ) + } + return {(wt, idx) => renderWt(wt, idx)} + })()}
{(() => { @@ -2622,10 +2691,10 @@ const AgentManagerContent: Component = () => { onClick={() => { if (reviewActive()) { closeReviewTab() - setDiffOpen(true) + setSidePanel("diff") return } - setDiffOpen((prev) => !prev) + setSidePanel((prev) => (prev === "diff" ? null : "diff")) }} title={t("agentManager.diff.toggle")} > @@ -2752,7 +2821,7 @@ const AgentManagerContent: Component = () => { {/* Chat + side diff panel (hidden when review tab is active) */}
@@ -2810,7 +2879,7 @@ const AgentManagerContent: Component = () => {
- +
{ }} />
- setDiffOpen(false)} - onExpand={selection() !== null ? openReviewTab : undefined} - onRequestDiff={requestDiffFile} - onOpenFile={(file) => { - const id = currentDiffSessionId() - if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file }) - else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file }) - }} - /> + + setSidePanel(null)} + onExpand={selection() !== null ? openReviewTab : undefined} + onRequestDiff={requestDiffFile} + onOpenFile={(file) => { + const id = currentDiffSessionId() + if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file }) + else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file }) + }} + /> +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 3c00d20d5cd..af67780fe9a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -307,6 +307,11 @@ export const DiffPanel: Component = (props) => { sendAllToChat() } + const handleExpandAll = () => { + const allOpen = open().length === props.diffs.length + setOpen(allOpen ? [] : props.diffs.map((d) => d.file)) + } + const totals = createMemo(() => ({ files: props.diffs.length, additions: props.diffs.reduce((sum, diff) => sum + diff.additions, 0), @@ -354,6 +359,28 @@ export const DiffPanel: Component = (props) => {
+ 0}> + + + + void + onRename: (name: string) => void + onDelete: () => void + onSetColor: (color: string | null) => void + /** Called when rename ends (commit or cancel) so parent clears autoRename. */ + onRenameEnd?: () => void + onMoveUp?: () => void + onMoveDown?: () => void + isFirst?: boolean + isLast?: boolean +} + +const SectionHeader: Component = (props) => { + const { t } = useLanguage() + const [renaming, setRenaming] = createSignal(false) + const [value, setValue] = createSignal("") + + const border = () => colorCss(props.section.color) ?? "var(--vscode-panel-border)" + + const startRename = () => { + setValue(props.section.name) + setRenaming(true) + } + + const commit = () => { + const trimmed = value().trim() + setRenaming(false) + props.onRenameEnd?.() + if (trimmed && trimmed !== props.section.name) { + props.onRename(trimmed) + } + } + + const cancel = () => { + setRenaming(false) + props.onRenameEnd?.() + } + + createEffect(() => { + if (props.autoRename && !renaming()) startRename() + }) + + const handleClick = (e: MouseEvent) => { + if (e.button !== 0 || renaming()) return + props.onToggle() + } + + const droppable = createDroppable(props.section.id) + + return ( +
+ + +
+ + setValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commit() + if (e.key === "Escape") cancel() + }} + onBlur={commit} + onClick={(e) => e.stopPropagation()} + ref={(el) => + requestAnimationFrame(() => + requestAnimationFrame(() => { + el.focus() + el.select() + }), + ) + } + /> + } + > + {props.section.name} + +
+ {props.count} +
+ + + + + {t("agentManager.section.rename")} + + + + {t("agentManager.section.setColor")} +
+ props.onSetColor(null)} class="am-color-grid-item"> + + + {SECTION_COLORS.map((c) => ( + props.onSetColor(c.label)} class="am-color-grid-item"> + + + ))} +
+
+ + props.onMoveUp?.()} disabled={props.isFirst}> + + {t("agentManager.section.moveUp")} + + props.onMoveDown?.()} disabled={props.isLast}> + + {t("agentManager.section.moveDown")} + + + + + {t("agentManager.section.delete")} + +
+
+
+ {props.children} +
+ ) +} + +export default SectionHeader diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx index 1bf24c5c087..693710eaf28 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeItem.tsx @@ -2,7 +2,7 @@ * Sidebar worktree item with inline delete confirmation, HoverCard, rename, and stats. * Extracted from AgentManagerApp for reuse and visual-regression testing via Storybook. */ -import { Component, Show, createSignal } from "solid-js" +import { Component, For, Show, createSignal } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" @@ -10,7 +10,8 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { HoverCard } from "@kilocode/kilo-ui/hover-card" import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { Button } from "@kilocode/kilo-ui/button" -import type { WorktreeState, WorktreeGitStats } from "../src/types/messages" +import type { WorktreeState, WorktreeGitStats, PRStatus, SectionState } from "../src/types/messages" +import { colorCss } from "./section-colors" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" @@ -22,14 +23,16 @@ interface WorktreeItemProps { worktree: WorktreeState /** Display label (resolved from label, first session title, or branch). */ label: string + /** Branch name shown as subtitle when it differs from the label. */ + subtitle?: string active: boolean pendingDelete: boolean busy: boolean /** Whether an agent session on this worktree is actively working (shows spinner instead of branch icon). */ working: boolean stale: boolean - /** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0 or >9 to hide. */ - shortcut: number + /** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */ + shortcut?: number stats?: WorktreeGitStats /** Navigation hint text shown in the hover card (e.g. "⌘⌥↑"). */ navHint?: string @@ -51,6 +54,18 @@ interface WorktreeItemProps { closeKeybind: string /** Keybinding string for the open-in-vscode action. */ openKeybind: string + /** PR status for this worktree's branch, or null if no PR. */ + pr?: PRStatus | null + /** Callback when the PR badge is clicked. */ + onOpenPR?: () => void + /** Available sections for the "Move to Section" submenu. */ + sections?: SectionState[] + /** ID of the section this worktree currently belongs to (for disabling current item). */ + currentSectionId?: string + /** Move this worktree to a section (or null for ungrouped). */ + onMoveToSection?: (sectionId: string | null) => void + /** Move this worktree to a new section. */ + onMoveToNewSection?: () => void onClick: () => void onDelete: (e: MouseEvent) => void @@ -68,11 +83,41 @@ const MAX_SHORTCUT = 9 const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats => !!s && (s.files > 0 || s.additions > 0 || s.deletions > 0 || s.ahead > 0 || s.behind > 0) +/** Returns the accent color for a PR badge based on state priority. */ +export function prAccentColor(pr: PRStatus): string { + if (pr.state === "draft") return "var(--text-weaker)" + if (pr.state === "merged") return "#a78bfa" + if (pr.state === "closed") return "#f87171" + if (pr.checks.status === "failure") return "#ef4444" + if (pr.review === "changes_requested") return "#fbbf24" + if (pr.checks.status === "pending") return "#fbbf24" + return "#34d399" +} + +function prStateLabel(state: PRStatus["state"]): string { + if (state === "draft") return "Draft" + if (state === "merged") return "Merged" + if (state === "closed") return "Closed" + return "Open" +} + +function reviewLabel(review: string): string { + if (review === "approved") return "Approved" + if (review === "changes_requested") return "Changes Requested" + return "Pending" +} + export const WorktreeItem: Component = (props) => { const { t } = useLanguage() const [hovered, setHovered] = createSignal(false) const [overClose, setOverClose] = createSignal(false) + const handleOpenPR = (e: MouseEvent) => { + e.stopPropagation() + e.preventDefault() + props.onOpenPR?.() + } + return ( <> @@ -102,128 +147,164 @@ export const WorktreeItem: Component = (props) => { data-sidebar-id={props.worktree.id} onClick={() => props.onClick()} > - }> - - - - - - - - - - { - e.stopPropagation() - props.onStartRename(props.label) - }} - title={t("agentManager.worktree.doubleClickRename")} - > - {props.label} - - } - > - props.onRenameInput(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - props.onCommitRename() - } - if (e.key === "Escape") { - e.preventDefault() - props.onCancelRename() - } - }} - onBlur={() => props.onCommitRename()} - onClick={(e) => e.stopPropagation()} - ref={(el) => - requestAnimationFrame(() => - requestAnimationFrame(() => { - el.focus() - el.select() - }), - ) - } - /> - - = 2 && props.shortcut <= MAX_SHORTCUT}> - - {isMac ? "⌘" : "Ctrl+"} - {props.shortcut} - - - -
-
-
-
- - -
+
+ }> + + +
+
+ {/* Row 1: label + stale badge + stats/hover-actions overlay */} +
+ + + + + + + 0 || props.stats!.deletions > 0} + when={props.renaming} fallback={ - 0}> - {props.stats!.files}f + { + e.stopPropagation() + props.onStartRename(props.label) + }} + title={t("agentManager.worktree.doubleClickRename")} + > + {props.label} + + } + > + props.onRenameInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + props.onCommitRename() + } + if (e.key === "Escape") { + e.preventDefault() + props.onCancelRename() + } + }} + onBlur={() => props.onCommitRename()} + onClick={(e) => e.stopPropagation()} + ref={(el) => + requestAnimationFrame(() => + requestAnimationFrame(() => { + el.focus() + el.select() + }), + ) + } + /> + + {/* Grid cell: stats visible by default, hover actions on top */} +
+ +
+
+
+ + +
+ 0}> + ↓{props.stats!.behind} + + 0}> + ↑{props.stats!.ahead} + + 0 || props.stats!.deletions > 0} + fallback={ + 0}> + {props.stats!.files}f + + } + > + 0}> + +{props.stats!.additions} + + 0}> + −{props.stats!.deletions} + + +
+
+ + {t("agentManager.worktree.confirmDelete")} + +
+ = 2 && props.shortcut <= MAX_SHORTCUT} + > + + {isMac ? "⌘" : "Ctrl+"} + {props.shortcut} + + + +
setOverClose(true)} + onMouseLeave={() => setOverClose(false)} + > + + props.onDelete(e)} + /> + +
+
+
+
+
+ {/* Row 2: branch subtitle + PR badge */} +
+ + {props.subtitle} + + +
} > -
- 0}> - +{props.stats!.additions} - - 0}> - −{props.stats!.deletions} - -
- - 0 || props.stats!.behind > 0}> -
- 0}> - ↑{props.stats!.ahead} - - 0}> - ↓{props.stats!.behind} - -
+ {(pr) => { + const accent = () => prAccentColor(pr()) + return ( + + + #{pr().number} + + ) + }}
-
- - {t("agentManager.worktree.confirmDelete")} - - -
setOverClose(true)} - onMouseLeave={() => setOverClose(false)} - > - - props.onDelete(e)} - /> - -
-
+
} @@ -313,6 +394,34 @@ export const WorktreeItem: Component = (props) => {
+ + {(pr) => ( + <> +
+
+ PR #{pr().number} + + + + + {prStateLabel(pr().state)} + +
+ +
+ Review + {reviewLabel(pr().review!)} +
+
+
+ Checks + + {pr().checks.passed}/{pr().checks.total} passed + +
+ + )} +
@@ -353,6 +462,38 @@ export const WorktreeItem: Component = (props) => { {t("agentManager.worktree.copyPath")} + + props.onMoveToNewSection?.()}> + + {t("agentManager.worktree.newSection")} + + 0}> + + props.onMoveToSection?.(null)}> + + + + {t("agentManager.worktree.ungrouped")} + + + {(sec) => ( + props.onMoveToSection?.(sec.id)}> + + } + > + + + {sec.name} + + )} + + diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 6cce27a333a..688eefd69ed 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -62,8 +62,7 @@ } .am-local-item-active .am-local-branch { - color: var(--text-on-interactive-base); - opacity: 0.7; + color: color-mix(in srgb, var(--text-on-interactive-base) 70%, transparent); } .am-local-item-active .am-stat-files { @@ -92,8 +91,8 @@ } .am-local-branch { - font-size: var(--font-size-small); - color: var(--text-weak); + font-size: 10px; + color: var(--text-weaker); line-height: 1.2; overflow: hidden; text-overflow: ellipsis; @@ -181,12 +180,11 @@ button.am-section-toggle:hover .am-section-label { } .am-worktree-item { - position: relative; display: flex; - align-items: center; + align-items: flex-start; gap: 8px; - padding: 8px 10px; - min-height: 40px; + padding: 6px 10px; + min-height: 36px; box-sizing: border-box; border-radius: var(--radius-sm); cursor: pointer; @@ -197,6 +195,87 @@ button.am-section-toggle:hover .am-section-label { transition: background 200ms ease; } +/* Left icon column — shrink-0, vertically centered with first row */ +.am-wt-icon { + flex-shrink: 0; + display: flex; + align-items: center; + height: 20px; +} + +/* Right content column — flex-1 with two rows */ +.am-wt-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* Row 1: label + stats/hover-actions */ +.am-wt-row1 { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +/* Grid overlay cell: stats visible by default, hover actions stacked on top */ +.am-wt-actions-cell { + position: relative; + flex-shrink: 0; + display: grid; + align-items: center; + margin-left: auto; +} +.am-wt-actions-cell > * { + grid-column: 1; + grid-row: 1; +} + +/* Hover actions (shortcut badge + close button) — hidden by default, shown on hover */ +.am-wt-hover-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + opacity: 0; + visibility: hidden; +} +.am-worktree-item:hover .am-wt-hover-actions { + opacity: 1; + visibility: visible; +} +/* Stats fade out on hover (replaced by hover actions in same grid cell) */ +.am-worktree-item:hover .am-wt-actions-cell > .am-worktree-stats, +.am-worktree-item:hover .am-wt-actions-cell > .am-worktree-stats-skeleton { + opacity: 0; + visibility: hidden; +} + +/* Row 2: always present for consistent height; PR badge right-aligned */ +.am-wt-row2 { + display: flex; + align-items: center; + gap: 6px; + min-height: 14px; +} + +.am-worktree-subtitle { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + color: var(--text-weaker); + line-height: 1.2; + min-width: 0; +} + +.am-worktree-item-active .am-worktree-subtitle { + color: color-mix(in srgb, var(--text-on-interactive-base) 70%, transparent); +} + .am-worktree-item:hover { background: var(--surface-inset-base-hover); } @@ -238,13 +317,9 @@ button.am-section-toggle:hover .am-section-label { outline: none; } -/* Shortcut badge — hover-reveal index badge (⌘1, ⌘2, etc.) matching Superset style */ +/* Shortcut badge — hover-reveal index badge (⌘1, ⌘2, etc.) */ .am-shortcut-badge { - position: absolute; - right: 30px; - opacity: 0; - transition: opacity 150ms ease; font-size: 10px; font-variant-numeric: tabular-nums; color: var(--text-weaker); @@ -256,8 +331,7 @@ button.am-section-toggle:hover .am-section-label { right: 8px; } -.am-local-item:hover .am-shortcut-badge, -.am-worktree-item:hover .am-shortcut-badge { +.am-local-item:hover .am-shortcut-badge { opacity: 1; } @@ -278,29 +352,27 @@ button.am-section-toggle:hover .am-section-label { color: var(--text-weak); } -.am-worktree-item:has(.am-worktree-rename-input) .am-shortcut-badge { - opacity: 0; +.am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 { + display: none; } .am-worktree-close { - position: absolute; - right: 4px; flex-shrink: 0; +} +/* Compact close button inside worktree items */ +.am-worktree-close [data-component="icon-button"] { + width: 18px; + height: 18px; + padding: 2px; +} +.am-worktree-close [data-component="icon"] { + width: 14px; + height: 14px; +} + +.am-worktree-item:has(.am-worktree-rename-input) .am-wt-hover-actions { opacity: 0; -} - -.am-worktree-item:hover .am-worktree-branch { - mask-image: linear-gradient(to right, black calc(100% - 72px), transparent calc(100% - 36px)); - -webkit-mask-image: linear-gradient(to right, black calc(100% - 72px), transparent calc(100% - 36px)); -} - -.am-worktree-item:hover .am-worktree-close { - opacity: 1; -} - -.am-worktree-item:has(.am-worktree-rename-input) .am-worktree-close { - opacity: 0; - pointer-events: none; + visibility: hidden; } /* Inline delete confirmation */ @@ -315,7 +387,8 @@ button.am-section-toggle:hover .am-section-label { background: color-mix(in srgb, var(--surface-critical-strong) 25%, transparent); } -.am-worktree-pending-delete .am-worktree-branch { +.am-worktree-pending-delete .am-worktree-branch, +.am-worktree-pending-delete .am-worktree-subtitle { opacity: 0.5; transition: opacity 200ms ease; } @@ -325,16 +398,22 @@ button.am-section-toggle:hover .am-section-label { display: none; } -.am-worktree-pending-delete .am-shortcut-badge { +.am-worktree-pending-delete .am-wt-row2 { + visibility: hidden; +} + +.am-worktree-pending-delete .am-wt-hover-actions { opacity: 0 !important; + visibility: hidden !important; } .am-worktree-delete-hint { + position: absolute; + right: 0; font-size: var(--font-size-small); color: var(--text-on-brand-base); background: var(--surface-critical-strong); white-space: nowrap; - flex-shrink: 0; padding: 1px 8px; border-radius: 4px; cursor: pointer; @@ -383,16 +462,14 @@ button.am-section-toggle:hover .am-section-label { .am-worktree-spinner { width: 16px; height: 16px; - flex-shrink: 0; } /* Per-worktree git stats (diff lines + commits missing from origin) */ .am-worktree-stats { display: flex; - flex-direction: column; - align-items: flex-end; - gap: 1px; + align-items: center; + gap: 4px; flex-shrink: 0; font-family: var(--font-mono, monospace); font-size: 10px; @@ -408,9 +485,7 @@ button.am-section-toggle:hover .am-section-label { .am-worktree-stats-skeleton { display: flex; - flex-direction: column; - align-items: flex-end; - gap: 3px; + align-items: center; flex-shrink: 0; } @@ -422,8 +497,15 @@ button.am-section-toggle:hover .am-section-label { animation: am-skeleton-pulse 1.5s ease-in-out infinite; } -.am-worktree-stats-skeleton-row:nth-child(2) { - animation-delay: 0.15s; +.am-pr-badge-skeleton { + width: 52px; + height: 14px; + border-radius: 6px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; + animation-delay: 0.3s; + opacity: 0; + margin-left: auto; } .am-stat-files { @@ -438,6 +520,76 @@ button.am-section-toggle:hover .am-section-label { color: #f87171; } +/* PR pill badge — second row inside am-wt-row2. + Uses --pr-accent custom property (set inline) to derive all colors. */ +.am-pr-badge { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px 7px 2px 4px; + border-radius: 6px; + border: none; + background: color-mix(in srgb, var(--pr-accent) 12%, transparent); + color: var(--pr-accent); + cursor: pointer; + font-size: 11px; + font-weight: 500; + font-variant-numeric: tabular-nums; + transition: background 150ms ease; + margin-left: auto; +} +.am-pr-badge:hover { + background: color-mix(in srgb, var(--pr-accent) 25%, transparent); +} +.am-pr-badge [data-component="icon"] { + width: 12px; + height: 12px; + color: var(--pr-accent); +} +.am-pr-badge-number { + line-height: 1; + color: var(--text-muted); +} +.am-pr-badge:hover .am-pr-badge-number { + color: var(--pr-accent); +} +.am-pr-badge[data-pending] { + animation: am-pr-pulse 1.5s ease-in-out infinite; +} +@keyframes am-pr-pulse { + 0%, + 100% { + opacity: 0.9; + } + 50% { + opacity: 0.4; + } +} + +.am-worktree-item-active .am-pr-badge { + background: color-mix(in srgb, var(--pr-accent) 18%, transparent); +} +.am-worktree-item-active .am-pr-badge:hover { + background: color-mix(in srgb, var(--pr-accent) 30%, transparent); +} + +/* Clickable link icon in hover card to open PR on GitHub */ +.am-pr-link { + display: inline-flex; + align-items: center; + margin-right: 3px; + cursor: pointer; + opacity: 0.5; + transition: opacity 100ms ease; +} +.am-pr-link:hover { + opacity: 1; +} +.am-pr-link [data-component="icon"] { + width: 11px; + height: 11px; +} + .am-worktree-item-active .am-stat-files { color: rgba(255, 255, 255, 0.7); } @@ -470,13 +622,156 @@ button.am-section-toggle:hover .am-section-label { color: #fca5a5; } -.am-worktree-item:hover .am-worktree-stats, -.am-worktree-item:hover .am-worktree-stats-skeleton, .am-local-item:hover .am-worktree-stats, .am-local-item:hover .am-worktree-stats-skeleton { opacity: 0; } +/* User-defined sections — collapsible, color-coded groups */ + +.am-section-group { + margin-top: 2px; + border-left: 2px solid var(--section-color, var(--vscode-panel-border)); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + transition: border-color 120ms; +} + +.am-section-group-drop { + background: color-mix(in srgb, var(--section-color) 10%, transparent); + border-color: var(--section-color); + border-style: solid; +} + +.am-section-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px 5px 6px; + cursor: pointer; + user-select: none; + border-radius: var(--radius-sm); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + text-transform: uppercase; + color: var(--text-weak); +} + +.am-section-group-header:hover { + background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground)); +} + +.am-section-group-left { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; +} + +.am-section-group-chevron { + flex-shrink: 0; + opacity: 0.7; + transition: transform 120ms ease; +} + +.am-section-group-chevron-collapsed { + transform: rotate(-90deg); +} + +.am-section-group-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.am-section-group-count { + flex-shrink: 0; + font-size: 10px; + color: var(--text-weaker); + min-width: 14px; + text-align: center; +} + +.am-section-group-body { + padding-left: 4px; +} + +.am-section-group-rename { + background: var(--input-base, var(--vscode-input-background, #3c3c3c)); + color: var(--vscode-input-foreground); + border: 1px solid var(--border-focus, var(--vscode-focusBorder, #007fd4)); + border-radius: var(--radius-sm); + padding: 1px 4px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + text-transform: uppercase; + outline: none; + width: 100%; + min-width: 0; +} + +/* Color picker grid in section context menu */ + +.am-color-grid { + display: flex; + flex-wrap: wrap; + gap: 2px; + padding: 4px 8px; +} + +.am-color-grid-item { + padding: 3px !important; + border-radius: 4px !important; + min-height: unset !important; +} + +.am-color-swatch { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + flex-shrink: 0; + border: 1.5px solid transparent; +} + +.am-color-swatch-active { + border-color: var(--vscode-foreground); + box-shadow: 0 0 0 1px var(--vscode-editor-background); +} + +.am-color-swatch-default { + background: var(--vscode-panel-border); + position: relative; +} + +.am-color-swatch-default::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 70%; + height: 1.5px; + background: var(--text-weaker); + transform: translate(-50%, -50%) rotate(-45deg); +} + +.am-color-swatch-sm { + width: 10px; + height: 10px; +} + +.am-icon-flip { + transform: rotate(180deg); +} + +/* Context menu danger item (delete section) */ + +.am-ctx-menu-danger { + color: var(--text-error, var(--vscode-errorForeground, #f44)); +} + /* Grouped worktrees — visual grouping with header and left accent */ .am-wt-group-header { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/constrain-drag-x.ts b/packages/kilo-vscode/webview-ui/agent-manager/constrain-drag-x.ts new file mode 100644 index 00000000000..3c9ed79504c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/constrain-drag-x.ts @@ -0,0 +1,21 @@ +import { type Component, createRoot, onCleanup } from "solid-js" +import { useDragDropContext, type Transformer } from "@thisbeyond/solid-dnd" + +/** Lock drag movement to the Y axis (vertical-only worktree dragging). */ +export const ConstrainDragXAxis: Component = () => { + const ctx = useDragDropContext() + if (!ctx) return null + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = ctx + const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) } + const dispose = createRoot((d) => { + onDragStart(({ draggable }) => { + if (draggable) addTransformer("draggables", draggable.id as string, xform) + }) + onDragEnd(({ draggable }) => { + if (draggable) removeTransformer("draggables", draggable.id as string, xform.id) + }) + return d + }) + onCleanup(dispose) + return null +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 461c4288671..9c2d75384ec 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "نسخ المسار", "agentManager.worktree.openInVscode": "فتح في VS Code", "agentManager.worktree.rename": "إعادة تسمية", + "agentManager.worktree.moveToSection": "نقل إلى القسم", + "agentManager.worktree.newSection": "قسم جديد", + "agentManager.worktree.ungrouped": "غير مجمع", + "agentManager.section.rename": "إعادة تسمية القسم", + "agentManager.section.setColor": "تعيين اللون", + "agentManager.section.delete": "حذف القسم", + "agentManager.section.defaultColor": "افتراضي", + "agentManager.section.defaultName": "قسم جديد", + "agentManager.section.moveUp": "تحريك لأعلى", + "agentManager.section.moveDown": "تحريك لأسفل", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "الفرع", "agentManager.hoverCard.base": "الأساس", "agentManager.hoverCard.sessions": "الجلسات", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index a2a5531c4b4..b8f8c9fcc46 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Copiar Caminho", "agentManager.worktree.openInVscode": "Abrir no VS Code", "agentManager.worktree.rename": "Renomear", + "agentManager.worktree.moveToSection": "Mover para a Seção", + "agentManager.worktree.newSection": "Nova Seção", + "agentManager.worktree.ungrouped": "Não Agrupado", + "agentManager.section.rename": "Renomear Seção", + "agentManager.section.setColor": "Definir Cor", + "agentManager.section.delete": "Excluir Seção", + "agentManager.section.defaultColor": "Padrão", + "agentManager.section.defaultName": "Nova Seção", + "agentManager.section.moveUp": "Mover para Cima", + "agentManager.section.moveDown": "Mover para Baixo", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessões", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index d92d7434157..9a778861061 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Kopiraj putanju", "agentManager.worktree.openInVscode": "Otvori u VS Code", "agentManager.worktree.rename": "Preimenuj", + "agentManager.worktree.moveToSection": "Premjesti u sekciju", + "agentManager.worktree.newSection": "Nova sekcija", + "agentManager.worktree.ungrouped": "Bez grupe", + "agentManager.section.rename": "Preimenuj sekciju", + "agentManager.section.setColor": "Postavi boju", + "agentManager.section.delete": "Izbriši sekciju", + "agentManager.section.defaultColor": "Zadano", + "agentManager.section.defaultName": "Nova sekcija", + "agentManager.section.moveUp": "Pomjeri gore", + "agentManager.section.moveDown": "Pomjeri dolje", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesije", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index b6b7b692229..e5036de5057 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Kopier sti", "agentManager.worktree.openInVscode": "Åbn i VS Code", "agentManager.worktree.rename": "Omdøb", + "agentManager.worktree.moveToSection": "Flyt til sektion", + "agentManager.worktree.newSection": "Ny sektion", + "agentManager.worktree.ungrouped": "Ugrupperet", + "agentManager.section.rename": "Omdøb sektion", + "agentManager.section.setColor": "Indstil farve", + "agentManager.section.delete": "Slet sektion", + "agentManager.section.defaultColor": "Standard", + "agentManager.section.defaultName": "Ny sektion", + "agentManager.section.moveUp": "Flyt op", + "agentManager.section.moveDown": "Flyt ned", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessioner", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index bc6a0e0f5bc..2d26633d9d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Pfad kopieren", "agentManager.worktree.openInVscode": "In VS Code öffnen", "agentManager.worktree.rename": "Umbenennen", + "agentManager.worktree.moveToSection": "In Bereich verschieben", + "agentManager.worktree.newSection": "Neuer Bereich", + "agentManager.worktree.ungrouped": "Nicht gruppiert", + "agentManager.section.rename": "Bereich umbenennen", + "agentManager.section.setColor": "Farbe festlegen", + "agentManager.section.delete": "Bereich löschen", + "agentManager.section.defaultColor": "Standard", + "agentManager.section.defaultName": "Neuer Bereich", + "agentManager.section.moveUp": "Nach oben verschieben", + "agentManager.section.moveDown": "Nach unten verschieben", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Basis", "agentManager.hoverCard.sessions": "Sitzungen", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index a4ed3a190e4..dab774f0af9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -20,6 +20,17 @@ export const dict = { "agentManager.worktree.copyPath": "Copy Path", "agentManager.worktree.openInVscode": "Open in VS Code", "agentManager.worktree.rename": "Rename", + "agentManager.worktree.moveToSection": "Move to Section", + "agentManager.worktree.newSection": "New Section", + "agentManager.worktree.ungrouped": "Ungrouped", + "agentManager.section.rename": "Rename Section", + "agentManager.section.setColor": "Set Color", + "agentManager.section.delete": "Delete Section", + "agentManager.section.defaultColor": "Default", + "agentManager.section.defaultName": "New Section", + "agentManager.section.moveUp": "Move Up", + "agentManager.section.moveDown": "Move Down", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 576d2c6e11a..161e7c8347a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Copiar ruta", "agentManager.worktree.openInVscode": "Abrir en VS Code", "agentManager.worktree.rename": "Renombrar", + "agentManager.worktree.moveToSection": "Mover a la sección", + "agentManager.worktree.newSection": "Nueva sección", + "agentManager.worktree.ungrouped": "Desagrupado", + "agentManager.section.rename": "Renombrar sección", + "agentManager.section.setColor": "Establecer color", + "agentManager.section.delete": "Eliminar sección", + "agentManager.section.defaultColor": "Predeterminado", + "agentManager.section.defaultName": "Nueva sección", + "agentManager.section.moveUp": "Mover hacia arriba", + "agentManager.section.moveDown": "Mover hacia abajo", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sesiones", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 3f880892387..9f14a9440da 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Copier le chemin", "agentManager.worktree.openInVscode": "Ouvrir dans VS Code", "agentManager.worktree.rename": "Renommer", + "agentManager.worktree.moveToSection": "Déplacer vers la section", + "agentManager.worktree.newSection": "Nouvelle section", + "agentManager.worktree.ungrouped": "Non groupé", + "agentManager.section.rename": "Renommer la section", + "agentManager.section.setColor": "Définir la couleur", + "agentManager.section.delete": "Supprimer la section", + "agentManager.section.defaultColor": "Par défaut", + "agentManager.section.defaultName": "Nouvelle section", + "agentManager.section.moveUp": "Déplacer vers le haut", + "agentManager.section.moveDown": "Déplacer vers le bas", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCHE", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Sessions", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 44c226ad37c..867dd44a3e5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "パスをコピー", "agentManager.worktree.openInVscode": "VS Codeで開く", "agentManager.worktree.rename": "名前を変更", + "agentManager.worktree.moveToSection": "セクションに移動", + "agentManager.worktree.newSection": "新しいセクション", + "agentManager.worktree.ungrouped": "グループなし", + "agentManager.section.rename": "セクションの名前変更", + "agentManager.section.setColor": "色の設定", + "agentManager.section.delete": "セクションの削除", + "agentManager.section.defaultColor": "デフォルト", + "agentManager.section.defaultName": "新しいセクション", + "agentManager.section.moveUp": "上に移動", + "agentManager.section.moveDown": "下に移動", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ブランチ", "agentManager.hoverCard.base": "ベース", "agentManager.hoverCard.sessions": "セッション", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 8258caba61a..f3fa9011a0b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "경로 복사", "agentManager.worktree.openInVscode": "VS Code에서 열기", "agentManager.worktree.rename": "이름 바꾸기", + "agentManager.worktree.moveToSection": "섹션으로 이동", + "agentManager.worktree.newSection": "새 섹션", + "agentManager.worktree.ungrouped": "그룹 해제됨", + "agentManager.section.rename": "섹션 이름 바꾸기", + "agentManager.section.setColor": "색상 설정", + "agentManager.section.delete": "섹션 삭제", + "agentManager.section.defaultColor": "기본값", + "agentManager.section.defaultName": "새 섹션", + "agentManager.section.moveUp": "위로 이동", + "agentManager.section.moveDown": "아래로 이동", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "브랜치", "agentManager.hoverCard.base": "베이스", "agentManager.hoverCard.sessions": "세션", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 40394720d21..00da3f0282d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -20,6 +20,17 @@ export const dict = { "agentManager.worktree.copyPath": "Pad kopiëren", "agentManager.worktree.openInVscode": "Openen in VS Code", "agentManager.worktree.rename": "Hernoemen", + "agentManager.worktree.moveToSection": "Verplaatsen naar sectie", + "agentManager.worktree.newSection": "Nieuwe sectie", + "agentManager.worktree.ungrouped": "Niet gegroepeerd", + "agentManager.section.rename": "Sectie hernoemen", + "agentManager.section.setColor": "Kleur instellen", + "agentManager.section.delete": "Sectie verwijderen", + "agentManager.section.defaultColor": "Standaard", + "agentManager.section.defaultName": "Nieuwe sectie", + "agentManager.section.moveUp": "Omhoog verplaatsen", + "agentManager.section.moveDown": "Omlaag verplaatsen", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Basis", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 1a67eac546f..dab80f396f1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Kopier sti", "agentManager.worktree.openInVscode": "Åpne i VS Code", "agentManager.worktree.rename": "Gi nytt navn", + "agentManager.worktree.moveToSection": "Flytt til seksjon", + "agentManager.worktree.newSection": "Ny seksjon", + "agentManager.worktree.ungrouped": "Ugruppert", + "agentManager.section.rename": "Gi seksjon nytt navn", + "agentManager.section.setColor": "Angi farge", + "agentManager.section.delete": "Slett seksjon", + "agentManager.section.defaultColor": "Standard", + "agentManager.section.defaultName": "Ny seksjon", + "agentManager.section.moveUp": "Flytt opp", + "agentManager.section.moveDown": "Flytt ned", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Base", "agentManager.hoverCard.sessions": "Økter", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 57a71417611..e91b0ac2526 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Kopiuj ścieżkę", "agentManager.worktree.openInVscode": "Otwórz w VS Code", "agentManager.worktree.rename": "Zmień nazwę", + "agentManager.worktree.moveToSection": "Przenieś do sekcji", + "agentManager.worktree.newSection": "Nowa sekcja", + "agentManager.worktree.ungrouped": "Niezgrupowane", + "agentManager.section.rename": "Zmień nazwę sekcji", + "agentManager.section.setColor": "Ustaw kolor", + "agentManager.section.delete": "Usuń sekcję", + "agentManager.section.defaultColor": "Domyślny", + "agentManager.section.defaultName": "Nowa sekcja", + "agentManager.section.moveUp": "Przenieś w górę", + "agentManager.section.moveDown": "Przenieś w dół", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "Baza", "agentManager.hoverCard.sessions": "Sesje", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 5748502ce48..5113cea6b47 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "Копировать путь", "agentManager.worktree.openInVscode": "Открыть в VS Code", "agentManager.worktree.rename": "Переименовать", + "agentManager.worktree.moveToSection": "Переместить в раздел", + "agentManager.worktree.newSection": "Новый раздел", + "agentManager.worktree.ungrouped": "Без группы", + "agentManager.section.rename": "Переименовать раздел", + "agentManager.section.setColor": "Задать цвет", + "agentManager.section.delete": "Удалить раздел", + "agentManager.section.defaultColor": "По умолчанию", + "agentManager.section.defaultName": "Новый раздел", + "agentManager.section.moveUp": "Переместить вверх", + "agentManager.section.moveDown": "Переместить вниз", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ВЕТКА", "agentManager.hoverCard.base": "Основа", "agentManager.hoverCard.sessions": "Сессии", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 62c833e0b70..61bf7c4ea13 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "คัดลอกเส้นทาง", "agentManager.worktree.openInVscode": "เปิดใน VS Code", "agentManager.worktree.rename": "เปลี่ยนชื่อ", + "agentManager.worktree.moveToSection": "ย้ายไปที่ส่วน", + "agentManager.worktree.newSection": "ส่วนใหม่", + "agentManager.worktree.ungrouped": "ไม่ได้จัดกลุ่ม", + "agentManager.section.rename": "เปลี่ยนชื่อส่วน", + "agentManager.section.setColor": "ตั้งค่าสี", + "agentManager.section.delete": "ลบส่วน", + "agentManager.section.defaultColor": "ค่าเริ่มต้น", + "agentManager.section.defaultName": "ส่วนใหม่", + "agentManager.section.moveUp": "เลื่อนขึ้น", + "agentManager.section.moveDown": "เลื่อนลง", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "BRANCH", "agentManager.hoverCard.base": "ฐาน", "agentManager.hoverCard.sessions": "เซสชัน", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 10ce0c20985..aa25ee92f25 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -20,6 +20,17 @@ export const dict = { "agentManager.worktree.copyPath": "Yolu Kopyala", "agentManager.worktree.openInVscode": "VS Code'da Aç", "agentManager.worktree.rename": "Yeniden Adlandır", + "agentManager.worktree.moveToSection": "Bölüme Taşı", + "agentManager.worktree.newSection": "Yeni Bölüm", + "agentManager.worktree.ungrouped": "Gruplandırılmamış", + "agentManager.section.rename": "Bölümü Yeniden Adlandır", + "agentManager.section.setColor": "Renk Ayarla", + "agentManager.section.delete": "Bölümü Sil", + "agentManager.section.defaultColor": "Varsayılan", + "agentManager.section.defaultName": "Yeni Bölüm", + "agentManager.section.moveUp": "Yukarı Taşı", + "agentManager.section.moveDown": "Aşağı Taşı", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "DAL", "agentManager.hoverCard.base": "Temel", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index ae8f2cd143f..9ccde643c3f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -20,6 +20,17 @@ export const dict = { "agentManager.worktree.copyPath": "Копіювати шлях", "agentManager.worktree.openInVscode": "Відкрити у VS Code", "agentManager.worktree.rename": "Перейменувати", + "agentManager.worktree.moveToSection": "Перемістити до розділу", + "agentManager.worktree.newSection": "Новий розділь", + "agentManager.worktree.ungrouped": "Без групи", + "agentManager.section.rename": "Перейменувати розділ", + "agentManager.section.setColor": "Встановити колір", + "agentManager.section.delete": "Видалити розділ", + "agentManager.section.defaultColor": "За замовчуванням", + "agentManager.section.defaultName": "Новий розділ", + "agentManager.section.moveUp": "Перемістити вгору", + "agentManager.section.moveDown": "Перемістити вниз", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "ГІЛКА", "agentManager.hoverCard.base": "База", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index fd8f3a5071d..046a0bc4e0f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "复制路径", "agentManager.worktree.openInVscode": "在 VS Code 中打开", "agentManager.worktree.rename": "重命名", + "agentManager.worktree.moveToSection": "移动到分区", + "agentManager.worktree.newSection": "新分区", + "agentManager.worktree.ungrouped": "未分组", + "agentManager.section.rename": "重命名分区", + "agentManager.section.setColor": "设置颜色", + "agentManager.section.delete": "删除分区", + "agentManager.section.defaultColor": "默认", + "agentManager.section.defaultName": "新分区", + "agentManager.section.moveUp": "上移", + "agentManager.section.moveDown": "下移", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "分支", "agentManager.hoverCard.base": "基础", "agentManager.hoverCard.sessions": "会话", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 0906b6f73f0..ed02e907a6c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -19,6 +19,17 @@ export const dict = { "agentManager.worktree.copyPath": "複製路徑", "agentManager.worktree.openInVscode": "在 VS Code 中打開", "agentManager.worktree.rename": "重新命名", + "agentManager.worktree.moveToSection": "移動到分區", + "agentManager.worktree.newSection": "新分區", + "agentManager.worktree.ungrouped": "未分組", + "agentManager.section.rename": "重新命名分區", + "agentManager.section.setColor": "設定顏色", + "agentManager.section.delete": "刪除分區", + "agentManager.section.defaultColor": "預設", + "agentManager.section.defaultName": "新分區", + "agentManager.section.moveUp": "上移", + "agentManager.section.moveDown": "下移", + "agentManager.section.count": "{{count}}", "agentManager.hoverCard.branch": "分支", "agentManager.hoverCard.base": "基底", "agentManager.hoverCard.sessions": "工作階段", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-colors.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-colors.ts new file mode 100644 index 00000000000..2d988b95601 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-colors.ts @@ -0,0 +1,22 @@ +/** Section color palette using VS Code theme CSS variables for theme-adaptive colors. */ +export const SECTION_COLORS = [ + { label: "Red", css: "var(--vscode-terminal-ansiRed)" }, + { label: "Orange", css: "var(--vscode-charts-orange)" }, + { label: "Yellow", css: "var(--vscode-terminal-ansiYellow)" }, + { label: "Green", css: "var(--vscode-terminal-ansiGreen)" }, + { label: "Cyan", css: "var(--vscode-terminal-ansiCyan)" }, + { label: "Blue", css: "var(--vscode-charts-blue)" }, + { label: "Purple", css: "var(--vscode-charts-purple)" }, + { label: "Magenta", css: "var(--vscode-terminal-ansiMagenta)" }, +] as const + +/** Map a stored color label to its CSS variable string. Returns undefined for null/unknown labels. */ +export function colorCss(label: string | null): string | undefined { + if (!label) return undefined + return SECTION_COLORS.find((c) => c.label === label)?.css +} + +/** Pick a random color label for new sections. */ +export function randomColor(): string { + return SECTION_COLORS[Math.floor(Math.random() * SECTION_COLORS.length)]!.label +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-dnd.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-dnd.ts new file mode 100644 index 00000000000..d625f795ca8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-dnd.ts @@ -0,0 +1,35 @@ +/** + * Section drag-and-drop helpers. Separated from section-helpers.ts to avoid + * pulling solid-dnd into test environments. + */ +import { closestCenter } from "@thisbeyond/solid-dnd" +import type { CollisionDetector } from "@thisbeyond/solid-dnd" + +/** + * Collision detector that prioritizes section drop zones when a worktree is + * dragged (checks bounding box, not just center). Skips the worktree's home + * section so within-section reorder works. Falls back to closestCenter. + * + * @param secIds Accessor for all section IDs + * @param home Accessor for worktree ID → its sectionId (or undefined if ungrouped) + */ +export function sectionAwareDetector( + secIds: () => Set, + home: () => Map, +): CollisionDetector { + return (draggable, droppables, ctx) => { + const secs = secIds() + const id = draggable.id as string + const mySection = home().get(id) + if (!secs.has(id)) { + const pt = draggable.transformed.center + for (const d of droppables) { + if (!secs.has(d.id as string)) continue + if (d.id === mySection) continue + const { top, bottom, left, right } = d.layout + if (pt.x >= left && pt.x <= right && pt.y >= top && pt.y <= bottom) return d + } + } + return closestCenter(draggable, droppables, ctx) + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts new file mode 100644 index 00000000000..6b15ab6ef64 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts @@ -0,0 +1,62 @@ +/** + * Section computation helpers for the agent manager sidebar. + * Pure functions — no solid-dnd dependency so they remain testable. + */ +import type { WorktreeState, SectionState } from "../src/types/messages" + +export type TopLevelItem = { kind: "section"; section: SectionState } | { kind: "worktree"; wt: WorktreeState } + +/** Check if this worktree is part of a multi-version group. */ +export const isGrouped = (wt: WorktreeState) => !!wt.groupId + +/** Check if this is the first item in its group within a given list. */ +export const isGroupStart = (wt: WorktreeState, idx: number, list: WorktreeState[]) => { + if (!wt.groupId) return false + if (idx === 0) return true + return list[idx - 1]?.groupId !== wt.groupId +} + +/** Check if this is the last item in its group within a given list. */ +export const isGroupEnd = (wt: WorktreeState, idx: number, list: WorktreeState[]) => { + if (!wt.groupId) return false + if (idx === list.length - 1) return true + return list[idx + 1]?.groupId !== wt.groupId +} + +/** + * Build the interleaved list of sections and ungrouped worktrees + * ordered by sidebarWorktreeOrder. + */ +export function buildTopLevelItems( + secs: SectionState[], + ungrouped: WorktreeState[], + all: WorktreeState[], + order: string[], +): TopLevelItem[] { + if (secs.length === 0) { + return all.map((wt) => ({ kind: "worktree" as const, wt })) + } + const secMap = new Map(secs.map((s) => [s.id, s])) + const wtMap = new Map(ungrouped.map((wt) => [wt.id, wt])) + const result: TopLevelItem[] = [] + const placed = new Set() + + for (const id of order) { + if (placed.has(id)) continue + placed.add(id) + const sec = secMap.get(id) + if (sec) { + result.push({ kind: "section", section: sec }) + continue + } + const wt = wtMap.get(id) + if (wt) result.push({ kind: "worktree", wt }) + } + for (const sec of secs) { + if (!placed.has(sec.id)) result.push({ kind: "section", section: sec }) + } + for (const wt of ungrouped) { + if (!placed.has(wt.id)) result.push({ kind: "worktree", wt }) + } + return result +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx index e0d01ef86e6..06d410d1ed4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/KiloNotifications.tsx @@ -62,12 +62,16 @@ export const KiloNotifications: Component = () => { return true }) + const MAX_NAME = 30 + const suggestedName = createMemo(() => { const suggestion = suggestedModel() if (!suggestion) return undefined const model = provider.findModel(suggestion) if (!model?.name) return undefined - return stripSubProviderPrefix(model.name) + const name = stripSubProviderPrefix(model.name) + if (name.length > MAX_NAME) return undefined + return name }) const handleTryModel = () => { @@ -98,7 +102,9 @@ export const KiloNotifications: Component = () => {
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index feaca6ac761..3326f9872e6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -244,7 +244,7 @@ export const PromptInput: Component = (props) => { window.addEventListener("compactSession", onCompact) onCleanup(() => window.removeEventListener("compactSession", onCompact)) - const isBusy = () => session.status() === "busy" + const isBusy = () => session.status() !== "idle" const isDisabled = () => !server.isConnected() const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 const canSend = () => hasInput() && !isDisabled() && !props.blocked?.() diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index 1b3eac83992..5349789bae3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -1004,7 +1004,15 @@ const AgentBehaviourTab: Component = () => { > {path} - removeInstruction(index())} /> +
+ vscode.postMessage({ type: "openFile", filePath: path })} + /> + removeInstruction(index())} /> +
)} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 1ae56f28f2b..dc7b8191361 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -29,6 +29,7 @@ import { sanitizeName, } from "./model-selector-utils" import { ModelPreview } from "./ModelPreview" +import { searchMatch } from "../../utils/search-match" // --------------------------------------------------------------------------- // Row / group key helpers — single source of truth for key formatting @@ -167,11 +168,13 @@ export const ModelSelectorBase: Component = (props) => { // Flat filtered list for keyboard navigation const filtered = createMemo(() => { - const q = debouncedSearch().toLowerCase() + const q = debouncedSearch().trim() if (!q) { return visibleModels() } - return visibleModels().filter((m) => m.name.toLowerCase().includes(q)) + return visibleModels().filter( + (m) => searchMatch(q, m.name) || searchMatch(q, m.id) || searchMatch(q, m.providerName), + ) }) // Live set of favorited keys — drives star icon visual state (filled vs outline). diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx index 6d35fed0344..9585b87bf86 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/WorkingIndicator.tsx @@ -4,14 +4,17 @@ * Matches the v1.0.25 working indicator UX. */ -import { Component, Show, createSignal, createEffect, onCleanup } from "solid-js" +import { type Component, Show, createSignal, createEffect, onCleanup } from "solid-js" import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Button } from "@kilocode/kilo-ui/button" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" +import { useVSCode } from "../../context/vscode" export const WorkingIndicator: Component = () => { const session = useSession() const language = useLanguage() + const vscode = useVSCode() const [elapsed, setElapsed] = createSignal(0) const [retryCountdown, setRetryCountdown] = createSignal(0) @@ -80,6 +83,15 @@ export const WorkingIndicator: Component = () => { return perms.length > 0 || questions.length > 0 } + const isRetrying = () => session.statusInfo().type === "retry" + + const handleCancelRetry = () => { + const sid = session.currentSessionID() + if (sid) { + vscode.postMessage({ type: "abort", sessionID: sid }) + } + } + return (
@@ -88,6 +100,17 @@ export const WorkingIndicator: Component = () => { 0}> {formatElapsed()} + + +
) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 3699decc2a6..3ce48123df2 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -414,6 +414,10 @@ export const SessionProvider: ParentComponent = (props) => { function selectModel(providerID: string, modelID: string) { applyModel(selectedAgentName(), { providerID, modelID }) + const sid = currentSessionID() + if (sid) { + setStore("messages", sid, (msgs = []) => msgs.filter((m) => !m.error)) + } } /** The config/default model for the current mode (what settings says). */ diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 1fa8b58eda4..1911d9160d2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -935,6 +935,10 @@ export const dict = { "session.status.retrying": "...إعادة المحاولة (المحاولة {{ attempt }})… {{ message }}", "session.status.working": "...جارٍ العمل", + "ui.sessionTurn.cancel": "إلغاء", + "ui.sessionTurn.status.thinking": "...جارٍ التفكير", + "ui.sessionTurn.status.consideringNextSteps": "...جارٍ التفكير في الخطوات التالية", + "dialog.model.noProviders": "لا يوجد موفرون", "prompt.placeholder.connecting": "جارٍ الاتصال بالخادم...", @@ -1330,4 +1334,5 @@ export const dict = { "notifications.action.next": "التالي", "notifications.action.close": "إغلاق", "notifications.action.tryModel": "جرّب {{model}}", + "notifications.action.tryModelGeneric": "جرّب النموذج", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 4689add917c..723bd0ace71 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -943,6 +943,10 @@ export const dict = { "session.status.retrying": "Tentando novamente (tentativa {{ attempt }})… {{ message }}", "session.status.working": "Trabalhando…", + "ui.sessionTurn.cancel": "Cancelar", + "ui.sessionTurn.status.thinking": "Pensando...", + "ui.sessionTurn.status.consideringNextSteps": "Considerando próximos passos...", + "dialog.model.noProviders": "Nenhum provedor", "prompt.placeholder.connecting": "Conectando ao servidor...", @@ -1359,4 +1363,5 @@ export const dict = { "notifications.action.next": "Próximo", "notifications.action.close": "Fechar", "notifications.action.tryModel": "Experimentar {{model}}", + "notifications.action.tryModelGeneric": "Experimentar modelo", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index eb264cc52da..414701e0653 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -948,6 +948,10 @@ export const dict = { "session.status.retrying": "Ponovni pokušaj (pokušaj {{ attempt }})… {{ message }}", "session.status.working": "Radim…", + "ui.sessionTurn.cancel": "Otkaži", + "ui.sessionTurn.status.thinking": "Razmišljam...", + "ui.sessionTurn.status.consideringNextSteps": "Razmatram sljedeće korake...", + "dialog.model.noProviders": "Nema pružatelja", "prompt.placeholder.connecting": "Povezivanje na server...", @@ -1355,4 +1359,5 @@ export const dict = { "notifications.action.next": "Sljedeći", "notifications.action.close": "Zatvori", "notifications.action.tryModel": "Probaj {{model}}", + "notifications.action.tryModelGeneric": "Probaj model", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index a6a486f428f..c7a0ee2bb4f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -941,6 +941,10 @@ export const dict = { "session.status.retrying": "Prøver igen (forsøg {{ attempt }})… {{ message }}", "session.status.working": "Arbejder…", + "ui.sessionTurn.cancel": "Annuller", + "ui.sessionTurn.status.thinking": "Tænker...", + "ui.sessionTurn.status.consideringNextSteps": "Overvejer næste trin...", + "dialog.model.noProviders": "Ingen udbydere", "prompt.placeholder.connecting": "Opretter forbindelse til server...", @@ -1346,4 +1350,5 @@ export const dict = { "notifications.action.next": "Næste", "notifications.action.close": "Luk", "notifications.action.tryModel": "Prøv {{model}}", + "notifications.action.tryModelGeneric": "Prøv model", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 2f2bdbb999c..139cc84bfa5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -953,6 +953,10 @@ export const dict = { "session.status.retrying": "Erneuter Versuch ({{ attempt }})… {{ message }}", "session.status.working": "Wird bearbeitet…", + "ui.sessionTurn.cancel": "Abbrechen", + "ui.sessionTurn.status.thinking": "Denke nach...", + "ui.sessionTurn.status.consideringNextSteps": "Überlege nächste Schritte...", + "dialog.model.noProviders": "Keine Anbieter", "prompt.placeholder.connecting": "Verbindung zum Server wird hergestellt...", @@ -1373,4 +1377,5 @@ export const dict = { "notifications.action.next": "Weiter", "notifications.action.close": "Schließen", "notifications.action.tryModel": "{{model}} ausprobieren", + "notifications.action.tryModelGeneric": "Modell ausprobieren", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 8bc6b2db359..f375960517c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -941,6 +941,10 @@ export const dict = { "session.status.retrying": "Retrying (attempt {{ attempt }})… {{ message }}", "session.status.working": "Working...", + "ui.sessionTurn.cancel": "Cancel", + "ui.sessionTurn.status.thinking": "Thinking...", + "ui.sessionTurn.status.consideringNextSteps": "Considering next steps...", + "dialog.model.noProviders": "No providers", "prompt.placeholder.connecting": "Connecting to server...", @@ -1171,7 +1175,8 @@ export const dict = { "settings.agentBehaviour.editMode.prompt": "System Prompt", "settings.agentBehaviour.editMode.save": "Done", "settings.agentBehaviour.editMode.back": "Back to list", - "settings.agentBehaviour.editMode.native": "Built-in mode (read-only definition)", + "settings.agentBehaviour.editMode.native": + "This is a built-in mode. Its base definition cannot be changed, but you can configure overrides below.", "settings.agentBehaviour.editMode.promptOverride": "Custom prompt override for this built-in mode", "settings.autoApprove.description": @@ -1356,4 +1361,5 @@ export const dict = { "notifications.action.next": "Next", "notifications.action.close": "Close", "notifications.action.tryModel": "Try {{model}}", + "notifications.action.tryModelGeneric": "Try Model", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 4fc84109126..b05fadfa265 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -949,6 +949,10 @@ export const dict = { "session.status.retrying": "Reintentando (intento {{ attempt }})… {{ message }}", "session.status.working": "Trabajando…", + "ui.sessionTurn.cancel": "Cancelar", + "ui.sessionTurn.status.thinking": "Pensando...", + "ui.sessionTurn.status.consideringNextSteps": "Considerando siguientes pasos...", + "dialog.model.noProviders": "Sin proveedores", "prompt.placeholder.connecting": "Conectando al servidor...", @@ -1362,4 +1366,5 @@ export const dict = { "notifications.action.next": "Siguiente", "notifications.action.close": "Cerrar", "notifications.action.tryModel": "Probar {{model}}", + "notifications.action.tryModelGeneric": "Probar modelo", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index eff79d27b83..d9ee62fe576 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -955,6 +955,10 @@ export const dict = { "session.status.retrying": "Nouvelle tentative (essai {{ attempt }})… {{ message }}", "session.status.working": "En cours…", + "ui.sessionTurn.cancel": "Annuler", + "ui.sessionTurn.status.thinking": "Réflexion...", + "ui.sessionTurn.status.consideringNextSteps": "Envisage les prochaines étapes...", + "dialog.model.noProviders": "Aucun fournisseur", "prompt.placeholder.connecting": "Connexion au serveur...", @@ -1378,4 +1382,5 @@ export const dict = { "notifications.action.next": "Suivant", "notifications.action.close": "Fermer", "notifications.action.tryModel": "Essayer {{model}}", + "notifications.action.tryModelGeneric": "Essayer le modèle", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 88020c7bb59..2466dd4acab 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -940,6 +940,10 @@ export const dict = { "session.status.retrying": "再試行中({{ attempt }}回目)… {{ message }}", "session.status.working": "作業中…", + "ui.sessionTurn.cancel": "キャンセル", + "ui.sessionTurn.status.thinking": "考え中...", + "ui.sessionTurn.status.consideringNextSteps": "次のステップを検討中...", + "dialog.model.noProviders": "プロバイダーなし", "prompt.placeholder.connecting": "サーバーに接続中...", @@ -1345,4 +1349,5 @@ export const dict = { "notifications.action.next": "次へ", "notifications.action.close": "閉じる", "notifications.action.tryModel": "{{model}}を試す", + "notifications.action.tryModelGeneric": "モデルを試す", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 98493230812..2ebaca8dedc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -940,6 +940,10 @@ export const dict = { "session.status.retrying": "재시도 중 ({{ attempt }}번째 시도)… {{ message }}", "session.status.working": "작업 중...", + "ui.sessionTurn.cancel": "취소", + "ui.sessionTurn.status.thinking": "생각 중...", + "ui.sessionTurn.status.consideringNextSteps": "다음 단계 고려 중...", + "dialog.model.noProviders": "공급자 없음", "prompt.placeholder.connecting": "서버에 연결 중...", @@ -1333,4 +1337,5 @@ export const dict = { "notifications.action.next": "다음", "notifications.action.close": "닫기", "notifications.action.tryModel": "{{model}} 시도", + "notifications.action.tryModelGeneric": "모델 시도", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index c8e5c7e21df..fb18d132f80 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -942,6 +942,10 @@ export const dict = { "session.status.retrying": "Opnieuw proberen (poging {{ attempt }})... {{ message }}", "session.status.working": "Bezig...", + "ui.sessionTurn.cancel": "Annuleren", + "ui.sessionTurn.status.thinking": "Denken...", + "ui.sessionTurn.status.consideringNextSteps": "Volgende stappen overwegen...", + "dialog.model.noProviders": "Geen providers", "prompt.placeholder.connecting": "Verbinden met server...", @@ -1351,6 +1355,7 @@ export const dict = { "notifications.action.next": "Volgende", "notifications.action.close": "Sluiten", "notifications.action.tryModel": "Probeer {{model}}", + "notifications.action.tryModelGeneric": "Probeer model", // Missing translations - English fallbacks until translated "settings.agentBehaviour.createMode": "Nieuwe modus aanmaken", "settings.agentBehaviour.createMode.button": "Aanmaken", @@ -1373,7 +1378,8 @@ export const dict = { "settings.agentBehaviour.editMode": "Modus bewerken", "settings.agentBehaviour.editMode.back": "Terug naar lijst", "settings.agentBehaviour.editMode.description": "Beschrijving", - "settings.agentBehaviour.editMode.native": "Ingebouwde modus (alleen-lezen definitie)", + "settings.agentBehaviour.editMode.native": + "Dit is een ingebouwde modus. De basisdefinitie kan niet worden gewijzigd, maar u kunt hieronder overrides configureren.", "settings.agentBehaviour.editMode.prompt": "Systeemprompt", "settings.agentBehaviour.editMode.promptOverride": "Aangepaste systeemprompt voor deze ingebouwde modus", "settings.agentBehaviour.editMode.save": "Klaar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 97437a53ce3..f99649fca0f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -945,6 +945,10 @@ export const dict = { "session.status.retrying": "Prøver på nytt (forsøk {{ attempt }})… {{ message }}", "session.status.working": "Arbeider…", + "ui.sessionTurn.cancel": "Avbryt", + "ui.sessionTurn.status.thinking": "Tenker...", + "ui.sessionTurn.status.consideringNextSteps": "Vurderer neste steg...", + "dialog.model.noProviders": "Ingen leverandører", "prompt.placeholder.connecting": "Kobler til server...", @@ -1344,4 +1348,5 @@ export const dict = { "notifications.action.next": "Neste", "notifications.action.close": "Lukk", "notifications.action.tryModel": "Prøv {{model}}", + "notifications.action.tryModelGeneric": "Prøv modell", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index cd6ecc31c49..896cbff05d0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -945,6 +945,10 @@ export const dict = { "session.status.retrying": "Ponawiam próbę ({{ attempt }})… {{ message }}", "session.status.working": "Pracuję…", + "ui.sessionTurn.cancel": "Anuluj", + "ui.sessionTurn.status.thinking": "Myślę...", + "ui.sessionTurn.status.consideringNextSteps": "Rozważam następne kroki...", + "dialog.model.noProviders": "Brak dostawców", "prompt.placeholder.connecting": "Łączenie z serwerem...", @@ -1354,4 +1358,5 @@ export const dict = { "notifications.action.next": "Następny", "notifications.action.close": "Zamknij", "notifications.action.tryModel": "Wypróbuj {{model}}", + "notifications.action.tryModelGeneric": "Wypróbuj model", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index bd6b3873120..2d8a1bb2cc3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -948,6 +948,10 @@ export const dict = { "session.status.retrying": "Повторная попытка ({{ attempt }})… {{ message }}", "session.status.working": "Работаю…", + "ui.sessionTurn.cancel": "Отмена", + "ui.sessionTurn.status.thinking": "Думаю...", + "ui.sessionTurn.status.consideringNextSteps": "Продумываю следующие шаги...", + "dialog.model.noProviders": "Нет провайдеров", "prompt.placeholder.connecting": "Подключение к серверу...", @@ -1353,4 +1357,5 @@ export const dict = { "notifications.action.next": "Далее", "notifications.action.close": "Закрыть", "notifications.action.tryModel": "Попробовать {{model}}", + "notifications.action.tryModelGeneric": "Попробовать модель", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 9703f2f0a4c..cffeb9fd8b4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -936,6 +936,10 @@ export const dict = { "session.status.retrying": "กำลังลองใหม่ (ครั้งที่ {{ attempt }})… {{ message }}", "session.status.working": "กำลังทำงาน...", + "ui.sessionTurn.cancel": "ยกเลิก", + "ui.sessionTurn.status.thinking": "กำลังคิด...", + "ui.sessionTurn.status.consideringNextSteps": "กำลังพิจารณาขั้นตอนถัดไป...", + "dialog.model.noProviders": "ไม่มีผู้ให้บริการ", "prompt.placeholder.connecting": "กำลังเชื่อมต่อกับเซิร์ฟเวอร์...", @@ -1329,4 +1333,5 @@ export const dict = { "notifications.action.next": "ถัดไป", "notifications.action.close": "ปิด", "notifications.action.tryModel": "ลองใช้ {{model}}", + "notifications.action.tryModelGeneric": "ลองใช้โมเดล", } diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 7e02b9f0d39..0a0e15abd92 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -944,6 +944,10 @@ export const dict = { "session.status.retrying": "Yeniden deneniyor (deneme {{ attempt }})… {{ message }}", "session.status.working": "Çalışıyor...", + "ui.sessionTurn.cancel": "İptal", + "ui.sessionTurn.status.thinking": "Düşünüyor...", + "ui.sessionTurn.status.consideringNextSteps": "Sonraki adımları değerlendiriyor...", + "dialog.model.noProviders": "Sağlayıcı yok", "prompt.placeholder.connecting": "Sunucuya bağlanılıyor...", @@ -1343,6 +1347,7 @@ export const dict = { "notifications.action.next": "Sonraki", "notifications.action.close": "Kapat", "notifications.action.tryModel": "Dene {{model}}", + "notifications.action.tryModelGeneric": "Modeli Dene", // Missing translations - English fallbacks until translated "profile.switchingAccount": "Hesap değiştiriliyor…", "settings.agentBehaviour.createMode": "Yeni Mod Oluştur", @@ -1366,7 +1371,8 @@ export const dict = { "settings.agentBehaviour.editMode": "Modu Düzenle", "settings.agentBehaviour.editMode.back": "Listeye dön", "settings.agentBehaviour.editMode.description": "Açıklama", - "settings.agentBehaviour.editMode.native": "Yerleşik mod (salt okunur tanım)", + "settings.agentBehaviour.editMode.native": + "Bu yerleşik bir moddur. Temel tanımı değiştirilemez, ancak aşağıdan geçersiz kılmaları yapılandırabilirsiniz.", "settings.agentBehaviour.editMode.prompt": "Sistem İstemi", "settings.agentBehaviour.editMode.promptOverride": "Bu yerleşik mod için özel sistem istemi geçersiz kılma", "settings.agentBehaviour.editMode.save": "Tamam", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 613ab873cbc..0eb7b024046 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -945,6 +945,10 @@ export const dict = { "session.status.retrying": "Повторна спроба (спроба {{ attempt }})… {{ message }}", "session.status.working": "Працює...", + "ui.sessionTurn.cancel": "Скасувати", + "ui.sessionTurn.status.thinking": "Думаю...", + "ui.sessionTurn.status.consideringNextSteps": "Обдумую наступні кроки...", + "dialog.model.noProviders": "Немає провайдерів", "prompt.placeholder.connecting": "Підключення до сервера...", @@ -1344,6 +1348,7 @@ export const dict = { "notifications.action.next": "Далі", "notifications.action.close": "Закрити", "notifications.action.tryModel": "Спробувати {{model}}", + "notifications.action.tryModelGeneric": "Спробувати модель", // Missing translations - English fallbacks until translated "profile.switchingAccount": "Перемикання акаунту…", "settings.agentBehaviour.createMode": "Створити новий режим", @@ -1367,7 +1372,8 @@ export const dict = { "settings.agentBehaviour.editMode": "Редагувати режим", "settings.agentBehaviour.editMode.back": "Назад до списку", "settings.agentBehaviour.editMode.description": "Опис", - "settings.agentBehaviour.editMode.native": "Вбудований режим (визначення лише для читання)", + "settings.agentBehaviour.editMode.native": + "Це вбудований режим. Його базове визначення неможливо змінити, але ви можете налаштувати перевизначення нижче.", "settings.agentBehaviour.editMode.prompt": "Системний запит", "settings.agentBehaviour.editMode.promptOverride": "Власне перевизначення системного запиту для цього вбудованого режиму", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 531129eeaea..7cc9ef99319 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -928,6 +928,10 @@ export const dict = { "session.status.retrying": "正在重试(第 {{ attempt }} 次)… {{ message }}", "session.status.working": "处理中…", + "ui.sessionTurn.cancel": "取消", + "ui.sessionTurn.status.thinking": "思考中...", + "ui.sessionTurn.status.consideringNextSteps": "正在考虑下一步...", + "dialog.model.noProviders": "无供应商", "prompt.placeholder.connecting": "正在连接服务器...", @@ -1304,4 +1308,5 @@ export const dict = { "notifications.action.next": "下一个", "notifications.action.close": "关闭", "notifications.action.tryModel": "尝试 {{model}}", + "notifications.action.tryModelGeneric": "尝试模型", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 124ad90b4d1..71a73f5ee6a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -930,6 +930,10 @@ export const dict = { "session.status.retrying": "正在重試(第 {{ attempt }} 次)… {{ message }}", "session.status.working": "處理中…", + "ui.sessionTurn.cancel": "取消", + "ui.sessionTurn.status.thinking": "思考中...", + "ui.sessionTurn.status.consideringNextSteps": "正在考慮下一步...", + "dialog.model.noProviders": "沒有供應商", "prompt.placeholder.connecting": "正在連線至伺服器...", @@ -1307,4 +1311,5 @@ export const dict = { "notifications.action.next": "下一個", "notifications.action.close": "關閉", "notifications.action.tryModel": "嘗試 {{model}}", + "notifications.action.tryModelGeneric": "嘗試模型", } satisfies Partial> diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 0b4f0f348c8..b894c9a36bf 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -799,6 +799,75 @@ export interface WorktreeState { groupId?: string /** User-provided display name for the worktree. */ label?: string + /** Cached PR number for instant badge display on reload. */ + prNumber?: number + /** Cached PR URL for instant badge display on reload. */ + prUrl?: string + /** Cached PR state for correct badge color on reload (open/merged/closed/draft). */ + prState?: string + /** Section this worktree belongs to, or undefined for ungrouped. */ + sectionId?: string +} + +export interface SectionState { + id: string + name: string + /** Color label (e.g. "Red", "Blue") or null for default. */ + color: string | null + order: number + collapsed: boolean +} + +// --------------------------------------------------------------------------- +// PR status types (mirrored from extension types.ts) +// --------------------------------------------------------------------------- + +export type PRState = "open" | "draft" | "merged" | "closed" +export type ReviewDecision = "approved" | "changes_requested" | "pending" +export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled" +export type AggregateCheckStatus = "success" | "failure" | "pending" | "none" + +export interface PRCheck { + name: string + status: CheckStatus + url?: string + duration?: string +} + +export interface PRComment { + id: string + author: string + avatar?: string + body: string + file?: string + line?: number + url?: string + resolved: boolean + createdAt?: number +} + +export interface PRStatus { + number: number + title: string + url: string + state: PRState + review: ReviewDecision | null + checks: { + status: AggregateCheckStatus + total: number + passed: number + failed: number + pending: number + items: PRCheck[] + } + comments?: { + total: number + unresolved: number + items: PRComment[] + } + additions: number + deletions: number + files: number } export interface ManagedSessionState { @@ -827,6 +896,7 @@ export interface AgentManagerStateMessage { type: "agentManager.state" worktrees: WorktreeState[] sessions: ManagedSessionState[] + sections?: SectionState[] staleWorktreeIds?: string[] tabOrder?: Record worktreeOrder?: string[] @@ -983,6 +1053,14 @@ export interface AgentManagerLocalStatsMessage { stats: LocalGitStats } +// Agent Manager: PR status push (extension → webview) +export interface AgentManagerPRStatusMessage { + type: "agentManager.prStatus" + worktreeId: string + pr: PRStatus | null + error?: "gh_missing" | "gh_auth" | "fetch_failed" +} + // Sidebar: Live worktree diff stats (extension → webview) export interface WorktreeStatsLoadedMessage { type: "worktreeStatsLoaded" @@ -1360,6 +1438,7 @@ export type ExtensionMessage = | AgentManagerApplyWorktreeDiffResultMessage | AgentManagerWorktreeStatsMessage | AgentManagerLocalStatsMessage + | AgentManagerPRStatusMessage // legacy-migration start | MigrationStateMessage | LegacyMigrationDataMessage @@ -1959,6 +2038,17 @@ export interface StopDiffWatchMessage { type: "agentManager.stopDiffWatch" } +// Agent Manager: PR messages (webview → extension) +export interface RefreshPRMessage { + type: "agentManager.refreshPR" + worktreeId: string +} + +export interface OpenPRMessage { + type: "agentManager.openPR" + worktreeId: string +} + export interface ApplyWorktreeDiffMessage { type: "agentManager.applyWorktreeDiff" worktreeId: string @@ -2084,6 +2174,48 @@ export interface ContinueInWorktreeRequest { sessionId: string } +// Section CRUD messages (webview → extension) +export interface CreateSectionRequest { + type: "agentManager.createSection" + name: string + color?: string + worktreeIds?: string[] +} + +export interface RenameSectionRequest { + type: "agentManager.renameSection" + sectionId: string + name: string +} + +export interface DeleteSectionRequest { + type: "agentManager.deleteSection" + sectionId: string +} + +export interface SetSectionColorRequest { + type: "agentManager.setSectionColor" + sectionId: string + color: string | null +} + +export interface ToggleSectionCollapsedRequest { + type: "agentManager.toggleSectionCollapsed" + sectionId: string +} + +export interface MoveToSectionRequest { + type: "agentManager.moveToSection" + worktreeIds: string[] + sectionId: string | null +} + +export interface MoveSectionRequest { + type: "agentManager.moveSection" + sectionId: string + dir: -1 | 1 +} + export type ContinueInWorktreeStatus = | "capturing" | "creating" @@ -2198,6 +2330,8 @@ export type WebviewMessage = | RequestWorktreeDiffFileMessage | StartDiffWatchMessage | StopDiffWatchMessage + | RefreshPRMessage + | OpenPRMessage // legacy-migration start | RequestLegacyMigrationDataMessage | StartLegacyMigrationMessage @@ -2227,6 +2361,13 @@ export type WebviewMessage = | ToggleFavoriteRequest | RequestFavoritesMessage | ContinueInWorktreeRequest + | CreateSectionRequest + | RenameSectionRequest + | DeleteSectionRequest + | SetSectionColorRequest + | ToggleSectionCollapsedRequest + | MoveToSectionRequest + | MoveSectionRequest // ============================================ // VS Code API type diff --git a/packages/kilo-vscode/webview-ui/src/utils/search-match.ts b/packages/kilo-vscode/webview-ui/src/utils/search-match.ts new file mode 100644 index 00000000000..dfcc6ca0167 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/search-match.ts @@ -0,0 +1,59 @@ +/** + * Word-boundary matching for model/provider search. + * + * Splits text at camelCase transitions and common delimiters so that e.g. + * "clso" matches "Claude Sonnet" (Cl + So) and "gpt5" matches "gpt-5". + * Multi-word queries like "claude sonnet" require every fragment to match + * independently. + * + * Ported from legacy word-boundary-fzf.ts. + */ + +// Split at positions before uppercase letters (camelCase/PascalCase) +// and at common delimiters: hyphen, underscore, dot, colon, whitespace, +// forward/back slash, brackets, parentheses. +const WORD_BOUNDARY = /(?=[A-Z])|[[\]_.:\s/\\(){}-]+/ + +/** + * Match a single query fragment against text using word-boundary acronym + * matching. Each character in `query` must match the start of a word in + * `text`, consuming consecutive characters from the same word before moving + * to the next. + * + * Examples: + * - acronymMatch("Claude Sonnet", "clso") → true (Cl + So) + * - acronymMatch("gitRebase", "gr") → true (git + Rebase) + * - acronymMatch("faoboc", "foo") → false (no word boundary) + */ +export function acronymMatch(text: string, query: string): boolean { + const words = text + .split(WORD_BOUNDARY) + .filter((w) => w.length > 0) + .map((w) => w.toLowerCase()) + + const attempt = (wi: number, qi: number): boolean => { + if (qi === query.length) return true + if (wi >= words.length) return false + const word = words[wi]! + let consumed = 0 + while (qi + consumed < query.length && consumed < word.length && word[consumed] === query[qi + consumed]) consumed++ + if (consumed > 0 && attempt(wi + 1, qi + consumed)) return true + return attempt(wi + 1, qi) + } + + return attempt(0, 0) +} + +/** + * High-level search: trims and lowercases the query, splits multi-word + * queries at word boundaries, and requires every fragment to match. + * + * Returns `true` when `query` is empty/whitespace-only. + */ +export function searchMatch(query: string, text: string): boolean { + const q = query.toLowerCase().trim() + if (!q) return true + const parts = q.split(WORD_BOUNDARY).filter((w) => w.length > 0) + if (parts.length === 0) return true + return parts.every((p) => acronymMatch(text, p)) +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1449cda2dd2..ee31c7c871a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.1.22", + "version": "7.2.0", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/config.ts b/packages/opencode/src/cli/cmd/config.ts new file mode 100644 index 00000000000..746946bff81 --- /dev/null +++ b/packages/opencode/src/cli/cmd/config.ts @@ -0,0 +1,40 @@ +// kilocode_change - new file +import { EOL } from "os" +import { Config } from "../../config/config" +import { bootstrap } from "../bootstrap" +import { cmd } from "./cmd" +import { UI } from "../ui" + +export const ConfigCommand = cmd({ + command: "config", + describe: "configuration tools", + builder: (yargs) => + yargs + .command({ + command: "check", + describe: "check configuration for warnings and errors", + async handler() { + await bootstrap(process.cwd(), async () => { + const list = await Config.warnings() + if (list.length === 0) { + process.stdout.write("No config warnings." + EOL) + return + } + const S = UI.Style + for (const warning of list) { + process.stderr.write(S.TEXT_WARNING_BOLD + warning.path + S.TEXT_NORMAL + EOL) + process.stderr.write(" " + warning.message + EOL) + if (warning.detail) { + for (const line of warning.detail.split("\n")) { + process.stderr.write(" " + S.TEXT_DIM + line + S.TEXT_NORMAL + EOL) + } + } + process.stderr.write(EOL) + } + process.exitCode = 1 + }) + }, + }) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 68660bb9ed1..f8935c321bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -636,6 +636,7 @@ export function Prompt(props: PromptProps) { }) .catch(() => {}) } + toast.dismiss() // kilocode_change - dismiss persistent config warning on first submit history.append({ ...store.prompt, mode: currentMode, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e7049fcbe4e..aa4766bf959 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -29,6 +29,7 @@ import { batch, onMount } from "solid-js" import { Log } from "@/util/log" import type { Path } from "@kilocode/sdk" import type { Workspace } from "@kilocode/sdk/v2" +import { useToast } from "../ui/toast" // kilocode_change export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -106,6 +107,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) const sdk = useSDK() + const toast = useToast() // kilocode_change const fullSyncedSessions = new Set() // kilocode_change @@ -464,6 +466,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), syncWorkspaces(), + // kilocode_change start - show config warnings as persistent toast + sdk.client.config + .warnings() + .then((x) => { + const list = x.data ?? [] + if (list.length === 0) return + const first = list[0] + const suffix = list.length > 1 ? ` (and ${list.length - 1} more)` : "" + toast.show({ + title: "Config Warning", + message: first.message + suffix, + variant: "warning", + duration: 0, + }) + }) + .catch(() => {}), + // kilocode_change end ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 36095580fb0..cbe2eb8550c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -60,10 +60,21 @@ function init() { const { duration, ...currentToast } = parsedOptions setStore("currentToast", currentToast) if (timeoutHandle) clearTimeout(timeoutHandle) - timeoutHandle = setTimeout(() => { - setStore("currentToast", null) - }, duration).unref() + // kilocode_change start + timeoutHandle = null + if (duration && duration > 0) { + timeoutHandle = setTimeout(() => { + setStore("currentToast", null) + timeoutHandle = null + }, duration).unref() + } }, + dismiss() { + if (timeoutHandle) clearTimeout(timeoutHandle) + timeoutHandle = null + setStore("currentToast", null) + }, + // kilocode_change end error: (err: any) => { if (err instanceof Error) return toast.show({ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5b3e3b28de4..d4cba10d1a1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -52,6 +52,15 @@ export namespace Config { const log = Log.create({ service: "config" }) + // kilocode_change start + export const Warning = z.object({ + path: z.string(), + message: z.string(), + detail: z.string().optional(), + }) + export type Warning = z.infer + // kilocode_change end + // Managed settings directory for enterprise deployments (highest priority, admin-controlled) // These settings override all user and project settings function systemManagedConfigDir(): string { @@ -85,6 +94,18 @@ export namespace Config { // kilocode_change start — capture init so resetState() can invalidate the cache entry const stateInit = async () => { + // kilocode_change end + // kilocode_change start + const warnings: Warning[] = [] + const caught = (err: unknown, source: string) => { + const w = toWarning(err) + if (w) { + warnings.push(w) + log.warn("skipped config due to error", { source, err }) + return + } + throw err + } // kilocode_change end const auth = await Auth.all() @@ -200,24 +221,34 @@ export namespace Config { for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") - process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) - const response = await fetch(`${url}/.well-known/opencode`) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) + // kilocode_change start + const source = `${url}/.well-known/opencode` + try { + process.env[value.key] = value.token + log.debug("fetching remote config", { url: source }) + 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 any + const remoteConfig = wellknown.config ?? {} + // Add $schema to prevent load() from trying to write back to a non-existent file + if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" + result = mergeConfigConcatArrays( + result, + await load(JSON.stringify(remoteConfig), { + dir: path.dirname(source), + source, + }), + ) + log.debug("loaded remote config from well-known", { url }) + } catch (err) { + const w = toWarning(err) + if (w) warnings.push(w) + else warnings.push({ path: source, message: err instanceof Error ? err.message : String(err) }) + log.warn("skipped remote config due to error", { url, err }) + // kilocode_change end } - const wellknown = (await response.json()) as any - const remoteConfig = wellknown.config ?? {} - // Add $schema to prevent load() from trying to write back to a non-existent file - if (!remoteConfig.$schema) remoteConfig.$schema = "https://app.kilo.ai/config.json" // kilocode_change - result = mergeConfigConcatArrays( - result, - await load(JSON.stringify(remoteConfig), { - dir: path.dirname(`${url}/.well-known/opencode`), - source: `${url}/.well-known/opencode`, - }), - ) - log.debug("loaded remote config from well-known", { url }) } } @@ -226,21 +257,37 @@ export namespace Config { } // Global user config overrides remote config. - result = mergeConfigConcatArrays(result, await global()) + // kilocode_change start + try { + result = mergeConfigConcatArrays(result, await global()) + } catch (err) { + caught(err, "global config") + } + // kilocode_change end // Custom config path overrides global config. if (Flag.KILO_CONFIG) { - result = mergeConfigConcatArrays(result, await loadFile(Flag.KILO_CONFIG)) - log.debug("loaded custom config", { path: Flag.KILO_CONFIG }) + // kilocode_change start + try { + result = mergeConfigConcatArrays(result, await loadFile(Flag.KILO_CONFIG)) + log.debug("loaded custom config", { path: Flag.KILO_CONFIG }) + } catch (err) { + caught(err, Flag.KILO_CONFIG) + } + // kilocode_change end } // Project config overrides global and remote config. if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { // kilocode_change start for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { - // kilocode_change end - result = mergeConfigConcatArrays(result, await loadFile(file)) + try { + result = mergeConfigConcatArrays(result, await loadFile(file)) + } catch (err) { + caught(err, file) + } } + // kilocode_change end } result.agent = result.agent || {} @@ -265,14 +312,18 @@ export namespace Config { dir === Flag.KILO_CONFIG_DIR ) { for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { - // kilocode_change end log.debug(`loading config from ${path.join(dir, file)}`) - result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) + try { + result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) + } catch (err) { + caught(err, path.join(dir, file)) + } // to satisfy the type checker result.agent ??= {} result.mode ??= {} result.plugin ??= [] } + // kilocode_change end } deps.push( @@ -282,22 +333,34 @@ export namespace Config { }), ) - result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) - result.agent = mergeDeep(result.agent, await loadAgent(dir)) - result.agent = mergeDeep(result.agent, await loadMode(dir)) - result.plugin.push(...(await loadPlugin(dir))) + // kilocode_change start + try { + result.command = mergeDeep(result.command ?? {}, await loadCommand(dir, warnings)) + result.agent = mergeDeep(result.agent, await loadAgent(dir, warnings)) + result.agent = mergeDeep(result.agent, await loadMode(dir, warnings)) + result.plugin.push(...(await loadPlugin(dir))) + } catch (err: unknown) { + log.error("failed to load config directory", { dir, err }) + } + // kilocode_change end } // Inline config content overrides all non-managed config sources. if (process.env.KILO_CONFIG_CONTENT) { - result = mergeConfigConcatArrays( - result, - await load(process.env.KILO_CONFIG_CONTENT, { - dir: Instance.directory, - source: "KILO_CONFIG_CONTENT", - }), - ) - log.debug("loaded custom config from KILO_CONFIG_CONTENT") + // kilocode_change start + try { + result = mergeConfigConcatArrays( + result, + await load(process.env.KILO_CONFIG_CONTENT, { + dir: Instance.directory, + source: "KILO_CONFIG_CONTENT", + }), + ) + log.debug("loaded custom config from KILO_CONFIG_CONTENT") + } catch (err) { + caught(err, "KILO_CONFIG_CONTENT") + } + // kilocode_change end } // Load managed config files last (highest priority) - enterprise admin-controlled @@ -307,9 +370,9 @@ export namespace Config { if (existsSync(managedDir)) { // kilocode_change start for (const file of ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json"]) { - // kilocode_change end result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file))) } + // kilocode_change end } // Migrate deprecated mode field to agent field @@ -361,6 +424,7 @@ export namespace Config { config: result, directories, deps, + warnings, // kilocode_change } } // kilocode_change start — create state from named init so resetState() can invalidate it @@ -463,7 +527,64 @@ export namespace Config { return ext.length ? file.slice(0, -ext.length) : file } - async function loadCommand(dir: string) { + // kilocode_change start + function toWarning(err: unknown): Warning | undefined { + if (ConfigPaths.JsonError.isInstance(err)) + return { + path: err.data.path, + message: `Config file at ${err.data.path} is not valid JSON(C)`, + detail: err.data.message || undefined, + } + if (ConfigPaths.InvalidError.isInstance(err)) { + const text = err.data.issues ? detail(err.data.issues) : err.data.message + return { + path: err.data.path, + message: text + ? `Configuration is invalid at ${err.data.path}: ${text}` + : `Configuration is invalid at ${err.data.path}`, + } + } + return undefined + } + + function detail(issues: z.core.$ZodIssue[]) { + return issues + .map((issue) => { + const loc = issue.path.map(String).join(".") + if (!loc) return issue.message + return `${loc}: ${issue.message}` + }) + .join("\n") + } + + async function invalid( + kind: "agent" | "command", + item: string, + issues: z.core.$ZodIssue[], + cause: Error, + warnings?: Warning[], + ) { + const text = detail(issues) + const message = text ? `Config file at ${item} is invalid: ${text}` : `Config file at ${item} is invalid` + const err = new InvalidError({ path: item, issues }, { cause }) + if (warnings) warnings.push({ path: item, message, detail: text || undefined }) + try { + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } catch (e) { + log.warn("could not publish session error", { message, err: e }) + } + if (kind === "command") { + log.error("failed to load command", { command: item, err, message }) + return + } + log.error("failed to load agent", { agent: item, err, message }) + } + // kilocode_change end + + // kilocode_change start + async function loadCommand(dir: string, warnings?: Warning[]) { + // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{command,commands}/**/*.md", { cwd: dir, @@ -475,10 +596,17 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse command ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + // kilocode_change start + if (warnings) warnings.push({ path: item, message }) + try { + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } catch (e) { + log.warn("could not publish session error", { message, err: e }) + } log.error("failed to load command", { command: item, err }) return undefined + // kilocode_change end }) if (!md) continue @@ -505,12 +633,16 @@ export namespace Config { result[config.name] = parsed.data continue } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + // kilocode_change start + await invalid("command", item, parsed.error.issues, parsed.error, warnings) + // kilocode_change end } return result } - async function loadAgent(dir: string) { + // kilocode_change start + async function loadAgent(dir: string, warnings?: Warning[]) { + // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{agent,agents}/**/*.md", { @@ -523,10 +655,17 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse agent ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + // kilocode_change start + if (warnings) warnings.push({ path: item, message }) + try { + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } catch (e) { + log.warn("could not publish session error", { message, err: e }) + } log.error("failed to load agent", { agent: item, err }) return undefined + // kilocode_change end }) if (!md) continue @@ -555,12 +694,16 @@ export namespace Config { result[config.name] = parsed.data continue } - throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) + // kilocode_change start + await invalid("agent", item, parsed.error.issues, parsed.error, warnings) + // kilocode_change end } return result } - async function loadMode(dir: string) { + // kilocode_change start + async function loadMode(dir: string, warnings?: Warning[]) { + // kilocode_change end const result: Record = {} for (const item of await Glob.scan("{mode,modes}/*.md", { cwd: dir, @@ -572,10 +715,17 @@ export namespace Config { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse mode ${item}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + // kilocode_change start + if (warnings) warnings.push({ path: item, message }) + try { + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + } catch (e) { + log.warn("could not publish session error", { message, err: e }) + } log.error("failed to load mode", { mode: item, err }) return undefined + // kilocode_change end }) if (!md) continue @@ -592,6 +742,9 @@ export namespace Config { } continue } + // kilocode_change start + await invalid("agent", item, parsed.error.issues, parsed.error, warnings) + // kilocode_change end } return result } @@ -1497,6 +1650,12 @@ export namespace Config { return state().then((x) => x.config) } + // kilocode_change start + export async function warnings() { + return state().then((x) => x.warnings) + } + // kilocode_change end + export async function getGlobal() { return global() } @@ -1706,5 +1865,3 @@ export namespace Config { return state().then((x) => x.directories) } } -Filesystem.write -Filesystem.write diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 225983131c3..b9731040c7d 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -31,8 +31,6 @@ export namespace FileIgnore { "mypy_cache", ".history", ".gradle", - ".kilocode", // kilocode_change — ignore legacy local storage (#8379) - ".opencode", // kilocode_change — ignore legacy local storage (#8379) ]) const FILES = [ diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 688bc25b742..0b5bc16bc36 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -15,6 +15,7 @@ import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve" import { Filesystem } from "./util/filesystem" +import { ConfigCommand as ConfigCLICommand } from "./cli/cmd/config" // kilocode_change import { DebugCommand } from "./cli/cmd/debug" import { StatsCommand } from "./cli/cmd/stats" import { McpCommand } from "./cli/cmd/mcp" @@ -195,6 +196,7 @@ let cli = yargs(hideBin(process.argv)) .command(SessionCommand) .command(RemoteCommand) // kilocode_change .command(DbCommand) + .command(ConfigCLICommand) // kilocode_change if (Installation.isLocal()) { cli = cli.command(WorkspaceServeCommand) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 76249cf9d5f..d45abad6926 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -17,6 +17,7 @@ import simpleGit from "simple-git" import { RemoteWS } from "@/kilo-sessions/remote-ws" import { RemoteSender } from "@/kilo-sessions/remote-sender" import { SessionStatus } from "@/session/status" +import { Telemetry } from "@kilocode/kilo-telemetry" export namespace KiloSessions { const log = Log.create({ service: "kilo-sessions" }) @@ -293,6 +294,7 @@ export namespace KiloSessions { remote = { conn, sender, heartbeat } log.info("remote connection enabled") + Telemetry.trackRemoteConnectionOpened() })().finally(() => { if (remoteSeq === seq) enabling = undefined }) diff --git a/packages/opencode/src/kilocode/review/review.ts b/packages/opencode/src/kilocode/review/review.ts index 7d216082366..3e1aad0e144 100644 --- a/packages/opencode/src/kilocode/review/review.ts +++ b/packages/opencode/src/kilocode/review/review.ts @@ -396,12 +396,25 @@ export namespace Review { log.info("getting branch changes", { baseBranch: base }) - // git diff base...HEAD shows all changes on current branch since diverging - // Using triple-dot to get merge-base comparison - const result = await $`git -c core.quotepath=false diff ${base}...HEAD`.cwd(Instance.directory).quiet().nothrow() + // Compute merge-base explicitly, then diff working tree against it. + // This matches WorktreeDiff (the diff viewer) and includes uncommitted + // changes + untracked files — unlike `git diff base...HEAD` which only + // shows committed differences. + const ancestor = await $`git merge-base HEAD ${base}`.cwd(Instance.directory).quiet().nothrow() + if (ancestor.exitCode !== 0) { + log.warn("git merge-base failed", { + exitCode: ancestor.exitCode, + stderr: ancestor.stderr.toString(), + baseBranch: base, + }) + return { files: [], raw: "" } + } + const hash = ancestor.stdout.toString().trim() + + // Two-dot diff against working tree: includes staged, unstaged, and committed changes since merge-base + const result = await $`git -c core.quotepath=false diff ${hash}`.cwd(Instance.directory).quiet().nothrow() if (result.exitCode !== 0) { - // May fail if on base branch or no common ancestor log.warn("git diff failed", { exitCode: result.exitCode, stderr: result.stderr.toString(), @@ -413,6 +426,23 @@ export namespace Review { const raw = result.stdout.toString() const parsed = parseDiff(raw) + // Include untracked files (same as WorktreeDiff) so new files show up in the review + const untracked = await $`git ls-files --others --exclude-standard`.cwd(Instance.directory).quiet().nothrow() + if (untracked.exitCode === 0) { + const paths = untracked.stdout.toString().trim() + if (paths) { + const existing = new Set(parsed.files.map((f) => f.path)) + for (const file of paths.split("\n")) { + if (!file || existing.has(file)) continue + parsed.files.push({ + path: file, + status: "added", + hunks: [], + }) + } + } + } + log.info("parsed branch changes", { baseBranch: base, fileCount: parsed.files.length, diff --git a/packages/opencode/src/kilocode/skills/builtin.ts b/packages/opencode/src/kilocode/skills/builtin.ts index 9172a39cf95..96accd8f5ad 100644 --- a/packages/opencode/src/kilocode/skills/builtin.ts +++ b/packages/opencode/src/kilocode/skills/builtin.ts @@ -15,7 +15,7 @@ export const BUILTIN_SKILLS: BuiltinSkill[] = [ { name: "kilo-config", description: - "Guide for configuring Kilo CLI: commands, agents, MCP servers, skills, permissions, instructions, plugins, providers, all kilo.json fields, and TUI settings (themes, appearance, keybinds, ctrl+p commands). Use when the user asks about configuring, customizing, or changing settings in Kilo.", + "Guide for configuring Kilo CLI and locating config, command, agent, and skill paths (global, project, legacy), plus MCP servers, permissions, instructions, plugins, providers, kilo.json fields, and TUI settings (themes, appearance, keybinds, ctrl+p commands). Use when the user asks about configuring Kilo, where it loads things from, or how to change settings.", content: KILO_CONFIG, }, ] diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 70289d08dcd..1588f873f81 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -2,9 +2,11 @@ All config lives in `kilo.json` (or `kilo.jsonc`). Precedence low-to-high: remote well-known, global (`~/.config/kilo/kilo.json`), env `KILO_CONFIG`, project `./kilo.json`, `.kilo/kilo.json`, `KILO_CONFIG_CONTENT`, managed (see Config File Locations). Deep-merged; later wins. +This also covers where Kilo looks for config files, commands, agents, and skills across project, global, and legacy paths such as `.kilo/`, `.kilocode/`, `.opencode/`, and `~/.config/kilo/`. + ## Commands (`.kilo/command/*.md`) -Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. +Markdown files with YAML frontmatter. The filename (minus `.md`) becomes the command name invoked via `/name`. Commands can live in `.kilo/`, `.kilocode/`, `.opencode/`, and global config roots, with both `command/` and `commands/` directory names supported. See Config File Locations for the full search order. ```yaml --- @@ -20,8 +22,28 @@ Reference files with @file and shell output with !`cmd`. Template variables: `$1`-`$N` (positional args), `$ARGUMENTS` (full string), `@file` (file contents), `` !`cmd` `` (shell output). +### Finding a named command + +When asked where `/name` lives, do not search only the repo root. Search these roots explicitly, and use an explicit search `path` for each one: + +1. `~/.config/kilo/` +2. `~/.kilo/` +3. `~/.kilocode/` +4. `~/.opencode/` +5. The `KILO_CONFIG_DIR` directory (if the env var is set) +6. project `.kilo/`, `.kilocode/`, and `.opencode/` directories from the current working directory up to the worktree root + +Use exact patterns first: + +- `**/command/.md` +- `**/commands/.md` + +If found, return the full path. If not found in those roots, explain that the command is not present in the loaded config paths. + ## Agents (`.kilo/agent/*.md`) +Also loaded from `.kilocode/` and `.opencode/` directories (legacy), and plural `agents/` variants. + ```yaml --- description: When to use this agent @@ -41,6 +63,10 @@ System prompt for this agent. `mode` values: `primary` = selectable as main agent, `subagent` = only via Task tool, `all` = both. +## Workflows (legacy) + +Markdown files in `.kilo/workflows/` or `.kilocode/workflows/` (project-level) and `~/.kilo/workflows/` or `~/.kilocode/workflows/` (global). These are automatically converted to commands at startup. The filename (minus `.md`) becomes the command name. Project workflows override global ones with the same name. + ## Permissions Scalar form applies to all patterns. Object form maps glob patterns to actions. Evaluated top-to-bottom; first match wins. @@ -127,7 +153,7 @@ Additional skill directories and remote URLs: } ``` -Skills are markdown files at `skills//SKILL.md` with `name` and `description` in frontmatter. +Skills are markdown files at `skills//SKILL.md` (or `skill//SKILL.md`) with `name` and `description` in frontmatter. Discovered inside `.kilo/`, `.kilocode/`, and `.opencode/` directories. ## Other Top-Level Fields @@ -200,12 +226,49 @@ Toggle notifications, Toggle animations, Toggle diff wrapping, Toggle sidebar (` ## Config File Locations -| Scope | Path | -| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project | `./kilo.json`, `./.kilo/kilo.json` | -| Global | `~/.config/kilo/kilo.json` | -| Managed | Linux: `/etc/kilo/kilo.json`, macOS: `/Library/Application Support/kilo/kilo.json`, Windows: `%ProgramData%\kilo\kilo.json` (enterprise, highest priority) | -| Commands | `.kilo/command/*.md` (project), `~/.config/kilo/command/*.md` (global) | -| Agents | `.kilo/agent/*.md` (project), `~/.config/kilo/agent/*.md` (global) | -| Skills | `.kilo/skill/*/SKILL.md`, `.kilo/skills/*/SKILL.md` | -| Instructions | `AGENTS.md`, `.kilo/instructions.md`, glob patterns from `instructions` | +### Config files (kilo.json) + +| Scope | Path | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project | `./kilo.json`, `./kilo.jsonc`, `./opencode.json` (legacy), `./opencode.jsonc` (legacy) | +| Global | `~/.config/kilo/kilo.json`, `~/.config/kilo/kilo.jsonc`, `~/.config/kilo/opencode.json` (legacy), `~/.config/kilo/opencode.jsonc` (legacy), `~/.config/kilo/config.json` (legacy) | +| Managed | Linux: `/etc/kilo/`, macOS: `/Library/Application Support/kilo/`, Windows: `%ProgramData%\kilo\` — loads `kilo.json`, `kilo.jsonc`, `opencode.json`, `opencode.jsonc` (enterprise, highest priority) | + +Each config directory (`.kilo/`, `.kilocode/`, `.opencode/`) can also contain `kilo.json`, `kilo.jsonc`, `opencode.json`, or `opencode.jsonc`. + +### Config directories + +Three directory names are scanned: `.kilo` (modern), `.kilocode` (legacy), `.opencode` (legacy). All three are checked at each level: + +- **Project**: walks up from CWD to the git worktree root, checking for all three at each directory level +- **Home**: `~/.kilo/`, `~/.kilocode/`, `~/.opencode/` +- **XDG global**: `~/.config/kilo/` (always loaded, lowest file-based precedence) + +### Commands, agents, modes, plugins + +Glob patterns run inside every discovered config directory (including legacy): + +| Type | Pattern | +| ------- | ---------------------------- | +| Command | `{command,commands}/**/*.md` | +| Agent | `{agent,agents}/**/*.md` | +| Mode | `{mode,modes}/*.md` | +| Plugin | `{plugin,plugins}/*.{ts,js}` | + +Example: `~/.config/kilo/command/*.md` (modern global), `~/.kilocode/command/*.md` (legacy global), `.opencode/commands/*.md` (legacy project) all load commands. + +### Skills and instructions + +| Scope | Path | +| ------------ | -------------------------------------------------------------------------------------- | +| Skills | `{skill,skills}//SKILL.md` inside any config directory | +| Instructions | `AGENTS.md`, `CLAUDE.md`, `CONTEXT.md`, glob patterns from `instructions` config field | + +### Environment variable overrides + +| Variable | Description | +| ----------------------------- | ---------------------------------------------------------------- | +| `KILO_CONFIG` | Path to an additional config file (loaded after global) | +| `KILO_CONFIG_DIR` | Path to an additional config directory (appended to search list) | +| `KILO_CONFIG_CONTENT` | Inline JSON config string (high precedence, after project dirs) | +| `KILO_DISABLE_PROJECT_CONFIG` | Skip all project-level config (files and directories) | diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts index f9ac7e42976..faa46c40972 100644 --- a/packages/opencode/src/server/routes/config.ts +++ b/packages/opencode/src/server/routes/config.ts @@ -62,6 +62,29 @@ export const ConfigRoutes = lazy(() => return c.json(config) }, ) + // kilocode_change start + .get( + "/warnings", + describeRoute({ + summary: "Get config warnings", + description: "Get warnings generated during config loading (e.g., invalid JSON, schema errors).", + operationId: "config.warnings", + responses: { + 200: { + description: "Config warnings", + content: { + "application/json": { + schema: resolver(Config.Warning.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Config.warnings()) + }, + ) + // kilocode_change end .get( "/providers", describeRoute({ diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 7764b874a0c..0a38ddb70b9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,12 +10,14 @@ import { Config } from "../config/config" import { Instance } from "../project/instance" import { Scheduler } from "../scheduler" import * as KiloSnapshot from "../kilocode/snapshot" // kilocode_change +import { Lock } from "../util/lock" // kilocode_change export namespace Snapshot { const log = Log.create({ service: "snapshot" }) const hour = 60 * 60 * 1000 const prune = "7.days" export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change + const MAX_SNAPSHOT_FILE_SIZE = 2 * 1024 * 1024 // kilocode_change — skip files >2MB during snapshot add export function init() { Scheduler.register({ @@ -36,6 +38,7 @@ export namespace Snapshot { .then(() => true) .catch(() => false) if (!exists) return + using _lock = await Lock.write(git) // kilocode_change const result = await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` .quiet() @@ -57,6 +60,7 @@ export namespace Snapshot { const cfg = await Config.get() if (cfg.snapshot === false) return const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree` .quiet() @@ -75,6 +79,7 @@ export namespace Snapshot { export async function patch(hash: string): Promise { const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) const result = await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` @@ -103,6 +108,7 @@ export namespace Snapshot { export async function restore(snapshot: string) { log.info("restore", { commit: snapshot }) const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change const result = await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` .quiet() @@ -119,41 +125,136 @@ export namespace Snapshot { } } - export async function revert(patches: Patch[]) { - const files = new Set() - for (const item of patches) { - const git = await KiloSnapshot.prepare() // kilocode_change - for (const file of item.files) { - if (files.has(file)) continue - log.info("reverting", { file, hash: item.hash }) - const result = - await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` - .quiet() - .cwd(Instance.worktree) - .nothrow() - if (result.exitCode !== 0) { - const relativePath = path.relative(Instance.worktree, file) - const checkTree = - await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` - .quiet() - .cwd(Instance.worktree) - .nothrow() - if (checkTree.exitCode === 0 && checkTree.text().trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { - file, - }) - } else { - log.info("file did not exist in snapshot, deleting", { file }) - await fs.unlink(file).catch(() => {}) - } - } - files.add(file) + // kilocode_change start — batched revert: group up to 100 files per git checkout (port of upstream #20564) + type RevertOp = { hash: string; file: string; rel: string } + + /** Revert a single file: checkout from snapshot or delete if it didn't exist. */ + async function revertSingle(git: string, worktree: string, op: RevertOp) { + log.info("reverting", { file: op.file, hash: op.hash }) + const result = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} checkout ${op.hash} -- ${op.file}` + .quiet() + .cwd(worktree) + .nothrow() + if (result.exitCode === 0) return + const tree = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} ls-tree ${op.hash} -- ${op.rel}` + .quiet() + .cwd(worktree) + .nothrow() + if (tree.exitCode === 0 && tree.text().trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) + return + } + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + await fs.unlink(op.file).catch(() => {}) + } + + /** Revert a batch of files sharing the same hash. Falls back to single-file on failure. */ + async function revertBatch(git: string, worktree: string, batch: RevertOp[]) { + const hash = batch[0]!.hash + + // Check which files exist in the snapshot + const tree = + await $`git -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} ls-tree --name-only ${hash} -- ${batch.map((op) => op.rel)}` + .quiet() + .cwd(worktree) + .nothrow() + + if (tree.exitCode !== 0) { + log.info("batched ls-tree failed, falling back to single-file revert", { hash, files: batch.length }) + for (const op of batch) await revertSingle(git, worktree, op) + return + } + + const existing = new Set(tree.text().trim().split("\n").map((l) => l.trim()).filter(Boolean)) + + // Checkout files that exist in the snapshot + const toCheckout = batch.filter((op) => existing.has(op.rel)) + if (toCheckout.length) { + log.info("reverting", { hash, files: toCheckout.length }) + const result = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} checkout ${hash} -- ${toCheckout.map((op) => op.file)}` + .quiet() + .cwd(worktree) + .nothrow() + if (result.exitCode !== 0) { + log.info("batched checkout failed, falling back to single-file revert", { hash, files: toCheckout.length }) + for (const op of batch) await revertSingle(git, worktree, op) + return } } + + // Delete files that didn't exist in the snapshot + for (const op of batch) { + if (existing.has(op.rel)) continue + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + await fs.unlink(op.file).catch(() => {}) + } } + /** True when one path is a parent of the other (e.g. "a/b" and "a/b/c"). */ + function pathsClash(a: string, b: string) { + return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) + } + + /** Can this op be added to the current batch? */ + function canBatch(batch: RevertOp[], op: RevertOp): boolean { + if (batch.length >= 100) return false + if (op.hash !== batch[0]!.hash) return false + if (batch.some((existing) => pathsClash(existing.rel, op.rel))) return false + return true + } + + /** + * Group consecutive ops into batches that share the same hash, + * have no path conflicts, and contain at most 100 files each. + */ + function groupIntoBatches(ops: RevertOp[]): RevertOp[][] { + const batches: RevertOp[][] = [] + let batch: RevertOp[] = [] + + for (const op of ops) { + if (batch.length > 0 && !canBatch(batch, op)) { + batches.push(batch) + batch = [] + } + batch.push(op) + } + if (batch.length > 0) batches.push(batch) + + return batches + } + + export async function revert(patches: Patch[]) { + const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change + const worktree = Instance.worktree + + // Deduplicate files preserving patch order + const ops: RevertOp[] = [] + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + ops.push({ hash: item.hash, file, rel: path.relative(worktree, file).replaceAll("\\", "/") }) + } + } + + for (const batch of groupIntoBatches(ops)) { + if (batch.length === 1) { + await revertSingle(git, worktree, batch[0]!) + } else { + await revertBatch(git, worktree, batch) + } + } + } + // kilocode_change end + export async function diff(hash: string) { const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) const result = await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` @@ -208,10 +309,11 @@ export namespace Snapshot { diffCache.set(key, pending) return pending } + // kilocode_change end async function diffFullUncached(from: string, to: string): Promise { const git = await KiloSnapshot.prepare() // kilocode_change - // kilocode_change end + using _lock = await Lock.write(git) // kilocode_change const result: FileDiff[] = [] const status = new Map() @@ -277,26 +379,72 @@ export namespace Snapshot { return KiloSnapshot.gitdir() // kilocode_change } + // kilocode_change start — incremental add: diff-files + ls-files + size filter (port of upstream #17878) async function add(git: string) { - await syncExclude(git) - await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .` + const cwd = Instance.directory + const worktree = Instance.worktree + + // Run diff-files and ls-files concurrently to find changed + untracked files + const [diffResult, otherResult] = await Promise.all([ + $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} diff-files --name-only -z -- .` + .quiet() + .cwd(cwd) + .nothrow(), + $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} ls-files --others --exclude-standard -z -- .` + .quiet() + .cwd(cwd) + .nothrow(), + ]) + + if (diffResult.exitCode !== 0 || otherResult.exitCode !== 0) { + log.warn("failed to list snapshot files", { + diffCode: diffResult.exitCode, + diffStderr: diffResult.stderr.toString(), + otherCode: otherResult.exitCode, + otherStderr: otherResult.stderr.toString(), + }) + return + } + + const tracked = diffResult.text().split("\0").filter(Boolean) + const all = Array.from(new Set([...tracked, ...otherResult.text().split("\0").filter(Boolean)])) + if (!all.length) { + await syncExclude(git) + return + } + + // Filter out oversized files (>2MB) + const large = ( + await Promise.all( + all.map(async (item) => { + const stat = await fs.stat(path.join(cwd, item)).catch(() => null) + return stat?.isFile() && stat.size > MAX_SNAPSHOT_FILE_SIZE ? item : undefined + }), + ) + ).filter(Boolean) as string[] + + await syncExclude(git, large) + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} add --sparse .` .quiet() - .cwd(Instance.directory) + .cwd(cwd) .nothrow() } - async function syncExclude(git: string) { + async function syncExclude(git: string, largeFiles: string[] = []) { const file = await excludes() const target = path.join(git, "info", "exclude") await fs.mkdir(path.join(git, "info"), { recursive: true }) - if (!file) { - await Filesystem.write(target, "") - return + const parts: string[] = [] + if (file) { + const text = await Filesystem.readText(file).catch(() => "") + if (text.trim()) parts.push(text.trimEnd()) } - const text = await Filesystem.readText(file).catch(() => "") - - await Filesystem.write(target, text) + for (const item of largeFiles) { + parts.push(`/${item.replaceAll("\\", "/")}`) + } + await Filesystem.write(target, parts.length ? parts.join("\n") + "\n" : "") } + // kilocode_change end async function excludes() { const file = await $`git rev-parse --path-format=absolute --git-path info/exclude` diff --git a/packages/opencode/test/kilocode/config-resilience.test.ts b/packages/opencode/test/kilocode/config-resilience.test.ts new file mode 100644 index 00000000000..5cb72a91385 --- /dev/null +++ b/packages/opencode/test/kilocode/config-resilience.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await Instance.disposeAll() + Config.global.reset() +}) + +describe("config resilience", () => { + test("skips invalid agent markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "agent", "skip.md"), + `--- +mode: "banana" +--- +Broken agent prompt`, + ) + await Filesystem.write( + path.join(dir, ".kilo", "agent", "keep.md"), + `--- +model: test/model +--- +Valid agent prompt`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + + expect(cfg.agent?.["skip"]).toBeUndefined() + expect(cfg.agent?.["keep"]).toMatchObject({ + name: "keep", + model: "test/model", + prompt: "Valid agent prompt", + }) + }, + }) + }) + + test("reports a warning for invalid agent markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "agent", "skip.md"), + `--- +mode: "banana" +--- +Broken agent prompt`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + const warns = await Config.warnings() + + expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("mode"))).toBe(true) + }, + }) + }) + + test("skips invalid command markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "command", "skip.md"), + `--- +subtask: "banana" +--- +Broken command template`, + ) + await Filesystem.write( + path.join(dir, ".kilo", "command", "keep.md"), + `--- +description: Valid command +--- +Valid command template`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + + expect(cfg.command?.["skip"]).toBeUndefined() + expect(cfg.command?.["keep"]).toEqual({ + description: "Valid command", + template: "Valid command template", + }) + }, + }) + }) + + test("reports a warning for invalid command markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "command", "skip.md"), + `--- +subtask: "banana" +--- +Broken command template`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + const warns = await Config.warnings() + + expect(warns.some((w) => w.path.includes("skip.md") && w.message.includes("subtask"))).toBe(true) + }, + }) + }) + + test("collects warnings for invalid agent markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "agent", "broken.md"), + `--- +mode: "banana" +--- +Broken agent`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + const warns = await Config.warnings() + + expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true) + }, + }) + }) + + test("collects warnings for invalid command markdown configs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, ".kilo", "command", "broken.md"), + `--- +subtask: "banana" +--- +Broken command`, + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + const warns = await Config.warnings() + + expect(warns.some((w) => w.path.includes("broken.md") && w.message.includes("invalid"))).toBe(true) + }, + }) + }) + + test("collects warnings for invalid JSON in .kilo directory config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, ".kilo", "kilo.json"), "{ not valid json !!!") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + const warns = await Config.warnings() + + // Config loading should not crash + expect(cfg).toBeDefined() + // Warning should reference the bad file + expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("not valid JSON"))).toBe(true) + }, + }) + }) + + test("collects warnings for invalid schema in .kilo directory config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, ".kilo", "kilo.json"), JSON.stringify({ unknownField: true })) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const cfg = await Config.get() + const warns = await Config.warnings() + + expect(cfg).toBeDefined() + expect(warns.some((w) => w.path.includes("kilo.json") && w.message.includes("invalid"))).toBe(true) + }, + }) + }) + + test("returns empty warnings when config is valid", async () => { + await using tmp = await tmpdir({ + config: { model: "test/model" }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Config.get() + const warns = await Config.warnings() + + expect(warns).toEqual([]) + }, + }) + }) +}) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 1804ab5c2a2..e70ce1d2eed 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1178,3 +1178,414 @@ test("diffFull with whitespace changes", async () => { }, }) }) + +// ── Tests for snapshot optimizations (upstream #17878, #20564) ──────── + +test("concurrent track() calls return consistent results", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Snapshot.track() + + await Filesystem.write(`${tmp.path}/a.txt`, "concurrent-change") + + const results = await Promise.all([ + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + ]) + + const hashes = results.filter(Boolean) + expect(hashes.length).toBe(5) + // All concurrent calls must return the same hash + expect(new Set(hashes).size).toBe(1) + }, + }) +}) + +test("batch revert with many files sharing same hash", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create enough files to trigger batching (>1 per hash) + for (let i = 0; i < 20; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify all 20 files + for (let i = 0; i < 20; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + // Add 5 new files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/new${i}.txt`, `new-${i}`) + } + + const patch = await Snapshot.patch(before!) + expect(patch.files.length).toBe(25) + + await Snapshot.revert([patch]) + + // Modified files should be restored + for (let i = 0; i < 20; i++) { + const content = await Filesystem.readText(`${tmp.path}/file${i}.txt`) + expect(content).toBe(`original-${i}`) + } + // New files should be deleted + for (let i = 0; i < 5; i++) { + expect( + await fs + .access(`${tmp.path}/new${i}.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) + } + }, + }) +}) + +test("batch revert with files in nested subdirectories", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(`${dir}/a/b/c`, { recursive: true }) + await fs.mkdir(`${dir}/d/e`, { recursive: true }) + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/a/b/c/file${i}.txt`, `deep-${i}`) + await Filesystem.write(`${dir}/d/e/file${i}.txt`, `other-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/a/b/c/file${i}.txt`, `modified-${i}`) + await Filesystem.write(`${tmp.path}/d/e/file${i}.txt`, `modified-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/a/b/c/file${i}.txt`)).toBe(`deep-${i}`) + expect(await Filesystem.readText(`${tmp.path}/d/e/file${i}.txt`)).toBe(`other-${i}`) + } + }, + }) +}) + +test("incremental add skips new files larger than 2MB", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Create a file larger than 2MB + const largeContent = "x".repeat(3 * 1024 * 1024) + await Filesystem.write(`${tmp.path}/large.bin`, largeContent) + // Also create a small file + await Filesystem.write(`${tmp.path}/small.txt`, "small change") + + const after = await Snapshot.track() + expect(after).toBeTruthy() + + const patch = await Snapshot.patch(before!) + // Small file should be tracked, large file should be excluded + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("small.txt") + expect(files).not.toContain("large.bin") + }, + }) +}) + +test("incremental add: tracked files that grow past 2MB are still visible in patch", async () => { + // The 2MB size filter only prevents NEW large files from being added to the + // snapshot index. Already-tracked files that grow past 2MB still appear in + // patch/diff because those compare against the working tree. This matches + // upstream OpenCode behavior. + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Filesystem.write(`${tmp.path}/growing.txt`, "small") + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Filesystem.write(`${tmp.path}/growing.txt`, "x".repeat(3 * 1024 * 1024)) + await Filesystem.write(`${tmp.path}/a.txt`, "changed") + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("a.txt") + expect(files).toContain("growing.txt") + }, + }) +}) + +test("concurrent patch() calls return consistent results", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Filesystem.write(`${tmp.path}/a.txt`, "changed") + + const results = await Promise.all([ + Snapshot.patch(before!), + Snapshot.patch(before!), + Snapshot.patch(before!), + ]) + + // All should report the same changed files + for (const result of results) { + expect(result.files).toContain(fwd(tmp.path, "a.txt")) + } + }, + }) +}) + +test("batch revert with mix of modified, new, and deleted files", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 15; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify some files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `modified-${i}`) + } + // Delete some files + for (let i = 5; i < 10; i++) { + await fs.unlink(`${tmp.path}/file${i}.txt`) + } + // Add new files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/added${i}.txt`, `added-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + // Modified files restored + for (let i = 0; i < 5; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // Deleted files restored + for (let i = 5; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // Untouched files unchanged + for (let i = 10; i < 15; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // New files removed + for (let i = 0; i < 5; i++) { + expect( + await fs + .access(`${tmp.path}/added${i}.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) + } + }, + }) +}) + +test("batch revert with multiple patches from different snapshots", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `v0-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Snapshot A + const snapA = await Snapshot.track() + expect(snapA).toBeTruthy() + + // Modify first 5 files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `v1-${i}`) + } + + // Snapshot B (captures v1 state) + const snapB = await Snapshot.track() + expect(snapB).toBeTruthy() + + // Modify last 5 files + for (let i = 5; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `v2-${i}`) + } + + // Revert with patches from two different hashes + const patchA = await Snapshot.patch(snapA!) + const patchB = await Snapshot.patch(snapB!) + // patchA covers files 0-4 (changed in v1) + files 5-9 (changed in v2) + // patchB covers files 5-9 (changed in v2) + // Reverting [patchA, patchB]: patchA's hash wins for all files (first seen) + await Snapshot.revert([patchA, patchB]) + + // All files should be at v0 (snapA's state) + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`v0-${i}`) + } + }, + }) +}) + +test("concurrent track calls each produce a valid snapshot", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Snapshot.track() // warm up + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + + // Fire 5 concurrent tracks, then verify each hash is a usable snapshot + const hashes = (await Promise.all([ + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + ])).filter(Boolean) as string[] + + expect(hashes.length).toBe(5) + + // Every hash should produce a valid diffFull against itself (empty diff) + for (const hash of hashes) { + const diff = await Snapshot.diffFull(hash, hash) + expect(diff).toEqual([]) + } + + // Every hash should be usable for restore without error + await Snapshot.restore(hashes[0]!) + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`changed-${i}`) + } + }, + }) +}) + +test("track after revert produces clean snapshot", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + // After revert, a new track should match the original snapshot + const after = await Snapshot.track() + expect(after).toBeTruthy() + expect(after).toBe(before) + }, + }) +}) + +test("incremental add tracks newly created files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Create several new files in subdirectories + await fs.mkdir(`${tmp.path}/newdir/sub`, { recursive: true }) + await Filesystem.write(`${tmp.path}/newdir/one.txt`, "one") + await Filesystem.write(`${tmp.path}/newdir/sub/two.txt`, "two") + await Filesystem.write(`${tmp.path}/three.txt`, "three") + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("one.txt") + expect(files).toContain("two.txt") + expect(files).toContain("three.txt") + }, + }) +}) + +test("incremental add tracks modified and deleted files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify one file, delete the other + await Filesystem.write(`${tmp.path}/a.txt`, "modified-a") + await fs.unlink(`${tmp.path}/b.txt`) + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("a.txt") + expect(files).toContain("b.txt") + }, + }) +}) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 70649dd9016..0fa1f13d4be 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -109,4 +109,36 @@ Use this skill. process.env.KILO_TEST_HOME = home } }) + + test("built-in kilo-config includes named command lookup guidance", async () => { + await using tmp = await tmpdir({ git: true }) + + const home = process.env.KILO_TEST_HOME + process.env.KILO_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await SkillTool.init() + const ctx: Tool.Context = { + ...baseCtx, + ask: async () => {}, + } + + const result = await tool.execute({ name: "kilo-config" }, ctx) + + expect(tool.description).toContain("where it loads things from") + expect(result.metadata.dir).toBe("builtin") + expect(result.output).toContain("### Finding a named command") + expect(result.output).toContain("`~/.config/kilo/`") + expect(result.output).toContain("`~/.kilocode/`") + expect(result.output).toContain("`**/command/.md`") + expect(result.output).toContain("explicit search `path`") + }, + }) + } finally { + process.env.KILO_TEST_HOME = home + } + }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 75d2c26d6b4..63bcd4a3bd1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index ec4fd0a3e4b..1a6b1ee9aac 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -8,7 +8,7 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.1.22", + "version": "7.2.0", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index be174960413..1c2f28473c6 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0217a99a5fd..fa72a0bb006 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -21,6 +21,7 @@ import type { ConfigProvidersResponses, ConfigUpdateErrors, ConfigUpdateResponses, + ConfigWarningsResponses, EnhancePromptEnhanceErrors, EnhancePromptEnhanceResponses, EventSubscribeResponses, @@ -843,6 +844,36 @@ export class Config2 extends HeyApiClient { }) } + /** + * Get config warnings + * + * Get warnings generated during config loading (e.g., invalid JSON, schema errors). + */ + public warnings( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config/warnings", + ...options, + ...params, + }) + } + /** * List config providers * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4b719f8b994..44dfca17bb5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2462,6 +2462,29 @@ export type ConfigUpdateResponses = { export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type ConfigWarningsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/config/warnings" +} + +export type ConfigWarningsResponses = { + /** + * Config warnings + */ + 200: Array<{ + path: string + message: string + detail?: string + }> +} + +export type ConfigWarningsResponse = ConfigWarningsResponses[keyof ConfigWarningsResponses] + export type ConfigProvidersData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 905852293d2..65994bb0b9e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -27,10 +27,7 @@ "type": "string" } }, - "required": [ - "healthy", - "version" - ] + "required": ["healthy", "version"] } } } @@ -745,10 +742,7 @@ "type": "number" } }, - "required": [ - "rows", - "cols" - ] + "required": ["rows", "cols"] } } } @@ -978,6 +972,62 @@ ] } }, + "/config/warnings": { + "get": { + "operationId": "config.warnings", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Get config warnings", + "description": "Get warnings generated during config loading (e.g., invalid JSON, schema errors).", + "responses": { + "200": { + "description": "Config warnings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "message": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": ["path", "message"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.config.warnings({\n ...\n})" + } + ] + } + }, "/config/providers": { "get": { "operationId": "config.providers", @@ -1023,10 +1073,7 @@ } } }, - "required": [ - "providers", - "default" - ] + "required": ["providers", "default"] } } } @@ -1233,11 +1280,7 @@ ] } }, - "required": [ - "type", - "branch", - "extra" - ] + "required": ["type", "branch", "extra"] } } } @@ -2148,9 +2191,7 @@ ], "summary": "Get session", "description": "Retrieve detailed information about a specific Kilo session.", - "tags": [ - "Session" - ], + "tags": ["Session"], "responses": { "200": { "description": "Get session", @@ -2377,9 +2418,7 @@ } ], "summary": "Get session children", - "tags": [ - "Session" - ], + "tags": ["Session"], "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { @@ -2576,11 +2615,7 @@ "pattern": "^msg.*" } }, - "required": [ - "modelID", - "providerID", - "messageID" - ] + "required": ["modelID", "providerID", "messageID"] } } } @@ -3004,10 +3039,7 @@ "type": "boolean" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] } } } @@ -3077,10 +3109,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3161,10 +3190,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3210,10 +3236,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "agent": { "type": "string" @@ -3283,9 +3306,7 @@ } } }, - "required": [ - "parts" - ] + "required": ["parts"] } } } @@ -3355,10 +3376,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3725,10 +3743,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "agent": { "type": "string" @@ -3798,9 +3813,7 @@ } } }, - "required": [ - "parts" - ] + "required": ["parts"] } } } @@ -3861,10 +3874,7 @@ } } }, - "required": [ - "info", - "parts" - ] + "required": ["info", "parts"] } } } @@ -3942,20 +3952,13 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "type", - "mime", - "url" - ] + "required": ["type", "mime", "url"] } ] } } }, - "required": [ - "arguments", - "command" - ] + "required": ["arguments", "command"] } } } @@ -4049,19 +4052,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "agent", - "command" - ] + "required": ["agent", "command"] } } } @@ -4150,9 +4147,7 @@ "pattern": "^prt.*" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] } } } @@ -4312,16 +4307,10 @@ "properties": { "response": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] } }, - "required": [ - "response" - ] + "required": ["response"] } } } @@ -4458,19 +4447,13 @@ "properties": { "reply": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] }, "message": { "type": "string" } }, - "required": [ - "reply" - ] + "required": ["reply"] } } } @@ -4738,9 +4721,7 @@ } } }, - "required": [ - "answers" - ] + "required": ["answers"] } } } @@ -4917,15 +4898,10 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ], + "required": ["field"], "additionalProperties": false } ] @@ -4961,16 +4937,10 @@ "type": "number" } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "limit": { "type": "object", @@ -4985,10 +4955,7 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "modalities": { "type": "object", @@ -4997,70 +4964,39 @@ "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } }, "output": { "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated" - ] + "enum": ["alpha", "beta", "deprecated"] }, "options": { "type": "object", @@ -5117,12 +5053,7 @@ } } }, - "required": [ - "name", - "env", - "id", - "models" - ] + "required": ["name", "env", "id", "models"] } }, "default": { @@ -5141,11 +5072,7 @@ } } }, - "required": [ - "all", - "default", - "connected" - ] + "required": ["all", "default", "connected"] } } } @@ -5272,9 +5199,7 @@ "type": "number" } }, - "required": [ - "method" - ] + "required": ["method"] } } } @@ -5354,9 +5279,7 @@ "type": "string" } }, - "required": [ - "method" - ] + "required": ["method"] } } } @@ -5431,9 +5354,7 @@ "additionalProperties": {} } }, - "required": [ - "event" - ] + "required": ["event"] } } } @@ -5482,10 +5403,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -5535,10 +5453,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -5588,10 +5503,7 @@ "type": "boolean" } }, - "required": [ - "enabled", - "connected" - ] + "required": ["enabled", "connected"] } } } @@ -5638,9 +5550,7 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } } } @@ -5678,9 +5588,7 @@ "type": "string" } }, - "required": [ - "path" - ] + "required": ["path"] } } } @@ -5726,9 +5634,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -5756,9 +5662,7 @@ "minLength": 1 } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -5810,10 +5714,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -5877,13 +5778,7 @@ } } }, - "required": [ - "id", - "worktree", - "timeCreated", - "timeUpdated", - "sandboxes" - ] + "required": ["id", "worktree", "timeCreated", "timeUpdated", "sandboxes"] } } } @@ -5935,10 +5830,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -5966,6 +5858,9 @@ "projectID": { "type": "string" }, + "force": { + "type": "boolean" + }, "workspaceID": { "type": "string" }, @@ -6010,11 +5905,7 @@ } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "revert": { "type": "object", @@ -6032,9 +5923,7 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] }, "permission": { "type": "object", @@ -6056,16 +5945,7 @@ "type": "number" } }, - "required": [ - "id", - "projectID", - "slug", - "directory", - "title", - "version", - "timeCreated", - "timeUpdated" - ] + "required": ["id", "projectID", "slug", "directory", "title", "version", "timeCreated", "timeUpdated"] } } } @@ -6117,10 +5997,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -6167,9 +6044,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "agent": { "type": "string" @@ -6184,10 +6059,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "tools": { "type": "object", @@ -6199,12 +6071,7 @@ } } }, - "required": [ - "role", - "time", - "agent", - "model" - ] + "required": ["role", "time", "agent", "model"] }, { "type": "object", @@ -6223,9 +6090,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "parentID": { "type": "string" @@ -6252,10 +6117,7 @@ "type": "string" } }, - "required": [ - "cwd", - "root" - ] + "required": ["cwd", "root"] }, "summary": { "type": "boolean" @@ -6288,18 +6150,10 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] }, "structured": {}, "variant": { @@ -6325,12 +6179,7 @@ ] } }, - "required": [ - "id", - "sessionID", - "timeCreated", - "data" - ] + "required": ["id", "sessionID", "timeCreated", "data"] } } } @@ -6382,10 +6231,7 @@ "type": "boolean" } }, - "required": [ - "ok", - "id" - ] + "required": ["ok", "id"] } } } @@ -6447,9 +6293,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -6459,10 +6303,7 @@ "additionalProperties": {} } }, - "required": [ - "type", - "text" - ] + "required": ["type", "text"] }, { "type": "object", @@ -6491,16 +6332,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "type", - "text", - "time" - ] + "required": ["type", "text", "time"] }, { "type": "object", @@ -6535,11 +6370,7 @@ "type": "string" } }, - "required": [ - "status", - "input", - "raw" - ] + "required": ["status", "input", "raw"] }, { "type": "object", @@ -6572,16 +6403,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "status", - "input", - "time" - ] + "required": ["status", "input", "time"] }, { "type": "object", @@ -6623,20 +6448,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ] + "required": ["status", "input", "output", "title", "metadata", "time"] }, { "type": "object", @@ -6672,18 +6487,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "error", - "time" - ] + "required": ["status", "input", "error", "time"] } ] }, @@ -6695,22 +6502,12 @@ "additionalProperties": {} } }, - "required": [ - "type", - "callID", - "tool", - "state" - ] + "required": ["type", "callID", "tool", "state"] } ] } }, - "required": [ - "id", - "messageID", - "sessionID", - "data" - ] + "required": ["id", "messageID", "sessionID", "data"] } } } @@ -6776,9 +6573,7 @@ "type": "string" } }, - "required": [ - "location" - ] + "required": ["location"] } } } @@ -6844,9 +6639,7 @@ "type": "string" } }, - "required": [ - "name" - ] + "required": ["name"] } } } @@ -6912,17 +6705,11 @@ "type": "string" } }, - "required": [ - "id", - "name", - "role" - ] + "required": ["id", "name", "role"] } } }, - "required": [ - "email" - ] + "required": ["email"] }, "balance": { "anyOf": [ @@ -6933,9 +6720,7 @@ "type": "number" } }, - "required": [ - "balance" - ] + "required": ["balance"] }, { "type": "null" @@ -6953,11 +6738,7 @@ ] } }, - "required": [ - "profile", - "balance", - "currentOrgId" - ] + "required": ["profile", "balance", "currentOrgId"] } } } @@ -7041,9 +6822,7 @@ ] } }, - "required": [ - "organizationId" - ] + "required": ["organizationId"] } } } @@ -7171,9 +6950,7 @@ } } }, - "required": [ - "modes" - ] + "required": ["modes"] } } } @@ -7284,10 +7061,7 @@ "type": "number" } }, - "required": [ - "prefix", - "suffix" - ] + "required": ["prefix", "suffix"] } } } @@ -7350,10 +7124,7 @@ "type": "string" } }, - "required": [ - "actionText", - "actionURL" - ] + "required": ["actionText", "actionURL"] }, "showIn": { "type": "array", @@ -7365,11 +7136,7 @@ "type": "string" } }, - "required": [ - "id", - "title", - "message" - ] + "required": ["id", "title", "message"] } } } @@ -7512,9 +7279,7 @@ "type": "string" } }, - "required": [ - "sessionId" - ] + "required": ["sessionId"] } } } @@ -7560,14 +7325,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "provisioned", - "starting", - "restarting", - "running", - "stopped", - "destroying" - ] + "enum": ["provisioned", "starting", "restarting", "running", "stopped", "destroying"] }, { "type": "null" @@ -7590,10 +7348,7 @@ "type": "number" } }, - "required": [ - "cpus", - "memory_mb" - ] + "required": ["cpus", "memory_mb"] }, "openclawVersion": { "anyOf": [ @@ -7635,9 +7390,7 @@ "type": "string" } }, - "required": [ - "status" - ] + "required": ["status"] } } } @@ -7695,12 +7448,7 @@ "type": "string" } }, - "required": [ - "apiKey", - "userId", - "userToken", - "channelId" - ] + "required": ["apiKey", "userId", "userToken", "channelId"] }, { "type": "null" @@ -7797,13 +7545,7 @@ "type": "number" } }, - "required": [ - "session_id", - "title", - "created_at", - "updated_at", - "version" - ] + "required": ["session_id", "title", "created_at", "updated_at", "version"] } }, "nextCursor": { @@ -7817,10 +7559,7 @@ ] } }, - "required": [ - "cliSessions", - "nextCursor" - ] + "required": ["cliSessions", "nextCursor"] } } } @@ -7890,9 +7629,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "lines": { "type": "object", @@ -7901,9 +7638,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "line_number": { "type": "number" @@ -7923,9 +7658,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] }, "start": { "type": "number" @@ -7934,21 +7667,11 @@ "type": "number" } }, - "required": [ - "match", - "start", - "end" - ] + "required": ["match", "start", "end"] } } }, - "required": [ - "path", - "lines", - "line_number", - "absolute_offset", - "submatches" - ] + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] } } } @@ -7994,10 +7717,7 @@ "name": "dirs", "schema": { "type": "string", - "enum": [ - "true", - "false" - ] + "enum": ["true", "false"] } }, { @@ -8005,10 +7725,7 @@ "name": "type", "schema": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] } }, { @@ -8357,10 +8074,7 @@ ] } }, - "required": [ - "name", - "config" - ] + "required": ["name", "config"] } } } @@ -8415,9 +8129,7 @@ "type": "string" } }, - "required": [ - "authorizationUrl" - ] + "required": ["authorizationUrl"] } } } @@ -8491,9 +8203,7 @@ "const": true } }, - "required": [ - "success" - ] + "required": ["success"] } } } @@ -8589,9 +8299,7 @@ "type": "string" } }, - "required": [ - "code" - ] + "required": ["code"] } } } @@ -8822,9 +8530,7 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } } } @@ -9136,9 +8842,7 @@ "type": "string" } }, - "required": [ - "command" - ] + "required": ["command"] } } } @@ -9198,12 +8902,7 @@ }, "variant": { "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] + "enum": ["info", "success", "warning", "error"] }, "duration": { "description": "Duration in milliseconds", @@ -9211,10 +8910,7 @@ "type": "number" } }, - "required": [ - "message", - "variant" - ] + "required": ["message", "variant"] } } } @@ -9365,9 +9061,7 @@ "pattern": "^ses" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } } } @@ -9414,10 +9108,7 @@ }, "body": {} }, - "required": [ - "path", - "body" - ] + "required": ["path", "body"] } } } @@ -9702,12 +9393,7 @@ "level": { "description": "Log level", "type": "string", - "enum": [ - "debug", - "info", - "error", - "warn" - ] + "enum": ["debug", "info", "error", "warn"] }, "message": { "description": "Log message", @@ -9722,11 +9408,7 @@ "additionalProperties": {} } }, - "required": [ - "service", - "level", - "message" - ] + "required": ["service", "level", "message"] } } } @@ -9827,12 +9509,7 @@ "type": "string" } }, - "required": [ - "name", - "description", - "location", - "content" - ] + "required": ["name", "description", "location", "content"] } } } @@ -9993,15 +9670,10 @@ "type": "string" } }, - "required": [ - "version" - ] + "required": ["version"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.installation.update-available": { "type": "object", @@ -10017,15 +9689,10 @@ "type": "string" } }, - "required": [ - "version" - ] + "required": ["version"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Project": { "type": "object", @@ -10079,10 +9746,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "sandboxes": { "type": "array", @@ -10091,12 +9755,7 @@ } } }, - "required": [ - "id", - "worktree", - "time", - "sandboxes" - ] + "required": ["id", "worktree", "time", "sandboxes"] }, "Event.project.updated": { "type": "object", @@ -10109,10 +9768,7 @@ "$ref": "#/components/schemas/Project" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.server.instance.disposed": { "type": "object", @@ -10128,15 +9784,10 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.server.connected": { "type": "object", @@ -10150,10 +9801,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.global.disposed": { "type": "object", @@ -10167,10 +9815,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.global.config.updated": { "type": "object", @@ -10184,10 +9829,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.lsp.client.diagnostics": { "type": "object", @@ -10206,16 +9848,10 @@ "type": "string" } }, - "required": [ - "serverID", - "path" - ] + "required": ["serverID", "path"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.lsp.updated": { "type": "object", @@ -10229,10 +9865,7 @@ "properties": {} } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.file.edited": { "type": "object", @@ -10248,15 +9881,10 @@ "type": "string" } }, - "required": [ - "file" - ] + "required": ["file"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "OutputFormatText": { "type": "object", @@ -10266,9 +9894,7 @@ "const": "text" } }, - "required": [ - "type" - ] + "required": ["type"] }, "JSONSchema": { "type": "object", @@ -10294,10 +9920,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "schema" - ] + "required": ["type", "schema"] }, "OutputFormat": { "anyOf": [ @@ -10329,20 +9952,10 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "before", - "after", - "additions", - "deletions" - ] + "required": ["file", "before", "after", "additions", "deletions"] }, "UserMessage": { "type": "object", @@ -10364,9 +9977,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -10387,9 +9998,7 @@ } } }, - "required": [ - "diffs" - ] + "required": ["diffs"] }, "agent": { "type": "string" @@ -10404,10 +10013,7 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "system": { "type": "string" @@ -10448,14 +10054,7 @@ } } }, - "required": [ - "id", - "sessionID", - "role", - "time", - "agent", - "model" - ] + "required": ["id", "sessionID", "role", "time", "agent", "model"] }, "ProviderAuthError": { "type": "object", @@ -10474,16 +10073,10 @@ "type": "string" } }, - "required": [ - "providerID", - "message" - ] + "required": ["providerID", "message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "UnknownError": { "type": "object", @@ -10499,15 +10092,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "MessageOutputLengthError": { "type": "object", @@ -10521,10 +10109,7 @@ "properties": {} } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "MessageAbortedError": { "type": "object", @@ -10540,15 +10125,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "StructuredOutputError": { "type": "object", @@ -10567,16 +10147,10 @@ "type": "number" } }, - "required": [ - "message", - "retries" - ] + "required": ["message", "retries"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "ContextOverflowError": { "type": "object", @@ -10595,15 +10169,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "APIError": { "type": "object", @@ -10646,16 +10215,10 @@ } } }, - "required": [ - "message", - "isRetryable" - ] + "required": ["message", "isRetryable"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "AssistantMessage": { "type": "object", @@ -10680,9 +10243,7 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] }, "error": { "anyOf": [ @@ -10734,10 +10295,7 @@ "type": "string" } }, - "required": [ - "cwd", - "root" - ] + "required": ["cwd", "root"] }, "summary": { "type": "boolean" @@ -10770,18 +10328,10 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] }, "structured": {}, "variant": { @@ -10830,15 +10380,10 @@ "$ref": "#/components/schemas/Message" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.removed": { "type": "object", @@ -10857,16 +10402,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID" - ] + "required": ["sessionID", "messageID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "TextPart": { "type": "object", @@ -10903,9 +10442,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -10915,13 +10452,7 @@ "additionalProperties": {} } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text" - ] + "required": ["id", "sessionID", "messageID", "type", "text"] }, "SubtaskPart": { "type": "object", @@ -10958,24 +10489,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "prompt", - "description", - "agent" - ] + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] }, "ReasoningPart": { "type": "object", @@ -11013,19 +10533,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "text", - "time" - ] + "required": ["id", "sessionID", "messageID", "type", "text", "time"] }, "FilePartSourceText": { "type": "object", @@ -11044,11 +10555,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] }, "FileSource": { "type": "object", @@ -11064,11 +10571,7 @@ "type": "string" } }, - "required": [ - "text", - "type", - "path" - ] + "required": ["text", "type", "path"] }, "Range": { "type": "object", @@ -11083,10 +10586,7 @@ "type": "number" } }, - "required": [ - "line", - "character" - ] + "required": ["line", "character"] }, "end": { "type": "object", @@ -11098,16 +10598,10 @@ "type": "number" } }, - "required": [ - "line", - "character" - ] + "required": ["line", "character"] } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] }, "SymbolSource": { "type": "object", @@ -11134,14 +10628,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "text", - "type", - "path", - "range", - "name", - "kind" - ] + "required": ["text", "type", "path", "range", "name", "kind"] }, "ResourceSource": { "type": "object", @@ -11160,12 +10647,7 @@ "type": "string" } }, - "required": [ - "text", - "type", - "clientName", - "uri" - ] + "required": ["text", "type", "clientName", "uri"] }, "FilePartSource": { "anyOf": [ @@ -11209,14 +10691,7 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "mime", - "url" - ] + "required": ["id", "sessionID", "messageID", "type", "mime", "url"] }, "ToolStatePending": { "type": "object", @@ -11236,11 +10711,7 @@ "type": "string" } }, - "required": [ - "status", - "input", - "raw" - ] + "required": ["status", "input", "raw"] }, "ToolStateRunning": { "type": "object", @@ -11273,16 +10744,10 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] } }, - "required": [ - "status", - "input", - "time" - ] + "required": ["status", "input", "time"] }, "ToolStateCompleted": { "type": "object", @@ -11324,10 +10789,7 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] }, "attachments": { "type": "array", @@ -11336,14 +10798,7 @@ } } }, - "required": [ - "status", - "input", - "output", - "title", - "metadata", - "time" - ] + "required": ["status", "input", "output", "title", "metadata", "time"] }, "ToolStateError": { "type": "object", @@ -11379,18 +10834,10 @@ "type": "number" } }, - "required": [ - "start", - "end" - ] + "required": ["start", "end"] } }, - "required": [ - "status", - "input", - "error", - "time" - ] + "required": ["status", "input", "error", "time"] }, "ToolState": { "anyOf": [ @@ -11441,15 +10888,7 @@ "additionalProperties": {} } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "callID", - "tool", - "state" - ] + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] }, "StepStartPart": { "type": "object", @@ -11471,12 +10910,7 @@ "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type" - ] + "required": ["id", "sessionID", "messageID", "type"] }, "StepFinishPart": { "type": "object", @@ -11528,29 +10962,13 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ] + "required": ["input", "output", "reasoning", "cache"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "reason", - "cost", - "tokens" - ] + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] }, "SnapshotPart": { "type": "object", @@ -11572,13 +10990,7 @@ "type": "string" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "snapshot" - ] + "required": ["id", "sessionID", "messageID", "type", "snapshot"] }, "PatchPart": { "type": "object", @@ -11606,14 +11018,7 @@ } } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "hash", - "files" - ] + "required": ["id", "sessionID", "messageID", "type", "hash", "files"] }, "AgentPart": { "type": "object", @@ -11651,20 +11056,10 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "name" - ] + "required": ["id", "sessionID", "messageID", "type", "name"] }, "RetryPart": { "type": "object", @@ -11695,20 +11090,10 @@ "type": "number" } }, - "required": [ - "created" - ] + "required": ["created"] } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "attempt", - "error", - "time" - ] + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] }, "CompactionPart": { "type": "object", @@ -11733,13 +11118,7 @@ "type": "boolean" } }, - "required": [ - "id", - "sessionID", - "messageID", - "type", - "auto" - ] + "required": ["id", "sessionID", "messageID", "type", "auto"] }, "Part": { "anyOf": [ @@ -11795,15 +11174,10 @@ "$ref": "#/components/schemas/Part" } }, - "required": [ - "part" - ] + "required": ["part"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.part.delta": { "type": "object", @@ -11831,19 +11205,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID", - "partID", - "field", - "delta" - ] + "required": ["sessionID", "messageID", "partID", "field", "delta"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.message.part.removed": { "type": "object", @@ -11865,17 +11230,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "messageID", - "partID" - ] + "required": ["sessionID", "messageID", "partID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "PermissionRequest": { "type": "object", @@ -11920,20 +11278,10 @@ "type": "string" } }, - "required": [ - "messageID", - "callID" - ] + "required": ["messageID", "callID"] } }, - "required": [ - "id", - "sessionID", - "permission", - "patterns", - "metadata", - "always" - ] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] }, "Event.permission.asked": { "type": "object", @@ -11946,10 +11294,7 @@ "$ref": "#/components/schemas/PermissionRequest" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.permission.replied": { "type": "object", @@ -11969,24 +11314,13 @@ }, "reply": { "type": "string", - "enum": [ - "once", - "always", - "reject" - ] + "enum": ["once", "always", "reject"] } }, - "required": [ - "sessionID", - "requestID", - "reply" - ] + "required": ["sessionID", "requestID", "reply"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "SessionStatus": { "anyOf": [ @@ -11998,9 +11332,7 @@ "const": "idle" } }, - "required": [ - "type" - ] + "required": ["type"] }, { "type": "object", @@ -12019,12 +11351,7 @@ "type": "number" } }, - "required": [ - "type", - "attempt", - "message", - "next" - ] + "required": ["type", "attempt", "message", "next"] }, { "type": "object", @@ -12034,9 +11361,7 @@ "const": "busy" } }, - "required": [ - "type" - ] + "required": ["type"] } ] }, @@ -12057,16 +11382,10 @@ "$ref": "#/components/schemas/SessionStatus" } }, - "required": [ - "sessionID", - "status" - ] + "required": ["sessionID", "status"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.idle": { "type": "object", @@ -12082,15 +11401,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "QuestionOption": { "type": "object", @@ -12108,10 +11422,7 @@ "type": "string" } }, - "required": [ - "label", - "description" - ] + "required": ["label", "description"] }, "QuestionInfo": { "type": "object", @@ -12140,11 +11451,7 @@ "type": "boolean" } }, - "required": [ - "question", - "header", - "options" - ] + "required": ["question", "header", "options"] }, "QuestionRequest": { "type": "object", @@ -12174,17 +11481,10 @@ "type": "string" } }, - "required": [ - "messageID", - "callID" - ] + "required": ["messageID", "callID"] } }, - "required": [ - "id", - "sessionID", - "questions" - ] + "required": ["id", "sessionID", "questions"] }, "Event.question.asked": { "type": "object", @@ -12197,10 +11497,7 @@ "$ref": "#/components/schemas/QuestionRequest" } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "QuestionAnswer": { "type": "array", @@ -12231,17 +11528,10 @@ } } }, - "required": [ - "sessionID", - "requestID", - "answers" - ] + "required": ["sessionID", "requestID", "answers"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.question.rejected": { "type": "object", @@ -12260,16 +11550,10 @@ "type": "string" } }, - "required": [ - "sessionID", - "requestID" - ] + "required": ["sessionID", "requestID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.compacted": { "type": "object", @@ -12285,15 +11569,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.file.watcher.updated": { "type": "object", @@ -12325,16 +11604,10 @@ ] } }, - "required": [ - "file", - "event" - ] + "required": ["file", "event"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Todo": { "type": "object", @@ -12352,11 +11625,7 @@ "type": "string" } }, - "required": [ - "content", - "status", - "priority" - ] + "required": ["content", "status", "priority"] }, "Event.todo.updated": { "type": "object", @@ -12378,16 +11647,10 @@ } } }, - "required": [ - "sessionID", - "todos" - ] + "required": ["sessionID", "todos"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.prompt.append": { "type": "object", @@ -12403,15 +11666,10 @@ "type": "string" } }, - "required": [ - "text" - ] + "required": ["text"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.command.execute": { "type": "object", @@ -12452,15 +11710,10 @@ ] } }, - "required": [ - "command" - ] + "required": ["command"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.toast.show": { "type": "object", @@ -12480,12 +11733,7 @@ }, "variant": { "type": "string", - "enum": [ - "info", - "success", - "warning", - "error" - ] + "enum": ["info", "success", "warning", "error"] }, "duration": { "description": "Duration in milliseconds", @@ -12493,16 +11741,10 @@ "type": "number" } }, - "required": [ - "message", - "variant" - ] + "required": ["message", "variant"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.tui.session.select": { "type": "object", @@ -12520,15 +11762,10 @@ "pattern": "^ses" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.mcp.tools.changed": { "type": "object", @@ -12544,15 +11781,10 @@ "type": "string" } }, - "required": [ - "server" - ] + "required": ["server"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.mcp.browser.open.failed": { "type": "object", @@ -12571,16 +11803,10 @@ "type": "string" } }, - "required": [ - "mcpName", - "url" - ] + "required": ["mcpName", "url"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.command.executed": { "type": "object", @@ -12607,26 +11833,14 @@ "pattern": "^msg.*" } }, - "required": [ - "name", - "sessionID", - "arguments", - "messageID" - ] + "required": ["name", "sessionID", "arguments", "messageID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "PermissionAction": { "type": "string", - "enum": [ - "allow", - "deny", - "ask" - ] + "enum": ["allow", "deny", "ask"] }, "PermissionRule": { "type": "object", @@ -12641,11 +11855,7 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": [ - "permission", - "pattern", - "action" - ] + "required": ["permission", "pattern", "action"] }, "PermissionRuleset": { "type": "array", @@ -12704,26 +11914,14 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "additions", - "deletions" - ] + "required": ["file", "additions", "deletions"] } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "share": { "type": "object", @@ -12732,9 +11930,7 @@ "type": "string" } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "type": "string" @@ -12758,10 +11954,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -12782,20 +11975,10 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] } }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "title", - "version", - "time" - ] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] }, "Event.session.created": { "type": "object", @@ -12811,15 +11994,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.updated": { "type": "object", @@ -12835,15 +12013,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.deleted": { "type": "object", @@ -12859,15 +12032,10 @@ "$ref": "#/components/schemas/Session" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.diff": { "type": "object", @@ -12889,16 +12057,10 @@ } } }, - "required": [ - "sessionID", - "diff" - ] + "required": ["sessionID", "diff"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.error": { "type": "object", @@ -12941,10 +12103,7 @@ } } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.turn.open": { "type": "object", @@ -12960,15 +12119,10 @@ "type": "string" } }, - "required": [ - "sessionID" - ] + "required": ["sessionID"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.session.turn.close": { "type": "object", @@ -12985,23 +12139,13 @@ }, "reason": { "type": "string", - "enum": [ - "completed", - "error", - "interrupted" - ] + "enum": ["completed", "error", "interrupted"] } }, - "required": [ - "sessionID", - "reason" - ] + "required": ["sessionID", "reason"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.vcs.branch.updated": { "type": "object", @@ -13019,10 +12163,7 @@ } } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.workspace.ready": { "type": "object", @@ -13038,15 +12179,10 @@ "type": "string" } }, - "required": [ - "name" - ] + "required": ["name"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.workspace.failed": { "type": "object", @@ -13062,15 +12198,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Pty": { "type": "object", @@ -13096,24 +12227,13 @@ }, "status": { "type": "string", - "enum": [ - "running", - "exited" - ] + "enum": ["running", "exited"] }, "pid": { "type": "number" } }, - "required": [ - "id", - "title", - "command", - "args", - "cwd", - "status", - "pid" - ] + "required": ["id", "title", "command", "args", "cwd", "status", "pid"] }, "Event.pty.created": { "type": "object", @@ -13129,15 +12249,10 @@ "$ref": "#/components/schemas/Pty" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.updated": { "type": "object", @@ -13153,15 +12268,10 @@ "$ref": "#/components/schemas/Pty" } }, - "required": [ - "info" - ] + "required": ["info"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.exited": { "type": "object", @@ -13181,16 +12291,10 @@ "type": "number" } }, - "required": [ - "id", - "exitCode" - ] + "required": ["id", "exitCode"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.pty.deleted": { "type": "object", @@ -13207,15 +12311,10 @@ "pattern": "^pty.*" } }, - "required": [ - "id" - ] + "required": ["id"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.worktree.ready": { "type": "object", @@ -13234,16 +12333,10 @@ "type": "string" } }, - "required": [ - "name", - "branch" - ] + "required": ["name", "branch"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event.worktree.failed": { "type": "object", @@ -13259,15 +12352,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "type", - "properties" - ] + "required": ["type", "properties"] }, "Event": { "anyOf": [ @@ -13427,20 +12515,12 @@ "$ref": "#/components/schemas/Event" } }, - "required": [ - "directory", - "payload" - ] + "required": ["directory", "payload"] }, "LogLevel": { "description": "Log level", "type": "string", - "enum": [ - "DEBUG", - "INFO", - "WARN", - "ERROR" - ] + "enum": ["DEBUG", "INFO", "WARN", "ERROR"] }, "ServerConfig": { "description": "Server configuration for kilo serve and web commands", @@ -13478,11 +12558,7 @@ "anyOf": [ { "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] + "enum": ["ask", "allow", "deny"] }, { "type": "null" @@ -13625,11 +12701,7 @@ }, "mode": { "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] + "enum": ["subagent", "primary", "all"] }, "hidden": { "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", @@ -13651,15 +12723,7 @@ }, { "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] } ] }, @@ -13745,15 +12809,10 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ], + "required": ["field"], "additionalProperties": false } ] @@ -13789,16 +12848,10 @@ "type": "number" } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "limit": { "type": "object", @@ -13813,10 +12866,7 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "modalities": { "type": "object", @@ -13825,70 +12875,39 @@ "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } }, "output": { "type": "array", "items": { "type": "string", - "enum": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "enum": ["text", "audio", "image", "video", "pdf"] } } }, - "required": [ - "input", - "output" - ] + "required": ["input", "output"] }, "recommendedIndex": { "type": "number" }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] }, "experimental": { "type": "boolean" }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated" - ] + "enum": ["alpha", "beta", "deprecated"] }, "options": { "type": "object", @@ -14024,10 +13043,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "command" - ], + "required": ["type", "command"], "additionalProperties": false }, "McpOAuthConfig": { @@ -14093,19 +13109,13 @@ "maximum": 9007199254740991 } }, - "required": [ - "type", - "url" - ], + "required": ["type", "url"], "additionalProperties": false }, "LayoutConfig": { "description": "@deprecated Always uses stretch layout.", "type": "string", - "enum": [ - "auto", - "stretch" - ] + "enum": ["auto", "stretch"] }, "Config": { "type": "object", @@ -14145,9 +13155,7 @@ "type": "boolean" } }, - "required": [ - "template" - ] + "required": ["template"] } }, "skills": { @@ -14193,11 +13201,7 @@ "share": { "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", "type": "string", - "enum": [ - "manual", - "auto", - "disabled" - ] + "enum": ["manual", "auto", "disabled"] }, "autoshare": { "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", @@ -14352,9 +13356,7 @@ "type": "boolean" } }, - "required": [ - "enabled" - ], + "required": ["enabled"], "additionalProperties": false } ] @@ -14424,9 +13426,7 @@ "const": true } }, - "required": [ - "disabled" - ] + "required": ["disabled"] }, { "type": "object", @@ -14463,9 +13463,7 @@ "additionalProperties": {} } }, - "required": [ - "command" - ] + "required": ["command"] } ] } @@ -14582,11 +13580,7 @@ "const": false } }, - "required": [ - "data", - "errors", - "success" - ] + "required": ["data", "errors", "success"] }, "OAuth": { "type": "object", @@ -14611,12 +13605,7 @@ "type": "string" } }, - "required": [ - "type", - "refresh", - "access", - "expires" - ] + "required": ["type", "refresh", "access", "expires"] }, "ApiAuth": { "type": "object", @@ -14629,10 +13618,7 @@ "type": "string" } }, - "required": [ - "type", - "key" - ] + "required": ["type", "key"] }, "WellKnownAuth": { "type": "object", @@ -14648,11 +13634,7 @@ "type": "string" } }, - "required": [ - "type", - "key", - "token" - ] + "required": ["type", "key", "token"] }, "Auth": { "anyOf": [ @@ -14681,15 +13663,10 @@ "type": "string" } }, - "required": [ - "message" - ] + "required": ["message"] } }, - "required": [ - "name", - "data" - ] + "required": ["name", "data"] }, "Model": { "type": "object", @@ -14713,11 +13690,7 @@ "type": "string" } }, - "required": [ - "id", - "url", - "npm" - ] + "required": ["id", "url", "npm"] }, "name": { "type": "string" @@ -14759,13 +13732,7 @@ "type": "boolean" } }, - "required": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "required": ["text", "audio", "image", "video", "pdf"] }, "output": { "type": "object", @@ -14786,13 +13753,7 @@ "type": "boolean" } }, - "required": [ - "text", - "audio", - "image", - "video", - "pdf" - ] + "required": ["text", "audio", "image", "video", "pdf"] }, "interleaved": { "anyOf": [ @@ -14804,28 +13765,15 @@ "properties": { "field": { "type": "string", - "enum": [ - "reasoning_content", - "reasoning_details" - ] + "enum": ["reasoning_content", "reasoning_details"] } }, - "required": [ - "field" - ] + "required": ["field"] } ] } }, - "required": [ - "temperature", - "reasoning", - "attachment", - "toolcall", - "input", - "output", - "interleaved" - ] + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] }, "cost": { "type": "object", @@ -14846,10 +13794,7 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] }, "experimentalOver200K": { "type": "object", @@ -14870,24 +13815,13 @@ "type": "number" } }, - "required": [ - "read", - "write" - ] + "required": ["read", "write"] } }, - "required": [ - "input", - "output", - "cache" - ] + "required": ["input", "output", "cache"] } }, - "required": [ - "input", - "output", - "cache" - ] + "required": ["input", "output", "cache"] }, "limit": { "type": "object", @@ -14902,19 +13836,11 @@ "type": "number" } }, - "required": [ - "context", - "output" - ] + "required": ["context", "output"] }, "status": { "type": "string", - "enum": [ - "alpha", - "beta", - "deprecated", - "active" - ] + "enum": ["alpha", "beta", "deprecated", "active"] }, "options": { "type": "object", @@ -14953,26 +13879,14 @@ }, "prompt": { "type": "string", - "enum": [ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo" - ] + "enum": ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] }, "isFree": { "type": "boolean" }, "ai_sdk_provider": { "type": "string", - "enum": [ - "anthropic", - "openai", - "openai-compatible", - "openrouter" - ] + "enum": ["anthropic", "openai", "openai-compatible", "openrouter"] } }, "required": [ @@ -15000,12 +13914,7 @@ }, "source": { "type": "string", - "enum": [ - "env", - "config", - "custom", - "api" - ] + "enum": ["env", "config", "custom", "api"] }, "env": { "type": "array", @@ -15033,14 +13942,7 @@ } } }, - "required": [ - "id", - "name", - "source", - "env", - "options", - "models" - ] + "required": ["id", "name", "source", "env", "options", "models"] }, "ToolIDs": { "type": "array", @@ -15059,11 +13961,7 @@ }, "parameters": {} }, - "required": [ - "id", - "description", - "parameters" - ] + "required": ["id", "description", "parameters"] }, "ToolList": { "type": "array", @@ -15123,15 +14021,7 @@ "type": "string" } }, - "required": [ - "id", - "type", - "branch", - "name", - "directory", - "extra", - "projectID" - ] + "required": ["id", "type", "branch", "name", "directory", "extra", "projectID"] }, "Worktree": { "type": "object", @@ -15146,11 +14036,7 @@ "type": "string" } }, - "required": [ - "name", - "branch", - "directory" - ] + "required": ["name", "branch", "directory"] }, "WorktreeCreateInput": { "type": "object", @@ -15171,9 +14057,7 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] }, "WorktreeResetInput": { "type": "object", @@ -15182,9 +14066,7 @@ "type": "string" } }, - "required": [ - "directory" - ] + "required": ["directory"] }, "WorktreeDiffItem": { "type": "object", @@ -15206,11 +14088,7 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] }, "tracked": { "type": "boolean" @@ -15250,10 +14128,7 @@ "type": "string" } }, - "required": [ - "id", - "worktree" - ] + "required": ["id", "worktree"] }, "GlobalSession": { "type": "object", @@ -15306,26 +14181,14 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "file", - "additions", - "deletions" - ] + "required": ["file", "additions", "deletions"] } } }, - "required": [ - "additions", - "deletions", - "files" - ] + "required": ["additions", "deletions", "files"] }, "share": { "type": "object", @@ -15334,9 +14197,7 @@ "type": "string" } }, - "required": [ - "url" - ] + "required": ["url"] }, "title": { "type": "string" @@ -15360,10 +14221,7 @@ "type": "number" } }, - "required": [ - "created", - "updated" - ] + "required": ["created", "updated"] }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -15384,9 +14242,7 @@ "type": "string" } }, - "required": [ - "messageID" - ] + "required": ["messageID"] }, "project": { "anyOf": [ @@ -15399,16 +14255,7 @@ ] } }, - "required": [ - "id", - "slug", - "projectID", - "directory", - "title", - "version", - "time", - "project" - ] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] }, "McpResource": { "type": "object", @@ -15429,11 +14276,7 @@ "type": "string" } }, - "required": [ - "name", - "uri", - "client" - ] + "required": ["name", "uri", "client"] }, "TextPartInput": { "type": "object", @@ -15464,9 +14307,7 @@ "type": "number" } }, - "required": [ - "start" - ] + "required": ["start"] }, "metadata": { "type": "object", @@ -15476,10 +14317,7 @@ "additionalProperties": {} } }, - "required": [ - "type", - "text" - ] + "required": ["type", "text"] }, "FilePartInput": { "type": "object", @@ -15504,11 +14342,7 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": [ - "type", - "mime", - "url" - ] + "required": ["type", "mime", "url"] }, "AgentPartInput": { "type": "object", @@ -15540,17 +14374,10 @@ "maximum": 9007199254740991 } }, - "required": [ - "value", - "start", - "end" - ] + "required": ["value", "start", "end"] } }, - "required": [ - "type", - "name" - ] + "required": ["type", "name"] }, "SubtaskPartInput": { "type": "object", @@ -15581,21 +14408,13 @@ "type": "string" } }, - "required": [ - "providerID", - "modelID" - ] + "required": ["providerID", "modelID"] }, "command": { "type": "string" } }, - "required": [ - "type", - "prompt", - "description", - "agent" - ] + "required": ["type", "prompt", "description", "agent"] }, "ProviderAuthMethod": { "type": "object", @@ -15616,10 +14435,7 @@ "type": "string" } }, - "required": [ - "type", - "label" - ] + "required": ["type", "label"] }, "ProviderAuthAuthorization": { "type": "object", @@ -15643,11 +14459,7 @@ "type": "string" } }, - "required": [ - "url", - "method", - "instructions" - ] + "required": ["url", "method", "instructions"] }, "Symbol": { "type": "object", @@ -15668,17 +14480,10 @@ "$ref": "#/components/schemas/Range" } }, - "required": [ - "uri", - "range" - ] + "required": ["uri", "range"] } }, - "required": [ - "name", - "kind", - "location" - ] + "required": ["name", "kind", "location"] }, "FileNode": { "type": "object", @@ -15694,32 +14499,20 @@ }, "type": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] }, "ignored": { "type": "boolean" } }, - "required": [ - "name", - "path", - "absolute", - "type", - "ignored" - ] + "required": ["name", "path", "absolute", "type", "ignored"] }, "FileContent": { "type": "object", "properties": { "type": { "type": "string", - "enum": [ - "text", - "binary" - ] + "enum": ["text", "binary"] }, "content": { "type": "string" @@ -15766,24 +14559,14 @@ } } }, - "required": [ - "oldStart", - "oldLines", - "newStart", - "newLines", - "lines" - ] + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] } }, "index": { "type": "string" } }, - "required": [ - "oldFileName", - "newFileName", - "hunks" - ] + "required": ["oldFileName", "newFileName", "hunks"] }, "encoding": { "type": "string", @@ -15793,10 +14576,7 @@ "type": "string" } }, - "required": [ - "type", - "content" - ] + "required": ["type", "content"] }, "File": { "type": "object", @@ -15816,19 +14596,10 @@ }, "status": { "type": "string", - "enum": [ - "added", - "deleted", - "modified" - ] + "enum": ["added", "deleted", "modified"] } }, - "required": [ - "path", - "added", - "removed", - "status" - ] + "required": ["path", "added", "removed", "status"] }, "MCPStatusConnected": { "type": "object", @@ -15838,9 +14609,7 @@ "const": "connected" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusDisabled": { "type": "object", @@ -15850,9 +14619,7 @@ "const": "disabled" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusFailed": { "type": "object", @@ -15865,10 +14632,7 @@ "type": "string" } }, - "required": [ - "status", - "error" - ] + "required": ["status", "error"] }, "MCPStatusNeedsAuth": { "type": "object", @@ -15878,9 +14642,7 @@ "const": "needs_auth" } }, - "required": [ - "status" - ] + "required": ["status"] }, "MCPStatusNeedsClientRegistration": { "type": "object", @@ -15893,10 +14655,7 @@ "type": "string" } }, - "required": [ - "status", - "error" - ] + "required": ["status", "error"] }, "MCPStatus": { "anyOf": [ @@ -15936,13 +14695,7 @@ "type": "string" } }, - "required": [ - "home", - "state", - "config", - "worktree", - "directory" - ] + "required": ["home", "state", "config", "worktree", "directory"] }, "VcsInfo": { "type": "object", @@ -15951,9 +14704,7 @@ "type": "string" } }, - "required": [ - "branch" - ] + "required": ["branch"] }, "Command": { "type": "object", @@ -15972,11 +14723,7 @@ }, "source": { "type": "string", - "enum": [ - "command", - "mcp", - "skill" - ] + "enum": ["command", "mcp", "skill"] }, "template": { "anyOf": [ @@ -15998,11 +14745,7 @@ } } }, - "required": [ - "name", - "template", - "hints" - ] + "required": ["name", "template", "hints"] }, "Agent": { "type": "object", @@ -16018,11 +14761,7 @@ }, "mode": { "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] + "enum": ["subagent", "primary", "all"] }, "native": { "type": "boolean" @@ -16055,10 +14794,7 @@ "type": "string" } }, - "required": [ - "modelID", - "providerID" - ] + "required": ["modelID", "providerID"] }, "variant": { "type": "string" @@ -16079,12 +14815,7 @@ "maximum": 9007199254740991 } }, - "required": [ - "name", - "mode", - "permission", - "options" - ] + "required": ["name", "mode", "permission", "options"] }, "LSPStatus": { "type": "object", @@ -16111,12 +14842,7 @@ ] } }, - "required": [ - "id", - "name", - "root", - "status" - ] + "required": ["id", "name", "root", "status"] }, "FormatterStatus": { "type": "object", @@ -16134,12 +14860,8 @@ "type": "boolean" } }, - "required": [ - "name", - "extensions", - "enabled" - ] + "required": ["name", "extensions", "enabled"] } } } -} \ No newline at end of file +} diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 406ac86df58..181a277cf52 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.1.22", + "version": "7.2.0", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index c9110706fca..f0a6d88dcef 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.1.22", + "version": "7.2.0", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index b8d00e59a36..2f92151be1c 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "7.1.22", + "version": "7.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/script/check-opencode-annotations.ts b/script/check-opencode-annotations.ts new file mode 100644 index 00000000000..314b399d460 --- /dev/null +++ b/script/check-opencode-annotations.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env bun + +/** + * Verifies that every Kilo-specific change in shared packages/opencode/ files + * is annotated with a kilocode_change marker. + * + * Usage: + * bun run script/check-opencode-annotations.ts # diff against origin/main + * bun run script/check-opencode-annotations.ts --base # diff against + * + * A line is "covered" if it: + * - contains // kilocode_change (inline annotation) + * - falls inside a // kilocode_change start/end block (block annotation) + * - is in a file whose first non-empty line is (whole-file annotation) + * // kilocode_change - new file + * - is empty / whitespace-only (skipped) + * - is itself a marker line (auto-covered) + * + * Exempt paths (no markers needed — entirely Kilo-specific): + * - packages/opencode/src/kilocode/** + * - packages/opencode/test/kilocode/** + * - Any path containing "kilocode" in directory or filename + */ + +import { spawnSync } from "node:child_process" +import { readFileSync } from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "..") +const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]) + +const args = process.argv.slice(2) +const baseIdx = args.indexOf("--base") +const base = baseIdx !== -1 ? args[baseIdx + 1] : "origin/main" + +function run(cmd: string, args: string[]) { + const result = spawnSync(cmd, args, { cwd: ROOT, encoding: "utf8" }) + return result.stdout?.trim() ?? "" +} + +function changedFiles() { + const out = run("git", ["diff", "--name-only", "--diff-filter=AMRT", `${base}...HEAD`, "--", "packages/opencode"]) + return out ? out.split("\n").filter(Boolean) : [] +} + +function isExempt(file: string) { + const norm = file.replaceAll("\\", "/").toLowerCase() + return norm.split("/").some((part) => part.includes("kilocode")) +} + +function isSource(file: string) { + return SOURCE_EXTS.has(path.extname(file)) +} + +function addedLines(file: string): Set { + const diff = run("git", ["diff", "--unified=0", "--diff-filter=AMRT", `${base}...HEAD`, "--", file]) + const out = new Set() + for (const line of diff.split("\n")) { + const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/) + if (!m) continue + const start = Number(m[1]) + const count = m[2] !== undefined ? Number(m[2]) : 1 + for (let i = 0; i < count; i++) out.add(start + i) + } + return out +} + +function coveredLines(text: string): { lines: string[]; covered: Set } { + const lines = text.split(/\r?\n/) + const covered = new Set() + + // Whole-file annotation: first non-empty line is "// kilocode_change - new file" + const first = lines.find((x) => x.trim() !== "") + if (first?.match(/\/\/\s*kilocode_change\s*-\s*new\s*file\b/)) { + for (let i = 1; i <= lines.length; i++) covered.add(i) + return { lines, covered } + } + + let block = false + for (let i = 0; i < lines.length; i++) { + const n = i + 1 + const line = lines[i] ?? "" + + if (line.match(/\/\/\s*kilocode_change\s+start\b/)) { + block = true + covered.add(n) + continue + } + + if (line.match(/\/\/\s*kilocode_change\s+end\b/)) { + covered.add(n) + block = false + continue + } + + if (block) { + covered.add(n) + continue + } + + if (line.match(/\/\/\s*kilocode_change\b/)) covered.add(n) + } + + return { lines, covered } +} + +// --- main --- + +const files = changedFiles().filter((f) => !isExempt(f) && isSource(f)) + +if (files.length === 0) { + console.log("No shared opencode source files changed — nothing to check.") + process.exit(0) +} + +const violations: string[] = [] + +for (const file of files) { + const nums = addedLines(file) + if (nums.size === 0) continue + + const abs = path.join(ROOT, file) + const text = readFileSync(abs, "utf8") + const { lines, covered } = coveredLines(text) + + for (const n of nums) { + const line = lines[n - 1] ?? "" + const trim = line.trim() + if (!trim) continue + if (trim.match(/\/\/\s*kilocode_change\b/)) continue + if (!covered.has(n)) violations.push(` ${file}:${n}: ${trim}`) + } +} + +if (violations.length === 0) { + console.log("All shared opencode changes are annotated with kilocode_change markers.") + process.exit(0) +} + +console.error( + [ + "Unannotated Kilo changes found in shared opencode files:", + "", + ...violations, + "", + "Every Kilo-specific change in packages/opencode/ must be annotated.", + "", + "Inline (single line):", + " const url = Flag.KILO_MODELS_URL || 'https://models.dev' // kilocode_change", + "", + "Block (multiple lines):", + " // kilocode_change start", + " ...", + " // kilocode_change end", + "", + "New file:", + " // kilocode_change - new file", + "", + "Exempt paths (no markers needed):", + " - packages/opencode/src/kilocode/**", + " - packages/opencode/test/kilocode/**", + " - Any path containing 'kilocode' in the directory or filename", + "", + "See AGENTS.md for details.", + ].join("\n"), +) + +process.exit(1) diff --git a/script/upstream/package.json b/script/upstream/package.json index facad75d5ae..d6dab3f44b3 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.1.22", + "version": "7.2.0", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index fe9c979ed85..d521dcb3c2b 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.1.22", + "version": "7.2.0", "publisher": "sst-dev", "repository": { "type": "git",