fix: address review feedback

This commit is contained in:
kirillk
2026-07-07 14:06:42 -04:00
parent 1f9e6a493d
commit 6fa48a8bbd
22 changed files with 397 additions and 80 deletions
@@ -390,8 +390,8 @@ You can also set options that apply to all models from a provider:
| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable |
| `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. |
{% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %}
`{env:VAR}` and `{file:...}` references in `apiKey` (or any option) are resolved **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}` — the reference is ignored and a warning is logged, so a provider configured this way in a repo will not authenticate. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config.
{% callout type="warning" title="{env:} requires trusted config; {file:} is project-confined" %}
`{env:VAR}` references in `apiKey` (or any option) resolve **only** when the config lives in a trusted location: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** resolve `{env:VAR}`; if one appears anywhere in that file, Kilo drops the entire file's config and logs a warning, so unrelated settings in the same file stop applying too. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` just by being opened. `{file:...}` works in trusted config and still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected. Keep provider credentials in your global config.
{% /callout %}
## Filtering Available Models
@@ -465,8 +465,8 @@ Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variab
}
```
{% callout type="warning" title="Only works in trusted config" %}
`{env:VAR}` (and `{file:...}`) references are resolved **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}` — the reference is ignored and a warning is logged. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected.
{% callout type="warning" title="{env:} requires trusted config; {file:} is project-confined" %}
`{env:VAR}` references resolve **only** in trusted config: your global config (`~/.config/kilo`), a config passed via `KILO_CONFIG` / `KILO_CONFIG_CONTENT`, or organization/MDM-managed config. A project-level `kilo.json` / `opencode.json` committed to a repository **cannot** use `{env:VAR}`; if one appears anywhere in that file, Kilo drops the entire file's config and logs a warning, so unrelated settings in the same file stop applying too. This prevents a malicious repository from exfiltrating your secrets to an attacker-controlled `baseURL` simply by being opened. `{file:...}` works in trusted config and still works in project config, but only for files that resolve inside the project root — references that leave it (absolute paths outside the root, `../` traversal, and symlink escapes) are rejected.
{% /callout %}
For full details on all configuration options including compaction, file watchers, plugins, and experimental features, see the [OpenCode Config documentation](https://opencode.ai/docs/config).
@@ -16,8 +16,10 @@ import okhttp3.Response
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import java.io.File
import java.io.RandomAccessFile
import java.security.MessageDigest
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream
import kotlin.math.roundToInt
@@ -36,55 +38,113 @@ class KiloCliDownloader(
companion object {
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
private val JSON = Json { ignoreUnknownKeys = true }
private val LOCKS = ConcurrentHashMap<String, Any>()
}
suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File =
withContext(Dispatchers.IO) {
val platform = KiloCliPlatform.current()
val dir = File(File(root, version), platform)
val exe = File(dir, "bin/${KiloCliPlatform.exe()}")
val done = File(dir, ".complete")
val cached = done.takeIf { it.isFile }?.readText()?.trim()
val ext = KiloCliPlatform.archive(platform)
val archive = File(dir, "kilo-$platform.$ext")
locked {
val platform = KiloCliPlatform.current()
val dir = File(File(root, version), platform)
val exe = File(dir, "bin/${KiloCliPlatform.exe()}")
val done = File(dir, ".complete")
val ext = KiloCliPlatform.archive(platform)
if (!force && exe.isFile && cached != null && cached.matches(DIGEST)) {
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
prune(version)
return@withContext exe
}
if (!force) {
cached(version, platform, exe, done)?.let { return@locked it }
}
val digest = asset(version, platform, ext)
val digest = asset(version, platform, ext)
val stage = stage(version, platform)
try {
val archive = File(stage, "kilo-$platform.$ext")
val staged = File(stage, "bin/${KiloCliPlatform.exe()}")
val complete = File(stage, ".complete")
if (dir.exists()) {
log.info("Deleting cached CLI $version under ${dir.absolutePath}")
if (!dir.deleteRecursively()) {
throw IllegalStateException("Failed to delete cached Kilo CLI $version under ${dir.absolutePath}")
log.info(
"Kilo CLI $version for $platform is not cached; downloading new release into ${stage.absolutePath}"
)
onProgress(CliDownload(0, version, platform))
download(version, platform, ext, archive, onProgress)
verify(archive, digest)
log.info(
"Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)"
)
extract(archive, stage)
if (!staged.isFile) {
throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}")
}
if (!SystemInfo.isWindows) staged.setExecutable(true)
if (archive.exists() && !archive.delete()) {
log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}")
}
complete.writeText("$digest\n")
replace(dir, stage)
onProgress(CliDownload(100, version, platform))
prune(version)
exe
} finally {
if (stage.exists() && !stage.deleteRecursively()) {
log.warn("Failed to delete staged Kilo CLI download ${stage.absolutePath}")
}
}
}
if (!dir.isDirectory && !dir.mkdirs()) {
throw IllegalStateException("Failed to create Kilo CLI cache directory ${dir.absolutePath}")
}
log.info("Kilo CLI $version for $platform is not cached; downloading new release into ${dir.absolutePath}")
onProgress(CliDownload(0, version, platform))
download(version, platform, ext, archive, onProgress)
verify(archive, digest)
log.info("Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)")
extract(archive, dir)
if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
if (archive.exists() && !archive.delete()) {
log.warn("Failed to delete extracted Kilo CLI archive ${archive.absolutePath}")
}
done.writeText("$digest\n")
onProgress(CliDownload(100, version, platform))
prune(version)
exe
}
private fun cached(version: String, platform: String, exe: File, done: File): File? {
val digest = done.takeIf { it.isFile }?.readText()?.trim()
if (!exe.isFile || digest == null || !digest.matches(DIGEST)) return null
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
prune(version)
return exe
}
private fun <T> locked(block: () -> T): T {
if (!root.isDirectory && !root.mkdirs()) {
throw IllegalStateException("Failed to create Kilo CLI cache root ${root.absolutePath}")
}
val file = File(root, ".lock").canonicalFile
val mutex = LOCKS.computeIfAbsent(file.absolutePath) { Any() }
return synchronized(mutex) {
RandomAccessFile(file, "rw").channel.use { channel ->
channel.lock().use { block() }
}
}
}
private fun stage(version: String, platform: String): File {
val tmp = File(root, ".tmp")
val dir = File(tmp, "$version-$platform-${System.nanoTime()}")
if (!dir.isDirectory && !dir.mkdirs()) {
throw IllegalStateException("Failed to create Kilo CLI staging directory ${dir.absolutePath}")
}
return dir
}
private fun replace(dir: File, stage: File) {
val parent = dir.parentFile
if (!parent.isDirectory && !parent.mkdirs()) {
throw IllegalStateException("Failed to create Kilo CLI cache directory ${parent.absolutePath}")
}
val backup = File(parent, ".${dir.name}.backup-${System.nanoTime()}")
if (dir.exists() && !dir.renameTo(backup)) {
throw IllegalStateException("Failed to move existing Kilo CLI cache ${dir.absolutePath} aside")
}
if (stage.renameTo(dir)) {
if (backup.exists() && !backup.deleteRecursively()) {
log.warn("Failed to delete previous Kilo CLI cache ${backup.absolutePath}")
}
return
}
if (backup.exists() && !backup.renameTo(dir)) {
log.warn("Failed to restore previous Kilo CLI cache ${backup.absolutePath} to ${dir.absolutePath}")
}
throw IllegalStateException("Failed to install Kilo CLI cache ${stage.absolutePath} to ${dir.absolutePath}")
}
private fun fail(message: String): Nothing {
log.warn(message)
throw IllegalStateException(message)
@@ -248,7 +308,7 @@ class KiloCliDownloader(
private fun prune(keep: String) {
val entries = root.listFiles() ?: return
for (entry in entries) {
if (!entry.isDirectory || entry.name == keep) continue
if (!entry.isDirectory || entry.name == keep || entry.name.startsWith(".")) continue
log.info("Removing stale Kilo CLI version ${entry.absolutePath}")
if (!entry.deleteRecursively()) {
log.warn("Failed to remove stale Kilo CLI version ${entry.absolutePath}")
@@ -48,7 +48,12 @@ class KiloCliDownloaderTest {
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first())
assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() })
assertContains(log.messages, "INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ${cli.parentFile.parentFile.absolutePath}")
assertTrue(
log.messages.any {
it.startsWith("INFO: Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} is not cached; downloading new release into ") &&
it.contains("/.tmp/")
}
)
val cachedProgress = mutableListOf<CliDownload>()
val cached = KiloCliDownloader(
@@ -113,23 +118,55 @@ class KiloCliDownloaderTest {
@Test
fun `forced resolve re-downloads and keeps only the active version`() = runBlocking {
MockWebServer().use { server ->
val bytes = archive()
repeat(2) {
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
}
val first = archive("#!/bin/old\n")
val next = archive("#!/bin/new\n")
server.enqueue(metadata(first))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(first)))
server.enqueue(metadata(next))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(next)))
val cli = KiloCliDownloader(
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
)
cli.resolve("1.2.3")
val old = cli.resolve("1.2.3")
assertEquals("#!/bin/old\n", old.readText())
assertEquals(2, server.requestCount)
val forced = cli.resolve("1.2.3", force = true)
assertTrue(forced.isFile)
assertEquals("#!/bin/new\n", forced.readText())
assertEquals(4, server.requestCount)
assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory }?.map { it.name })
assertEquals(listOf("1.2.3"), dir.listFiles()?.filter { it.isDirectory && !it.name.startsWith(".") }?.map { it.name })
}
}
@Test
fun `forced resolve keeps the existing cli when download fails`() = runBlocking {
MockWebServer().use { server ->
val bytes = archive("#!/bin/old\n")
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
server.enqueue(metadata(bytes))
server.enqueue(MockResponse().setResponseCode(503).setBody("unavailable"))
val cli = KiloCliDownloader(
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
).resolve("1.2.3")
val ex = assertFailsWith<IllegalStateException> {
KiloCliDownloader(
root = dir,
baseUrl = server.url("/release").toString(),
api = server.url("/api").toString(),
).resolve("1.2.3", force = true)
}
assertContains(ex.message.orEmpty(), "Failed to download")
assertTrue(cli.isFile)
assertEquals("#!/bin/old\n", cli.readText())
assertTrue(File(cli.parentFile.parentFile, ".complete").isFile)
}
}
@@ -232,9 +269,9 @@ class KiloCliDownloaderTest {
}
}
private fun archive(): ByteArray {
private fun archive(script: String = "#!/bin/sh\n"): ByteArray {
val files = mapOf(
"bin/${KiloCliPlatform.exe()}" to "#!/bin/sh\n".toByteArray(),
"bin/${KiloCliPlatform.exe()}" to script.toByteArray(),
"bin/kilo-sandbox-mutation-worker.js" to "worker\n".toByteArray(),
)
if (KiloCliPlatform.archive() == "zip") return zip(files)
@@ -50,6 +50,9 @@ class KiloAppService internal constructor(
/** Core version and platform from the backend, or null if unknown. */
@Volatile
private var info: CoreInfo? = null
private val coreLock = Any()
private val coreDone = mutableListOf<(CoreInfo?) -> Unit>()
private var coreJob: Job? = null
val core: CoreInfo? get() = info
@@ -151,18 +154,37 @@ class KiloAppService internal constructor(
/** Fetch the pinned Core version and platform and cache it. */
fun fetchCoreInfoAsync(done: (CoreInfo?) -> Unit = {}) {
info?.let {
done(it)
val cached = info
if (cached != null) {
done(cached)
return
}
cs.launch {
LOG.info("fetchCoreInfo: requesting Core version and platform")
val next = coreInfo()
if (next == null) {
done(null)
return@launch
synchronized(coreLock) {
val current = info
if (current != null) {
done(current)
return
}
done(next)
coreDone.add(done)
if (coreJob != null) return
coreJob = cs.launch {
fetchCoreInfo()
}
}
}
private suspend fun fetchCoreInfo() {
LOG.info("fetchCoreInfo: requesting Core version and platform")
val next = coreInfo()
val list = synchronized(coreLock) {
val items = coreDone.toList()
coreDone.clear()
coreJob = null
items
}
list.forEach { it(next) }
if (next != null) {
LOG.info("fetchCoreInfo: Core version is ${next.version} (${next.platform})")
}
}
@@ -174,9 +174,9 @@ class PromptPanel(
ed.scrollPane.border = JBUI.Borders.empty()
ed.scrollPane.viewportBorder = JBUI.Borders.empty(
0,
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
0,
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
)
ed.backgroundColor = style.editorScheme.defaultBackground
ed.scrollPane.background = style.editorScheme.defaultBackground
@@ -78,6 +78,8 @@ object SessionUiStyle {
const val CONTROL_GAP = 4
const val SHELL_VERTICAL_PADDING = 6
const val SHELL_HORIZONTAL_PADDING = 8
// Horizontal editor inset intentionally matches vertical shell padding to balance text and chrome.
const val EDITOR_HORIZONTAL_INSET = SHELL_VERTICAL_PADDING
fun separator(): Color = JBColor.namedColor(
"EditorTabs.underTabsBorderColor",
@@ -7,11 +7,14 @@ import ai.kilocode.rpc.dto.ProfileBalanceDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.ProfileOrganizationDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import kotlinx.coroutines.withTimeout
/**
* Service-level tests for [KiloAppService] profile/login/logout/org operations.
@@ -49,6 +52,47 @@ class KiloAppServiceTest : BasePlatformTestCase() {
currentOrgId: String? = null,
) = ProfileDto(email = email, name = name, organizations = orgs, balance = balance, currentOrgId = currentOrgId)
private suspend fun waitUntil(done: () -> Boolean) {
withTimeout(5_000) {
while (!done()) yield()
}
}
fun `test fetchCoreInfoAsync dedupes in flight requests`() = runBlocking(Dispatchers.Default) {
rpc.cliInfoGate = CompletableDeferred()
val seen = mutableListOf<CoreInfo?>()
app.fetchCoreInfoAsync { seen.add(it) }
app.fetchCoreInfoAsync { seen.add(it) }
waitUntil { rpc.cliVersionCalls == 1 }
assertEquals(1, rpc.cliVersionCalls)
assertEquals(0, rpc.cliPlatformCalls)
rpc.cliInfoGate!!.complete(Unit)
waitUntil { seen.size == 2 }
assertEquals(1, rpc.cliVersionCalls)
assertEquals(1, rpc.cliPlatformCalls)
assertEquals(listOf(CoreInfo("1.0.0", "darwin-arm64"), CoreInfo("1.0.0", "darwin-arm64")), seen)
}
fun `test fetchCoreInfoAsync retries after failure`() = runBlocking(Dispatchers.Default) {
val seen = mutableListOf<CoreInfo?>()
rpc.cliInfoError = RuntimeException("core failed")
app.fetchCoreInfoAsync { seen.add(it) }
waitUntil { seen.size == 1 }
assertNull(seen.single())
assertNull(app.core)
rpc.cliInfoError = null
app.fetchCoreInfoAsync { seen.add(it) }
waitUntil { seen.size == 2 }
assertEquals(CoreInfo("1.0.0", "darwin-arm64"), seen.last())
assertEquals(2, rpc.cliVersionCalls)
}
// ------ refreshProfile ------
fun `test refreshProfile updates app state profile on success`() = runBlocking(Dispatchers.Default) {
@@ -175,13 +175,13 @@ class PromptPanelTest : BasePlatformTestCase() {
assertSame(editor, applied)
}
fun `test prompt editor horizontal insets match top padding`() {
fun `test prompt editor horizontal insets use dedicated prompt inset`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
realize(panel, 260, 400)
val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!!
val ins = editor.scrollPane.viewportBorder.getBorderInsets(editor.scrollPane)
val pad = JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING)
val pad = JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET)
assertEquals(pad, ins.left)
assertEquals(pad, ins.right)
@@ -33,6 +33,12 @@ class FakeAppRpcApi : KiloAppRpcApi {
var health = HealthDto(healthy = true, version = "1.0.0")
var cliVersion = "1.0.0"
var cliPlatform = "darwin-arm64"
var cliInfoGate: CompletableDeferred<Unit>? = null
var cliInfoError: Exception? = null
var cliVersionCalls = 0
private set
var cliPlatformCalls = 0
private set
var models = ModelStateDto()
val selections = mutableListOf<ModelSelectionUpdateDto>()
val cleared = mutableListOf<String>()
@@ -71,11 +77,16 @@ class FakeAppRpcApi : KiloAppRpcApi {
override suspend fun cliVersion(): String {
assertNotEdt("cliVersion")
cliVersionCalls += 1
cliInfoGate?.await()
cliInfoError?.let { throw it }
return cliVersion
}
override suspend fun cliPlatform(): String {
assertNotEdt("cliPlatform")
cliPlatformCalls += 1
cliInfoError?.let { throw it }
return cliPlatform
}
@@ -47,4 +47,19 @@ describe("splitConfigByScope", () => {
})
expect(split.project).toEqual({})
})
it("writes sandbox writable paths to global config", () => {
const split = splitConfigByScope({
experimental: {
sandbox_writable_paths: ["/tmp/kilo"],
},
})
expect(split.global).toEqual({
experimental: {
sandbox_writable_paths: ["/tmp/kilo"],
},
})
expect(split.project).toEqual({})
})
})
@@ -0,0 +1,30 @@
import { describe, expect, it } from "bun:test"
import { cleanupSpeechCapture } from "../../webview-ui/src/components/speech-to-text/speech-cleanup"
describe("speech-to-text button", () => {
it("cancels active capture on component cleanup", () => {
let cancels = 0
cleanupSpeechCapture({
active: () => true,
cancel: () => {
cancels += 1
},
})
expect(cancels).toBe(1)
})
it("does not cancel idle capture on component cleanup", () => {
let cancels = 0
cleanupSpeechCapture({
active: () => false,
cancel: () => {
cancels += 1
},
})
expect(cancels).toBe(0)
})
})
@@ -1394,7 +1394,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<WandSparkles size={16} class={enhancing() ? "enhance-spinner" : ""} />
</Button>
</Tooltip>
<Show when={canUseSpeech()}>
<Show when={canUseSpeech() || speech.active()}>
<SpeechToTextButton speech={speech} disabled={isDisabled()} start={startSpeech} label={language.t} />
</Show>
<Show
@@ -3,6 +3,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { onCleanup, type Component } from "solid-js"
import type { SpeechToText } from "./useSpeechToText"
import { cleanupSpeechCapture } from "./speech-cleanup"
type Props = {
speech: SpeechToText
@@ -41,9 +42,7 @@ export const SpeechToTextButton: Component<Props> = (props) => {
props.start()
}
onCleanup(() => {
if (props.speech.active()) props.speech.cancel()
})
onCleanup(() => cleanupSpeechCapture(props.speech))
return (
<Tooltip value={label()} placement="top">
@@ -0,0 +1,5 @@
import type { SpeechToText } from "./useSpeechToText"
export function cleanupSpeechCapture(speech: Pick<SpeechToText, "active" | "cancel">) {
if (speech.active()) speech.cancel()
}
@@ -1738,11 +1738,8 @@ function StepFinishPart(props: { last: boolean; part: StepFinishPart; message: A
const { theme } = useTheme()
const info = useContext(RoutedModelMeta.Context)
const routed = createMemo(() => {
if (props.message.providerID !== "kilo") return undefined
if (!props.message.modelID.startsWith("kilo-auto/")) return undefined
const model = props.part.model
if (!model) return undefined
if (model.providerID === props.message.providerID && model.modelID === props.message.modelID) return undefined
if (!RoutedModelMeta.routed(model, props.message)) return undefined
return RoutedModelMeta.label(ctx.providers(), model)
})
const consumed = createMemo(() => info().consumed.has(props.part.id))
@@ -299,7 +299,7 @@ function resolveMotion(text: string, pos: number, key: string, count: number, aw
case "$": {
let t = pos
for (let i = 0; i < reps; i++) t = lineEnd(text, lineEnd(text, t) + (i === 0 ? 0 : 1))
return { target: lineEnd(text, t), inclusive: true, linewise: false }
return { target: Math.max(lineStart(text, t), lineEnd(text, t) - 1), inclusive: true, linewise: false }
}
case "G": {
let target = 0
@@ -39,10 +39,10 @@ export namespace RoutedModelMeta {
return KiloRoutedModel.displayName(text)
}
function routed(model: StepFinishPart["model"], message: Message) {
export function routed(model: StepFinishPart["model"], message: Message) {
if (!model) return undefined
if (message.providerID !== "kilo") return undefined
if (!message.modelID.startsWith("kilo-auto/")) return undefined
if (!KiloRoutedModel.routedSelection(message.modelID)) return undefined
if (model.providerID === message.providerID && model.modelID === message.modelID) return undefined
return model
}
@@ -47,10 +47,15 @@ export namespace KiloRoutedModel {
input: { providerID: ProviderID; modelID: string; selected?: string },
) {
if (input.providerID !== ProviderID.kilo) return undefined
if (!input.modelID.startsWith("kilo-auto/") && !input.modelID.includes("fable")) return undefined
if (!routedSelection(input.modelID)) return undefined
const model = read(meta, input.providerID)
if (!model) return undefined
if (model.modelID === input.modelID || model.modelID === input.selected) return undefined
return model
}
export function routedSelection(modelID: string) {
const id = modelID.trim()
return id.startsWith("kilo-auto/") || /(?:^|[/.])claude-fable(?:[-.:/]|$)/.test(id)
}
}
@@ -199,6 +199,28 @@ describe("vim edits", () => {
expect(doc.text).toBe("bar baz")
})
test("d$ deletes line content without consuming the newline", () => {
const doc = new MockDoc("abc\ndef", 0)
const state = createVimState("normal")
feed(doc, state, "d$")
expect(doc.text).toBe("\ndef")
})
test("c$ changes line content without consuming the newline", () => {
const doc = new MockDoc("abc\ndef", 0)
const state = createVimState("normal")
feed(doc, state, "c$")
expect(doc.text).toBe("\ndef")
expect(state.mode).toBe("insert")
})
test("y$ yanks line content without the newline", () => {
const doc = new MockDoc("abc\ndef", 0)
const state = createVimState("normal")
feed(doc, state, "y$")
expect(state.register).toEqual({ text: "abc", linewise: false })
})
test("dd deletes the current line", () => {
const doc = new MockDoc("one\ntwo\nthree", 4)
const state = createVimState("normal")
@@ -243,6 +243,51 @@ describe("kilocode indexing config", () => {
})
describe("kilocode sandbox writable paths config", () => {
test("honors sandbox_writable_paths from global config", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()
try {
await writeConfig(globalTmp.path, {
$schema: "https://app.kilo.ai/config.json",
experimental: { sandbox_writable_paths: ["/tmp/global"] },
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.experimental?.sandbox_writable_paths).toEqual(["/tmp/global"])
},
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
test("drops sandbox_writable_paths from project config", async () => {
await using tmp = await tmpdir({ git: true })
await writeConfig(tmp.path, {
experimental: { sandbox_writable_paths: ["/tmp/project"] },
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.experimental?.sandbox_writable_paths).toBeUndefined()
},
})
})
test("honors sandbox_writable_paths from global config only, ignoring project config", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
@@ -103,7 +103,7 @@ describe("session routed model", () => {
expect(KiloRoutedModel.displayName("o3")).toBe("o3")
})
test("shows compact labels only for Kilo auto selections", () => {
test("shows compact labels only for routed Kilo selections", () => {
const model = { providerID: "openai", modelID: "gpt-5.5" }
const parts = [reason, finish(model)]
@@ -115,6 +115,13 @@ describe("session routed model", () => {
expect(routed.footer).toBe("gpt-5.5")
expect(routed.consumed.has("finish")).toBe(true)
const fable = RoutedModelMeta.info(undefined, parts, false, {
providerID: "kilo",
modelID: "anthropic/claude-fable-5",
})
expect(fable.labels.get("reasoning")).toBe("gpt-5.5")
expect(fable.footer).toBe("gpt-5.5")
const explicit = RoutedModelMeta.info(undefined, parts, false, {
providerID: "openai",
modelID: "gpt-5.5",
@@ -169,7 +176,7 @@ describe("session routed model", () => {
expect(routed.consumed.has("last")).toBe(false)
})
test("reads routed model only for selected Kilo auto models", () => {
test("reads routed model only for selected Kilo routed models", () => {
const meta = { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } }
expect(
@@ -182,6 +189,22 @@ describe("session routed model", () => {
modelID: ModelID.make("openai/gpt-5.5-20260423"),
})
expect(
KiloRoutedModel.readAuto(meta, {
providerID: ProviderID.kilo,
modelID: "anthropic.claude-fable-5",
}),
).toEqual({
providerID: ProviderID.kilo,
modelID: ModelID.make("openai/gpt-5.5-20260423"),
})
expect(
KiloRoutedModel.readAuto(meta, {
providerID: ProviderID.kilo,
modelID: "openai/affable-model",
}),
).toBeUndefined()
expect(
KiloRoutedModel.readAuto(meta, {
providerID: ProviderID.kilo,