diff --git a/.changeset/agent-requirements.md b/.changeset/agent-requirements.md
new file mode 100644
index 00000000000..2065246f1c0
--- /dev/null
+++ b/.changeset/agent-requirements.md
@@ -0,0 +1,6 @@
+---
+"@kilocode/cli": patch
+"kilo-code": patch
+---
+
+Gate experimental agents on their declared skill, MCP, and VS Code extension requirements. VS Code shows requirement groups with Marketplace shortcuts, and the CLI stops before sending when requirements are unmet.
diff --git a/.changeset/anaconda-desktop-translations.md b/.changeset/anaconda-desktop-translations.md
new file mode 100644
index 00000000000..12d9c6d0ef9
--- /dev/null
+++ b/.changeset/anaconda-desktop-translations.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Translate the Anaconda Desktop provider strings into all supported languages instead of falling back to English.
diff --git a/.changeset/fix-autocomplete-error-messages.md b/.changeset/fix-autocomplete-error-messages.md
new file mode 100644
index 00000000000..43edd744812
--- /dev/null
+++ b/.changeset/fix-autocomplete-error-messages.md
@@ -0,0 +1,7 @@
+---
+"kilo-code": patch
+---
+
+Improve autocomplete error messages to clarify BYOK and credits issues
+
+When autocomplete is paused due to a payment or auth error, the messages now explain all possible causes: no Kilo credits, API key (BYOK) quota exhausted, not signed in, or invalid/missing API key.
diff --git a/.changeset/import-cloud-session-first.md b/.changeset/import-cloud-session-first.md
new file mode 100644
index 00000000000..b2e0accef86
--- /dev/null
+++ b/.changeset/import-cloud-session-first.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Allow the default TUI to import cloud-only sessions without rejecting their IDs as missing locally.
diff --git a/.changeset/keep-sandbox-toggle-drafts.md b/.changeset/keep-sandbox-toggle-drafts.md
index 00b8347bd1b..d1fd585ab57 100644
--- a/.changeset/keep-sandbox-toggle-drafts.md
+++ b/.changeset/keep-sandbox-toggle-drafts.md
@@ -2,4 +2,4 @@
"kilo-code": patch
---
-Preserve new-chat prompts and attachments when toggling sandbox before sending the first message.
+Preserve new-chat prompts, attachments, and scroll position when toggling sandbox, and keep sandbox status stable while switching sessions.
diff --git a/.changeset/open-model-selector-faster.md b/.changeset/open-model-selector-faster.md
new file mode 100644
index 00000000000..789dabdc2a2
--- /dev/null
+++ b/.changeset/open-model-selector-faster.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Open the model selector instantly, even with large model catalogs.
diff --git a/.changeset/show-external-permission-paths.md b/.changeset/show-external-permission-paths.md
new file mode 100644
index 00000000000..1061c6ce960
--- /dev/null
+++ b/.changeset/show-external-permission-paths.md
@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Show full external directory paths in permission auto-approve rules.
diff --git a/.gitattributes b/.gitattributes
index a53ef3c352f..792c880ab15 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -10,15 +10,20 @@ packages/kilo-ui/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
packages/kilo-vscode/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
packages/kilo-docs/public/img/screenshot-tests/**/*.png filter=lfs diff=lfs merge=lfs -text
-# Hide non-English localization files by default in GitHub PR diffs.
+# Hide most localization files by default in GitHub PR diffs. Show English as the source
+# language and German so reviewers can catch untranslated copy-pasted English strings.
**/i18n/*.ts linguist-generated=true
**/i18n/en.ts linguist-generated=false
**/i18n/en*.ts linguist-generated=false
+**/i18n/de.ts linguist-generated=false
+**/i18n/de*.ts linguist-generated=false
**/i18n/package-nls-en.ts linguist-generated=false
+**/i18n/package-nls-de.ts linguist-generated=false
**/i18n/index.ts linguist-generated=false
**/i18n/parity.test.ts linguist-generated=false
packages/kilo-i18n/src/*.ts linguist-generated=true
packages/kilo-i18n/src/en.ts linguist-generated=false
+packages/kilo-i18n/src/de.ts linguist-generated=false
# Auto-generated CLI reference docs
packages/kilo-docs/markdoc/partials/cli-commands-table.md linguist-generated=true
diff --git a/README.md b/README.md
index f61baf7c88a..e26b90a6bba 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | Українська | বাংলা | Ελληνικά | Tiếng Việt
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | Українська | বাংলা | Ελληνικά | Tiếng Việt
@@ -168,7 +168,7 @@ MIT. You're free to use, modify, and distribute this code, including commerciall
Where did Kilo CLI come from?
-Kilo CLI is a fork of [OpenCode](https://github.com/Kilo-Org/kilocode), enhanced to work within the Kilo agentic engineering platform.
+Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform.
diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts
index 9980f65e547..db408f52271 100644
--- a/packages/core/test/project.test.ts
+++ b/packages/core/test/project.test.ts
@@ -119,14 +119,14 @@ describe("ProjectV2.resolve", () => {
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
- yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
- yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
+ yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@example.com:owner/repo.git" }))
+ yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://example.com/owner/repo.git" }))
const project = yield* Project.Service
const a = yield* project.resolve(abs(ssh.path))
const b = yield* project.resolve(abs(https.path))
- expect(a.id).toBe(remoteID("github.com/owner/repo"))
+ expect(a.id).toBe(remoteID("example.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png
index c007e6d7330..ef51b5f9772 100644
--- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:af33f5d026513422db6efc37848eec76d9da511432e5933606ae0be73da684f8
-size 16675
+oid sha256:6ee59e48c176043eee3b22441ce1c50e8d17dbafe58e969cfc8e97f404f2608d
+size 17429
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-checking-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-checking-chromium-linux.png
new file mode 100644
index 00000000000..254906a5329
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-checking-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a093b9658cc15d0789da5afebde7163ec51e743af59975e2a031aed3e9144d56
+size 22336
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-malformed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-malformed-chromium-linux.png
new file mode 100644
index 00000000000..f0ed1261382
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-malformed-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0b20e8e50b583cc6a884e08648a9f1efcdfc2a9561b97145811ebc9c24d244fd
+size 23100
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-missing-extension-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-missing-extension-chromium-linux.png
new file mode 100644
index 00000000000..033e4563cb3
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-missing-extension-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3b24b0d2694d2eeec0ea4cd827bd3a61bb47150605b838b43e4f422c9b960f0e
+size 29744
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-missing-tools-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-missing-tools-chromium-linux.png
new file mode 100644
index 00000000000..37195aeeea7
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-missing-tools-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fd27610a2518018c16e4227728221ed9bf67a7d3c8ab207b2835dc60964ec63d
+size 33323
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-ready-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-ready-chromium-linux.png
new file mode 100644
index 00000000000..01bfcc79738
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-requirements-ready-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d8ad46cc736472c6a2a46bb80e6622333f49960e74dcb6782d35aa72fa32378c
+size 23725
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png
index ddc01144048..72c72e77131 100644
--- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:61b1d0bc725e3b33a40d1d777d71b70afcec1b6cdcfae15e2023667bc4574f23
-size 17201
+oid sha256:6f05d9512bbe3309c6d073fd8aca8e0800c7e2827bdefe6115e636e8a6e94070
+size 16239
diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-large-catalog-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-large-catalog-chromium-linux.png
new file mode 100644
index 00000000000..f6ca0c2ec8d
--- /dev/null
+++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-large-catalog-chromium-linux.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b197d749f21df8d3061b93d13281a346b52514bec1b6855b474a03cfaeeb117b
+size 2500
diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt
index f9380d9c897..8754e7d110c 100644
--- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt
+++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt
@@ -157,9 +157,9 @@ class MockCliServer : AutoCloseable {
/** Reset all request counters. */
fun resetCounts() { counts.clear() }
- private val executor = Executors.newCachedThreadPool { r ->
- Thread(r, "mock-cli-${Thread.currentThread().id}").apply { isDaemon = true }
- }
+ private val executor = Executors.newThreadPerTaskExecutor(
+ Thread.ofVirtual().name("mock-cli-", 0).factory(),
+ )
private val closed = AtomicBoolean(false)
private var server: ServerSocket? = null
@@ -221,6 +221,7 @@ class MockCliServer : AutoCloseable {
if (!closed.compareAndSet(false, true)) return
shutdownServer()
executor.shutdownNow()
+ check(executor.awaitTermination(5, TimeUnit.SECONDS)) { "Mock CLI executor did not terminate" }
}
private fun shutdownServer() {
diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt
index a834d47e1cd..128c04c04ed 100644
--- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt
+++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt
@@ -12,13 +12,16 @@ import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.jetbrains.api.client.DefaultApi
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -41,10 +44,13 @@ class KiloBackendWorkspaceTest {
@AfterTest
fun tearDown() {
- apps.forEach { it.dispose() }
- apps.clear()
- scope.cancel()
- mock.close()
+ runBlocking {
+ apps.forEach { it.dispose() }
+ apps.clear()
+ scope.cancel()
+ mock.close()
+ withTimeout(10_000) { scope.coroutineContext[Job]?.join() }
+ }
}
private fun setup(): KiloBackendAppService =
@@ -466,7 +472,7 @@ class KiloBackendWorkspaceTest {
mock.skills = SKILLS_JSON
val app = setup()
- ready(app)
+ val initial = ready(app)
// Change providers response then fire disposed event
mock.providers = """{
@@ -482,26 +488,16 @@ class KiloBackendWorkspaceTest {
"connected": ["openai"]
}"""
- mock.awaitSseConnection()
- mock.pushEvent("global.disposed", """{"type":"global.disposed"}""")
-
- // global.disposed triggers full app reload which restarts the
- // workspace manager (stop + start), clearing all cached workspaces.
- // Wait for app to reach Ready again after reload.
- withTimeout(15_000) {
- // App may briefly leave Ready during reload
- while (true) {
- val state = app.appState.value
- if (state is KiloAppState.Ready) {
- delay(300)
- if (app.appState.value is KiloAppState.Ready) break
- }
- delay(100)
- }
+ assertTrue(mock.awaitSseConnection())
+ val reload = async(start = CoroutineStart.UNDISPATCHED) {
+ app.appState.drop(1).first { it is KiloAppState.Ready }
}
+ mock.pushEvent("global.disposed", """{"type":"global.disposed"}""")
+ withTimeout(15_000) { reload.await() }
// Get a fresh workspace — old one was stopped during reload
val ws = app.workspaces.get("/test/project")
+ assertTrue(ws !== initial)
withTimeout(15_000) {
ws.state.first { it is KiloWorkspaceState.Ready }
}
diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts
index 1acfc676141..b2f73f52a7d 100644
--- a/packages/kilo-vscode/src/KiloProvider.ts
+++ b/packages/kilo-vscode/src/KiloProvider.ts
@@ -53,6 +53,7 @@ import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { diffSummary as localDiffSummary } from "./agent-manager/local-diff"
import { getWorkspaceRoot } from "./review-utils"
import { createMarketplaceRemover, removeAgent, removeMcp } from "./kilo-provider/remove-config-item"
+import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller"
import type { RemoteStatusService } from "./services/RemoteStatusService"
import { resolveProjectDirectory } from "./project-directory"
import { seedSessionStatuses } from "./session-status"
@@ -423,6 +424,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private diffVirtualProvider: import("./DiffVirtualProvider").DiffVirtualProvider | undefined
private remoteService: RemoteStatusService | null = null
private unsubscribeRemote: (() => void) | null = null
+ private readonly requirements: AgentRequirementsController
constructor(
private readonly extensionUri: vscode.Uri,
@@ -435,6 +437,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.unsubscribeSandboxPreference = this.connectionService.sandboxPreference?.onChange(() => {
if (this.connectionState === "connected") void this.fetchAndSendSandboxDefault()
})
+ this.requirements = new AgentRequirementsController({
+ post: (msg) => this.postMessage(msg),
+ client: () => this.client,
+ connected: () => this.connectionState === "connected",
+ generation: () => this.connectionGeneration,
+ root: () => this.getRootDirectory(),
+ folders: () => vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath),
+ project: () => this.projectDirectory,
+ sessions: () => this.sessionDirectories,
+ worktrees: this.opts.worktreeDirectories,
+ extension: (id) => vscode.extensions.getExtension(id),
+ subscribe:
+ typeof vscode.extensions.onDidChange === "function"
+ ? (listener) => vscode.extensions.onDidChange(listener)
+ : undefined,
+ error: getErrorMessage,
+ })
TelemetryProxy.getInstance().setProvider(this)
}
@@ -497,6 +516,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (this.projectDirectory === directory) return
this.projectDirectory = directory
this.postMessage({ type: "workspaceDirectoryChanged", directory: directory ?? "" })
+ this.requirements.clear()
}
public setDiffVirtualProvider(provider: import("./DiffVirtualProvider").DiffVirtualProvider): void {
@@ -537,6 +557,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
+ private openMarketplacePanel(directory: unknown): void {
+ if (typeof directory === "string" && directory) {
+ vscode.commands.executeCommand("kilo-code.new.marketplaceButtonClicked", directory)
+ return
+ }
+ vscode.commands.executeCommand("kilo-code.new.marketplaceButtonClicked", this.projectDirectory)
+ }
+
// Strip metadata unused by the webview to keep session switches fast.
// Logic in kilo-provider/slim-metadata.ts.
private slimInfo(info: T): T {
@@ -575,6 +603,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.cachedAgentsMessage = null
this.cachedConfigMessage = null
await Promise.all([this.fetchAndSendAgents(), this.fetchAndSendConfig()])
+ this.requirements.clear()
},
storage: this.extensionContext?.globalStorageUri,
}
@@ -743,12 +772,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
public setSessionDirectory(sessionId: string, directory: string): void {
this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId))
this.sessionDirectories.set(sessionId, directory)
+ this.requirements.clear()
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
}
public clearSessionDirectory(sessionId: string): void {
this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId))
this.sessionDirectories.delete(sessionId)
+ this.requirements.clear()
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
}
@@ -1016,7 +1047,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await openConfig(message.scope, message.labels, this.getProjectDirectory(this.currentSession?.id))
break
case "openMarketplacePanel":
- vscode.commands.executeCommand("kilo-code.new.marketplaceButtonClicked", this.projectDirectory)
+ this.openMarketplacePanel(message.directory)
break
case "forkSession":
handleForkSession(this.forkCtx, message.sessionId, message.messageId).catch((e) =>
@@ -1070,6 +1101,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestSkills":
this.fetchAndSendSkills().catch((e) => console.error("[Kilo New] fetchAndSendSkills failed:", e))
break
+ case "requestAgentRequirements":
+ this.requirements
+ .fetch({
+ agent: message.agent,
+ directory: message.directory,
+ sessionID: message.sessionID,
+ force: message.force === true,
+ })
+ .catch((e) => console.error("[Kilo New] fetchAndSendAgentRequirements failed:", e))
+ break
case "requestCommands":
this.fetchAndSendCommands().catch((e) => console.error("[Kilo New] fetchAndSendCommands failed:", e))
break
@@ -1091,7 +1132,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const c1 = this.client
if (c1) {
void McpOAuth.connectMcpServer(c1, message.name, this.getWorkspaceDirectory(), () =>
- this.fetchAndSendMcpStatus(),
+ this.refreshMcpStatus(),
).catch((e) => console.error("[Kilo New] connectMcpServer failed:", e))
}
break
@@ -1100,7 +1141,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const c2 = this.client
if (c2) {
void McpOAuth.disconnectMcpServer(c2, message.name, this.getWorkspaceDirectory(), () =>
- this.fetchAndSendMcpStatus(),
+ this.refreshMcpStatus(),
).catch((e) => console.error("[Kilo New] disconnectMcpServer failed:", e))
}
break
@@ -1109,7 +1150,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const c = this.client
if (c) {
void McpOAuth.authenticateMcpServer(c, message.name, this.getWorkspaceDirectory(), () =>
- this.fetchAndSendMcpStatus(),
+ this.refreshMcpStatus(),
).catch((e) => console.error("[Kilo New] authenticateMcpServer failed:", e))
}
break
@@ -1379,6 +1420,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
dir: () => this.getWorkspaceDirectory(this.currentSession?.id),
diff: this.diffVirtualProvider,
storage: this.extensionContext?.globalStorageUri,
+ post: (msg) => this.postMessage(msg),
})
}
@@ -1648,6 +1690,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
.catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e))
this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) })
+ this.requirements.clear()
this.client.session
.status({ directory: dir })
.then((r) => {
@@ -2204,6 +2247,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.cachedSkillsMessage = null
this.cachedCommandsMessage = null
await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()])
+ this.requirements.clear()
return true
}
@@ -2215,6 +2259,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (!result.error) {
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
+ this.requirements.clear()
return
}
} catch {
@@ -2232,6 +2277,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
+ private async refreshMcpStatus(): Promise {
+ await this.fetchAndSendMcpStatus()
+ this.requirements.clear()
+ }
+
private async fetchAndSendMcpStatus(): Promise {
if (!this.client) {
if (this.cachedMcpStatusMessage) {
@@ -2776,6 +2826,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
projectConfig: overlay?.project,
features: configFeatures(merged),
})
+ this.requirements.clear()
await Promise.all([
refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(),
refreshAgents ? this.fetchAndSendAgents() : Promise.resolve(),
@@ -2796,6 +2847,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
globalConfig: this.cachedGlobalConfig ?? undefined,
features: features ?? configFeatures(optimistic as Config),
})
+ this.requirements.clear()
} finally {
this.pending--
}
@@ -2975,6 +3027,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const sid = resolved!.sid
const dir = resolved!.dir
+ await this.requirements.assertAgentRequirements(agent, dir)
const editorContext = await this.gatherEditorContext(dir)
if (messageID) {
@@ -3064,6 +3117,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const sid = resolved!.sid
const dir = resolved!.dir
+ await this.requirements.assertAgentRequirements(agent, dir)
await this.checkpoints.get(sid)
await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", () =>
this.withRetry(
@@ -3372,6 +3426,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/** Re-fetch all server-side state after an auth change. */
private async reloadAfterAuthChange(): Promise {
+ this.requirements.clear()
await this.fetchAndSendConfig()
await Promise.all([
this.fetchAndSendProviders(),
@@ -3553,6 +3608,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Fetch and push the updated config + refresh agents and providers so the
// Settings panel and mode/model pickers reflect the change.
if (event.type === "global.config.updated") {
+ this.requirements.clear()
void Promise.all([this.fetchAndSendConfigUpdated(), this.fetchAndSendAgents(), this.fetchAndSendProviders()])
return
}
@@ -3972,6 +4028,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.anacondaDesktop.dispose()
this.aborts.clear()
this.sessionStatusMap.clear()
+ this.requirements.dispose()
this.ignoreController?.dispose()
this.chatAutocomplete?.dispose()
disposeGitChangesTarget()
diff --git a/packages/kilo-vscode/src/kilo-provider/agent-requirements-controller.ts b/packages/kilo-vscode/src/kilo-provider/agent-requirements-controller.ts
new file mode 100644
index 00000000000..f8277995228
--- /dev/null
+++ b/packages/kilo-vscode/src/kilo-provider/agent-requirements-controller.ts
@@ -0,0 +1,198 @@
+import type { KiloClient } from "@kilocode/sdk/v2/client"
+import {
+ applyVSCodeExtensionRequirements,
+ requirementDirectory,
+ requirementKey,
+ type BackendAgentRequirementResult,
+ type HostAgentRequirementResult,
+} from "./agent-requirements"
+
+type AgentRequirementsClient = {
+ agentRequirements: (
+ parameters: { agent: string; directory: string },
+ options: { throwOnError: true },
+ ) => Promise<{ data: BackendAgentRequirementResult }>
+}
+
+type LoadedMessage = {
+ type: "agentRequirementsLoaded"
+ result: HostAgentRequirementResult
+}
+
+type InvalidatedMessage = {
+ type: "agentRequirementsInvalidated"
+}
+
+type Disposable = {
+ dispose(): void
+}
+
+export type AgentRequirementsRequest = {
+ agent: string
+ directory: string
+ sessionID?: string
+ force?: boolean
+}
+
+export type AgentRequirementsControllerOptions = {
+ post: (message: LoadedMessage | InvalidatedMessage) => void
+ client: () => KiloClient | null
+ connected: () => boolean
+ generation: () => number
+ root: () => string
+ folders: () => readonly string[] | undefined
+ project: () => string | null | undefined
+ sessions: () => ReadonlyMap
+ worktrees?: () => readonly string[]
+ extension: (id: string) => unknown
+ subscribe?: (listener: () => void) => Disposable
+ error: (error: unknown) => string
+}
+
+export class AgentRequirementsController {
+ private readonly cache = new Map()
+ private readonly generations = new Map()
+ private readonly subscription: Disposable | undefined
+
+ constructor(private readonly opts: AgentRequirementsControllerOptions) {
+ this.subscription = opts.subscribe?.(() => this.clear())
+ }
+
+ clear(): void {
+ const active = this.cache.size > 0 || this.generations.size > 0
+ this.cache.clear()
+ this.generations.clear()
+ if (!active) return
+ this.opts.post({ type: "agentRequirementsInvalidated" })
+ }
+
+ dispose(): void {
+ this.subscription?.dispose()
+ this.cache.clear()
+ this.generations.clear()
+ }
+
+ fetch(request: AgentRequirementsRequest): Promise {
+ const dir = this.scope(request.directory, request.sessionID)
+ if (!dir) {
+ this.error(request.agent, request.directory, "scope_mismatch", "The agent requirement scope is no longer active")
+ return Promise.resolve()
+ }
+
+ return this.load(request.agent, dir, request.force === true).then(
+ (result) => {
+ if (!this.scope(dir, request.sessionID)) return
+ this.opts.post({ type: "agentRequirementsLoaded", result })
+ },
+ (error) => {
+ this.error(request.agent, dir, "request_failed", error)
+ },
+ )
+ }
+
+ private scope(requested: string, sessionID?: string): string | undefined {
+ return requirementDirectory({
+ requested,
+ sessionID,
+ workspaceDirectory: this.opts.root(),
+ workspaceDirectories: this.opts.folders(),
+ projectDirectory: this.opts.project(),
+ sessionDirectories: this.opts.sessions(),
+ worktreeDirectories: this.opts.worktrees,
+ })
+ }
+
+ private apply(result: BackendAgentRequirementResult, directory: string): HostAgentRequirementResult {
+ return applyVSCodeExtensionRequirements({ ...result, directory }, (id) => this.opts.extension(id))
+ }
+
+ private error(agent: string, directory: string, code: "scope_mismatch" | "request_failed", error: unknown): void {
+ this.opts.post({
+ type: "agentRequirementsLoaded",
+ result: {
+ agent,
+ directory,
+ enabled: true,
+ state: "error",
+ skills: [],
+ mcps: [],
+ vscode_extensions: [],
+ error: {
+ code,
+ message: this.opts.error(error) || "Failed to check agent requirements",
+ },
+ },
+ })
+ }
+
+ async assertAgentRequirements(agent: string | undefined, directory: string): Promise {
+ if (!agent) return
+
+ const dir = this.scope(directory)
+ if (!dir) throw new Error("The agent requirement scope is no longer active")
+
+ const result = await this.load(agent, dir)
+ if (!this.blocked(result)) return
+
+ this.opts.post({ type: "agentRequirementsLoaded", result })
+ throw new Error(this.message(result))
+ }
+
+ private async load(agent: string, directory: string, force = false): Promise {
+ const key = requirementKey(agent, directory)
+ if (!force) {
+ const cached = this.cache.get(key)
+ if (cached) return cached
+ }
+
+ const client = this.opts.client()
+ if (!client || !this.opts.connected()) throw new Error("Not connected to CLI backend")
+
+ const connection = this.opts.generation()
+ const token = {}
+ this.generations.set(key, token)
+
+ const endpoint = client.kilocode as typeof client.kilocode & AgentRequirementsClient
+ const response = await endpoint.agentRequirements({ agent, directory }, { throwOnError: true }).catch((error) => {
+ if (this.generations.get(key) === token) this.generations.delete(key)
+ throw error
+ })
+
+ if (this.opts.generation() !== connection || this.opts.client() !== client) {
+ if (this.generations.get(key) === token) this.generations.delete(key)
+ throw new Error("Connection changed while checking agent requirements")
+ }
+ if (this.generations.get(key) !== token) throw new Error("Agent requirement check was superseded")
+
+ const result = this.apply(response.data, directory)
+ this.cache.set(key, result)
+ this.generations.delete(key)
+ return result
+ }
+
+ private blocked(result: HostAgentRequirementResult): boolean {
+ if (!result.enabled || result.state === "disabled") return false
+ if (result.state === "blocked" || result.state === "error") return true
+
+ return (
+ result.skills.some((item) => item.status !== "ready") ||
+ result.mcps.some((item) => item.status !== "ready") ||
+ result.vscode_extensions.some((item) => item.status !== "ready")
+ )
+ }
+
+ private message(result: HostAgentRequirementResult): string {
+ if (result.error?.message) return result.error.message
+
+ const missing = [
+ ...result.skills.filter((item) => item.status !== "ready").map((item) => `skill ${item.name}`),
+ ...result.mcps.filter((item) => item.status !== "ready").map((item) => `MCP ${item.name}`),
+ ...result.vscode_extensions
+ .filter((item) => item.status !== "ready")
+ .map((item) => `VS Code extension ${item.name}`),
+ ]
+
+ if (missing.length) return `Agent requirements are not met: ${missing.join(", ")}`
+ return "Agent requirements are not met"
+ }
+}
diff --git a/packages/kilo-vscode/src/kilo-provider/agent-requirements.ts b/packages/kilo-vscode/src/kilo-provider/agent-requirements.ts
new file mode 100644
index 00000000000..43e71d4b6aa
--- /dev/null
+++ b/packages/kilo-vscode/src/kilo-provider/agent-requirements.ts
@@ -0,0 +1,103 @@
+import * as path from "path"
+import { sameDirectory } from "../kilo-provider-utils"
+
+export type RequirementStatus = "ready" | "missing" | "error"
+
+export type RequirementState = "disabled" | "ready" | "blocked" | "error"
+
+export type RequirementErrorCode =
+ | "unknown_agent"
+ | "malformed_declaration"
+ | "discovery_failed"
+ | "mcp_status_failed"
+ | "scope_mismatch"
+ | "request_failed"
+
+export type RequirementItem = {
+ name: string
+ status: RequirementStatus
+ message?: string
+}
+
+export type BackendVSCodeExtensionRequirement = {
+ name: string
+ id: string
+ message?: string
+}
+
+export type BackendAgentRequirementResult = {
+ agent: string
+ directory: string
+ enabled: boolean
+ state: RequirementState
+ skills: RequirementItem[]
+ mcps: RequirementItem[]
+ vscode_extensions: BackendVSCodeExtensionRequirement[]
+ error?: {
+ code: RequirementErrorCode
+ message: string
+ }
+}
+
+export type HostVSCodeExtensionRequirement = BackendVSCodeExtensionRequirement & {
+ status: RequirementStatus
+}
+
+export type HostAgentRequirementResult = Omit & {
+ vscode_extensions: HostVSCodeExtensionRequirement[]
+}
+
+export type RequirementDirectoryInput = {
+ requested: string
+ sessionID?: string
+ workspaceDirectory: string
+ workspaceDirectories?: readonly string[]
+ projectDirectory?: string | null
+ sessionDirectories: ReadonlyMap
+ worktreeDirectories?: () => readonly string[]
+}
+
+export type VSCodeExtensionLookup = (id: string) => unknown
+
+function normalize(directory: string): string {
+ if (!directory) return ""
+ const resolved = path.resolve(directory)
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved
+}
+
+export function requirementKey(agent: string, directory: string): string {
+ return `${normalize(directory)}\0${agent}`
+}
+
+export function requirementDirectory(input: RequirementDirectoryInput): string | undefined {
+ if (!input.requested) return undefined
+
+ const dirs = [
+ input.sessionID ? input.sessionDirectories.get(input.sessionID) : undefined,
+ input.workspaceDirectory,
+ ...(input.workspaceDirectories ?? []),
+ input.projectDirectory ?? undefined,
+ ...input.sessionDirectories.values(),
+ ...(input.worktreeDirectories?.() ?? []),
+ ].filter((dir): dir is string => !!dir)
+
+ return dirs.find((dir) => sameDirectory(dir, input.requested))
+}
+
+export function applyVSCodeExtensionRequirements(
+ result: BackendAgentRequirementResult,
+ lookup: VSCodeExtensionLookup,
+): HostAgentRequirementResult {
+ const extensions = result.vscode_extensions.map((extension) => {
+ const status: RequirementStatus = lookup(extension.id) ? "ready" : "missing"
+ return { ...extension, status }
+ })
+ const blocked =
+ result.enabled && result.state === "ready" && extensions.some((extension) => extension.status !== "ready")
+
+ return {
+ ...result,
+ state: blocked ? "blocked" : result.state,
+ vscode_extensions: extensions,
+ }
+}
diff --git a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts
index 1c55c65ec6b..c92ede42873 100644
--- a/packages/kilo-vscode/src/kilo-provider/editor-actions.ts
+++ b/packages/kilo-vscode/src/kilo-provider/editor-actions.ts
@@ -1,6 +1,7 @@
import * as vscode from "vscode"
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../image-preview"
-import { isAbsolutePath } from "../path-utils"
+import { escapeGlob, isAbsolutePath } from "../path-utils"
+import { validateFiles } from "./file-links"
import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider"
type EditorOpenMessage = {
@@ -68,11 +69,14 @@ export function handleEditorAction(
initialDiffStyle?: unknown
dataUrl?: string
filename?: string
+ id?: string
+ paths?: string[]
},
opts: {
dir: () => string
diff?: DiffVirtualProvider
storage?: vscode.Uri
+ post?: (msg: unknown) => void
},
): boolean {
if (message.type === "openFile") {
@@ -83,6 +87,18 @@ export function handleEditorAction(
if (message.content) openContent(message.content, message.language)
return true
}
+ if (message.type === "validateFiles") {
+ const id = message.id
+ const paths = message.paths
+ if (id && paths && opts.post) {
+ const post = opts.post
+ validateFiles(opts.dir(), paths).then(
+ (existing) => post({ type: "validateFilesResult", id, existing }),
+ (err) => console.error("[Kilo New] KiloProvider: validateFiles failed:", err),
+ )
+ }
+ return true
+ }
if (message.type === "openExternal") {
openExternal(message.url)
return true
@@ -105,6 +121,56 @@ function openContent(content: string, language?: string): void {
)
}
+function show(uri: vscode.Uri, line?: number, column?: number): void {
+ vscode.workspace.openTextDocument(uri).then(
+ (doc) => {
+ const options: vscode.TextDocumentShowOptions = { preview: true }
+ if (line !== undefined && line > 0) {
+ const col = column !== undefined && column > 0 ? column - 1 : 0
+ const pos = new vscode.Position(line - 1, col)
+ options.selection = new vscode.Range(pos, pos)
+ }
+ vscode.window
+ .showTextDocument(doc, options)
+ .then(undefined, (err) => console.error("[Kilo New] KiloProvider: Failed to show document:", uri.fsPath, err))
+ },
+ (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
+ )
+}
+
+/**
+ * Fallback when the exact path does not exist: search the session directory by
+ * filename. Opens the file directly on a single match, prompts on multiple,
+ * warns on none. The search is scoped to `dir` (the active session's directory)
+ * via a RelativePattern so it can't cross into another worktree/branch.
+ */
+function findFallback(dir: string, filePath: string, line?: number, column?: number): void {
+ const name = filePath.split(/[\\/]/).pop() || filePath
+ // VS Code globs don't honor backslash escapes, so bracket-escape metacharacters
+ // (e.g. `[id].tsx`) instead — otherwise such names never match.
+ const pattern = new vscode.RelativePattern(vscode.Uri.file(dir), `**/${escapeGlob(name)}`)
+ Promise.resolve(vscode.workspace.findFiles(pattern, "**/node_modules/**", 5)).then(
+ (matches) => {
+ if (matches.length === 1) {
+ show(matches[0], line, column)
+ return
+ }
+ if (matches.length > 1) {
+ const items = matches.map((m) => ({ label: vscode.workspace.asRelativePath(m), uri: m }))
+ vscode.window.showQuickPick(items, { placeHolder: `Multiple matches for "${name}"` }).then(
+ (pick) => {
+ if (pick) show(pick.uri, line, column)
+ },
+ (err) => console.error("[Kilo New] KiloProvider: showQuickPick failed:", err),
+ )
+ return
+ }
+ vscode.window.showWarningMessage(`File not found: ${filePath}`)
+ },
+ (err: unknown) => console.error("[Kilo New] KiloProvider: findFiles failed:", err),
+ )
+}
+
function openFile(dir: string, filePath: string, line?: number, column?: number): void {
const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) : vscode.Uri.joinPath(vscode.Uri.file(dir), filePath)
vscode.workspace.fs.stat(uri).then(
@@ -113,19 +179,8 @@ function openFile(dir: string, filePath: string, line?: number, column?: number)
vscode.commands.executeCommand("revealInExplorer", uri)
return
}
- vscode.workspace.openTextDocument(uri).then(
- (doc) => {
- const options: vscode.TextDocumentShowOptions = { preview: true }
- if (line !== undefined && line > 0) {
- const col = column !== undefined && column > 0 ? column - 1 : 0
- const pos = new vscode.Position(line - 1, col)
- options.selection = new vscode.Range(pos, pos)
- }
- vscode.window.showTextDocument(doc, options)
- },
- (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
- )
+ show(uri, line, column)
},
- (err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err),
+ () => findFallback(dir, filePath, line, column),
)
}
diff --git a/packages/kilo-vscode/src/kilo-provider/file-links.ts b/packages/kilo-vscode/src/kilo-provider/file-links.ts
new file mode 100644
index 00000000000..d038005bea3
--- /dev/null
+++ b/packages/kilo-vscode/src/kilo-provider/file-links.ts
@@ -0,0 +1,40 @@
+import { realpath } from "node:fs/promises"
+import * as path from "node:path"
+import * as vscode from "vscode"
+import { contains } from "../path-utils"
+
+/**
+ * Stat-check candidate paths and return which ones are actual files (not directories).
+ *
+ * The webview marks every inline code span as a file-link candidate; this confirms
+ * which of those candidates resolve to a real file so the webview can promote them
+ * to clickable links and leave the rest as plain code.
+ *
+ * Containment is enforced twice so auto-validated model output can't probe host
+ * files outside the session `root`:
+ * 1. a lexical check rejects absolute paths elsewhere, UNC paths, and `../`
+ * traversal before touching the filesystem at all;
+ * 2. the candidate's real path (symlinks resolved) must still be inside the
+ * real root, so a checked-in symlink can't escape the root either.
+ */
+export function validateFiles(root: string, paths: string[]): Promise {
+ return Promise.resolve(realpath(root)).then(
+ (realRoot) => {
+ const check = (p: string): Promise => {
+ if (!contains(root, p)) return Promise.resolve(null)
+ return Promise.resolve(realpath(path.resolve(root, p))).then(
+ (real) => {
+ if (!contains(realRoot, real)) return null
+ return Promise.resolve(vscode.workspace.fs.stat(vscode.Uri.file(real))).then(
+ (s) => (s.type & vscode.FileType.File ? p : null),
+ () => null,
+ )
+ },
+ () => null,
+ )
+ }
+ return Promise.all(paths.map(check)).then((r) => r.filter((x): x is string => x !== null))
+ },
+ () => [],
+ )
+}
diff --git a/packages/kilo-vscode/src/path-utils.ts b/packages/kilo-vscode/src/path-utils.ts
index 856a76038a2..ab3f829abb4 100644
--- a/packages/kilo-vscode/src/path-utils.ts
+++ b/packages/kilo-vscode/src/path-utils.ts
@@ -1,3 +1,5 @@
+import * as path from "node:path"
+
/**
* Check whether a file path is absolute.
*
@@ -25,3 +27,29 @@ export function isAbsolutePath(filePath: string): boolean {
return true
return false
}
+
+/**
+ * Whether `candidate` resolves to a location inside `root`.
+ *
+ * Rejects UNC candidates, absolute paths outside the root, and `../` traversal
+ * that escapes the root. Used to keep filesystem probes scoped to the trusted
+ * session directory so model-generated paths can't reach arbitrary host files.
+ */
+export function contains(root: string, candidate: string): boolean {
+ if (!root || !candidate) return false
+ // UNC candidates can trigger outbound filesystem requests on Windows — never allow them.
+ if (candidate.startsWith("\\\\") || candidate.startsWith("//")) return false
+ const base = path.resolve(root)
+ const rel = path.relative(base, path.resolve(base, candidate))
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
+}
+
+/**
+ * Escape glob metacharacters so a literal filename can be embedded in a VS Code
+ * glob pattern. VS Code globs do not honor backslash escapes, so each special
+ * character is wrapped in a single-character bracket expression — e.g.
+ * `[id].tsx` becomes `[[]id[]].tsx`.
+ */
+export function escapeGlob(name: string): string {
+ return name.replace(/[*?{}[\]]/g, (c) => (c === "]" ? "[]]" : `[${c}]`))
+}
diff --git a/packages/kilo-vscode/src/services/autocomplete/i18n/en.ts b/packages/kilo-vscode/src/services/autocomplete/i18n/en.ts
index c9d7fb229c8..e2e2f423dd4 100644
--- a/packages/kilo-vscode/src/services/autocomplete/i18n/en.ts
+++ b/packages/kilo-vscode/src/services/autocomplete/i18n/en.ts
@@ -42,8 +42,8 @@ export const dict = {
"kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "Disable Copilot",
"kilocode:autocomplete.incompatibilityExtensionPopup.disableInlineAssist": "Disable Autocomplete",
"kilocode:autocomplete.creditsExhausted.message":
- "Kilo Code Autocomplete has been paused because your account has no remaining credits. Add credits to resume autocomplete.",
+ "Kilo Code Autocomplete has been paused. Possible causes: your Kilo account has no remaining credits, or your configured API key (BYOK) has reached its quota limit. Add Kilo credits or check your API key configuration to resume autocomplete.",
"kilocode:autocomplete.creditsExhausted.addCredits": "Add Credits",
"kilocode:autocomplete.authError.message":
- "Kilo Code Autocomplete has been paused due to an authentication error. Please sign in again.",
+ "Kilo Code Autocomplete has been paused due to an authentication issue. Possible causes: you are not signed in to Kilo, or your API key (BYOK) is invalid or missing. Please sign in again or check your provider API key settings.",
}
diff --git a/packages/kilo-vscode/src/services/marketplace/types.ts b/packages/kilo-vscode/src/services/marketplace/types.ts
index 3e380e50544..f7bf682ed20 100644
--- a/packages/kilo-vscode/src/services/marketplace/types.ts
+++ b/packages/kilo-vscode/src/services/marketplace/types.ts
@@ -41,6 +41,11 @@ export interface AgentContent {
prompt: string
options?: Record
permission?: Record
+ requirements?: {
+ skills?: string[]
+ mcps?: string[]
+ vscode_extensions?: Array<{ name: string; id: string }>
+ }
}
export interface AgentMarketplaceItem extends MarketplaceItemBase {
diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts
index 527380a8803..45a7a339fdc 100644
--- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts
+++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts
@@ -183,6 +183,24 @@ test("selected favorite remains selected when its duplicate group is collapsed",
await expect(combobox).toHaveAttribute("aria-activedescendant", await favorites.getAttribute("id"))
})
+test("large catalogs keep the rendered tree bounded and navigate to distant models", async ({ page }) => {
+ await load(page, "shared--model-selector-large-catalog")
+
+ await page.getByRole("button", { name: "Select model: Provider 0 / Model 300" }).click()
+ const combobox = page.getByRole("combobox", { name: "Select model: Provider 0 / Model 300. Search models" })
+ const tree = page.getByRole("tree", { name: "Select model" })
+
+ // The window mounts before we measure it, yet stays far smaller than the catalog.
+ await expect.poll(() => tree.getByRole("treeitem").count()).toBeGreaterThan(0)
+ await expect.poll(() => tree.getByRole("treeitem").count()).toBeLessThan(50)
+
+ // Reaching a distant model scrolls it into the mounted window and activates it.
+ await combobox.fill("Model 599")
+ const last = page.getByRole("treeitem", { name: "Model 599" })
+ await expect(last).toBeVisible()
+ await expect(combobox).toHaveAttribute("aria-activedescendant", await last.getAttribute("id"))
+})
+
test("Enter selects the active option and Escape restores selector focus", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
@@ -252,6 +270,35 @@ test("settings and mode editing expose distinct model field purposes", async ({
)
})
+test("mode picker focuses the selected mode as it opens", async ({ page }) => {
+ await load(page, "prompt-input--default-420")
+
+ await page.getByRole("button", { name: "Code", exact: true }).click()
+ await expect(page.locator(".mode-switcher-item.selected")).toBeFocused()
+})
+
+test("variant picker focuses the selected effort as it opens", async ({ page }) => {
+ await load(page, "prompt-input--with-thinking-420")
+
+ await page.getByRole("button", { name: "Medium", exact: true }).click()
+ await expect(page.locator(".thinking-selector-item.selected")).toBeFocused()
+})
+
+test("slash mode picker Escape returns focus to the prompt", async ({ page }) => {
+ await load(page, "prompt-input--default-420")
+
+ const prompt = page.locator("textarea.prompt-input")
+ await prompt.evaluate((el) => el.setAttribute("aria-disabled", "false"))
+ await prompt.fill("/agents")
+ await prompt.press("Enter")
+
+ const selected = page.locator(".mode-switcher-item.selected")
+ await expect(selected).toBeFocused()
+ await selected.press("Escape")
+
+ await expect(prompt).toBeFocused()
+})
+
test("chat picker Escape returns focus to the prompt", async ({ page }) => {
await load(page, "prompt-input--default-420")
diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts
index 4c02a47eeea..ac94c151cc1 100644
--- a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts
+++ b/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts
@@ -211,6 +211,21 @@ test.describe("Permission Dock Dropdown — external directory", () => {
await page.waitForSelector("#storybook-root *", { state: "attached" })
await openDropdown(page)
+ const text = "Read External Directory /Users/developer/projects/kilo-bench/dashboard/app/routes/*"
+ const hint = page.locator('[data-slot="permission-hint"]')
+ await expect(hint).toHaveText(text)
+ await expect(hint).toHaveAttribute("title", text)
+ expect(
+ await hint.evaluate((node) => node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight),
+ ).toBe(false)
+
+ const rule = page.locator('[data-slot="permission-rule"]')
+ await expect(rule).toHaveText(text)
+ await expect(rule).toHaveAttribute("title", text)
+ expect(
+ await rule.evaluate((node) => node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight),
+ ).toBe(false)
+
const root = page.locator("#storybook-root")
await expect(root).toHaveScreenshot(["permission-dock-dropdown", "external-dir-expanded-pending.png"])
})
diff --git a/packages/kilo-vscode/tests/unit/agent-requirements.test.ts b/packages/kilo-vscode/tests/unit/agent-requirements.test.ts
new file mode 100644
index 00000000000..26290cff011
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/agent-requirements.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, it } from "bun:test"
+import type { KiloClient } from "@kilocode/sdk/v2/client"
+import {
+ applyVSCodeExtensionRequirements,
+ requirementDirectory,
+ requirementKey,
+ type BackendAgentRequirementResult,
+ type HostAgentRequirementResult,
+} from "../../src/kilo-provider/agent-requirements"
+import { AgentRequirementsController } from "../../src/kilo-provider/agent-requirements-controller"
+import { keepAgentRequirementsResult } from "../../webview-ui/src/context/agent-requirements-state"
+
+const root = "/repo"
+
+function result(input: Partial = {}): BackendAgentRequirementResult {
+ return {
+ agent: "demo",
+ directory: root,
+ enabled: true,
+ state: "ready",
+ skills: [],
+ mcps: [],
+ vscode_extensions: [],
+ ...input,
+ }
+}
+
+function client(value: BackendAgentRequirementResult | (() => BackendAgentRequirementResult)) {
+ const calls: Array<{ agent: string; directory: string }> = []
+ const api = {
+ kilocode: {
+ agentRequirements: async (parameters: { agent: string; directory: string }) => {
+ calls.push(parameters)
+ return { data: typeof value === "function" ? value() : value }
+ },
+ },
+ } as unknown as KiloClient
+ return { calls, api }
+}
+
+function controller(
+ input: {
+ api?: KiloClient | null
+ posts?: unknown[]
+ sessions?: ReadonlyMap
+ worktrees?: () => readonly string[]
+ extensions?: ReadonlySet
+ subscribe?: (listener: () => void) => { dispose(): void }
+ } = {},
+) {
+ const posts = input.posts ?? []
+ return new AgentRequirementsController({
+ post: (message) => posts.push(message),
+ client: () => input.api ?? null,
+ connected: () => true,
+ generation: () => 1,
+ root: () => root,
+ folders: () => [root, "/workspace"],
+ project: () => root,
+ sessions: () => input.sessions ?? new Map(),
+ worktrees: input.worktrees,
+ extension: (id) => input.extensions?.has(id),
+ subscribe: input.subscribe,
+ error: (error) => (error instanceof Error ? error.message : String(error)),
+ })
+}
+
+describe("agent requirement helpers", () => {
+ it("builds stable cache keys from normalized directories", () => {
+ expect(requirementKey("demo", "/repo/./subdir")).toBe(requirementKey("demo", "/repo/subdir"))
+ expect(requirementKey("other", "/repo/subdir")).not.toBe(requirementKey("demo", "/repo/subdir"))
+ })
+
+ it("scopes requests to active workspace, session, and worktree directories", () => {
+ const sessions = new Map([["session-1", "/repo/.kilo/worktrees/one"]])
+ const base = {
+ workspaceDirectory: root,
+ workspaceDirectories: ["/workspace"],
+ projectDirectory: root,
+ sessionDirectories: sessions,
+ worktreeDirectories: () => ["/repo/.kilo/worktrees/two"],
+ }
+
+ expect(requirementDirectory({ ...base, requested: "/repo" })).toBe(root)
+ expect(requirementDirectory({ ...base, requested: "/repo/.kilo/worktrees/one", sessionID: "session-1" })).toBe(
+ "/repo/.kilo/worktrees/one",
+ )
+ expect(requirementDirectory({ ...base, requested: "/repo/.kilo/worktrees/two" })).toBe("/repo/.kilo/worktrees/two")
+ expect(requirementDirectory({ ...base, requested: "/other" })).toBeUndefined()
+ })
+
+ it("adds VS Code extension statuses and blocks ready backend results when extensions are missing", () => {
+ const checked = applyVSCodeExtensionRequirements(
+ result({
+ vscode_extensions: [
+ { name: "Ready Extension", id: "publisher.ready" },
+ { name: "Missing Extension", id: "publisher.missing" },
+ ],
+ }),
+ (id) => id === "publisher.ready",
+ )
+
+ expect(checked.state).toBe("blocked")
+ expect(checked.vscode_extensions).toEqual([
+ { name: "Ready Extension", id: "publisher.ready", status: "ready" },
+ { name: "Missing Extension", id: "publisher.missing", status: "missing" },
+ ])
+ })
+})
+
+describe("agent requirement webview state", () => {
+ const blocked: HostAgentRequirementResult = {
+ ...result({ state: "blocked", skills: [{ name: "skill", status: "missing" }] }),
+ vscode_extensions: [],
+ }
+
+ it("preserves ready, blocked, and error results only for the same agent and directory", () => {
+ expect(keepAgentRequirementsResult(blocked, "demo", root)).toBe(true)
+ expect(keepAgentRequirementsResult({ ...blocked, state: "error" }, "demo", root)).toBe(true)
+ expect(keepAgentRequirementsResult({ ...blocked, state: "ready" }, "demo", root)).toBe(true)
+ expect(keepAgentRequirementsResult({ ...blocked, state: "disabled" }, "demo", root)).toBe(false)
+ expect(keepAgentRequirementsResult(blocked, "other", root)).toBe(false)
+ expect(keepAgentRequirementsResult(blocked, "demo", "/other")).toBe(false)
+ })
+})
+
+describe("AgentRequirementsController", () => {
+ it("caches fetched results until invalidated", async () => {
+ const posts: unknown[] = []
+ const backend = client(result())
+ const requirements = controller({ api: backend.api, posts })
+
+ await requirements.fetch({ agent: "demo", directory: root })
+ await requirements.fetch({ agent: "demo", directory: root })
+ requirements.clear()
+ await requirements.fetch({ agent: "demo", directory: root })
+
+ expect(backend.calls).toEqual([
+ { agent: "demo", directory: root },
+ { agent: "demo", directory: root },
+ ])
+ expect(
+ posts.filter((message) => (message as { type?: string }).type === "agentRequirementsInvalidated"),
+ ).toHaveLength(1)
+ })
+
+ it("posts scope mismatch errors for inactive directories", async () => {
+ const posts: unknown[] = []
+ const backend = client(result())
+ const requirements = controller({ api: backend.api, posts })
+
+ await requirements.fetch({ agent: "demo", directory: "/other" })
+
+ expect(backend.calls).toEqual([])
+ expect(posts.at(-1)).toMatchObject({
+ type: "agentRequirementsLoaded",
+ result: { state: "error", error: { code: "scope_mismatch" } },
+ })
+ })
+
+ it("clears requirements when the extension subscription fires", async () => {
+ const posts: unknown[] = []
+ let listener: (() => void) | undefined
+ let disposed = false
+ const backend = client(result())
+ const requirements = controller({
+ api: backend.api,
+ posts,
+ subscribe: (fn) => {
+ listener = fn
+ return { dispose: () => void (disposed = true) }
+ },
+ })
+
+ await requirements.fetch({ agent: "demo", directory: root })
+ posts.length = 0
+ listener?.()
+ requirements.dispose()
+
+ expect(posts).toEqual([{ type: "agentRequirementsInvalidated" }])
+ expect(disposed).toBe(true)
+ })
+
+ it("asserts and reports blocked requirements before host sends", async () => {
+ const posts: unknown[] = []
+ const backend = client(
+ result({
+ vscode_extensions: [{ name: "Missing Extension", id: "publisher.missing" }],
+ }),
+ )
+ const requirements = controller({ api: backend.api, posts })
+
+ await expect(requirements.assertAgentRequirements("demo", root)).rejects.toThrow(
+ "VS Code extension Missing Extension",
+ )
+ expect(posts.at(-1)).toMatchObject({
+ type: "agentRequirementsLoaded",
+ result: { state: "blocked", vscode_extensions: [{ status: "missing" }] },
+ })
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts b/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts
index a576f64d95c..37262e11533 100644
--- a/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts
+++ b/packages/kilo-vscode/tests/unit/marketplace-installer.test.ts
@@ -4,7 +4,9 @@ import * as os from "os"
import * as path from "path"
import { MarketplaceInstaller } from "../../src/services/marketplace/installer"
import { MarketplacePaths } from "../../src/services/marketplace/paths"
+import type { AgentMarketplaceItem } from "../../src/services/marketplace/types"
import { exec } from "../../src/util/process"
+import * as yaml from "yaml"
const tmpDir = path.join(os.tmpdir(), `kilo-test-${Date.now()}`)
@@ -32,6 +34,24 @@ function skill(content: string, id = "test-skill") {
}
}
+function agent(content: AgentMarketplaceItem["content"], id = "test-agent"): AgentMarketplaceItem {
+ return {
+ type: "agent",
+ id,
+ name: "Test Agent",
+ description: "test",
+ category: "development",
+ content,
+ }
+}
+
+async function frontmatter(file: string) {
+ const content = await fs.readFile(file, "utf-8")
+ const match = content.match(/^---\n([\s\S]*?)\n---/)
+ expect(match).not.toBeNull()
+ return yaml.parse(match?.[1] ?? "") as Record
+}
+
async function archive(): Promise {
const root = path.join(tmpDir, "archive")
const source = path.join(root, "source")
@@ -248,3 +268,27 @@ describe("MarketplaceInstaller skills", () => {
}
})
})
+
+describe("MarketplaceInstaller agents", () => {
+ it("preserves requirements in installed agent frontmatter", async () => {
+ const installer = new MarketplaceInstaller(new TestPaths())
+ const item = agent({
+ mode: "all",
+ description: "Requires local setup",
+ prompt: "Use the available project tools.",
+ requirements: {
+ skills: ["project-skill"],
+ mcps: ["project-mcp"],
+ vscode_extensions: [{ name: "Project Helper", id: "publisher.project-helper" }],
+ },
+ })
+
+ const result = await installer.installAgent(item, "project", tmpDir)
+
+ expect(result.success).toBe(true)
+ expect(result.filePath).toBeDefined()
+ if (!result.filePath) throw new Error("agent install did not return a file path")
+ const data = await frontmatter(result.filePath)
+ expect(data.requirements).toEqual(item.content.requirements)
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/path-utils.test.ts b/packages/kilo-vscode/tests/unit/path-utils.test.ts
index 517e89eded9..0c9dfc3082d 100644
--- a/packages/kilo-vscode/tests/unit/path-utils.test.ts
+++ b/packages/kilo-vscode/tests/unit/path-utils.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
-import { isAbsolutePath } from "../../src/path-utils"
+import { contains, escapeGlob, isAbsolutePath } from "../../src/path-utils"
describe("isAbsolutePath", () => {
// ── Unix absolute paths ──────────────────────────────────────────────
@@ -163,3 +163,47 @@ describe("isAbsolutePath", () => {
})
})
})
+
+describe("contains", () => {
+ it("accepts relative paths inside the root", () => {
+ expect(contains("/work", "src/a.ts")).toBe(true)
+ expect(contains("/work", "./src/a.ts")).toBe(true)
+ expect(contains("/work", "a.ts")).toBe(true)
+ })
+
+ it("rejects parent traversal that escapes the root", () => {
+ expect(contains("/work", "../etc/passwd")).toBe(false)
+ expect(contains("/work", "../../secret.ts")).toBe(false)
+ })
+
+ it("rejects absolute paths outside the root", () => {
+ expect(contains("/work", "/etc/passwd")).toBe(false)
+ })
+
+ it("rejects UNC candidates", () => {
+ expect(contains("/work", "\\\\server\\share\\file.ts")).toBe(false)
+ expect(contains("/work", "//server/share/file.ts")).toBe(false)
+ })
+
+ it("rejects empty inputs", () => {
+ expect(contains("", "a.ts")).toBe(false)
+ expect(contains("/work", "")).toBe(false)
+ })
+})
+
+describe("escapeGlob", () => {
+ it("bracket-escapes dynamic-route filenames", () => {
+ expect(escapeGlob("[id].tsx")).toBe("[[]id[]].tsx")
+ expect(escapeGlob("[...slug].tsx")).toBe("[[]...slug[]].tsx")
+ })
+
+ it("escapes wildcard and brace metacharacters", () => {
+ expect(escapeGlob("a*b?.ts")).toBe("a[*]b[?].ts")
+ expect(escapeGlob("{x,y}.ts")).toBe("[{]x,y[}].ts")
+ })
+
+ it("leaves ordinary filenames unchanged", () => {
+ expect(escapeGlob("index.ts")).toBe("index.ts")
+ expect(escapeGlob("file-path.test.ts")).toBe("file-path.test.ts")
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/permission-description.test.ts b/packages/kilo-vscode/tests/unit/permission-description.test.ts
index d67145ed20c..b9cff740003 100644
--- a/packages/kilo-vscode/tests/unit/permission-description.test.ts
+++ b/packages/kilo-vscode/tests/unit/permission-description.test.ts
@@ -181,6 +181,7 @@ describe("describeRule", () => {
test("preserves existing permission rule labels", () => {
expect(describeRule("read", "*", t)).toBe("Read")
+ expect(describeRule("external_directory", "*", t)).toBe("Read External Directory")
expect(describeRule("read", "src/app.ts", t)).toBe("Read src/app.ts")
})
})
diff --git a/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts b/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts
index 319f88995fc..a10e100ed4c 100644
--- a/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts
+++ b/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts
@@ -60,17 +60,21 @@ describe("movePromptDraft", () => {
const text = new Map([[source, "Keep this prompt"]])
const comments = new Map([[source, [comment]]])
const images = new Map([[source, [image]]])
+ const scrolls = new Map([[source, 128]])
- expect(movePromptDraft({ text, comments, images }, source, target)).toEqual({
+ expect(movePromptDraft({ text, comments, images, scrolls }, source, target)).toEqual({
text: "Keep this prompt",
comments: [comment],
images: [image],
+ scroll: 128,
})
expect(text.get(target)).toBe("Keep this prompt")
expect(comments.get(target)).toEqual([comment])
expect(images.get(target)).toEqual([image])
+ expect(scrolls.get(target)).toBe(128)
expect(text.has(source)).toBe(false)
expect(comments.has(source)).toBe(false)
expect(images.has(source)).toBe(false)
+ expect(scrolls.has(source)).toBe(false)
})
})
diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts
index 9bcd471faa9..8141a6c54cf 100644
--- a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts
+++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts
@@ -41,7 +41,8 @@ describe("PromptInput sandbox toggle", () => {
expect(toggle).toContain("sessionID,")
expect(toggle).toContain("requestID,")
expect(toggle).not.toContain("draftID:")
- expect(toggle).toContain("setSandboxTarget(sessionID ?? null)")
+ expect(toggle).toContain('setSandboxRequests((current) => ({ ...current, [sessionID ?? ""]: requestID }))')
+ expect(toggle).not.toContain("setSandboxTarget")
expect(toggle).not.toContain('type: "updateConfig"')
})
@@ -58,6 +59,26 @@ describe("PromptInput sandbox toggle", () => {
expect(end).toBeGreaterThan(start)
expect(save).toBeGreaterThan(-1)
expect(move).toBeGreaterThan(save)
+ expect(created).toContain("{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls }")
+ })
+
+ it("restores each prompt draft's textarea and highlight scroll positions", () => {
+ expect(src).toContain("const scrolls = new Map()")
+ expect(src).toContain("const scroll = scrolls.get(key) ?? 0")
+ expect(src).toContain("textareaRef.scrollTop = scroll")
+ expect(src).toContain("if (highlightRef) highlightRef.scrollTop = scroll")
+ expect(src).toContain("scrolls.set(draftKey(), textareaRef.scrollTop)")
+ expect(src).toContain("images: imageAttach.images(),\n scroll: textareaRef?.scrollTop")
+ expect(src).toContain("draft.text, draft.comments, draft.images, draft.scroll")
+ })
+
+ it("tracks in-flight toggles per session while switching", () => {
+ expect(src).toContain("const [sandboxRequests, setSandboxRequests] = createSignal>({})")
+ expect(src).toContain('const sandboxRequest = (sessionID?: string) => sandboxRequests()[sessionID ?? ""]')
+ expect(src).toContain("sandboxRequest(sandboxID()) !== undefined")
+ expect(src).toContain("if (current[key] !== requestID) return current")
+ expect(src).toContain("clearSandboxRequest(message.sessionID, message.requestID!)")
+ expect(src).not.toContain("setSandboxTarget")
})
it("keeps persisted sandbox state visible independently of the configured default", () => {
@@ -70,11 +91,12 @@ describe("PromptInput sandbox toggle", () => {
expect(src).toContain('if (!sandboxVisible()) hidden.add("sandbox")')
expect(src).toContain("onToggle={toggleSandbox}")
expect(src).toContain('message.type === "sandboxStatus"')
- expect(src).toContain("message.sessionID !== sandboxID() && !matching")
- expect(src).toContain("setSandboxState(state)")
- expect(src).toContain("message.requestID === sandboxRequest()")
- expect(src).toContain("const target = untrack(sandboxTarget)")
- expect(src).toContain("if (target !== undefined && target !== sessionID) clearSandboxRequest()")
+ expect(src).not.toContain("message.sessionID !== sandboxID() && !matching")
+ expect(src).toContain("const next = applySandboxStates(current, message)")
+ expect(src).toContain("if (next !== current) setSandboxes(next)")
+ expect(src).toContain("message.requestID === sandboxRequest(message.sessionID)")
+ expect(src).toContain("if (message.sessionID === sandboxID())")
+ expect(src).toContain("if (message.sessionID === sandboxID()) retrySandbox(message.sessionID)")
expect(src).toContain("sandboxID() ? sandbox()?.enabled : sandboxDefault()?.enabled")
expect(src).toContain('type: "requestSandboxDefault", agentManagerContext: ctx()')
expect(src).toContain(" {
expect(src).toContain("!sandboxReady()")
expect(button).toContain("aria-pressed={props.enabled}")
expect(button).toContain('class={`prompt-status-button ${props.enabled ? "prompt-status-button--active" : ""}`}')
- expect(src).toContain("if (sandboxRequest() && target === null) return")
+ expect(src).toContain("if (sandboxRequest(undefined)) return")
expect(src).not.toContain("if (state === current) return true")
})
diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts
index 258e14f4b27..fbbcab51ad8 100644
--- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts
+++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts
@@ -11,6 +11,7 @@ import {
isQuestioning,
isPathMention,
applySandboxState,
+ applySandboxStates,
} from "../../webview-ui/src/components/chat/prompt-input-utils"
describe("applySandboxState", () => {
@@ -43,6 +44,17 @@ describe("applySandboxState", () => {
state(false, 6, "ses_1", "/worktree"),
)
})
+
+ it("caches independently ordered statuses for worktree switching", () => {
+ const first = applySandboxStates({}, state(true, 5, "ses_1"))
+ const second = applySandboxStates(first, state(false, 1, "ses_2"))
+
+ expect(second).toEqual({
+ ses_1: state(true, 5, "ses_1"),
+ ses_2: state(false, 1, "ses_2"),
+ })
+ expect(applySandboxStates(second, state(false, 4, "ses_1"))).toBe(second)
+ })
})
describe("fileName", () => {
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
index a092686fcb5..d3539b200c0 100644
--- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
+++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
@@ -81,6 +81,7 @@ import { KiloEmbeddingModelsProvider } from "../src/context/kilo-embedding-model
import { NotificationsProvider } from "../src/context/notifications"
import { FeedbackProvider } from "../src/context/feedback"
import { SessionProvider, useSession } from "../src/context/session"
+import { AgentRequirementsProvider } from "../src/context/agent-requirements"
import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { ChatView } from "../src/components/chat"
import HistoryView from "../src/components/history/HistoryView"
@@ -3136,13 +3137,15 @@ export const AgentManagerApp: Component = () => {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx
index de57030df63..a4fbb410260 100644
--- a/packages/kilo-vscode/webview-ui/src/App.tsx
+++ b/packages/kilo-vscode/webview-ui/src/App.tsx
@@ -19,6 +19,7 @@ import { ConfigProvider } from "./context/config"
import { DisplayProvider } from "./context/display"
import { WorkStyleProvider } from "./context/work-style"
import { IndexingProvider } from "./context/indexing"
+import { AgentRequirementsProvider } from "./context/agent-requirements"
import { SessionProvider, useSession } from "./context/session"
import { LanguageBridge } from "./context/language-bridge"
import { ChatView } from "./components/chat"
@@ -149,6 +150,35 @@ export const DataBridge: Component<{ children: any }> = (props) => {
vscode.postMessage({ type: "openContent", content, language })
}
+ // File existence validation for code span candidates
+ const pending = new Map void>()
+ const counter = { n: 0 }
+ const validateFiles = (paths: string[]): Promise => {
+ const id = `vf-${++counter.n}`
+ return new Promise((resolve) => {
+ pending.set(id, resolve)
+ vscode.postMessage({ type: "validateFiles", id, paths })
+ setTimeout(() => {
+ if (pending.has(id)) {
+ pending.delete(id)
+ resolve([])
+ }
+ }, 3000)
+ })
+ }
+ const handler = (event: MessageEvent) => {
+ const msg = event.data
+ if (msg?.type === "validateFilesResult" && msg.id) {
+ const cb = pending.get(msg.id)
+ if (cb) {
+ pending.delete(msg.id)
+ cb(msg.existing ?? [])
+ }
+ }
+ }
+ onMount(() => window.addEventListener("message", handler))
+ onCleanup(() => window.removeEventListener("message", handler))
+
const directory = () => {
const dir = server.workspaceDirectory()
if (!dir) return ""
@@ -167,6 +197,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
onOpenDiff={openDiff}
onOpenUrl={openUrl}
onOpenContent={openContent}
+ onValidateFiles={validateFiles}
>
{props.children}
@@ -384,11 +415,13 @@ const App: Component = () => {
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AgentRequirements.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AgentRequirements.tsx
new file mode 100644
index 00000000000..1238718b4d0
--- /dev/null
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/AgentRequirements.tsx
@@ -0,0 +1,193 @@
+/** @jsxImportSource solid-js */
+
+import { For, Show, type Component } from "solid-js"
+import { Button } from "@kilocode/kilo-ui/button"
+import { Card } from "@kilocode/kilo-ui/card"
+import { Icon } from "@kilocode/kilo-ui/icon"
+import { useAgentRequirements } from "../../context/agent-requirements"
+import { useLanguage } from "../../context/language"
+import { useVSCode } from "../../context/vscode"
+import type { AgentRequirementMCP, AgentRequirementSkill, AgentRequirementVSCodeExtension } from "../../types/messages"
+
+type Status = "ready" | "missing" | "error"
+
+export const AgentRequirements: Component = () => {
+ const requirements = useAgentRequirements()
+ const language = useLanguage()
+ const vscode = useVSCode()
+ const result = requirements.result
+ const skills = () => result()?.skills ?? []
+ const mcps = () => result()?.mcps ?? []
+ const extensions = () => result()?.vscode_extensions ?? []
+ const total = () => skills().length + mcps().length + extensions().length
+ const marketplace = () =>
+ skills().some((item) => item.status !== "ready") || mcps().some((item) => item.status !== "ready")
+ const tools = () => {
+ if (skills().length && mcps().length) {
+ return `${language.t("agentRequirements.group.skills")} / ${language.t("agentRequirements.group.mcps")}`
+ }
+ if (skills().length) return language.t("agentRequirements.group.skills")
+ return language.t("agentRequirements.group.mcps")
+ }
+ const open = () => {
+ const current = result()
+ if (!current) return
+ vscode.postMessage({ type: "openMarketplacePanel", directory: current.directory })
+ }
+ const title = () =>
+ (result()?.agent ?? "")
+ .split(/[-_]/)
+ .filter(Boolean)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ")
+
+ const error = () => {
+ const err = result()?.error
+ if (!err) return undefined
+ if (err.code === "unknown_agent") return language.t("agentRequirements.error.unknownAgent")
+ if (err.code === "malformed_declaration") return language.t("agentRequirements.error.malformedDeclaration")
+ if (err.code === "discovery_failed") return language.t("agentRequirements.error.discoveryFailed")
+ if (err.code === "mcp_status_failed") return language.t("agentRequirements.error.mcpStatusFailed")
+ if (err.code === "scope_mismatch") return language.t("agentRequirements.error.scopeMismatch")
+ return err.message || language.t("agentRequirements.error.requestFailed")
+ }
+
+ const skill = (item: AgentRequirementSkill) => {
+ if (item.message) return item.message
+ if (item.status === "ready") return language.t("agentRequirements.skill.installed")
+ if (item.status === "error") return language.t("agentRequirements.skill.checkFailed")
+ return language.t("agentRequirements.skill.missing")
+ }
+
+ const mcp = (item: AgentRequirementMCP) => {
+ if (item.message) return item.message
+ if (item.status === "ready") return language.t("agentRequirements.mcp.connected")
+ if (item.status === "error") return language.t("agentRequirements.mcp.checkFailed")
+ return language.t("agentRequirements.mcp.missing")
+ }
+
+ const extension = (item: AgentRequirementVSCodeExtension) => {
+ if (item.message) return item.message
+ if (item.status === "ready") return language.t("agentRequirements.extension.installed")
+ if (item.status === "error") return language.t("agentRequirements.extension.checkFailed")
+ return language.t("agentRequirements.extension.missing")
+ }
+
+ const StatusIcon = (props: { status: Status }) => (
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+ return (
+
+
+
+
+ {language.t("agentRequirements.blocked.title", { agent: title() })}
+
+
{language.t("agentRequirements.blocked.description")}
+
+
+
+ {(message) => {message}
}
+
+
+
+
+
+
+ {tools()}
+
+
+
+
{language.t("agentRequirements.group.skills")}
+
+
+ {(item) => (
+
+
+
+ {item.name}
+ {skill(item)}
+
+
+ )}
+
+
+
+
+
+
+
{language.t("agentRequirements.group.mcps")}
+
+
+ {(item) => (
+
+
+
+ {item.name}
+ {mcp(item)}
+
+
+ )}
+
+
+
+
+
+
+
+ {language.t("agentRequirements.action.openMarketplace")}
+
+
+
+
+
+
+
+
+
+
+
+ {language.t("agentRequirements.group.extensions")}
+
+
+ {language.t("agentRequirements.extension.description")}
+
+
+
+
+
+
+ {(item) => (
+
+
+
+
+ {item.name} ({item.id})
+
+ {extension(item)}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
index d9e69e6528d..219556d4713 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
@@ -14,6 +14,7 @@ import { showToast } from "@kilocode/kilo-ui/toast"
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
import { TaskHeader } from "./TaskHeader"
import { MessageList } from "./MessageList"
+import { AgentRequirements } from "./AgentRequirements"
import { PromptInput } from "./PromptInput"
import { PermissionDock } from "./PermissionDock"
import { StartupErrorBanner } from "./StartupErrorBanner"
@@ -22,6 +23,7 @@ import { useVSCode } from "../../context/vscode"
import { useLanguage } from "../../context/language"
import { useWorktreeMode } from "../../context/worktree-mode"
import { useServer } from "../../context/server"
+import { useAgentRequirements } from "../../context/agent-requirements"
import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils"
interface ChatViewProps {
@@ -43,6 +45,7 @@ export const ChatView: Component = (props) => {
const language = useLanguage()
const worktreeMode = useWorktreeMode()
const server = useServer()
+ const requirements = useAgentRequirements()
// Show "Show Changes" only in the standalone sidebar, not inside Agent Manager
const isSidebar = () => worktreeMode === undefined
// Show "Continue in Worktree": only when explicitly enabled via prop
@@ -71,9 +74,11 @@ export const ChatView: Component = (props) => {
const standaloneQuestions = createMemo(() => familyQuestions().filter((q) => !q.tool))
const standaloneSuggestions = createMemo(() => familySuggestions().filter((s) => !s.tool))
const permissionRequest = () => familyPermissions().find((p) => p.sessionID === id()) ?? familyPermissions()[0]
- // Prompt input is decoupled from questions/suggestions — only permissions block.
+ // Questions and suggestions do not block input; permissions and agent requirements do.
// Pending questions and suggestions are auto-dismissed in sendMessage/sendCommand.
- const blocked = () => isPromptBlocked(familyPermissions().length)
+ const blocked = () => isPromptBlocked(familyPermissions().length) || (!props.readonly && requirements.blocked())
+ const requirementReason = () =>
+ !props.readonly && requirements.blocked() ? language.t("agentRequirements.prompt.blocked") : undefined
// Session is busy only because a suggestion tool call is pending — prompt should behave as idle
const suggesting = () => isSuggesting(blocked(), familySuggestions().length)
// Session is busy only because a question tool call is pending — prompt should behave as idle
@@ -324,16 +329,23 @@ export const ChatView: Component = (props) => {
@@ -357,6 +369,7 @@ export const ChatView: Component = (props) => {
(command() ? label(rule) : describeRule(props.request.toolName, rule, language.t))
+ const external = () => props.request.toolName === "external_directory"
const cmdDescription = () => {
const val = props.request.args?.description
return typeof val === "string" && val.length > 0 ? val : undefined
@@ -248,8 +250,13 @@ export const PermissionDock: Component<{
-
- {command() ? label(rule) : describeRule(props.request.toolName, rule, language.t)}
+
+
+
+ {language.t("ui.permission.toolLabel.externalDirectory")}{" "}
+
+ {rule}
+
)}
@@ -268,7 +275,16 @@ export const PermissionDock: Component<{
const desc = description()
if (!desc)
return !command() && toolDescription() ? {toolDescription()}
: null
- if (desc.kind === "single") return {desc.text}
+ if (desc.kind === "single")
+ return (
+
+ {desc.text}
+
+ )
return (
{desc.title}
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 946afb7ae91..7050ddb12b6 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
@@ -44,7 +44,7 @@ import {
insertSpacedText,
isPromptBusy,
isPathMention,
- applySandboxState,
+ applySandboxStates,
type SandboxDefaultState,
type SandboxState,
} from "./prompt-input-utils"
@@ -65,6 +65,7 @@ import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
const drafts = new Map
()
const reviewDrafts = new Map()
const imageDrafts = new Map()
+const scrolls = new Map()
function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]): ReviewComment[] {
if (incoming.length === 0) return current
@@ -77,6 +78,7 @@ function mergeReviewComments(current: ReviewComment[], incoming: ReviewComment[]
interface PromptInputProps {
blocked?: () => boolean
+ blockedReason?: () => string | undefined
/** When true, session is busy only because a suggestion is pending — treat as idle for input */
suggesting?: () => boolean
/** When true, session is busy only because a question is pending — treat as idle for input */
@@ -125,35 +127,48 @@ export const PromptInput: Component = (props) => {
adjustHeight()
})
const history = usePromptHistory()
+ let textareaRef: HTMLTextAreaElement | undefined
+ let highlightRef: HTMLDivElement | undefined
+ let dropdownRef: HTMLDivElement | undefined
+ let slashDropdownRef: HTMLDivElement | undefined
const boxKey = () => props.boxId ?? "prompt:default"
+ const blockedHelpId = () => `${boxKey().replace(/[^a-zA-Z0-9_-]/g, "-")}-blocked-help`
const rawKey = () =>
sessionDraftKey(session.currentSessionID()) ??
pendingDraftKey(props.pendingSessionID ?? session.draftSessionID()) ??
"new"
const draftKey = () => scopeDraftKey(boxKey(), rawKey())
- const saveDraft = (key: string, next: string, comments: ReviewComment[], imgs: ImageAttachment[]) => {
+ const saveDraft = (
+ key: string,
+ next: string,
+ comments: ReviewComment[],
+ imgs: ImageAttachment[],
+ scroll = textareaRef?.scrollTop ?? scrolls.get(key) ?? 0,
+ ) => {
if (next) drafts.set(key, next)
else drafts.delete(key)
if (comments.length > 0) reviewDrafts.set(key, comments)
else reviewDrafts.delete(key)
if (imgs.length > 0) imageDrafts.set(key, imgs)
else imageDrafts.delete(key)
+ if (next || comments.length > 0 || imgs.length > 0) scrolls.set(key, scroll)
+ else scrolls.delete(key)
}
const readDraft = () => ({
text: text().trim(),
comments: reviewComments(),
images: imageAttach.images(),
+ scroll: textareaRef?.scrollTop ?? scrolls.get(draftKey()) ?? 0,
})
const [text, setText] = createSignal("")
const [reviewComments, setReviewComments] = createSignal([])
const [enhancing, setEnhancing] = createSignal(false)
const [autoApprove, setAutoApprove] = createSignal(false)
- const [sandboxState, setSandboxState] = createSignal()
+ const [sandboxes, setSandboxes] = createSignal>({})
const [sandboxDefault, setSandboxDefault] = createSignal()
- const [sandboxRequest, setSandboxRequest] = createSignal()
- const [sandboxTarget, setSandboxTarget] = createSignal()
+ const [sandboxRequests, setSandboxRequests] = createSignal>({})
let sandboxRetry: ReturnType | undefined
let sandboxAttempts = 0
const sandboxID = () => {
@@ -162,16 +177,17 @@ export const PromptInput: Component = (props) => {
}
const sandboxVisible = () => features().sandboxControls && !session.currentSessionID()?.startsWith("cloud:")
const sandbox = () => {
- const state = sandboxState()
- return state?.sessionID === sandboxID() ? state : undefined
+ const id = sandboxID()
+ return id ? sandboxes()[id] : undefined
}
const sandboxEnabled = () => (sandboxID() ? sandbox()?.enabled : sandboxDefault()?.enabled) ?? false
const sandboxAvailable = () => (sandboxID() ? sandbox()?.available : sandboxDefault()?.available) ?? false
const sandboxReason = () => (sandboxID() ? sandbox()?.reason : sandboxDefault()?.reason)
const sandboxReady = () => (sandboxID() ? sandbox() !== undefined : sandboxDefault() !== undefined)
const sandboxNetworkEnabled = () => config().experimental?.sandbox_restrict_network !== false
+ const sandboxRequest = (sessionID?: string) => sandboxRequests()[sessionID ?? ""]
const sandboxDisabled = () =>
- !server.isConnected() || !sandboxReady() || !sandboxAvailable() || sandboxRequest() !== undefined
+ !server.isConnected() || !sandboxReady() || !sandboxAvailable() || sandboxRequest(sandboxID()) !== undefined
const requestSandbox = () => {
if (server.connectionState() !== "connected") return
const sessionID = sandboxID()
@@ -186,8 +202,7 @@ export const PromptInput: Component = (props) => {
if (!sandboxVisible() || sandboxDisabled()) return
const requestID = crypto.randomUUID()
if (!sessionID) saveDraft(draftKey(), text(), reviewComments(), imageAttach.images())
- setSandboxRequest(requestID)
- setSandboxTarget(sessionID ?? null)
+ setSandboxRequests((current) => ({ ...current, [sessionID ?? ""]: requestID }))
if (!sessionID) {
vscode.postMessage({
type: "setSandboxDefault",
@@ -214,9 +229,14 @@ export const PromptInput: Component = (props) => {
return hidden
},
)
- const clearSandboxRequest = () => {
- setSandboxRequest(undefined)
- setSandboxTarget(undefined)
+ const clearSandboxRequest = (sessionID: string | undefined, requestID: string) => {
+ setSandboxRequests((current) => {
+ const key = sessionID ?? ""
+ if (current[key] !== requestID) return current
+ const next = { ...current }
+ delete next[key]
+ return next
+ })
}
const retrySandbox = (sessionID: string) => {
if (sandboxAttempts >= 2) return
@@ -233,20 +253,17 @@ export const PromptInput: Component = (props) => {
createEffect(() => {
const sessionID = sandboxID()
const connected = server.connectionState() === "connected"
- const target = untrack(sandboxTarget)
if (sandboxRetry) clearTimeout(sandboxRetry)
sandboxRetry = undefined
sandboxAttempts = 0
if (!connected) {
- clearSandboxRequest()
- setSandboxState(undefined)
+ setSandboxRequests({})
+ setSandboxes({})
setSandboxDefault(undefined)
return
}
- if (target !== undefined && target !== sessionID) clearSandboxRequest()
if (!sessionID) {
- setSandboxState(undefined)
- if (sandboxRequest() && target === null) return
+ if (sandboxRequest(undefined)) return
requestSandbox()
return
}
@@ -271,10 +288,6 @@ export const PromptInput: Component = (props) => {
replaceReviewComments(reviewComments().filter((item) => item.id !== id))
}
- let textareaRef: HTMLTextAreaElement | undefined
- let highlightRef: HTMLDivElement | undefined
- let dropdownRef: HTMLDivElement | undefined
- let slashDropdownRef: HTMLDivElement | undefined
// Save/restore input text when switching sessions.
// Uses `on()` to track only draftKey — avoids re-running on every keystroke.
createEffect(
@@ -284,6 +297,7 @@ export const PromptInput: Component = (props) => {
}
const draft = drafts.get(key) ?? ""
const pending = reviewDrafts.get(key) ?? []
+ const scroll = scrolls.get(key) ?? 0
setText(draft)
setReviewComments(pending)
imageAttach.replace(imageDrafts.get(key) ?? [])
@@ -295,6 +309,8 @@ export const PromptInput: Component = (props) => {
// Reset height then adjust
textareaRef.style.height = "auto"
textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px`
+ textareaRef.scrollTop = scroll
+ if (highlightRef) highlightRef.scrollTop = scroll
}
window.dispatchEvent(new Event("focusPrompt"))
}),
@@ -347,10 +363,11 @@ export const PromptInput: Component = (props) => {
const draft = text().trim()
const comments = reviewComments()
const imgs = imageAttach.images()
+ const scroll = textareaRef?.scrollTop ?? 0
session.clearCurrentSession()
// After clearing, draftKey() points to the "new" bucket — save there
// so the session-switch effect restores the prompt in the new-task view.
- saveDraft(draftKey(), draft, comments, imgs)
+ saveDraft(draftKey(), draft, comments, imgs, scroll)
}
window.addEventListener("newTaskRequest", onNewTaskRequest)
onCleanup(() => window.removeEventListener("newTaskRequest", onNewTaskRequest))
@@ -372,7 +389,7 @@ export const PromptInput: Component = (props) => {
const draft = captured.get(id)
captured.delete(id)
if (!draft) return
- saveDraft(scopeDraftKey(box, sessionDraftKey(sid)), draft.text, draft.comments, draft.images)
+ saveDraft(scopeDraftKey(box, sessionDraftKey(sid)), draft.text, draft.comments, draft.images, draft.scroll)
}
window.addEventListener("agentManagerApplyDraft", onAgentManagerApplyDraft)
onCleanup(() => window.removeEventListener("agentManagerApplyDraft", onAgentManagerApplyDraft))
@@ -421,6 +438,8 @@ export const PromptInput: Component = (props) => {
!props.blocked?.() &&
(speech.state() === "recording" || (hasInput() && !speech.active()))
const sendLabel = () => {
+ const reason = props.blockedReason?.()
+ if (reason) return reason
if (props.blocked?.()) return language.t("prompt.action.send.blocked")
if (speech.state() === "recording") return language.t("prompt.action.send.recording")
return language.t("prompt.action.send")
@@ -486,10 +505,10 @@ export const PromptInput: Component = (props) => {
const handleSandboxMessage = (message: ExtensionMessage) => {
if (message.type === "sandboxDefaultStatus") {
- const matching = message.requestID !== undefined && message.requestID === sandboxRequest()
+ const matching = message.requestID !== undefined && message.requestID === sandboxRequest(undefined)
if (sandboxID() && !matching) return false
if (!server.isConnected()) return true
- if (matching) clearSandboxRequest()
+ if (matching) clearSandboxRequest(undefined, message.requestID!)
const current = sandboxDefault()
if (!current || current.revision <= message.revision) {
setSandboxDefault({
@@ -511,16 +530,18 @@ export const PromptInput: Component = (props) => {
}
if (message.type === "sandboxStatus") {
- const matching = message.requestID !== undefined && message.requestID === sandboxRequest()
- if (message.sessionID !== sandboxID() && !matching) return false
+ const matching = message.requestID !== undefined && message.requestID === sandboxRequest(message.sessionID)
if (!server.isConnected()) return true
- const current = sandboxState()
- if (matching) clearSandboxRequest()
- const state = applySandboxState(current, message)
- if (state !== current) setSandboxState(state)
- sandboxAttempts = 0
- if (sandboxRetry) clearTimeout(sandboxRetry)
- sandboxRetry = undefined
+ const current = sandboxes()
+ if (matching) clearSandboxRequest(message.sessionID, message.requestID!)
+ const next = applySandboxStates(current, message)
+ if (next !== current) setSandboxes(next)
+ const state = next[message.sessionID]
+ if (message.sessionID === sandboxID()) {
+ sandboxAttempts = 0
+ if (sandboxRetry) clearTimeout(sandboxRetry)
+ sandboxRetry = undefined
+ }
if (matching && !state.available) {
showToast({
variant: "error",
@@ -532,24 +553,26 @@ export const PromptInput: Component = (props) => {
}
if (message.type === "sandboxStatusError") {
- const matching = message.requestID !== undefined && message.requestID === sandboxRequest()
- if (message.sessionID !== sandboxID() && !matching) return false
+ const matching = message.requestID !== undefined && message.requestID === sandboxRequest(message.sessionID)
if (!server.isConnected()) return true
- const current = sandboxState()
- if (matching) clearSandboxRequest()
- if ((current?.revision ?? -1) > message.revision) return true
+ const current = sandboxes()
+ const state = current[message.sessionID]
+ if (matching) clearSandboxRequest(message.sessionID, message.requestID!)
+ if ((state?.revision ?? -1) > message.revision) return true
if (!message.requestID) {
- const same = current?.sessionID === message.sessionID && current.directory === message.directory
- setSandboxState({
- sessionID: message.sessionID,
- directory: message.directory,
- enabled: same ? current.enabled : false,
- available: false,
- reason: message.message,
- version: same ? current.version : 0,
- revision: message.revision,
- })
- retrySandbox(message.sessionID)
+ const same = state?.directory === message.directory
+ setSandboxes(
+ applySandboxStates(current, {
+ sessionID: message.sessionID,
+ directory: message.directory,
+ enabled: same ? state.enabled : false,
+ available: false,
+ reason: message.message,
+ version: same ? state.version : 0,
+ revision: message.revision,
+ }),
+ )
+ if (message.sessionID === sandboxID()) retrySandbox(message.sessionID)
}
if (matching) {
showToast({
@@ -614,12 +637,12 @@ export const PromptInput: Component = (props) => {
}
if (message.type === "sessionCreated") {
- const raw = createdDraftKey(message.draftID, sandboxRequest() !== undefined && sandboxTarget() === null)
+ const raw = createdDraftKey(message.draftID, sandboxRequest(undefined) !== undefined)
if (raw) {
const source = scopeDraftKey(boxKey(), raw)
const target = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id))
if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())
- movePromptDraft({ text: drafts, comments: reviewDrafts, images: imageDrafts }, source, target)
+ movePromptDraft({ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls }, source, target)
}
if (
message.draftID &&
@@ -696,9 +719,9 @@ export const PromptInput: Component = (props) => {
}
const syncHighlightScroll = () => {
- if (highlightRef && textareaRef) {
- highlightRef.scrollTop = textareaRef.scrollTop
- }
+ if (!textareaRef) return
+ scrolls.set(draftKey(), textareaRef.scrollTop)
+ if (highlightRef) highlightRef.scrollTop = textareaRef.scrollTop
}
const adjustHeight = () => {
@@ -918,6 +941,7 @@ export const PromptInput: Component = (props) => {
drafts.delete(draftKey())
reviewDrafts.delete(draftKey())
imageDrafts.delete(draftKey())
+ scrolls.delete(draftKey())
if (textareaRef) textareaRef.style.height = "auto"
matched.action()
return
@@ -978,6 +1002,7 @@ export const PromptInput: Component = (props) => {
drafts.delete(key)
reviewDrafts.delete(key)
imageDrafts.delete(key)
+ scrolls.delete(key)
if (draftKey() !== key) return
history.append(draft)
@@ -1191,10 +1216,18 @@ export const PromptInput: Component = (props) => {
}}
onScroll={syncHighlightScroll}
aria-disabled={isDisabled()}
+ aria-describedby={props.blockedReason?.() ? blockedHelpId() : undefined}
rows={1}
/>
+
+ {(reason) => (
+
+ {reason}
+
+ )}
+