Merge remote-tracking branch 'origin/main' into whimsical-adasaurus

# Conflicts:
#	packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
This commit is contained in:
marius-kilocode
2026-05-15 10:00:07 +02:00
89 changed files with 3576 additions and 215 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-ui": patch
---
Show the assistant message copy tooltip above the button so it no longer overlaps the thumbs up/down buttons.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Ensure snapshot installs package a fresh CLI backend so new extension features are available in local VSIX builds.
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/kilo-gateway": minor
---
Support experimental speech-to-text voice input in VS Code prompt fields through Kilo Gateway.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Support viewing Agent Manager terminal tabs alongside the right diff sidebar, and send review comments to the active terminal.
+167
View File
@@ -0,0 +1,167 @@
# kilocode_change - new file
name: publish-jetbrains
on:
push:
tags:
- "jetbrains/*"
concurrency:
group: publish-jetbrains-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
publish:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y patchelf zip
- name: Validate version tag
id: version
run: |
tag="$GITHUB_REF_NAME"
if [[ "$tag" != jetbrains/v* ]]; then
echo "Unsupported tag '$tag'. Expected jetbrains/v<version>." >&2
exit 1
fi
version="${tag#jetbrains/v}"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
{
echo "version=$version"
echo "kind=rc"
echo "marketplace_channel=eap"
echo "cli_channel=rc"
} >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
{
echo "version=$version"
echo "kind=stable"
echo "marketplace_channel=default"
echo "cli_channel=latest"
} >> "$GITHUB_OUTPUT"
echo "Stable JetBrains Marketplace publishing is implemented but intentionally disabled; use an rc tag such as jetbrains/v7.0.1-rc.1." >&2
exit 1
fi
echo "Unsupported JetBrains plugin version '$version'. Expected jetbrains/vx.y.z-rc.n or jetbrains/vx.y.z." >&2
exit 1
- name: Validate publishing secrets
run: |
missing=0
for name in JETBRAINS_MARKETPLACE_TOKEN JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
if [[ -z "${!name}" ]]; then
echo "Missing required secret: $name" >&2
missing=1
fi
done
exit "$missing"
env:
JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }}
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Prepare CLI resources
working-directory: packages/kilo-jetbrains
run: bun script/build.ts --production --prepare-cli
env:
KILO_VERSION: ${{ steps.version.outputs.version }}
KILO_CHANNEL: ${{ steps.version.outputs.cli_channel }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
- name: Verify plugin
working-directory: packages/kilo-jetbrains
run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL"
env:
CHANNEL: ${{ steps.version.outputs.marketplace_channel }}
- name: Publish to JetBrains Marketplace
working-directory: packages/kilo-jetbrains
run: ./gradlew publishPlugin -Pproduction=true -Pkilo.channel="$CHANNEL"
env:
CHANNEL: ${{ steps.version.outputs.marketplace_channel }}
JETBRAINS_MARKETPLACE_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }}
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Resolve plugin archive
id: archive
run: |
mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
if [[ "${#signed[@]}" -eq 1 ]]; then
echo "path=${signed[0]}" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${#signed[@]}" -gt 1 ]]; then
echo "Expected exactly one signed JetBrains plugin ZIP, found ${#signed[@]}." >&2
printf '%s\n' "${signed[@]}" >&2
exit 1
fi
mapfile -t files < <(compgen -G "packages/kilo-jetbrains/build/distributions/*.zip")
if [[ "${#files[@]}" -ne 1 ]]; then
echo "Expected exactly one JetBrains plugin ZIP, found ${#files[@]}." >&2
printf '%s\n' "${files[@]}" >&2
exit 1
fi
echo "path=${files[0]}" >> "$GITHUB_OUTPUT"
- name: Upload to GitHub Release
run: |
tag="$GITHUB_REF_NAME"
title="JetBrains $VERSION"
notes="JetBrains plugin $VERSION."
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$tag" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
exit 0
fi
gh release create "$tag" "$ARCHIVE" --title "$title" --notes "$notes" --prerelease --repo "$GITHUB_REPOSITORY"
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.version }}
ARCHIVE: ${{ steps.archive.outputs.path }}
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: kilo-jetbrains-${{ steps.version.outputs.version }}
path: packages/kilo-jetbrains/build/distributions/*.zip
if-no-files-found: ignore
+80 -8
View File
@@ -132,6 +132,21 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
cost: z.number().optional(),
})
const TranscriptionResponse = z.object({
text: z.string(),
usage: z.unknown().optional(),
})
const getProxyAuth = async () => {
const auth = await Auth.get("kilo")
const token = auth?.type === "api" ? auth.key : auth?.type === "oauth" ? auth.access : undefined
return {
auth,
token,
organizationId: auth?.type === "oauth" ? auth.accountId : undefined,
}
}
return new Hono()
.get(
"/profile",
@@ -324,19 +339,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
}),
),
async (c: any) => {
const auth = await Auth.get("kilo")
const proxy = await getProxyAuth()
if (!auth) {
if (!proxy.auth) {
return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
}
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
if (!token) {
if (!proxy.token) {
return c.json({ error: "No valid token found" }, 401)
}
const organizationId = auth.type === "oauth" ? auth.accountId : undefined
const { prefix, suffix, model, maxTokens, temperature } = c.req.valid("json")
const fimModel = model ?? "mistralai/codestral-2501"
const fimMaxTokens = maxTokens ?? 256
@@ -347,8 +359,8 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }),
Authorization: `Bearer ${proxy.token}`,
...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }),
[HEADER_FEATURE]: "autocomplete",
}
@@ -390,6 +402,66 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
})
},
)
.post(
"/audio/transcriptions",
describeRoute({
summary: "Speech to text transcription",
description: "Proxy an audio transcription request to the Kilo Gateway",
operationId: "kilo.audio.transcriptions",
responses: {
200: {
description: "Transcription response",
content: {
"application/json": {
schema: resolver(TranscriptionResponse),
},
},
},
...errors(400, 401),
},
}),
validator(
"json",
z.object({
model: z.string(),
input_audio: z.object({
data: z.string(),
format: z.string(),
}),
language: z.string().optional(),
temperature: z.number().optional(),
}),
),
async (c: any) => {
const proxy = await getProxyAuth()
if (!proxy.auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401)
if (!proxy.token) return c.json({ error: "No valid token found" }, 401)
const body = c.req.valid("json")
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${proxy.token}`,
...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }),
[HEADER_FEATURE]: "vscode-extension",
}
const response = await fetch(`${KILO_API_BASE}/api/gateway/v1/audio/transcriptions`, {
method: "POST",
headers,
signal: c.req.raw.signal,
body: JSON.stringify(body),
})
const text = await response.text()
return new Response(text, {
status: response.status,
headers: {
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
},
})
},
)
.get(
"/notifications",
describeRoute({
+23
View File
@@ -52,6 +52,29 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip
---
## Publish release candidates
JetBrains release-candidate builds publish from tags matching `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`. The git tag is the source of truth for the Marketplace plugin version; the `v` prefix is stripped before the version is set in the plugin metadata so the Marketplace version remains a plain semver (`x.y.z-rc.n`). Production Gradle builds fail if `HEAD` is not tagged with `jetbrains/v<version>`. Local dev builds use the placeholder `0.0.0-dev` version.
The `publish-jetbrains` workflow publishes RC builds to the JetBrains Marketplace `eap` channel and uploads the same plugin ZIP to a GitHub prerelease for the tag. Stable tags like `jetbrains/vx.y.z` are recognized by the workflow but intentionally rejected until default-channel publishing is enabled.
Testers can install or update EAP builds from this custom plugin repository:
```
https://plugins.jetbrains.com/plugins/eap/list
```
Required GitHub Actions secrets:
- `JETBRAINS_MARKETPLACE_TOKEN`
- `JETBRAINS_CERTIFICATE_CHAIN`
- `JETBRAINS_PRIVATE_KEY`
- `JETBRAINS_PRIVATE_KEY_PASSWORD`
Before the first publish, complete `RELEASE_TODO.md`.
---
## Run the plugin
Use the `runIde` Gradle task (available in the Gradle tool window or via the "Run JetBrains Plugin" run configuration) to launch a sandboxed IntelliJ instance with the plugin installed.
+44
View File
@@ -0,0 +1,44 @@
# JetBrains Release Todo
## One-Time Marketplace Setup
- Confirm `ai.kilocode.jetbrains` is the final permanent Marketplace plugin ID before the first upload.
- Build a production ZIP locally or in CI for the first Marketplace version.
- Manually upload the first plugin version in JetBrains Marketplace if the plugin has not been published before.
- Confirm the plugin is owned by the correct Kilo vendor or organization.
- Confirm the `eap` custom channel exists or is accepted on the first RC upload.
- Confirm Marketplace signing requirements before publishing RC builds.
## One-Time GitHub Setup
- Create a JetBrains Marketplace permanent token from Marketplace `My Tokens`.
- Add `JETBRAINS_MARKETPLACE_TOKEN` to GitHub Actions secrets or the protected environment.
- Confirm `GITHUB_TOKEN` has `contents: write` permission for creating and updating GitHub Releases from `jetbrains/v*` tags.
- Optionally create a protected `jetbrains-marketplace` GitHub Environment with required reviewers.
- If using an environment, move the Marketplace and signing secrets there and set the workflow job environment.
## One-Time Signing Certificate Setup
- Generate or locate the JetBrains plugin signing private key and certificate chain.
- Add `JETBRAINS_CERTIFICATE_CHAIN` with the full certificate chain PEM content.
- Add `JETBRAINS_PRIVATE_KEY` with the private key PEM content.
- Add `JETBRAINS_PRIVATE_KEY_PASSWORD` with the private key password.
- Keep PEM files out of git, local logs, and workflow output.
- If GitHub secret input mangles multiline PEM values, store base64-encoded values and verify the Gradle signing task decodes them correctly.
## Per-RC Release
- Choose an RC version in the form `x.y.z-rc.n`.
- Push tag `jetbrains/vx.y.z-rc.n`, for example `jetbrains/v7.0.1-rc.1`.
- Watch the `publish-jetbrains` workflow.
- Download and retain the workflow artifact if needed.
- Confirm the update appears on the JetBrains Marketplace `eap` channel.
- Confirm the GitHub Release for the `jetbrains/vx.y.z-rc.n` tag exists and contains the JetBrains plugin ZIP asset.
- Share `https://plugins.jetbrains.com/plugins/eap/list` with testers.
## Stable Release Guard
- Stable tags like `jetbrains/vx.y.z` are intentionally rejected for now.
- Before enabling stable releases, remove the workflow stable guard.
- Verify `kilo.channel=default` publishes to the default Marketplace channel.
- Update this checklist before stable releases are enabled.
+57 -2
View File
@@ -4,7 +4,32 @@ import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask
import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.SplitModeTarget
group = "ai.kilocode.jetbrains"
version = "7.0.1"
fun checked(value: String): String {
if (value == "0.0.0-dev") return value
require(Regex("^[0-9]+\\.[0-9]+\\.[0-9]+(-rc\\.[0-9]+)?$").matches(value)) {
"Invalid JetBrains plugin version: $value"
}
return value
}
fun gitTag(): String? {
val text = providers.exec {
commandLine("git", "tag", "--points-at", "HEAD")
}.standardOutput.asText.get()
return text.lineSequence().map { it.trim() }.firstOrNull { it.startsWith("jetbrains/v") }
}
val release = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false).get()
val ver = if (release) checked(
gitTag()?.removePrefix("jetbrains/v")
?: error("Missing JetBrains plugin version. Publish builds must run from a jetbrains/v<version> tag."),
) else checked(gitTag()?.removePrefix("jetbrains/v") ?: "0.0.0-dev")
val notes = providers.gradleProperty("kilo.changeNotes").orElse("Release candidate build.")
val channel = providers.gradleProperty("kilo.channel").map { it.trim() }.orElse("default")
version = ver
plugins {
application
@@ -59,9 +84,39 @@ intellijPlatform {
splitMode = true
splitModeTarget = SplitModeTarget.BOTH
pluginConfiguration {
id = "ai.kilocode.jetbrains"
name = "Kilo Code"
version = provider { ver }
changeNotes = notes
ideaVersion {
untilBuild = provider { null }
}
vendor {
name = "Kilo Code"
url = "https://kilo.ai"
}
}
publishing {
token = providers.environmentVariable("JETBRAINS_MARKETPLACE_TOKEN")
channels = channel.map { value ->
if (value.isBlank() || value == "default") return@map listOf("default")
listOf(value)
}
}
signing {
certificateChain = providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN")
privateKey = providers.environmentVariable("JETBRAINS_PRIVATE_KEY")
password = providers.environmentVariable("JETBRAINS_PRIVATE_KEY_PASSWORD")
}
pluginVerification {
ides {
create(IntelliJPlatformType.IntellijIdeaCommunity, libs.versions.intellij.platform)
create(IntelliJPlatformType.IntellijIdea, libs.versions.intellij.platform)
}
}
}
@@ -5,6 +5,7 @@ import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.scroll.SessionScroll
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.EmptySessionPanel
import ai.kilocode.client.session.ui.LoadingPanel
@@ -5,6 +5,8 @@ import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.iconButton
import com.intellij.icons.AllIcons
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.application.ApplicationManager
@@ -12,11 +14,7 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.actionSystem.impl.ActionButton
import com.intellij.openapi.util.Disposer
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.PopupHandler
@@ -180,12 +178,12 @@ class HistoryPanel(
private fun back(): BorderLayoutPanel {
val label = KiloBundle.message("history.back")
val action = object : AnAction(label, null, AllIcons.Actions.Back) {
override fun actionPerformed(e: AnActionEvent) = nav()
}
val presentation = Presentation(label).apply { icon = AllIcons.Actions.Back }
val btn = ActionButton(action, presentation, ActionPlaces.TOOLBAR, JBUI.size(16)).apply {
val btn = HoverIcon().apply {
icon = AllIcons.Actions.Back
toolTipText = label
accessibleContext.accessibleName = label
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addActionListener { nav() }
}
return BorderLayoutPanel().apply {
add(btn, BorderLayout.WEST)
@@ -379,17 +377,17 @@ class HistoryPanel(
internal fun backText(): String? {
val view = activeInfo().foreSideComponent ?: return null
return UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.presentation?.text
return UIUtil.uiTraverser(view).filter(HoverIcon::class.java).firstOrNull()?.toolTipText
}
internal fun backCursor(): Int? {
val view = activeInfo().foreSideComponent ?: return null
return UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.cursor?.type
return UIUtil.uiTraverser(view).filter(HoverIcon::class.java).firstOrNull()?.cursor?.type
}
internal fun clickBack() {
val view = activeInfo().foreSideComponent ?: return
UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.click()
UIUtil.uiTraverser(view).filter(HoverIcon::class.java).firstOrNull()?.doClick()
}
internal fun clickDelete() {
@@ -0,0 +1,17 @@
package ai.kilocode.client.session.scroll
import ai.kilocode.client.ui.colorizeIfPossible
import com.intellij.openapi.util.IconLoader
import com.intellij.util.ui.JBUI
import javax.swing.Icon
internal object ScrollButtonIcon {
private val icon = IconLoader.getIcon("/icons/scroll-bottom.svg", ScrollButtonIcon::class.java)
fun create(): Icon = icon.colorizeIfPossible(
fillColor = JBUI.CurrentTheme.Button.defaultButtonColorStart(),
borderColor = JBUI.CurrentTheme.Button.defaultButtonForeground(),
fillId = "ScrollButton.Background",
strokeId = "ScrollButton.Foreground",
)
}
@@ -1,4 +1,4 @@
package ai.kilocode.client.session
package ai.kilocode.client.session.scroll
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.SessionMessageListPanel
@@ -7,27 +7,18 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.icons.CachedImageIcon
import com.intellij.ui.svg.SvgAttributePatcher
import com.intellij.util.SVGLoader
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Cursor
import java.awt.Point
import java.awt.Rectangle
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JScrollBar
private const val ICON_DIGEST = 0x5c011b0bb17L
private const val OPAQUE_ALPHA = 255
internal class SessionScroll(
private val root: SessionRootPanel,
private val host: JPanel,
@@ -35,7 +26,6 @@ internal class SessionScroll(
body: JPanel,
) {
companion object {
private val ICON = IconLoader.getIcon("/icons/scroll-bottom.svg", SessionScroll::class.java)
private const val THRESHOLD = 32
private const val OPEN_PASSES = 12
}
@@ -58,7 +48,7 @@ internal class SessionScroll(
private var seq = 0
init {
jump = JBLabel(patchedIcon(ICON)).apply {
jump = JBLabel(ScrollButtonIcon.create()).apply {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
toolTipText = KiloBundle.message("session.scroll.bottom")
isVisible = false
@@ -130,7 +120,7 @@ internal class SessionScroll(
fun applyStyle(style: SessionEditorStyle) {
this.style = style
jump.icon = patchedIcon(ICON)
jump.icon = ScrollButtonIcon.create()
messages.applyStyle(style)
val view = component.viewport.view
if (view !== messages) (view as? SessionEditorStyleTarget)?.applyStyle(style)
@@ -231,34 +221,3 @@ internal class SessionScroll(
root.overlay.repaint()
}
}
private fun patchedIcon(icon: Icon): Icon {
val cached = icon as? CachedImageIcon ?: return icon
return cached.createWithPatcher(object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher {
override fun digest(): LongArray {
val bg = JBUI.CurrentTheme.Button.defaultButtonColorStart().rgb.toLong()
val fg = JBUI.CurrentTheme.Button.defaultButtonForeground().rgb.toLong()
return longArrayOf(bg, fg, ICON_DIGEST)
}
override fun attributeForPath(path: String) = this
override fun patchColors(attributes: MutableMap<String, String>) {
when (attributes["id"]) {
"ScrollButton.Background" ->
set(attributes, "fill", JBUI.CurrentTheme.Button.defaultButtonColorStart())
"ScrollButton.Foreground" ->
set(attributes, "stroke", JBUI.CurrentTheme.Button.defaultButtonForeground())
}
}
private fun set(attributes: MutableMap<String, String>, key: String, color: Color) {
if (!attributes.containsKey(key) || attributes[key] == "none") return
attributes[key] = "rgb(${color.red},${color.green},${color.blue})"
if (color.alpha != OPAQUE_ALPHA) {
attributes["$key-opacity"] = "${color.alpha / OPAQUE_ALPHA.toFloat()}"
}
}
})
}
@@ -1,7 +1,7 @@
package ai.kilocode.client.session.ui
import com.intellij.openapi.ui.popup.util.PopupUtil
import com.intellij.ui.ExperimentalUI
import com.intellij.ui.NewUI
import com.intellij.ui.popup.list.SelectablePanel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
@@ -23,7 +23,7 @@ internal class PickerRow : SelectablePanel() {
fun update(list: JList<*>, selected: Boolean, focused: Boolean) {
background = list.background
selectionColor = if (selected) UIUtil.getListBackground(true, focused) else null
if (ExperimentalUI.isNewUI()) {
if (NewUI.isEnabled()) {
PopupUtil.configListRendererFlexibleHeight(this)
return
}
@@ -9,8 +9,8 @@ import com.intellij.openapi.ui.popup.PopupShowOptions
import com.intellij.openapi.ui.popup.util.PopupUtil
import com.intellij.ui.CollectionListModel
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.ExperimentalUI
import com.intellij.ui.JBColor
import com.intellij.ui.NewUI
import com.intellij.ui.ListUtil
import com.intellij.ui.SearchTextField
import com.intellij.ui.ScrollPaneFactory
@@ -38,7 +38,7 @@ import javax.swing.SwingUtilities
import javax.swing.event.DocumentEvent
private val popupBackground: Color
get() = if (ExperimentalUI.isNewUI()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground()
get() = if (NewUI.isEnabled()) JBUI.CurrentTheme.Popup.BACKGROUND else UIUtil.getListBackground()
private const val MODEL_PICKER_MIN_WIDTH = 420
private const val MODEL_PICKER_MAX_WIDTH = 760
@@ -5,8 +5,8 @@ import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.CollectionListModel
import com.intellij.ui.ExperimentalUI
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.NewUI
import com.intellij.ui.JBColor
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
@@ -23,7 +23,7 @@ import java.awt.Graphics2D
import java.awt.Point
import java.awt.Rectangle
import java.awt.RenderingHints
import java.awt.Toolkit
import java.awt.font.FontRenderContext
import javax.swing.Icon
import javax.swing.JList
import javax.swing.JPanel
@@ -53,7 +53,7 @@ internal class ModelPickerRenderer(
}
private fun favoriteInset(list: JList<*>): Int {
if (!ExperimentalUI.isNewUI()) return 0
if (!NewUI.isEnabled()) return 0
val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets()
val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left
@@ -162,11 +162,9 @@ internal class ModelPickerRenderer(
private val text = KiloBundle.message("model.picker.free")
override fun getIconWidth(): Int {
val fm = JBFont.small().let { font ->
@Suppress("DEPRECATION")
Toolkit.getDefaultToolkit().getFontMetrics(font)
}
return fm.stringWidth(text) + JBUI.scale(12)
val font = JBFont.small()
val w = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt()
return w + JBUI.scale(12)
}
override fun getIconHeight(): Int = JBUI.scale(16)
@@ -117,7 +117,7 @@ class PromptPanel(
?: return@addActionListener
val ctx = DataManager.getInstance().getDataContext(button)
val event = AnActionEvent.createEvent(action, ctx, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null)
action.update(event)
ActionUtil.updateAction(action, event)
ActionUtil.performAction(action, event)
}
}
@@ -0,0 +1,46 @@
package ai.kilocode.client.ui
import com.intellij.ui.icons.CachedImageIcon
import com.intellij.ui.svg.SvgAttributePatcher
import com.intellij.util.SVGLoader
import java.awt.Color
import javax.swing.Icon
private const val OPAQUE_ALPHA = 255
internal fun Icon.colorizeIfPossible(
fillColor: Color,
borderColor: Color = fillColor,
fillId: String? = null,
strokeId: String? = null,
): Icon = (this as? CachedImageIcon)?.createWithPatcher(
colorPatcher = object : SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher {
private val digest = longArrayOf(0L, 440413911775177385)
override fun digest(): LongArray {
digest[0] = toLong(fillColor.rgb, borderColor.rgb)
return digest
}
override fun patchColors(attributes: MutableMap<String, String>) {
val id = attributes["id"]
if (fillId == null || id == fillId) setAttribute(attributes, "fill", fillColor)
if (strokeId == null || id == strokeId) setAttribute(attributes, "stroke", borderColor)
}
override fun attributeForPath(path: String) = this
private fun setAttribute(attributes: MutableMap<String, String>, key: String, color: Color) {
if (!attributes.containsKey(key) || attributes[key] == "none") return
attributes[key] = "rgb(${color.red},${color.green},${color.blue})"
val alpha = color.alpha
if (alpha != OPAQUE_ALPHA) {
attributes["$key-opacity"] = "${alpha / OPAQUE_ALPHA.toFloat()}"
}
}
private fun toLong(high: Int, low: Int): Long {
return (high.toLong() shl 32) or (low.toLong() and 0xFFFFFFFFL)
}
},
) ?: this
@@ -20,6 +20,7 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionTimeDto
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.Presentation
@@ -71,7 +72,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val local = localItem("ses_1")
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertTrue(event.presentation.isEnabledAndVisible)
}
@@ -81,7 +82,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val item = cloudItem("cloud_1")
val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertTrue(event.presentation.isEnabledAndVisible)
}
@@ -91,7 +92,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val local = localItem("ses_1")
val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -100,7 +101,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val action = OpenSessionAction()
val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -110,7 +111,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val items = listOf(localItem("ses_1"), localItem("ses_2"))
val event = event(action, manager, selection(HistorySource.LOCAL, items), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -160,7 +161,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val local = localItem("ses_1")
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertTrue(event.presentation.isEnabledAndVisible)
}
@@ -170,7 +171,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val item = cloudItem("cloud_1")
val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -180,7 +181,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val local = localItem("ses_1")
val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -189,7 +190,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val action = DeleteSessionAction()
val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -260,7 +261,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val local = localItem("ses_1")
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertTrue(event.presentation.isEnabledAndVisible)
}
@@ -269,7 +270,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val action = RenameSessionAction()
val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -279,7 +280,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val items = listOf(localItem("ses_1"), localItem("ses_2"))
val event = event(action, manager, selection(HistorySource.LOCAL, items), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -289,7 +290,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
val item = cloudItem("cloud_1")
val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabledAndVisible)
}
@@ -5,6 +5,7 @@ import ai.kilocode.client.session.SessionRef
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
@@ -14,7 +15,7 @@ class NewSessionActionTest : BasePlatformTestCase() {
val action = NewSessionAction()
val event = event(manager)
action.update(event)
ActionUtil.updateAction(action, event)
action.actionPerformed(event)
assertEquals(1, manager.created)
@@ -28,7 +29,7 @@ class NewSessionActionTest : BasePlatformTestCase() {
val action = NewSessionAction()
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
action.update(AnActionEvent.createFromDataContext("", presentation) { null })
ActionUtil.updateAction(action, AnActionEvent.createFromDataContext("", presentation) { null })
assertFalse(presentation.isEnabled)
}
@@ -3,6 +3,7 @@ package ai.kilocode.client.actions
import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.SendPromptContext
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -14,7 +15,7 @@ class SendPromptActionTest : BasePlatformTestCase() {
val action = SendPromptAction()
val event = event(action, ctx)
action.update(event)
ActionUtil.updateAction(action, event)
action.actionPerformed(event)
assertTrue(event.presentation.isEnabled)
@@ -27,7 +28,7 @@ class SendPromptActionTest : BasePlatformTestCase() {
val action = SendPromptAction()
val event = event(action, null)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabled)
}
@@ -37,7 +38,7 @@ class SendPromptActionTest : BasePlatformTestCase() {
val action = SendPromptAction()
val event = event(action, ctx)
action.update(event)
ActionUtil.updateAction(action, event)
action.actionPerformed(event)
assertFalse(event.presentation.isEnabled)
@@ -3,6 +3,7 @@ package ai.kilocode.client.actions
import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.SendPromptContext
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -14,7 +15,7 @@ class StopSessionActionTest : BasePlatformTestCase() {
val action = StopSessionAction()
val event = event(action, ctx)
action.update(event)
ActionUtil.updateAction(action, event)
action.actionPerformed(event)
assertTrue(event.presentation.isEnabled)
@@ -27,7 +28,7 @@ class StopSessionActionTest : BasePlatformTestCase() {
val action = StopSessionAction()
val event = event(action, null)
action.update(event)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabled)
}
@@ -37,7 +38,7 @@ class StopSessionActionTest : BasePlatformTestCase() {
val action = StopSessionAction()
val event = event(action, ctx)
action.update(event)
ActionUtil.updateAction(action, event)
action.actionPerformed(event)
assertFalse(event.presentation.isEnabled)
@@ -11,6 +11,7 @@ import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.scroll.SessionScroll
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
@@ -3,7 +3,7 @@ package ai.kilocode.client.session.ui.model
import ai.kilocode.rpc.dto.ModelSelectionDto
import com.intellij.icons.AllIcons
import com.intellij.ui.CollectionListModel
import com.intellij.ui.ExperimentalUI
import com.intellij.ui.NewUI
import com.intellij.ui.components.JBList
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.EmptyIcon
@@ -273,7 +273,7 @@ class ModelPickerTest : BasePlatformTestCase() {
) = ModelPicker.Item(id, display, provider, name, index, free = free)
private fun favoriteInset(list: JBList<*>): Int {
if (!ExperimentalUI.isNewUI()) return 0
if (!NewUI.isEnabled()) return 0
val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets()
val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left
@@ -1345,7 +1345,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-slot="assistant-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
placement="right"
placement="top"
gutter={4}
>
<IconButton
+9
View File
@@ -776,6 +776,15 @@
"default": false,
"description": "Enable chat textarea autocomplete"
},
"kilo-code.new.speechToText.enabled": {
"type": "boolean",
"default": false,
"description": "Enable experimental speech-to-text voice input in prompt fields. Uses your Kilo account through the Kilo Gateway."
},
"kilo-code.new.speechToText.model": {
"type": "string",
"description": "Model to use for experimental speech-to-text voice input. Requires Kilo Gateway."
},
"kilo-code.new.claudeCodeCompat": {
"type": "boolean",
"default": false,
+4
View File
@@ -2,6 +2,7 @@
import { $ } from "bun"
import { join } from "node:path"
import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs"
import { ensureFfmpegForTarget } from "./ffmpeg-helper"
const packageJsonPath = join(import.meta.dir, "..", "package.json")
const packageJson = await Bun.file(packageJsonPath).json()
@@ -81,6 +82,9 @@ for (const config of targets) {
console.log(` ✅ Binary ready at ${targetBinary}`)
console.log("Adding bundled FFmpeg helper...")
await ensureFfmpegForTarget(config.target, binDir)
console.log(` 📦 Packaging .vsix for ${config.target}${prerelease ? " (pre-release)" : ""}...`)
const vsixPath = join(outDir, `kilo-vscode-${config.target}.vsix`)
const args = ["--no-dependencies", "--skip-license", "--target", config.target, "-o", vsixPath]
+1 -1
View File
@@ -41,7 +41,7 @@ console.log("\n📦 Rebuilding SDK...")
await $`bun run --cwd ../sdk/js build`.cwd(root)
console.log("\n🔧 Preparing CLI binary...")
await $`bun script/local-bin.ts`.cwd(root)
await $`bun script/local-bin.ts --force`.cwd(root)
console.log("\n✅ Type-checking...")
await $`bun run typecheck`.cwd(root)
@@ -0,0 +1,49 @@
import { $ } from "bun"
import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"
import { join } from "node:path"
const packages: Record<string, string> = {
"darwin-x64": "@ffmpeg-installer/darwin-x64@4.1.0",
"darwin-arm64": "@ffmpeg-installer/darwin-arm64@4.1.5",
"linux-x64": "@ffmpeg-installer/linux-x64@4.1.0",
"linux-arm64": "@ffmpeg-installer/linux-arm64@4.1.4",
"alpine-x64": "@ffmpeg-installer/linux-x64@4.1.0",
"alpine-arm64": "@ffmpeg-installer/linux-arm64@4.1.4",
"win32-x64": "@ffmpeg-installer/win32-x64@4.1.0",
}
export async function ensureFfmpegForTarget(target: string, bin: string): Promise<void> {
if (target === "win32-arm64") {
console.warn("No Windows ARM64 FFmpeg helper package is available; speech input will use system FFmpeg.")
return
}
const spec = packages[target]
if (!spec) throw new Error(`No FFmpeg helper package configured for target ${target}`)
const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg"
const dest = join(bin, exe)
if (existsSync(dest)) return
const tmp = join(bin, ".ffmpeg-tmp")
rmSync(tmp, { recursive: true, force: true })
mkdirSync(tmp, { recursive: true })
try {
const packed = await $`npm pack ${spec} --pack-destination ${tmp}`.quiet()
const name = packed.text().trim().split(/\s+/).pop()
if (!name) throw new Error(`npm pack did not return a tarball for ${spec}`)
await $`tar -xzf ${join(tmp, name)} -C ${tmp}`.quiet()
copyFileSync(join(tmp, "package", exe), dest)
if (!target.startsWith("win32")) chmodSync(dest, 0o755)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
}
export function currentFfmpegTarget(): string {
const os = process.platform === "win32" ? "win32" : process.platform
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch
return `${os}-${arch}`
}
+3
View File
@@ -2,6 +2,7 @@
import { $ } from "bun"
import { join, relative, dirname, basename } from "node:path"
import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs"
import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper"
const forceRebuild = process.argv.includes("--force")
@@ -158,6 +159,7 @@ async function main() {
log(
`CLI binary already present at ${relative(kiloVscodeDir, targetBinPath)} (${Math.round(st.size / 1024 / 1024)}MB). Use --force to rebuild.`,
)
await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir)
return
}
@@ -181,6 +183,7 @@ async function main() {
await $`mkdir -p ${targetBinDir}`
await $`cp ${sourceBinPath} ${targetBinPath}`
chmodSync(targetBinPath, 0o755)
await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir)
// Record the CLI source version so future runs detect when a rebuild is needed
const hash = await cliSourceHash()
+26 -5
View File
@@ -68,10 +68,12 @@ import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network"
import { abortSession } from "./kilo-provider/abort"
import {
buildAutocompleteSettingsMessage,
routeAutocompleteMessage,
validAutocompleteSetting,
watchAutocompleteConfig,
} from "./services/autocomplete/settings"
import { validSpeechToTextSetting } from "./speech-to-text/settings"
import { sendSpeechToTextSettings, watchSpeechToTextConfig } from "./speech-to-text/config"
import { routeEarlyMessage } from "./kilo-provider/early-message"
import * as ModelState from "./kilo-provider/model-state"
import { handleForkSession } from "./kilo-provider/fork-session"
import { openConfig } from "./kilo-provider/open-config"
@@ -112,7 +114,7 @@ import {
handleQuestionReject,
fetchAndSendPendingQuestions,
} from "./kilo-provider/handlers/question"
import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion"
import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion"
import { nativeTitle } from "./kilo-provider/native-tab-title"
import {
@@ -230,6 +232,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private webviewMessageDisposable: vscode.Disposable | null = null
private autocompleteConfigDisposable: vscode.Disposable | null = null
private telemetryStateDisposable: vscode.Disposable | null = null
private speechToTextDisposable: vscode.Disposable | null = null
private viewStateDisposable: vscode.Disposable | null = null
private visibilityDisposable: vscode.Disposable | null = null
private autoApproveBridge: ReturnType<typeof createAutoApproveBridge> | null = null
@@ -593,6 +596,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.webviewMessageDisposable?.dispose()
this.autocompleteConfigDisposable?.dispose()
this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg))
this.speechToTextDisposable?.dispose()
this.speechToTextDisposable = watchSpeechToTextConfig(this)
this.telemetryStateDisposable?.dispose()
this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg))
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
@@ -605,9 +610,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (intercepted === null) return
message = intercepted
await routeSuggestionWebviewMessage(this.questionCtx, message)
if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return
if (await routeAutocompleteMessage(message, (msg) => this.postMessage(msg))) return
if (
await routeEarlyMessage(message, {
question: this.questionCtx,
client: this.client,
connection: this.connectionService,
dir: this.getWorkspaceDirectory(this.currentSession?.id),
post: (msg) => this.postMessage(msg),
})
) {
return
}
if (this.handleEditorOpenMessage(message)) return
if (
await handleSidebarWorktreeMessage(message, {
@@ -1302,6 +1315,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo })
this.sendNotificationSettings()
this.sendTimelineSetting()
this.sendSpeechToTextSettings()
this.postMessage({ type: "extensionDataReady" })
if (this.cachedGitRepo) this.startStatsPolling()
@@ -2343,6 +2357,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
private sendSpeechToTextSettings(): void {
sendSpeechToTextSettings(this)
}
/** Returns the number of sessions currently in "busy" state. */
private getBusySessionCount(): number {
return getBusySessionCount(this.sessionStatusMap)
@@ -2962,6 +2980,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleUpdateSetting(key: string, value: unknown): Promise<void> {
const { section, leaf } = buildSettingPath(key)
if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return
if (section === "speechToText" && !validSpeechToTextSetting(leaf, value)) return
const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`)
await config.update(leaf, value, vscode.ConfigurationTarget.Global)
}
@@ -3002,6 +3021,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Re-send all settings to the webview so the UI reflects the reset
this.postMessage(buildAutocompleteSettingsMessage())
this.sendSpeechToTextSettings()
this.sendBrowserSettings()
this.sendNotificationSettings()
this.sendTimelineSetting()
@@ -3503,6 +3523,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.visibilityDisposable?.dispose()
this.webviewMessageDisposable?.dispose()
this.autocompleteConfigDisposable?.dispose()
this.speechToTextDisposable?.dispose()
this.telemetryStateDisposable?.dispose()
this.autoApproveBridge?.dispose()
this.streams.dispose()
@@ -1679,7 +1679,11 @@ export class AgentManagerProvider implements Disposable {
}
public getWorktreeDirectories(): string[] {
return this.getStateManager()?.getWorktrees().map((wt) => wt.path) ?? []
return (
this.getStateManager()
?.getWorktrees()
.map((wt) => wt.path) ?? []
)
}
/**
@@ -0,0 +1,20 @@
import { routeSuggestionWebviewMessage } from "./handlers/suggestion"
import * as ModelState from "./model-state"
import { routeInputToolMessage } from "../services/input-tools"
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
import type { SuggestionContext } from "./handlers/suggestion"
import type { KiloClient } from "@kilocode/sdk/v2/client"
type Ctx = {
question: SuggestionContext
client: KiloClient | null
connection: KiloConnectionService
dir: string
post: (msg: unknown) => void
}
export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Promise<boolean> {
await routeSuggestionWebviewMessage(ctx.question, message)
if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true
return await routeInputToolMessage(message, { connection: ctx.connection, dir: ctx.dir, post: ctx.post })
}
@@ -0,0 +1,49 @@
import type { KiloConnectionService } from "./cli-backend/connection-service"
import { routeAutocompleteMessage } from "./autocomplete/settings"
import { buildSpeechToTextSettingsMessage } from "../speech-to-text/settings"
import { handleSpeechToTextCancel, handleSpeechToTextStart, handleSpeechToTextStop } from "../speech-to-text/handler"
type Msg = {
type: string
requestId?: string
model?: string
language?: string
}
type Ctx = {
connection: KiloConnectionService
dir: string
post: (msg: unknown) => void
}
export async function routeInputToolMessage(message: Msg, ctx: Ctx): Promise<boolean> {
if (await routeAutocompleteMessage(message, ctx.post)) return true
if (message.type === "requestSpeechToTextSettings") {
ctx.post(buildSpeechToTextSettingsMessage())
return true
}
if (message.type === "speechToTextStart") {
if (!message.requestId) return true
handleSpeechToTextStart(
{ requestId: message.requestId, model: message.model, language: message.language },
ctx.post,
)
return true
}
if (message.type === "speechToTextStop") {
if (!message.requestId) return true
handleSpeechToTextStop(ctx.connection, { requestId: message.requestId }, ctx.dir, ctx.post)
return true
}
if (message.type === "speechToTextCancel") {
if (!message.requestId) return true
handleSpeechToTextCancel({ requestId: message.requestId }, ctx.post)
return true
}
return false
}
@@ -0,0 +1,278 @@
import { existsSync } from "fs"
import { readFile, stat, unlink } from "fs/promises"
import * as os from "os"
import * as path from "path"
import type { ChildProcess } from "child_process"
import { exec, spawn } from "../util/process"
type Input = {
requestId: string
model: string
language?: string
}
type Recording = Input & {
file: string
proc: ChildProcess
stderr: string[]
stopped: boolean
exit?: {
code: number | null
signal: string | null
}
}
type Audio = {
data: string
format: "wav"
model: string
language?: string
}
let active: Recording | undefined
let starting: string | undefined
export async function startSpeechCapture(input: Input): Promise<boolean> {
if (active || starting) throw new Error("Speech recording is already in progress")
starting = input.requestId
try {
const bin = await findFFmpeg()
const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`)
const state = await startWithArgs(bin, file, input, await inputArgSets(bin))
return !state.stopped
} finally {
if (starting === input.requestId) starting = undefined
}
}
export async function stopSpeechCapture(requestId: string): Promise<Audio> {
const state = requireActive(requestId)
state.stopped = true
active = undefined
await stopProcess(state)
const size = await stat(state.file)
.then((info) => info.size)
.catch((err: unknown) => {
console.warn("[Kilo New] Failed to stat speech recording", err)
return 0
})
if (size < 44) {
await removeFile(state.file)
throw new Error(summary(state, "No audio was recorded"))
}
const file = await readFile(state.file)
await removeFile(state.file)
return { data: file.toString("base64"), format: "wav", model: state.model, language: state.language }
}
export async function cancelSpeechCapture(requestId: string): Promise<void> {
const state = active
if (!state || state.requestId !== requestId) return
state.stopped = true
active = undefined
await stopProcess(state)
await removeFile(state.file)
}
async function waitForStart(state: Recording): Promise<void> {
await new Promise<void>((resolve, reject) => {
const done = () => {
state.proc.off("error", onError)
state.proc.off("exit", onExit)
state.proc.stderr?.off("data", onData)
clearTimeout(timer)
resolve()
}
const onError = (err: Error) => {
state.proc.off("exit", onExit)
state.proc.stderr?.off("data", onData)
clearTimeout(timer)
reject(err)
}
const onExit = () => {
if (state.stopped) {
done()
return
}
onError(new Error(summary(state, "Could not start microphone recording")))
}
const onData = (data: Buffer) => {
if (/Output #0|Press \[q\]|size=\s*\d+/i.test(data.toString())) done()
}
const timer = setTimeout(() => {
onError(new Error(summary(state, "Timed out starting microphone recording")))
}, 5000)
state.proc.stderr?.on("data", onData)
state.proc.once("error", onError)
state.proc.once("exit", onExit)
}).catch(async (err: unknown) => {
if (active === state) active = undefined
await stopProcess(state)
await removeFile(state.file)
throw err
})
}
async function startWithArgs(bin: string, file: string, input: Input, args: string[][]): Promise<Recording> {
const [first, ...rest] = args
if (!first) throw new Error(`Unsupported platform for speech input: ${process.platform}`)
const proc = spawn(bin, ["-y", ...first, "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", "-f", "wav", file], {
stdio: ["pipe", "ignore", "pipe"],
})
const state: Recording = { ...input, file, proc, stderr: [], stopped: false }
active = state
proc.stderr?.on("data", (data: Buffer) => {
if (state.stderr.length < 20) state.stderr.push(data.toString())
})
proc.on("exit", (code, signal) => {
state.exit = { code, signal }
if (active === state && !state.stopped) active = undefined
})
proc.on("error", (err) => {
state.stderr.push(err.message)
if (active === state && !state.stopped) active = undefined
})
try {
await waitForStart(state)
return state
} catch (err) {
if (state.stopped) return state
if (rest.length === 0) throw err
return startWithArgs(bin, file, input, rest)
}
}
async function stopProcess(state: Recording): Promise<void> {
if (state.proc.exitCode !== null || state.proc.signalCode) return
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
if (!state.proc.killed) state.proc.kill("SIGKILL")
resolve()
}, 2000)
state.proc.once("exit", () => {
clearTimeout(timer)
resolve()
})
if (state.proc.stdin?.writable) {
state.proc.stdin.write("q")
state.proc.stdin.end()
return
}
state.proc.kill("SIGTERM")
})
}
function requireActive(requestId: string): Recording {
if (!active || active.requestId !== requestId) throw new Error("No active speech recording")
return active
}
async function findFFmpeg(): Promise<string> {
const paths = [
process.env.KILO_FFMPEG_PATH,
process.env.FFMPEG_PATH,
bundledPath(),
...platformPaths(),
"ffmpeg",
].filter(Boolean)
for (const bin of paths) {
if (!bin) continue
if (path.isAbsolute(bin) && !existsSync(bin)) continue
try {
await exec(bin, ["-version"], { timeout: 3000 })
return bin
} catch (err) {
console.warn(`[Kilo New] FFmpeg candidate failed: ${bin}`, err)
}
}
throw new Error("Speech input needs the bundled FFmpeg helper, but it was not found. Rebuild or reinstall Kilo Code.")
}
function bundledPath(): string {
return path.join(__dirname, "..", "bin", process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg")
}
function platformPaths(): string[] {
if (process.platform === "darwin")
return ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/opt/local/bin/ffmpeg"]
if (process.platform === "win32") {
return [
"C:\\ffmpeg\\bin\\ffmpeg.exe",
process.env.ProgramFiles ? path.join(process.env.ProgramFiles, "ffmpeg", "bin", "ffmpeg.exe") : undefined,
process.env["ProgramFiles(x86)"]
? path.join(process.env["ProgramFiles(x86)"], "ffmpeg", "bin", "ffmpeg.exe")
: undefined,
process.env.USERPROFILE
? path.join(process.env.USERPROFILE, "scoop", "apps", "ffmpeg", "current", "bin", "ffmpeg.exe")
: undefined,
"C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe",
].filter((item): item is string => !!item)
}
return ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/snap/bin/ffmpeg", "/home/linuxbrew/.linuxbrew/bin/ffmpeg"]
}
async function inputArgSets(bin: string): Promise<string[][]> {
if (process.platform === "darwin") return [["-f", "avfoundation", "-i", ":default"]]
if (process.platform === "linux")
return [
["-f", "pulse", "-i", "default"],
["-f", "alsa", "-i", "default"],
]
if (process.platform === "win32") return await windowsInputArgSets(bin)
return []
}
async function windowsInputArgSets(bin: string): Promise<string[][]> {
const configured = process.env.KILO_FFMPEG_AUDIO_DEVICE
if (configured) return [["-f", "dshow", "-i", `audio=${configured}`]]
const devices = await listDshowAudioDevices(bin)
if (devices.length === 0) throw new Error("No Windows audio input devices found for speech input")
return devices.map((device) => ["-f", "dshow", "-i", `audio=${device}`])
}
async function listDshowAudioDevices(bin: string): Promise<string[]> {
const raw = await exec(bin, ["-list_devices", "true", "-f", "dshow", "-i", "dummy"], { timeout: 5000 })
.then((result) => `${result.stdout}\n${result.stderr}`)
.catch((err: unknown) => processOutput(err))
return parseDshowAudioDevices(raw)
}
export function parseDshowAudioDevices(raw: string): string[] {
const devices = new Set<string>()
for (const match of raw.matchAll(/"([^"]+)"\s+\(audio\)/g)) devices.add(match[1]!)
return [...devices]
}
function processOutput(err: unknown): string {
if (!err || typeof err !== "object") return ""
const result = err as { stdout?: unknown; stderr?: unknown }
return `${typeof result.stdout === "string" ? result.stdout : ""}\n${typeof result.stderr === "string" ? result.stderr : ""}`
}
function summary(state: Recording, fallback: string): string {
const stderr = state.stderr.join("\n").trim()
if (!stderr) return fallback
return `${fallback}: ${stderr.slice(-800)}`
}
async function removeFile(file: string): Promise<void> {
await unlink(file).catch((err: unknown) => {
console.warn("[Kilo New] Failed to remove speech recording", err)
})
}
@@ -0,0 +1,13 @@
import * as vscode from "vscode"
import { buildSpeechToTextSettingsMessage } from "./settings"
import type { KiloProvider } from "../KiloProvider"
export function watchSpeechToTextConfig(provider: KiloProvider): vscode.Disposable {
return vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("kilo-code.new.speechToText")) sendSpeechToTextSettings(provider)
})
}
export function sendSpeechToTextSettings(provider: KiloProvider): void {
provider.postMessage(buildSpeechToTextSettingsMessage())
}
@@ -0,0 +1,129 @@
import { getErrorMessage } from "../kilo-provider-utils"
import { transcribeSpeech } from "./transcribe"
import { cancelSpeechCapture, startSpeechCapture, stopSpeechCapture } from "./capture"
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
type Msg = {
requestId: string
model?: string
language?: string
}
type Post = (msg: unknown) => void
const aborts = new Map<string, AbortController>()
const cancelled = new Set<string>()
const starts = new Map<string, Promise<boolean>>()
const stopping = new Set<string>()
export function handleSpeechToTextStart(message: Msg, post: Post): void {
const task = startSpeechCapture({
requestId: message.requestId,
model: message.model || "",
language: message.language,
})
starts.set(message.requestId, task)
void task
.then((started) => {
starts.delete(message.requestId)
if (!started) return
if (stopping.has(message.requestId) || cancelled.has(message.requestId)) return
post({ type: "speechToTextStarted", requestId: message.requestId })
})
.catch((err: unknown) => {
starts.delete(message.requestId)
stopping.delete(message.requestId)
if (cancelled.delete(message.requestId)) return
post({
type: "speechToTextError",
error: getErrorMessage(err) || "Speech recording failed",
requestId: message.requestId,
})
})
}
export function handleSpeechToTextStop(connection: KiloConnectionService, message: Msg, dir: string, post: Post): void {
const ctrl = new AbortController()
const ready = starts.get(message.requestId)?.catch(() => false) ?? Promise.resolve(true)
aborts.set(message.requestId, ctrl)
stopping.add(message.requestId)
void ready
.then((started) => {
if (!started) return undefined
return stopSpeechCapture(message.requestId).then((audio) => transcribeSpeech(connection, audio, dir, ctrl.signal))
})
.then((result) => {
aborts.delete(message.requestId)
stopping.delete(message.requestId)
if (!result) return
if (cancelled.delete(message.requestId)) return
if (!result.ok && result.code === "cancelled") return
if (result.ok) {
post({ type: "speechToTextResult", text: result.text, requestId: message.requestId })
return
}
post({ type: "speechToTextError", error: result.error, code: result.code, requestId: message.requestId })
})
.catch((err: unknown) => {
aborts.delete(message.requestId)
stopping.delete(message.requestId)
if (cancelled.delete(message.requestId)) return
post({
type: "speechToTextError",
error: getErrorMessage(err) || "Speech to text request failed",
requestId: message.requestId,
})
})
}
export function handleSpeechToTextCancel(message: Msg, post: Post): void {
const ctrl = aborts.get(message.requestId)
if (ctrl) {
cancelled.add(message.requestId)
stopping.delete(message.requestId)
ctrl.abort()
aborts.delete(message.requestId)
post({ type: "speechToTextCancelled", requestId: message.requestId })
return
}
const ready = starts.get(message.requestId)
if (ready) {
cancelled.add(message.requestId)
stopping.delete(message.requestId)
void ready
.catch(() => false)
.then((started) => {
if (!started) return undefined
return cancelSpeechCapture(message.requestId)
})
.then(() => {
cancelled.delete(message.requestId)
post({ type: "speechToTextCancelled", requestId: message.requestId })
})
.catch((err: unknown) => {
cancelled.delete(message.requestId)
post({
type: "speechToTextError",
error: getErrorMessage(err) || "Speech recording cancellation failed",
requestId: message.requestId,
})
})
return
}
void cancelSpeechCapture(message.requestId)
.then(() => {
post({ type: "speechToTextCancelled", requestId: message.requestId })
})
.catch((err: unknown) => {
post({
type: "speechToTextError",
error: getErrorMessage(err) || "Speech recording cancellation failed",
requestId: message.requestId,
})
})
}
@@ -0,0 +1,48 @@
export interface SpeechToTextModelDef {
readonly id: string
readonly label: string
readonly provider: string
}
const models: SpeechToTextModelDef[] = [
{
id: "openai/gpt-4o-mini-transcribe",
label: "GPT-4o Mini Transcribe",
provider: "OpenAI",
},
{
id: "openai/gpt-4o-transcribe",
label: "GPT-4o Transcribe",
provider: "OpenAI",
},
{
id: "openai/whisper-1",
label: "Whisper 1",
provider: "OpenAI",
},
{
id: "openai/whisper-large-v3-turbo",
label: "Whisper Large V3 Turbo",
provider: "OpenAI-compatible",
},
{
id: "openai/whisper-large-v3",
label: "Whisper Large V3",
provider: "OpenAI-compatible",
},
{
id: "google/chirp-3",
label: "Chirp 3",
provider: "Google",
},
]
export const SPEECH_TO_TEXT_MODELS: readonly SpeechToTextModelDef[] = models
export const DEFAULT_SPEECH_TO_TEXT_MODEL: SpeechToTextModelDef = models[0]!
export function getSpeechToTextModel(id: string | undefined): SpeechToTextModelDef {
for (const model of models) {
if (model.id === id) return model
}
return DEFAULT_SPEECH_TO_TEXT_MODEL
}
@@ -0,0 +1,19 @@
import * as vscode from "vscode"
import { getSpeechToTextModel } from "./models"
export function buildSpeechToTextSettingsMessage() {
const config = vscode.workspace.getConfiguration("kilo-code.new.speechToText")
return {
type: "speechToTextSettingsLoaded" as const,
settings: {
enabled: config.get<boolean>("enabled", false),
model: getSpeechToTextModel(config.get<string>("model")).id,
},
}
}
export function validSpeechToTextSetting(key: string, value: unknown) {
if (key === "enabled") return typeof value === "boolean"
if (key === "model") return typeof value === "string" && getSpeechToTextModel(value).id === value
return false
}
@@ -0,0 +1,106 @@
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
import { getErrorMessage } from "../kilo-provider-utils"
import { getSpeechToTextModel } from "./models"
const PATH = "/kilo/audio/transcriptions"
type Req = {
model?: string
data: string
format: string
language?: string
}
type Res = {
text?: unknown
}
type Ok = {
ok: true
text: string
}
type Err = {
ok: false
error: string
code?: string
}
export type SpeechToTextResult = Ok | Err
export async function transcribeSpeech(
connection: KiloConnectionService,
input: Req,
dir: string,
signal?: AbortSignal,
): Promise<SpeechToTextResult> {
const cfg = connection.getServerConfig()
if (!cfg) return { ok: false, error: "Not connected to the Kilo backend", code: "not_connected" }
const auth = Buffer.from(`kilo:${cfg.password}`).toString("base64")
const url = new URL(PATH, cfg.baseUrl)
if (dir) url.searchParams.set("directory", dir)
try {
const res = await fetch(url, {
method: "POST",
signal,
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: getSpeechToTextModel(input.model).id,
input_audio: {
data: input.data,
format: input.format,
},
...(input.language ? { language: input.language } : {}),
}),
})
const raw = await res.text()
const body = parse(raw)
if (!res.ok) {
return {
ok: false,
error: errorMessage(body, raw) ?? `Speech to text failed with status ${res.status}`,
code: res.status === 401 ? "not_authenticated" : undefined,
}
}
const text = typeof body?.text === "string" ? body.text.trim() : ""
if (!text) return { ok: false, error: "No speech was detected", code: "empty_transcript" }
return { ok: true, text }
} catch (err) {
if (signal?.aborted) return { ok: false, error: "Speech transcription cancelled", code: "cancelled" }
const msg = getErrorMessage(err) || "Speech to text request failed"
return { ok: false, error: msg, code: msg === "Failed to fetch" ? "not_available" : undefined }
}
}
function parse(raw: string): Res | Record<string, unknown> | null {
if (!raw) return null
try {
return JSON.parse(raw) as Res | Record<string, unknown>
} catch {
return null
}
}
function errorMessage(body: Record<string, unknown> | Res | null, raw: string): string | undefined {
if (body) {
const obj = body as Record<string, unknown>
const err = obj.error
if (typeof err === "string") return err
if (err && typeof err === "object") {
const msg = (err as Record<string, unknown>).message
if (typeof msg === "string") return msg
}
const msg = obj.message
if (typeof msg === "string") return msg
}
return raw.trim() || undefined
}
@@ -4,6 +4,7 @@ import {
dirName,
buildHighlightSegments,
atEnd,
insertSpacedText,
isPromptBlocked,
isPromptBusy,
isSuggesting,
@@ -196,6 +197,29 @@ describe("isPromptBusy", () => {
})
})
describe("insertSpacedText", () => {
it("inserts transcript into empty text", () => {
expect(insertSpacedText("", "hello", 0, 0)).toEqual({ text: "hello", pos: 5 })
})
it("adds spaces between surrounding words", () => {
expect(insertSpacedText("helloworld", "beautiful", 5, 5)).toEqual({ text: "hello beautiful world", pos: 16 })
})
it("does not duplicate existing spaces", () => {
expect(insertSpacedText("hello world", "beautiful", 6, 6)).toEqual({ text: "hello beautiful world", pos: 16 })
})
it("replaces selected text and keeps caret after transcript", () => {
expect(insertSpacedText("hello bad world", "beautiful", 6, 9)).toEqual({ text: "hello beautiful world", pos: 15 })
})
it("preserves leading and trailing insertion positions", () => {
expect(insertSpacedText("world", "hello", 0, 0)).toEqual({ text: "hello world", pos: 6 })
expect(insertSpacedText("hello", "world", 5, 5)).toEqual({ text: "hello world", pos: 11 })
})
})
describe("isSuggesting", () => {
it("returns true when not blocked and suggestions > 0", () => {
expect(isSuggesting(false, 1)).toBe(true)
@@ -0,0 +1,22 @@
import { describe, expect, it } from "bun:test"
import { parseDshowAudioDevices } from "../../src/speech-to-text/capture"
describe("parseDshowAudioDevices", () => {
it("extracts Windows dshow audio device names", () => {
const raw = `
[dshow @ 000001] DirectShow audio devices
[dshow @ 000001] "Microphone Array (Realtek Audio)" (audio)
[dshow @ 000001] "Webcam Microphone" (audio)
[dshow @ 000001] DirectShow video devices
[dshow @ 000001] "Integrated Camera" (video)
`
expect(parseDshowAudioDevices(raw)).toEqual(["Microphone Array (Realtek Audio)", "Webcam Microphone"])
})
it("deduplicates repeated dshow audio device names", () => {
const raw = `"Microphone" (audio)\n"Microphone" (audio)`
expect(parseDshowAudioDevices(raw)).toEqual(["Microphone"])
})
})
@@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { DEFAULT_SPEECH_TO_TEXT_MODEL, SPEECH_TO_TEXT_MODELS } from "../../src/speech-to-text/models"
describe("speech-to-text model settings", () => {
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"))
const prop = pkg.contributes.configuration.properties["kilo-code.new.speechToText.model"]
it("keeps the default model in code instead of duplicating it in package.json", () => {
expect(prop.default).toBeUndefined()
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe(SPEECH_TO_TEXT_MODELS[0]?.id)
})
it("keeps the selectable model list out of package.json", () => {
expect(prop.enum).toBeUndefined()
expect(prop.enumDescriptions).toBeUndefined()
})
})
@@ -1481,6 +1481,7 @@ const AgentManagerContent: Component = () => {
const openReviewTab = () => {
const sel = selection()
if (sel === null) return
terms.setActiveId(undefined)
setSidePanel(null)
setReviewOpenForContext(sel, true)
setReviewActive(true)
@@ -2961,77 +2962,74 @@ const AgentManagerContent: Component = () => {
/>
</Show>
<Show when={!contextEmpty() && !history()}>
{/* Stacking container: the terminal layer is an absolutely-
positioned overlay on top of the chat. This sub-container
is position:relative so the overlay does not cover the
tab bar above (which lives outside this stack). */}
{/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
<div class="am-detail-stack">
{/* Terminal tabs are kept mounted regardless of visibility so
output keeps streaming across worktree switches without
detaching xterm from the paint tree. */}
{renderTerminalLayer({ state: terms })}
{/* Chat + side diff panel (hidden when review tab or terminal tab is active) */}
{/* Chat/terminal + side diff panel. Keep it mounted under the
review tab so live xterm canvases never leave the paint tree. */}
<div
class={`am-detail-content ${sidePanel() !== null ? "am-detail-split" : ""}`}
style={{ display: reviewActive() || terms.activeId() ? "none" : undefined }}
class={`am-detail-content ${sidePanel() !== null ? "am-detail-split" : ""} ${reviewActive() ? "am-detail-content-hidden" : ""}`}
>
<div class="am-chat-wrapper">
<ChatView
onSelectSession={(id) => {
if (addSessionToCurrentWorktree(id)) return
if (localSessionIDs().includes(id)) {
session.selectSession(id)
if (selection() === null) setSelection(LOCAL)
return
}
// Navigate to owning worktree instead of forcing into local mode
if (worktreeSessionIds().has(id)) {
const ms = managedSessions().find((s) => s.id === id)
if (ms?.worktreeId) {
selectWorktree(ms.worktreeId)
<div class={`am-main-pane ${terms.activeId() ? "am-main-pane-terminal-active" : ""}`}>
{/* Keep terminal tabs mounted so output streams across worktree switches. */}
{renderTerminalLayer({ state: terms })}
<div class="am-chat-wrapper">
<ChatView
onSelectSession={(id) => {
if (addSessionToCurrentWorktree(id)) return
if (localSessionIDs().includes(id)) {
session.selectSession(id)
setReviewActive(false)
if (selection() === null) setSelection(LOCAL)
return
}
}
openLocally(id)
}}
onShowHistory={() => setHistory(true)}
onForkMessage={readOnly() ? undefined : handleForkSession}
readonly={readOnly()}
continueInWorktree={selection() === LOCAL}
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
<Icon name="branch" size="small" />
<span class="am-readonly-text">{t("agentManager.session.readonly")}</span>
<Button
variant="secondary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
openLocally(sid)
}}
>
{t("agentManager.session.openLocally")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (sid) vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
}}
>
{t("agentManager.session.openInWorktree")}
</Button>
</div>
</Show>
// Navigate to owning worktree instead of forcing into local mode
if (worktreeSessionIds().has(id)) {
const ms = managedSessions().find((s) => s.id === id)
if (ms?.worktreeId) {
selectWorktree(ms.worktreeId)
session.selectSession(id)
setReviewActive(false)
return
}
}
openLocally(id)
}}
onShowHistory={() => setHistory(true)}
onForkMessage={readOnly() ? undefined : handleForkSession}
readonly={readOnly()}
continueInWorktree={selection() === LOCAL}
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
<Icon name="branch" size="small" />
<span class="am-readonly-text">{t("agentManager.session.readonly")}</span>
<Button
variant="secondary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
openLocally(sid)
}}
>
{t("agentManager.session.openLocally")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (sid) vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
}}
>
{t("agentManager.session.openInWorktree")}
</Button>
</div>
</Show>
</div>
</div>
<Show when={sidePanel() !== null}>
<div class="am-diff-resize" style={{ width: `${diffWidth()}px` }}>
@@ -3076,6 +3074,7 @@ const AgentManagerContent: Component = () => {
}}
onRevertFile={revertCtl.revert}
revertingFiles={revertCtl.reverting()}
activeTerminalId={terms.activeId()}
/>
</Show>
</div>
@@ -3106,6 +3105,7 @@ const AgentManagerContent: Component = () => {
}}
onRevertFile={revertCtl.revert}
revertingFiles={revertCtl.reverting()}
activeTerminalId={terms.activeId()}
onClose={closeReviewTab}
/>
</div>
@@ -54,6 +54,7 @@ interface DiffPanelProps {
onOpenFile?: (relativePath: string, line?: number) => void
onRevertFile?: (file: string) => void
revertingFiles?: Set<string>
activeTerminalId?: string
}
export const DiffPanel: Component<DiffPanelProps> = (props) => {
@@ -305,6 +306,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
deleteComment,
cancelDraft,
labels: labels(),
activeTerminalId: props.activeTerminalId,
})
}
@@ -328,7 +330,14 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const all = comments()
if (all.length === 0) return
window.dispatchEvent(
new MessageEvent("message", { data: { type: "appendReviewComments", comments: all, autoSend: true } }),
new MessageEvent("message", {
data: {
type: props.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
comments: all,
autoSend: true,
targetTerminalId: props.activeTerminalId,
},
}),
)
preserveScroll(() => setComments([]))
props.onSendAll?.()
@@ -59,6 +59,7 @@ interface FullScreenDiffViewProps {
onOpenFile?: (relativePath: string, line?: number) => void
onRevertFile?: (file: string) => void
revertingFiles?: Set<string>
activeTerminalId?: string
/** Defaults to true. Hides the per-file Revert action when false. */
canRevert?: boolean
/** Defaults to true. Disables comment creation and "Send all" when false. */
@@ -320,6 +321,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
deleteComment,
cancelDraft,
labels: labels(),
activeTerminalId: props.activeTerminalId,
})
}
@@ -336,7 +338,14 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
const all = comments()
if (all.length === 0) return
window.dispatchEvent(
new MessageEvent("message", { data: { type: "appendReviewComments", comments: all, autoSend: true } }),
new MessageEvent("message", {
data: {
type: props.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
comments: all,
autoSend: true,
targetTerminalId: props.activeTerminalId,
},
}),
)
preserveScroll(() => setComments([]))
props.onSendAll?.()
@@ -15,8 +15,11 @@ import { useVSCode } from "../src/context/vscode"
import { useServer } from "../src/context/server"
import { useSession } from "../src/context/session"
import { useProvider } from "../src/context/provider"
import { useConfig } from "../src/context/config"
import { ModelSelectorBase } from "../src/components/shared/ModelSelector"
import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher"
import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton"
import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability"
import { ThinkingSelectorBase } from "../src/components/shared/ThinkingSelector"
import {
MultiModelSelector,
@@ -27,7 +30,9 @@ import {
} from "./MultiModelSelector"
import { useLanguage } from "../src/context/language"
import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments"
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
import { convertToMentionPath } from "../src/utils/path-mentions"
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect"
type VersionCount = 1 | 2 | 3 | 4
@@ -65,6 +70,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const server = useServer()
const session = useSession()
const provider = useProvider()
const { config, settings } = useConfig()
const [tab, setTab] = createSignal<DialogTab>("new")
@@ -91,6 +97,9 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const [compareOpen, setCompareOpen] = createSignal(false)
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
const [variant, setVariant] = createSignal<string | undefined>(session.currentVariant())
const speech = useSpeechToText(vscode, server, { t })
const canUseSpeech = () => canUseSpeechToText(settings(), config(), provider.connected(), server.profileData())
const speechModel = () => selectedSpeechToTextModel(settings())
// Variant list for the currently selected model
const variants = createMemo(() => {
@@ -196,6 +205,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const canSubmit = () => {
if (starting()) return false
if (speech.active()) return false
if (compareMode() && totalAllocations(modelAllocations()) === 0) return false
return true
}
@@ -250,6 +260,26 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px`
}
const insertSpeechText = (value: string) => {
const ref = textareaRef
const current = prompt()
const start = ref?.selectionStart ?? current.length
const end = ref?.selectionEnd ?? start
const result = insertSpacedText(current, value, start, end)
setPrompt(result.text)
persistPrompt(result.text)
if (!ref) return
ref.value = result.text
ref.setSelectionRange(result.pos, result.pos)
ref.focus()
adjustHeight()
}
const startSpeech = () => {
speech.start({ model: speechModel(), insert: insertSpeechText })
}
// --- Import tab state ---
const [prUrl, setPrUrl] = createSignal("")
const [prPending, setPrPending] = createSignal(false)
@@ -422,7 +452,11 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
</Show>
</Show>
</div>
<div class="prompt-input-hint-actions" />
<div class="prompt-input-hint-actions">
<Show when={canUseSpeech()}>
<SpeechToTextButton speech={speech} disabled={starting()} start={startSpeech} label={t} />
</Show>
</div>
</div>
</div>
@@ -1650,16 +1650,38 @@ body.am-wt-dragging-active * {
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
isolation: isolate;
}
.am-detail-content-hidden {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
z-index: 0;
}
.am-detail-split {
flex-direction: row;
}
.am-detail-split .am-chat-wrapper {
.am-main-pane {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
position: relative;
isolation: isolate;
}
.am-main-pane-terminal-active .am-chat-wrapper {
opacity: 0;
pointer-events: none;
}
.am-detail-split .am-main-pane {
border-right: 1px solid var(--border-weak-base);
}
@@ -4331,9 +4353,9 @@ body.vscode-high-contrast-light {
* subtree and stop xterm's internal rAF render loop, causing the
* "press Enter to see content" bug on worktree switches.
*
* Stack: position:relative container hosting the chat, review, and
* terminal layer. Sits below the tab bar inside `.am-detail`.
* Layer: absolute-positioned overlay covering the stack.
* Stack: position:relative container hosting the main pane, review,
* and side panel. Sits below the tab bar inside `.am-detail`.
* Layer: absolute-positioned overlay covering the main pane.
* `pointer-events: none` when no terminal is active, letting
* clicks fall through to the chat behind it.
* Slots: absolute-positioned and stacked inside the layer; only the
@@ -4355,7 +4377,7 @@ body.vscode-high-contrast-light {
opacity: 0;
pointer-events: none;
background: var(--vscode-terminal-background, #1e1e1e);
z-index: 1;
z-index: 30;
/* Force a dedicated compositor layer so opacity flips do not re-lay-out
the xterm canvases underneath. */
will-change: opacity;
@@ -35,6 +35,7 @@ interface AnnotationHandlers {
deleteComment: (id: string) => void
cancelDraft: () => void
labels: AnnotationLabels
activeTerminalId?: string
}
function focusWhenConnected(el: HTMLTextAreaElement): void {
@@ -269,7 +270,12 @@ export function buildReviewAnnotation(
makeActionButton(handlers.labels.sendToChat, makeIcon("M1 1l14 7-14 7V9l10-1L1 7z"), () => {
window.dispatchEvent(
new MessageEvent("message", {
data: { type: "appendReviewComments", comments: [comment], autoSend: true },
data: {
type: handlers.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
comments: [comment],
autoSend: true,
targetTerminalId: handlers.activeTerminalId,
},
}),
)
handlers.deleteComment(comment.id)
@@ -19,6 +19,7 @@ import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"
import "@xterm/xterm/css/xterm.css"
import { useVSCode } from "../../src/context/vscode"
import { useLanguage } from "../../src/context/language"
import { formatReviewCommentsMarkdown } from "../../src/utils/review-comment-markdown"
interface Props {
terminalId: string
@@ -279,6 +280,14 @@ export const TerminalTab: Component<Props> = (props) => {
pendingFrame = requestAnimationFrame(runRepaint)
}
const fontSub = vscode.onMessage((message) => {
if (message.type === "appendReviewCommentsToTerminal") {
if (message.targetTerminalId !== props.terminalId) return
const comments = message.comments
if (!Array.isArray(comments) || comments.length === 0) return
term.paste(`${formatReviewCommentsMarkdown(comments)}\n`)
return
}
const size =
message.type === "fontSizeChanged" ? message.fontSize : message.type === "ready" ? message.fontSize : undefined
if (size === undefined) return
@@ -19,8 +19,11 @@ import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import { useWorktreeMode } from "../../context/worktree-mode"
import { useConfig } from "../../context/config"
import { useProvider } from "../../context/provider"
import { ModelSelector } from "../shared/ModelSelector"
import { ModeSwitcher } from "../shared/ModeSwitcher"
import { SpeechToTextButton } from "../speech-to-text/SpeechToTextButton"
import { canUseSpeechToText, selectedSpeechToTextModel } from "../speech-to-text/availability"
import { ThinkingSelector } from "../shared/ThinkingSelector"
import { useFileMention } from "../../hooks/useFileMention"
import { useTerminalContext } from "../../hooks/useTerminalContext"
@@ -29,11 +32,12 @@ import { hasTerminalMention } from "../../hooks/terminal-context-utils"
import { hasGitChangesMention } from "../../hooks/git-changes-context-utils"
import { useSlashCommand } from "../../hooks/useSlashCommand"
import { useGhostText } from "../../hooks/useGhostText"
import { useSpeechToText } from "../speech-to-text/useSpeechToText"
import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments"
import { convertToMentionPath } from "../../utils/path-mentions"
import { usePromptHistory } from "../../hooks/usePromptHistory"
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
import { fileName, dirName, buildHighlightSegments, atEnd, isPromptBusy } from "./prompt-input-utils"
import { fileName, dirName, buildHighlightSegments, atEnd, insertSpacedText, isPromptBusy } from "./prompt-input-utils"
import type { ReviewComment, TextPart } from "../../types/messages"
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts"
@@ -66,7 +70,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const session = useSession()
const server = useServer()
const indexing = useIndexing()
const { features } = useConfig()
const { config, features, settings } = useConfig()
const provider = useProvider()
const language = useLanguage()
const vscode = useVSCode()
const worktree = useWorktreeMode()
@@ -135,6 +140,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let preEnhanceText: string | null = null
const ghost = useGhostText(vscode, text, () => server.isConnected())
const speech = useSpeechToText(vscode, server, language)
const replaceReviewComments = (next: ReviewComment[]) => {
setReviewComments(next)
@@ -325,8 +331,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isBusy = () => isPromptBusy(session.status(), !!props.suggesting?.(), !!props.questioning?.())
const isDisabled = () => !server.isConnected()
const canUseSpeech = () => canUseSpeechToText(settings(), config(), provider.connected(), server.profileData())
const speechModel = () => selectedSpeechToTextModel(settings())
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !git.pending() && !props.blocked?.()
const canSend = () =>
hasInput() && !isDisabled() && !speech.active() && !terminal.pending() && !git.pending() && !props.blocked?.()
const showStop = () => isBusy() && !hasInput()
const isAtEnd = () =>
textareaRef ? atEnd(textareaRef.selectionStart, textareaRef.selectionEnd, textareaRef.value.length) : false
@@ -645,6 +654,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
vscode.postMessage({ type: "enhancePrompt", text: draft, requestId: `enhance-${draftKey()}-${enhanceCounter}` })
}
const insertSpeechText = (value: string) => {
const ref = textareaRef
const current = text()
const start = ref?.selectionStart ?? current.length
const end = ref?.selectionEnd ?? start
const result = insertSpacedText(current, value, start, end)
setText(result.text)
if (!ref) return
ref.value = result.text
ref.setSelectionRange(result.pos, result.pos)
ref.focus()
adjustHeight()
syncHighlightScroll()
ghost.scheduleRequest(result.text, ref)
}
const startSpeech = () => {
speech.start({ model: speechModel(), insert: insertSpeechText })
}
const handleSend = async () => {
const draft = text().trim()
@@ -676,7 +706,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pending = reviewComments()
const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : ""
const message = draft && review ? `${review}\n\n${draft}` : draft || review
if ((!message && imgs.length === 0) || isDisabled() || terminal.pending() || git.pending() || props.blocked?.())
if (
(!message && imgs.length === 0) ||
isDisabled() ||
speech.active() ||
terminal.pending() ||
git.pending() ||
props.blocked?.()
)
return
const mentionFiles = mention.parseFileAttachments(draft)
@@ -1042,6 +1079,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<WandSparkles size={16} class={enhancing() ? "enhance-spinner" : ""} />
</Button>
</Tooltip>
<Show when={canUseSpeech()}>
<SpeechToTextButton speech={speech} disabled={isDisabled()} start={startSpeech} label={language.t} />
</Show>
<Show
when={showStop()}
fallback={
@@ -50,6 +50,23 @@ export function atEnd(start: number, end: number, len: number): boolean {
return start === end && end === len
}
export function insertSpacedText(
text: string,
value: string,
start: number,
end: number,
): { text: string; pos: number } {
const before = text.slice(0, start)
const after = text.slice(end)
const prefix = before && !/\s$/.test(before) ? " " : ""
const suffix = after && !/^\s/.test(after) ? " " : ""
const inserted = `${prefix}${value}${suffix}`
return {
text: `${before}${inserted}${after}`,
pos: before.length + inserted.length,
}
}
/**
* Whether the input prompt should be blocked.
*
@@ -5,9 +5,14 @@ import { TextField } from "@kilocode/kilo-ui/text-field"
import { Card } from "@kilocode/kilo-ui/card"
import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import { useProvider } from "../../context/provider"
import { useServer } from "../../context/server"
import { useVSCode } from "../../context/vscode"
import type { ExtensionMessage } from "../../types/messages"
import SettingsRow from "./SettingsRow"
import { DEFAULT_SPEECH_TO_TEXT_MODEL } from "../../../../src/speech-to-text/models"
import { hasSpeechToTextAccess, selectedSpeechToTextModel } from "../speech-to-text/availability"
import { SPEECH_TO_TEXT_MODEL_OPTIONS } from "../speech-to-text/model-selector"
interface ShareOption {
value: string
@@ -21,8 +26,10 @@ const SHARE_OPTIONS: ShareOption[] = [
]
const ExperimentalTab: Component = () => {
const { config, updateConfig } = useConfig()
const { config, settings, updateConfig, updateSetting } = useConfig()
const language = useLanguage()
const provider = useProvider()
const server = useServer()
const vscode = useVSCode()
const [active, setActive] = createSignal(false)
@@ -39,6 +46,8 @@ const ExperimentalTab: Component = () => {
})
const experimental = createMemo(() => config().experimental ?? {})
const kiloReady = createMemo(() => hasSpeechToTextAccess(config(), provider.connected(), server.profileData()))
const speechModel = createMemo(() => selectedSpeechToTextModel(settings()))
const updateExperimental = (key: string, value: unknown) => {
updateConfig({
@@ -193,6 +202,42 @@ const ExperimentalTab: Component = () => {
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.speechToText.title")}
description={
kiloReady()
? language.t("settings.experimental.speechToText.description")
: language.t("settings.experimental.speechToText.disabledDescription")
}
>
<Switch
checked={Boolean(settings()["speechToText.enabled"] ?? false)}
onChange={(checked) => updateSetting("speechToText.enabled", checked)}
disabled={!kiloReady()}
hideLabel
>
{language.t("settings.experimental.speechToText.title")}
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.speechToTextModel.title")}
description={language.t("settings.experimental.speechToTextModel.description")}
>
<Select
options={SPEECH_TO_TEXT_MODEL_OPTIONS}
current={SPEECH_TO_TEXT_MODEL_OPTIONS.find((item) => item.value === speechModel())}
value={(item) => item.value}
label={(item) => `${item.label} (${item.provider})`}
onSelect={(item) => updateSetting("speechToText.model", item?.value ?? DEFAULT_SPEECH_TO_TEXT_MODEL.id)}
variant="secondary"
size="small"
triggerVariant="settings"
disabled={!kiloReady()}
placeholder={DEFAULT_SPEECH_TO_TEXT_MODEL.label}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.continueOnDeny.title")}
description={language.t("settings.experimental.continueOnDeny.description")}
@@ -0,0 +1,72 @@
import { Button } from "@kilocode/kilo-ui/button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import type { Component } from "solid-js"
import type { SpeechToText } from "./useSpeechToText"
type Props = {
speech: SpeechToText
disabled?: boolean
start: () => void
label: (key: string) => string
}
export const SpeechToTextButton: Component<Props> = (props) => {
const disabled = () => !!props.disabled
const label = () => {
if (props.speech.state() === "recording") return props.label("speechToText.tooltip.stop")
if (props.speech.state() === "transcribing") return props.label("speechToText.tooltip.transcribing")
if (props.speech.state() === "error") return props.speech.error() || props.label("speechToText.tooltip.error")
return props.label("speechToText.tooltip.start")
}
const click = () => {
if (props.speech.state() === "recording") {
props.speech.stop()
return
}
if (props.speech.state() === "transcribing") {
props.speech.cancel()
return
}
if (props.speech.state() === "error") {
props.speech.clear()
return
}
if (disabled()) return
props.start()
}
return (
<Tooltip value={label()} placement="top">
<Button
variant="ghost"
size="small"
onClick={click}
disabled={disabled()}
aria-label={label()}
aria-pressed={props.speech.state() === "recording"}
class={`prompt-speech-button prompt-speech-button--${props.speech.state()}`}
>
{props.speech.state() === "transcribing" ? (
<Spinner style={{ width: "16px", height: "16px" }} />
) : (
<svg class="prompt-speech-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 10.5C9.38 10.5 10.5 9.38 10.5 8V4C10.5 2.62 9.38 1.5 8 1.5C6.62 1.5 5.5 2.62 5.5 4V8C5.5 9.38 6.62 10.5 8 10.5Z"
stroke="currentColor"
stroke-width="1.2"
/>
<path
d="M3.5 7.5C3.5 10 5.5 12 8 12C10.5 12 12.5 10 12.5 7.5"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
/>
<path d="M8 12V14.5M5.5 14.5H10.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
)}
</Button>
</Tooltip>
)
}
@@ -0,0 +1,23 @@
import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model"
import { getSpeechToTextModel } from "../../../../src/speech-to-text/models"
type Cfg = {
disabled_providers?: string[]
}
export function hasSpeechToTextAccess(cfg: Cfg, providers: readonly string[], profile: unknown | null): boolean {
return providers.includes(KILO_PROVIDER_ID) && !cfg.disabled_providers?.includes(KILO_PROVIDER_ID) && !!profile
}
export function canUseSpeechToText(
settings: Record<string, unknown>,
cfg: Cfg,
providers: readonly string[],
profile: unknown | null,
): boolean {
return settings["speechToText.enabled"] === true && hasSpeechToTextAccess(cfg, providers, profile)
}
export function selectedSpeechToTextModel(settings: Record<string, unknown>): string {
return getSpeechToTextModel(String(settings["speechToText.model"] ?? "")).id
}
@@ -0,0 +1,13 @@
import { SPEECH_TO_TEXT_MODELS } from "../../../../src/speech-to-text/models"
export type SpeechToTextModelOption = {
value: string
label: string
provider: string
}
export const SPEECH_TO_TEXT_MODEL_OPTIONS: SpeechToTextModelOption[] = SPEECH_TO_TEXT_MODELS.map((model) => ({
value: model.id,
label: model.label,
provider: model.provider,
}))
@@ -0,0 +1,167 @@
import { createSignal, onCleanup } from "solid-js"
import { showToast } from "@kilocode/kilo-ui/toast"
import type { Accessor } from "solid-js"
import type { ExtensionMessage, WebviewMessage } from "../../types/messages"
type VSCode = {
postMessage: (message: WebviewMessage) => void
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
}
type Server = {
profileData: Accessor<unknown | null>
goToLogin: () => void
}
type Lang = {
t: (key: string) => string
}
export type SpeechState = "idle" | "recording" | "transcribing" | "error"
export type InsertTranscript = (text: string) => void
type StartOptions = {
model: string
insert: InsertTranscript
}
export type SpeechToText = {
state: Accessor<SpeechState>
error: Accessor<string | undefined>
active: Accessor<boolean>
start: (opts: StartOptions) => void
stop: () => void
cancel: () => void
clear: () => void
}
export function useSpeechToText(vscode: VSCode, server: Server, lang: Lang): SpeechToText {
const [state, setState] = createSignal<SpeechState>("idle")
const [error, setError] = createSignal<string | undefined>()
const active = () => state() === "recording" || state() === "transcribing"
const prefix = globalThis.crypto?.randomUUID?.() ?? `stt-${Math.random().toString(36).slice(2)}`
let request = ""
let counter = 0
let insert: InsertTranscript | undefined
const unsub = vscode.onMessage((msg) => {
if (!isSpeechMessage(msg)) return
if (msg.requestId !== request) return
if (msg.type === "speechToTextStarted") {
setState("recording")
return
}
if (msg.type === "speechToTextCancelled") {
cleanup()
setState("idle")
setError(undefined)
return
}
if (msg.type === "speechToTextError") {
fail(msg.error)
return
}
const text = msg.text.trim()
if (!text) {
fail(lang.t("speechToText.error.emptyTranscript"))
return
}
insert?.(text)
cleanup()
setState("idle")
setError(undefined)
})
onCleanup(() => {
unsub()
cancel()
})
function start(opts: StartOptions) {
if (active()) return
insert = opts.insert
setError(undefined)
if (!server.profileData()) {
showToast({
variant: "error",
title: lang.t("speechToText.error.loginRequired"),
actions: [
{ label: lang.t("common.signIn"), onClick: server.goToLogin },
{ label: lang.t("common.dismiss"), onClick: "dismiss" },
],
})
fail(lang.t("speechToText.error.loginRequired"), false)
return
}
counter++
request = `${prefix}-${counter}`
setState("recording")
vscode.postMessage({
type: "speechToTextStart",
requestId: request,
model: opts.model,
language: langCode(),
})
}
function stop() {
if (state() !== "recording") return
setState("transcribing")
vscode.postMessage({ type: "speechToTextStop", requestId: request })
}
function cancel() {
if (request && active()) vscode.postMessage({ type: "speechToTextCancel", requestId: request })
cleanup()
setState("idle")
setError(undefined)
}
function clear() {
if (state() !== "error") return
cleanup()
setState("idle")
setError(undefined)
}
function fail(message: string, toast = true) {
cleanup()
setState("error")
setError(message)
if (toast) showToast({ variant: "error", title: lang.t("speechToText.error.title"), description: message })
}
function cleanup() {
request = ""
insert = undefined
}
return { state, error, active, start, stop, cancel, clear }
}
function isSpeechMessage(
msg: ExtensionMessage,
): msg is Extract<
ExtensionMessage,
{ type: "speechToTextStarted" | "speechToTextCancelled" | "speechToTextResult" | "speechToTextError" }
> {
return (
msg.type === "speechToTextStarted" ||
msg.type === "speechToTextCancelled" ||
msg.type === "speechToTextResult" ||
msg.type === "speechToTextError"
)
}
function langCode() {
return (navigator.language || "en").split("-")[0] || "en"
}
@@ -72,14 +72,19 @@ export const ConfigProvider: ParentComponent = (props) => {
// a configLoaded message that arrives before the DOM mount.
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
if (message.type === "autocompleteSettingsLoaded") {
const patch = {
mergeSettings({
"autocomplete.enableAutoTrigger": message.settings.enableAutoTrigger,
"autocomplete.enableSmartInlineTaskKeybinding": message.settings.enableSmartInlineTaskKeybinding,
"autocomplete.enableChatAutocomplete": message.settings.enableChatAutocomplete,
"autocomplete.model": message.settings.model,
}
setSavedSettings((prev) => ({ ...prev, ...patch }))
setSettings((prev) => ({ ...prev, ...patch, ...settingsDraft() }))
})
return
}
if (message.type === "speechToTextSettingsLoaded") {
mergeSettings({
"speechToText.enabled": message.settings.enabled,
"speechToText.model": message.settings.model,
})
return
}
if (message.type === "configLoaded") {
@@ -142,15 +147,24 @@ export const ConfigProvider: ParentComponent = (props) => {
onCleanup(unsubscribe)
function mergeSettings(patch: Record<string, unknown>) {
setSavedSettings((prev) => ({ ...prev, ...patch }))
setSettings((prev) => ({ ...prev, ...patch, ...settingsDraft() }))
}
const requestInitialData = () => {
vscode.postMessage({ type: "requestConfig" })
vscode.postMessage({ type: "requestAutocompleteSettings" })
vscode.postMessage({ type: "requestSpeechToTextSettings" })
}
// Request config immediately; if the extension's httpClient is not yet ready,
// extensionDataReady will fire once initialization completes and we retry once.
vscode.postMessage({ type: "requestConfig" })
vscode.postMessage({ type: "requestAutocompleteSettings" })
requestInitialData()
const fallback = setTimeout(() => {
if (loading()) {
vscode.postMessage({ type: "requestConfig" })
vscode.postMessage({ type: "requestAutocompleteSettings" })
requestInitialData()
}
}, 3000)
@@ -159,8 +173,7 @@ export const ConfigProvider: ParentComponent = (props) => {
unsubReady()
clearTimeout(fallback)
if (loading()) {
vscode.postMessage({ type: "requestConfig" })
vscode.postMessage({ type: "requestAutocompleteSettings" })
requestInitialData()
}
})
+21
View File
@@ -278,6 +278,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"زر «حسّن الموجه» يطوّر موجهك بإضافة سياق أو توضيح أو إعادة صياغة. جرّب اكتب موجه هنا ثم اضغط الزر مرة ثانية وشوف النتيجة.",
"speechToText.tooltip.start": "بدء الإدخال الصوتي",
"speechToText.tooltip.stop": "إيقاف التقاط الصوت",
"speechToText.tooltip.transcribing": "جاري تحويل الصوت إلى نص... انقر للإلغاء.",
"speechToText.tooltip.error": "فشل الإدخال الصوتي. انقر للمسح.",
"speechToText.error.title": "فشل الإدخال الصوتي",
"speechToText.error.loginRequired": "قم بتسجيل الدخول إلى Kilo لاستخدام الإدخال الصوتي.",
"speechToText.error.permission": "تم رفض إذن الوصول إلى الميكروفون.",
"speechToText.error.microphone": "تعذر بدء تشغيل الميكروفون.",
"speechToText.error.recording": "فشل التسجيل.",
"speechToText.error.emptyRecording": "لم يتم تسجيل أي صوت.",
"speechToText.error.emptyTranscript": "لم يتم اكتشاف أي كلام.",
"speechToText.error.encoding": "تعذر تشفير التسجيل.",
"speechToText.toast.transcribed": "تم إدراج النص المنسوخ",
"prompt.toast.pasteUnsupported.title": "لصق غير مدعوم",
"prompt.toast.pasteUnsupported.description": "يمكن لصق الصور أو ملفات PDF فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
@@ -1212,6 +1226,13 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "أداة Agent Manager",
"settings.experimental.agentManagerTool.description":
"السماح للوكلاء ببدء جلسات Agent Manager المحلية وجلسات worktree من استدعاء أداة",
"settings.experimental.speechToText.title": "تحويل الصوت إلى نص",
"settings.experimental.speechToText.description":
"تمكين الإدخال الصوتي في حقول المطالبة باستخدام حساب Kilo الخاص بك من خلال Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"قم بتمكين وتسجيل الدخول إلى Kilo provider لاستخدام الإدخال الصوتي في حقول المطالبة.",
"settings.experimental.speechToTextModel.title": "نموذج تحويل الصوت إلى نص",
"settings.experimental.speechToTextModel.description": "اختر نموذج نسخ Kilo Gateway للإدخال الصوتي.",
"settings.experimental.continueOnDeny.title": "المتابعة عند الرفض",
"settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن",
"settings.experimental.mcpTimeout.title": "مهلة MCP (مللي ثانية)",
+22
View File
@@ -282,6 +282,20 @@ export const dict = {
"O botão 'Aprimorar prompt' ajuda a melhorar seu pedido fornecendo contexto adicional, esclarecimentos ou reformulações. Tente digitar um pedido aqui e clique no botão novamente para ver como funciona.",
"prompt.action.indexing": "Configurações de indexação",
"speechToText.tooltip.start": "Iniciar entrada de voz",
"speechToText.tooltip.stop": "Parar captura",
"speechToText.tooltip.transcribing": "Transcrevendo... Clique para cancelar.",
"speechToText.tooltip.error": "Falha na entrada de voz. Clique para limpar.",
"speechToText.error.title": "Falha na entrada de voz",
"speechToText.error.loginRequired": "Faça login no Kilo para usar a entrada de voz.",
"speechToText.error.permission": "A permissão do microfone foi negada.",
"speechToText.error.microphone": "Não foi possível iniciar o microfone.",
"speechToText.error.recording": "A gravação falhou.",
"speechToText.error.emptyRecording": "Nenhum áudio foi gravado.",
"speechToText.error.emptyTranscript": "Nenhuma fala foi detectada.",
"speechToText.error.encoding": "Não foi possível codificar a gravação.",
"speechToText.toast.transcribed": "Transcrição inserida",
"prompt.toast.pasteUnsupported.title": "Colagem não suportada",
"prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
@@ -1241,6 +1255,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Ferramenta Agent Manager",
"settings.experimental.agentManagerTool.description":
"Permitir que agentes iniciem sessões locais e sessões worktree do Agent Manager a partir de uma chamada de ferramenta",
"settings.experimental.speechToText.title": "Fala para texto",
"settings.experimental.speechToText.description":
"Ative a entrada de voz nos campos de prompt usando sua conta do Kilo por meio do Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Ative e faça login no Kilo provider para usar a entrada de voz nos campos de prompt.",
"settings.experimental.speechToTextModel.title": "Modelo de fala para texto",
"settings.experimental.speechToTextModel.description":
"Escolha o modelo de transcrição do Kilo Gateway para entrada de voz.",
"settings.experimental.continueOnDeny.title": "Continuar ao negar",
"settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada",
"settings.experimental.mcpTimeout.title": "Tempo limite MCP (ms)",
+22
View File
@@ -282,6 +282,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"Dugme 'Poboljšaj prompt' pomaže poboljšati vaš zahtjev pružajući dodatni kontekst, pojašnjenje ili preformulaciju. Pokušajte upisati zahtjev ovdje i ponovo kliknite na dugme da vidite kako funkcioniše.",
"speechToText.tooltip.start": "Započni glasovni unos",
"speechToText.tooltip.stop": "Zaustavi hvatanje zvuka",
"speechToText.tooltip.transcribing": "Prepisivanje... Kliknite da otkažete.",
"speechToText.tooltip.error": "Glasovni unos nije uspio. Kliknite da očistite.",
"speechToText.error.title": "Glasovni unos nije uspio",
"speechToText.error.loginRequired": "Prijavite se na Kilo da koristite glasovni unos.",
"speechToText.error.permission": "Dozvola za mikrofon je odbijena.",
"speechToText.error.microphone": "Nije moguće pokrenuti mikrofon.",
"speechToText.error.recording": "Snimanje nije uspjelo.",
"speechToText.error.emptyRecording": "Zvuk nije snimljen.",
"speechToText.error.emptyTranscript": "Govor nije otkriven.",
"speechToText.error.encoding": "Nije moguće kodirati snimak.",
"speechToText.toast.transcribed": "Prijepis ubačen",
"prompt.toast.pasteUnsupported.title": "Nepodržano lijepljenje",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu zalijepiti samo slike ili PDF-ovi.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
@@ -1241,6 +1255,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager alat",
"settings.experimental.agentManagerTool.description":
"Dozvoli agentima da pokreću lokalne Agent Manager sesije i worktree sesije iz poziva alata",
"settings.experimental.speechToText.title": "Govor u tekst",
"settings.experimental.speechToText.description":
"Omogućite glasovni unos u poljima za promptove koristeći vaš Kilo račun preko Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Omogućite i prijavite se na Kilo provider da koristite glasovni unos u poljima za promptove.",
"settings.experimental.speechToTextModel.title": "Model govora u tekst",
"settings.experimental.speechToTextModel.description":
"Odaberite Kilo Gateway model za transkripciju za glasovni unos.",
"settings.experimental.continueOnDeny.title": "Nastavi pri odbijanju",
"settings.experimental.continueOnDeny.description": "Nastavi petlju agenta kada je dozvola odbijena",
"settings.experimental.mcpTimeout.title": "MCP istek vremena (ms)",
+21
View File
@@ -281,6 +281,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"Knappen 'Forbedr prompt' hjælper med at forbedre din forespørgsel ved at give ekstra kontekst, præcisering eller omformulering. Prøv at skrive en forespørgsel her og klik på knappen igen for at se hvordan det virker.",
"speechToText.tooltip.start": "Start stemmeinput",
"speechToText.tooltip.stop": "Stop lydoptagelse",
"speechToText.tooltip.transcribing": "Transskriberer... Klik for at annullere.",
"speechToText.tooltip.error": "Stemmeinput mislykkedes. Klik for at rydde.",
"speechToText.error.title": "Stemmeinput mislykkedes",
"speechToText.error.loginRequired": "Log ind på Kilo for at bruge stemmeinput.",
"speechToText.error.permission": "Mikrofontilladelse blev nægtet.",
"speechToText.error.microphone": "Kunne ikke starte mikrofonen.",
"speechToText.error.recording": "Optagelse mislykkedes.",
"speechToText.error.emptyRecording": "Ingen lyd blev optaget.",
"speechToText.error.emptyTranscript": "Ingen tale blev registreret.",
"speechToText.error.encoding": "Kunne ikke kode optagelsen.",
"speechToText.toast.transcribed": "Transskription indsat",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet indsæt",
"prompt.toast.pasteUnsupported.description": "Kun billeder eller PDF'er kan indsættes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
@@ -1234,6 +1248,13 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager-værktøj",
"settings.experimental.agentManagerTool.description":
"Tillad agenter at starte lokale Agent Manager-sessioner og worktree-sessioner fra et værktøjskald",
"settings.experimental.speechToText.title": "Tale til tekst",
"settings.experimental.speechToText.description":
"Aktivér stemmeinput i prompt-felter ved hjælp af din Kilo-konto gennem Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Aktivér og log ind på Kilo provider for at bruge stemmeinput i prompt-felter.",
"settings.experimental.speechToTextModel.title": "Model til tale til tekst",
"settings.experimental.speechToTextModel.description": "Vælg Kilo Gateway-transskriptionsmodellen til stemmeinput.",
"settings.experimental.continueOnDeny.title": "Fortsæt ved afvisning",
"settings.experimental.continueOnDeny.description": "Fortsæt agentløkken, når en tilladelse afvises",
"settings.experimental.mcpTimeout.title": "MCP-timeout (ms)",
+22
View File
@@ -285,6 +285,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"Die Schaltfläche 'Prompt verbessern' hilft, deine Anfrage durch zusätzlichen Kontext, Klarstellungen oder Umformulierungen zu verbessern. Versuche, hier eine Anfrage einzugeben und klicke erneut auf die Schaltfläche, um zu sehen, wie es funktioniert.",
"speechToText.tooltip.start": "Spracheingabe starten",
"speechToText.tooltip.stop": "Audioerfassung beenden",
"speechToText.tooltip.transcribing": "Transkribieren... Zum Abbrechen klicken.",
"speechToText.tooltip.error": "Spracheingabe fehlgeschlagen. Zum Löschen klicken.",
"speechToText.error.title": "Spracheingabe fehlgeschlagen",
"speechToText.error.loginRequired": "Melden Sie sich bei Kilo an, um die Spracheingabe zu verwenden.",
"speechToText.error.permission": "Mikrofonberechtigung wurde verweigert.",
"speechToText.error.microphone": "Mikrofon konnte nicht gestartet werden.",
"speechToText.error.recording": "Aufnahme fehlgeschlagen.",
"speechToText.error.emptyRecording": "Es wurde kein Audio aufgenommen.",
"speechToText.error.emptyTranscript": "Es wurde keine Sprache erkannt.",
"speechToText.error.encoding": "Aufnahme konnte nicht codiert werden.",
"speechToText.toast.transcribed": "Transkription eingefügt",
"prompt.toast.pasteUnsupported.title": "Nicht unterstütztes Einfügen",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder oder PDFs eingefügt werden.",
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
@@ -1256,6 +1270,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager-Werkzeug",
"settings.experimental.agentManagerTool.description":
"Agenten erlauben, lokale Agent Manager-Sitzungen und Worktree-Sitzungen per Werkzeugaufruf zu starten",
"settings.experimental.speechToText.title": "Sprache zu Text",
"settings.experimental.speechToText.description":
"Aktivieren Sie die Spracheingabe in Prompt-Feldern mit Ihrem Kilo-Konto über Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Aktivieren Sie Kilo provider und melden Sie sich an, um die Spracheingabe in Prompt-Feldern zu verwenden.",
"settings.experimental.speechToTextModel.title": "Sprache-zu-Text-Modell",
"settings.experimental.speechToTextModel.description":
"Wählen Sie das Kilo Gateway-Transkriptionsmodell für die Spracheingabe.",
"settings.experimental.continueOnDeny.title": "Bei Ablehnung fortfahren",
"settings.experimental.continueOnDeny.description":
"Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird",
@@ -280,6 +280,19 @@ export const dict = {
"prompt.action.resetModel": "Reset model to default",
"prompt.action.enhanceDescription":
"The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.",
"speechToText.tooltip.start": "Start voice input",
"speechToText.tooltip.stop": "Stop capturing",
"speechToText.tooltip.transcribing": "Transcribing... Click to cancel.",
"speechToText.tooltip.error": "Speech input failed. Click to clear.",
"speechToText.error.title": "Speech input failed",
"speechToText.error.loginRequired": "Sign in to Kilo to use speech input.",
"speechToText.error.permission": "Microphone permission was denied.",
"speechToText.error.microphone": "Could not start the microphone.",
"speechToText.error.recording": "Recording failed.",
"speechToText.error.emptyRecording": "No audio was recorded.",
"speechToText.error.emptyTranscript": "No speech was detected.",
"speechToText.error.encoding": "Could not encode the recording.",
"speechToText.toast.transcribed": "Transcription inserted",
"prompt.toast.pasteUnsupported.title": "Unsupported paste",
"prompt.toast.pasteUnsupported.description": "Only images or PDFs can be pasted here.",
@@ -1225,6 +1238,13 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager Tool",
"settings.experimental.agentManagerTool.description":
"Allow agents to start Agent Manager local sessions and worktree sessions from a tool call",
"settings.experimental.speechToText.title": "Speech to Text",
"settings.experimental.speechToText.description":
"Enable voice input in prompt fields using your Kilo account through Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Enable and sign in to the Kilo provider to use voice input in prompt fields.",
"settings.experimental.speechToTextModel.title": "Speech to Text Model",
"settings.experimental.speechToTextModel.description": "Choose the Kilo Gateway transcription model for voice input.",
"settings.experimental.continueOnDeny.title": "Continue on Deny",
"settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied",
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
+22
View File
@@ -283,6 +283,20 @@ export const dict = {
"El botón 'Mejorar el mensaje' ayuda a mejorar tu petición proporcionando contexto adicional, aclaraciones o reformulaciones. Intenta escribir una petición aquí y haz clic en el botón nuevamente para ver cómo funciona.",
"prompt.action.indexing": "Configuración de indexación",
"speechToText.tooltip.start": "Iniciar entrada de voz",
"speechToText.tooltip.stop": "Detener captura",
"speechToText.tooltip.transcribing": "Transcribiendo... Haz clic para cancelar.",
"speechToText.tooltip.error": "Falló la entrada de voz. Haz clic para borrar.",
"speechToText.error.title": "Falló la entrada de voz",
"speechToText.error.loginRequired": "Inicia sesión en Kilo para usar la entrada de voz.",
"speechToText.error.permission": "Se denegó el permiso del micrófono.",
"speechToText.error.microphone": "No se pudo iniciar el micrófono.",
"speechToText.error.recording": "La grabación falló.",
"speechToText.error.emptyRecording": "No se grabó ningún audio.",
"speechToText.error.emptyTranscript": "No se detectó voz.",
"speechToText.error.encoding": "No se pudo codificar la grabación.",
"speechToText.toast.transcribed": "Transcripción insertada",
"prompt.toast.pasteUnsupported.title": "Pegado no soportado",
"prompt.toast.pasteUnsupported.description": "Solo se pueden pegar imágenes o PDFs aquí.",
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
@@ -1248,6 +1262,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Herramienta Agent Manager",
"settings.experimental.agentManagerTool.description":
"Permitir que los agentes inicien sesiones locales y sesiones de worktree de Agent Manager desde una llamada de herramienta",
"settings.experimental.speechToText.title": "Voz a texto",
"settings.experimental.speechToText.description":
"Habilita la entrada de voz en los campos de prompt usando tu cuenta de Kilo a través de Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Habilita e inicia sesión en Kilo provider para usar la entrada de voz en los campos de prompt.",
"settings.experimental.speechToTextModel.title": "Modelo de voz a texto",
"settings.experimental.speechToTextModel.description":
"Elige el modelo de transcripción de Kilo Gateway para la entrada de voz.",
"settings.experimental.continueOnDeny.title": "Continuar al denegar",
"settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso",
"settings.experimental.mcpTimeout.title": "Tiempo de espera MCP (ms)",
+22
View File
@@ -284,6 +284,20 @@ export const dict = {
"Le bouton 'Améliorer la requête' aide à améliorer votre demande en fournissant un contexte supplémentaire, des clarifications ou des reformulations. Essayez de taper une demande ici et cliquez à nouveau sur le bouton pour voir comment cela fonctionne.",
"prompt.action.indexing": "Paramètres d'indexation",
"speechToText.tooltip.start": "Démarrer la saisie vocale",
"speechToText.tooltip.stop": "Arrêter la capture audio",
"speechToText.tooltip.transcribing": "Transcription en cours... Cliquez pour annuler.",
"speechToText.tooltip.error": "La saisie vocale a échoué. Cliquez pour effacer.",
"speechToText.error.title": "La saisie vocale a échoué",
"speechToText.error.loginRequired": "Connectez-vous à Kilo pour utiliser la saisie vocale.",
"speechToText.error.permission": "L'autorisation du microphone a été refusée.",
"speechToText.error.microphone": "Impossible de démarrer le microphone.",
"speechToText.error.recording": "L'enregistrement a échoué.",
"speechToText.error.emptyRecording": "Aucun son n'a été enregistré.",
"speechToText.error.emptyTranscript": "Aucune voix n'a été détectée.",
"speechToText.error.encoding": "Impossible d'encoder l'enregistrement.",
"speechToText.toast.transcribed": "Transcription insérée",
"prompt.toast.pasteUnsupported.title": "Collage non supporté",
"prompt.toast.pasteUnsupported.description": "Seules les images ou les PDF peuvent être collés ici.",
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
@@ -1260,6 +1274,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Outil Agent Manager",
"settings.experimental.agentManagerTool.description":
"Autoriser les agents à démarrer des sessions locales Agent Manager et des sessions worktree depuis un appel d'outil",
"settings.experimental.speechToText.title": "Transcription vocale",
"settings.experimental.speechToText.description":
"Activez la saisie vocale dans les champs de prompt en utilisant votre compte Kilo via Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Activez Kilo provider et connectez-vous pour utiliser la saisie vocale dans les champs de prompt.",
"settings.experimental.speechToTextModel.title": "Modèle de transcription vocale",
"settings.experimental.speechToTextModel.description":
"Choisissez le modèle de transcription Kilo Gateway pour la saisie vocale.",
"settings.experimental.continueOnDeny.title": "Continuer en cas de refus",
"settings.experimental.continueOnDeny.description":
"Continuer la boucle de l'agent lorsqu'une autorisation est refusée",
+21
View File
@@ -279,6 +279,20 @@ export const dict = {
"「プロンプトを強化」ボタンは、追加コンテキスト、説明、または言い換えを提供することで、リクエストを改善します。ここにリクエストを入力し、ボタンを再度クリックして動作を確認してください。",
"prompt.action.indexing": "インデックス設定",
"speechToText.tooltip.start": "音声入力を開始",
"speechToText.tooltip.stop": "音声キャプチャを停止",
"speechToText.tooltip.transcribing": "文字起こし中... クリックしてキャンセル。",
"speechToText.tooltip.error": "音声入力に失敗しました。クリックしてクリア。",
"speechToText.error.title": "音声入力に失敗しました",
"speechToText.error.loginRequired": "音声入力を使用するにはKiloにサインインしてください。",
"speechToText.error.permission": "マイクの許可が拒否されました。",
"speechToText.error.microphone": "マイクを起動できませんでした。",
"speechToText.error.recording": "録音に失敗しました。",
"speechToText.error.emptyRecording": "音声が録音されていません。",
"speechToText.error.emptyTranscript": "音声が検出されませんでした。",
"speechToText.error.encoding": "録音をエンコードできませんでした。",
"speechToText.toast.transcribed": "文字起こしを挿入しました",
"prompt.toast.pasteUnsupported.title": "サポートされていない貼り付け",
"prompt.toast.pasteUnsupported.description": "ここでは画像またはPDFのみ貼り付け可能です。",
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
@@ -1229,6 +1243,13 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager ツール",
"settings.experimental.agentManagerTool.description":
"エージェントがツール呼び出しから Agent Manager のローカルセッションとワークツリーセッションを開始できるようにする",
"settings.experimental.speechToText.title": "音声認識",
"settings.experimental.speechToText.description":
"Kilo Gateway経由でKiloアカウントを使用して、プロンプトフィールドでの音声入力を有効にします。",
"settings.experimental.speechToText.disabledDescription":
"プロンプトフィールドで音声入力を使用するには、Kilo providerを有効にしてサインインしてください。",
"settings.experimental.speechToTextModel.title": "音声認識モデル",
"settings.experimental.speechToTextModel.description": "音声入力に使用するKilo Gateway文字起こしモデルを選択します。",
"settings.experimental.continueOnDeny.title": "拒否時に続行",
"settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行",
"settings.experimental.mcpTimeout.title": "MCPタイムアウト(ミリ秒)",
+21
View File
@@ -281,6 +281,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"'프롬프트 향상' 버튼은 추가 컨텍스트, 명확화 또는 재구성을 제공하여 요청을 개선합니다. 여기에 요청을 입력한 다음 버튼을 다시 클릭하여 작동 방식을 확인해보세요.",
"speechToText.tooltip.start": "음성 입력 시작",
"speechToText.tooltip.stop": "음성 캡처 중지",
"speechToText.tooltip.transcribing": "변환 중... 취소하려면 클릭하세요.",
"speechToText.tooltip.error": "음성 입력에 실패했습니다. 지우려면 클릭하세요.",
"speechToText.error.title": "음성 입력 실패",
"speechToText.error.loginRequired": "음성 입력을 사용하려면 Kilo에 로그인하세요.",
"speechToText.error.permission": "마이크 권한이 거부되었습니다.",
"speechToText.error.microphone": "마이크를 시작할 수 없습니다.",
"speechToText.error.recording": "녹음에 실패했습니다.",
"speechToText.error.emptyRecording": "녹음된 오디오가 없습니다.",
"speechToText.error.emptyTranscript": "음성이 감지되지 않았습니다.",
"speechToText.error.encoding": "녹음을 인코딩할 수 없습니다.",
"speechToText.toast.transcribed": "변환 텍스트 삽입됨",
"prompt.toast.pasteUnsupported.title": "지원되지 않는 붙여넣기",
"prompt.toast.pasteUnsupported.description": "이미지나 PDF만 붙여넣을 수 있습니다.",
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
@@ -1223,6 +1237,13 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager 도구",
"settings.experimental.agentManagerTool.description":
"에이전트가 도구 호출로 Agent Manager 로컬 세션 및 워크트리 세션을 시작하도록 허용",
"settings.experimental.speechToText.title": "음성 텍스트 변환",
"settings.experimental.speechToText.description":
"Kilo Gateway를 통해 Kilo 계정을 사용하여 프롬프트 필드에서 음성 입력을 활성화합니다.",
"settings.experimental.speechToText.disabledDescription":
"프롬프트 필드에서 음성 입력을 사용하려면 Kilo provider를 활성화하고 로그인하세요.",
"settings.experimental.speechToTextModel.title": "음성 텍스트 변환 모델",
"settings.experimental.speechToTextModel.description": "음성 입력에 사용할 Kilo Gateway 변환 모델을 선택하세요.",
"settings.experimental.continueOnDeny.title": "거부 시 계속",
"settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속",
"settings.experimental.mcpTimeout.title": "MCP 타임아웃 (ms)",
+21
View File
@@ -283,6 +283,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"De knop 'Prompt verbeteren' helpt je prompt te verbeteren door extra context, verduidelijking of herformulering te bieden. Typ hier een prompt en klik nogmaals op de knop om te zien hoe het werkt.",
"speechToText.tooltip.start": "Spraakinvoer starten",
"speechToText.tooltip.stop": "Audio vastleggen stoppen",
"speechToText.tooltip.transcribing": "Transcriberen... Klik om te annuleren.",
"speechToText.tooltip.error": "Spraakinvoer mislukt. Klik om te wissen.",
"speechToText.error.title": "Spraakinvoer mislukt",
"speechToText.error.loginRequired": "Meld u aan bij Kilo om spraakinvoer te gebruiken.",
"speechToText.error.permission": "Microfoontoegang is geweigerd.",
"speechToText.error.microphone": "Kon de microfoon niet starten.",
"speechToText.error.recording": "Opname mislukt.",
"speechToText.error.emptyRecording": "Er is geen audio opgenomen.",
"speechToText.error.emptyTranscript": "Er is geen spraak gedetecteerd.",
"speechToText.error.encoding": "Kon de opname niet coderen.",
"speechToText.toast.transcribed": "Transcriptie ingevoegd",
"prompt.toast.pasteUnsupported.title": "Plakken niet ondersteund",
"prompt.toast.pasteUnsupported.description": "Hier kunnen alleen afbeeldingen of PDF's worden geplakt.",
"prompt.toast.modelAgentRequired.title": "Selecteer een agent en model",
@@ -1240,6 +1254,13 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager-tool",
"settings.experimental.agentManagerTool.description":
"Sta agents toe om lokale Agent Manager-sessies en worktree-sessies te starten vanuit een tool call",
"settings.experimental.speechToText.title": "Spraak naar tekst",
"settings.experimental.speechToText.description":
"Schakel spraakinvoer in promptvelden in met uw Kilo-account via Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Schakel Kilo provider in en meld u aan om spraakinvoer in promptvelden te gebruiken.",
"settings.experimental.speechToTextModel.title": "Spraak-naar-tekst-model",
"settings.experimental.speechToTextModel.description": "Kies het Kilo Gateway-transcriptiemodel voor spraakinvoer.",
"settings.experimental.continueOnDeny.title": "Doorgaan bij weigering",
"settings.experimental.continueOnDeny.description":
"Ga door met de agent loop wanneer een toestemming wordt geweigerd",
+21
View File
@@ -285,6 +285,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"Knappen 'Forbedre prompt' hjelper med å forbedre forespørselen din ved å gi ekstra kontekst, avklaring eller omformulering. Prøv å skrive en forespørsel her og klikk på knappen igjen for å se hvordan det fungerer.",
"speechToText.tooltip.start": "Start taleinndata",
"speechToText.tooltip.stop": "Stopp lydfangst",
"speechToText.tooltip.transcribing": "Transkriberer... Klikk for å avbryte.",
"speechToText.tooltip.error": "Taleinndata mislyktes. Klikk for å tømme.",
"speechToText.error.title": "Taleinndata mislyktes",
"speechToText.error.loginRequired": "Logg på Kilo for å bruke taleinndata.",
"speechToText.error.permission": "Mikrofontillatelse ble nektet.",
"speechToText.error.microphone": "Kunne ikke starte mikrofonen.",
"speechToText.error.recording": "Opptak mislyktes.",
"speechToText.error.emptyRecording": "Ingen lyd ble tatt opp.",
"speechToText.error.emptyTranscript": "Ingen tale ble oppdaget.",
"speechToText.error.encoding": "Kunne ikke kode opptaket.",
"speechToText.toast.transcribed": "Transkripsjon satt inn",
"prompt.toast.pasteUnsupported.title": "Liming ikke støttet",
"prompt.toast.pasteUnsupported.description": "Kun bilder eller PDF-er kan limes inn her.",
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
@@ -1200,6 +1214,13 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager-verktøy",
"settings.experimental.agentManagerTool.description":
"Tillat agenter å starte lokale Agent Manager-økter og worktree-økter fra et verktøykall",
"settings.experimental.speechToText.title": "Tale til tekst",
"settings.experimental.speechToText.description":
"Aktiver taleinndata i prompt-felt ved å bruke din Kilo-konto gjennom Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Aktiver Kilo provider og logg på for å bruke taleinndata i prompt-felt.",
"settings.experimental.speechToTextModel.title": "Tale-til-tekst-modell",
"settings.experimental.speechToTextModel.description": "Velg Kilo Gateway-transkripsjonsmodellen for taleinndata.",
"settings.experimental.continueOnDeny.title": "Fortsett ved avvisning",
"settings.experimental.continueOnDeny.description": "Fortsett agentløkken når en tillatelse avvises",
"settings.experimental.mcpTimeout.title": "MCP-tidsavbrudd (ms)",
+22
View File
@@ -282,6 +282,20 @@ export const dict = {
"Przycisk 'Ulepsz podpowiedź' pomaga ulepszyć Twoją prośbę, dostarczając dodatkowy kontekst, wyjaśnienia lub przeformułowania. Spróbuj wpisać prośbę tutaj i kliknij przycisk ponownie, aby zobaczyć, jak to działa.",
"prompt.action.indexing": "Ustawienia indeksowania",
"speechToText.tooltip.start": "Rozpocznij wprowadzanie głosowe",
"speechToText.tooltip.stop": "Zatrzymaj przechwytywanie dźwięku",
"speechToText.tooltip.transcribing": "Transkrybowanie... Kliknij, aby anulować.",
"speechToText.tooltip.error": "Wprowadzanie głosowe nie powiodło się. Kliknij, aby wyczyścić.",
"speechToText.error.title": "Wprowadzanie głosowe nie powiodło się",
"speechToText.error.loginRequired": "Zaloguj się do Kilo, aby korzystać z wprowadzania głosowego.",
"speechToText.error.permission": "Odmówiono dostępu do mikrofonu.",
"speechToText.error.microphone": "Nie udało się uruchomić mikrofonu.",
"speechToText.error.recording": "Nagrywanie nie powiodło się.",
"speechToText.error.emptyRecording": "Nie nagrano żadnego dźwięku.",
"speechToText.error.emptyTranscript": "Nie wykryto mowy.",
"speechToText.error.encoding": "Nie udało się zakodować nagrania.",
"speechToText.toast.transcribed": "Wstawiono transkrypcję",
"prompt.toast.pasteUnsupported.title": "Nieobsługiwane wklejanie",
"prompt.toast.pasteUnsupported.description": "Tylko obrazy lub pliki PDF mogą być tutaj wklejane.",
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
@@ -1199,6 +1213,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Narzędzie Agent Manager",
"settings.experimental.agentManagerTool.description":
"Zezwól agentom na uruchamianie lokalnych sesji Agent Manager i sesji worktree z wywołania narzędzia",
"settings.experimental.speechToText.title": "Mowa na tekst",
"settings.experimental.speechToText.description":
"Włącz wprowadzanie głosowe w polach promptów przy użyciu konta Kilo za pośrednictwem Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Włącz Kilo provider i zaloguj się, aby korzystać z wprowadzania głosowego w polach promptów.",
"settings.experimental.speechToTextModel.title": "Model mowy na tekst",
"settings.experimental.speechToTextModel.description":
"Wybierz model transkrypcji Kilo Gateway dla wprowadzania głosowego.",
"settings.experimental.continueOnDeny.title": "Kontynuuj przy odmowie",
"settings.experimental.continueOnDeny.description": "Kontynuuj pętlę agenta po odmowie uprawnienia",
"settings.experimental.mcpTimeout.title": "Limit czasu MCP (ms)",
+22
View File
@@ -280,6 +280,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"Кнопка 'Улучшить запрос' помогает сделать ваш запрос лучше, предоставляя дополнительный контекст, уточнения или переформулировку. Попробуйте ввести запрос и снова нажать кнопку, чтобы увидеть, как это работает.",
"speechToText.tooltip.start": "Начать голосовой ввод",
"speechToText.tooltip.stop": "Остановить захват звука",
"speechToText.tooltip.transcribing": "Распознавание... Нажмите для отмены.",
"speechToText.tooltip.error": "Ошибка голосового ввода. Нажмите, чтобы очистить.",
"speechToText.error.title": "Ошибка голосового ввода",
"speechToText.error.loginRequired": "Войдите в Kilo, чтобы использовать голосовой ввод.",
"speechToText.error.permission": "В доступе к микрофону отказано.",
"speechToText.error.microphone": "Не удалось запустить микрофон.",
"speechToText.error.recording": "Ошибка записи.",
"speechToText.error.emptyRecording": "Звук не был записан.",
"speechToText.error.emptyTranscript": "Речь не обнаружена.",
"speechToText.error.encoding": "Не удалось закодировать запись.",
"speechToText.toast.transcribed": "Транскрипция вставлена",
"prompt.toast.pasteUnsupported.title": "Неподдерживаемая вставка",
"prompt.toast.pasteUnsupported.description": "Сюда можно вставлять только изображения или PDF.",
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
@@ -1237,6 +1251,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Инструмент Agent Manager",
"settings.experimental.agentManagerTool.description":
"Разрешить агентам запускать локальные сеансы Agent Manager и сеансы worktree через вызов инструмента",
"settings.experimental.speechToText.title": "Речь в текст",
"settings.experimental.speechToText.description":
"Включите голосовой ввод в полях запросов, используя вашу учетную запись Kilo через Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Включите Kilo provider и войдите в него, чтобы использовать голосовой ввод в полях запросов.",
"settings.experimental.speechToTextModel.title": "Модель речи в текст",
"settings.experimental.speechToTextModel.description":
"Выберите модель транскрипции Kilo Gateway для голосового ввода.",
"settings.experimental.continueOnDeny.title": "Продолжить при отказе",
"settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении",
"settings.experimental.mcpTimeout.title": "Таймаут MCP (мс)",
+22
View File
@@ -280,6 +280,20 @@ export const dict = {
"ปุ่ม 'ปรับปรุงพรอมต์' ช่วยปรับปรุงพรอมต์ของคุณโดยให้บริบทเพิ่มเติม ชี้แจง หรือเขียนใหม่ ลองพิมพ์พรอมต์ที่นี่และคลิกปุ่มอีกครั้งเพื่อดูว่ามันทำงานอย่างไร",
"prompt.action.indexing": "การตั้งค่าการสร้างดัชนี",
"speechToText.tooltip.start": "เริ่มการป้อนข้อมูลด้วยเสียง",
"speechToText.tooltip.stop": "หยุดจับเสียง",
"speechToText.tooltip.transcribing": "กำลังถอดเสียง... คลิกเพื่อยกเลิก",
"speechToText.tooltip.error": "การป้อนข้อมูลด้วยเสียงล้มเหลว คลิกเพื่อล้าง",
"speechToText.error.title": "การป้อนข้อมูลด้วยเสียงล้มเหลว",
"speechToText.error.loginRequired": "ลงชื่อเข้าใช้ Kilo เพื่อใช้การป้อนข้อมูลด้วยเสียง",
"speechToText.error.permission": "การอนุญาตเข้าถึงไมโครโฟนถูกปฏิเสธ",
"speechToText.error.microphone": "ไม่สามารถเริ่มต้นไมโครโฟนได้",
"speechToText.error.recording": "การบันทึกล้มเหลว",
"speechToText.error.emptyRecording": "ไม่มีการบันทึกเสียง",
"speechToText.error.emptyTranscript": "ตรวจไม่พบเสียงพูด",
"speechToText.error.encoding": "ไม่สามารถเข้ารหัสการบันทึกได้",
"speechToText.toast.transcribed": "แทรกข้อความที่ถอดเสียงแล้ว",
"prompt.toast.pasteUnsupported.title": "การวางไม่รองรับ",
"prompt.toast.pasteUnsupported.description": "สามารถวางรูปภาพหรือ PDF เท่านั้น",
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
@@ -1220,6 +1234,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "เครื่องมือ Agent Manager",
"settings.experimental.agentManagerTool.description":
"อนุญาตให้เอเจนต์เริ่มเซสชัน Agent Manager ในเครื่องและเซสชัน worktree จากการเรียกเครื่องมือ",
"settings.experimental.speechToText.title": "แปลงเสียงเป็นข้อความ",
"settings.experimental.speechToText.description":
"เปิดใช้งานการป้อนข้อมูลด้วยเสียงในช่องพรอมต์โดยใช้บัญชี Kilo ของคุณผ่าน Kilo Gateway",
"settings.experimental.speechToText.disabledDescription":
"เปิดใช้งานและลงชื่อเข้าใช้ Kilo provider เพื่อใช้การป้อนข้อมูลด้วยเสียงในช่องพรอมต์",
"settings.experimental.speechToTextModel.title": "โมเดลแปลงเสียงเป็นข้อความ",
"settings.experimental.speechToTextModel.description":
"เลือกโมเดลการถอดเสียง Kilo Gateway สำหรับการป้อนข้อมูลด้วยเสียง",
"settings.experimental.continueOnDeny.title": "ดำเนินต่อเมื่อถูกปฏิเสธ",
"settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ",
"settings.experimental.mcpTimeout.title": "หมดเวลา MCP (มิลลิวินาที)",
+21
View File
@@ -280,6 +280,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"'Komutu Geliştir' düğmesi, ek bağlam, açıklama veya yeniden ifadelendirme sağlayarak komutunuzu iyileştirmeye yardımcı olur. Buraya bir komut yazıp düğmeye tekrar tıklayarak nasıl çalıştığını görebilirsiniz.",
"speechToText.tooltip.start": "Sesli girişi başlat",
"speechToText.tooltip.stop": "Ses yakalamayı durdur",
"speechToText.tooltip.transcribing": "Metne dönüştürülüyor... İptal etmek için tıklayın.",
"speechToText.tooltip.error": "Sesli giriş başarısız oldu. Temizlemek için tıklayın.",
"speechToText.error.title": "Sesli giriş başarısız oldu",
"speechToText.error.loginRequired": "Sesli girişi kullanmak için Kilo'ya giriş yapın.",
"speechToText.error.permission": "Mikrofon izni reddedildi.",
"speechToText.error.microphone": "Mikrofon başlatılamadı.",
"speechToText.error.recording": "Kayıt başarısız oldu.",
"speechToText.error.emptyRecording": "Hiçbir ses kaydedilmedi.",
"speechToText.error.emptyTranscript": "Hiçbir konuşma algılanmadı.",
"speechToText.error.encoding": "Kayıt kodlanamadı.",
"speechToText.toast.transcribed": "Döküm eklendi",
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen yapıştırma",
"prompt.toast.pasteUnsupported.description": "Buraya sadece görsel veya PDF yapıştırılabilir.",
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
@@ -1233,6 +1247,13 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager Aracı",
"settings.experimental.agentManagerTool.description":
"Ajanların bir araç çağrısından Agent Manager yerel oturumları ve worktree oturumları başlatmasına izin ver",
"settings.experimental.speechToText.title": "Sesten metne",
"settings.experimental.speechToText.description":
"Kilo Gateway üzerinden Kilo hesabınızı kullanarak komut alanlarında sesli girişi etkinleştirin.",
"settings.experimental.speechToText.disabledDescription":
"Komut alanlarında sesli girişi kullanmak için Kilo provider'ı etkinleştirin ve giriş yapın.",
"settings.experimental.speechToTextModel.title": "Sesten metne modeli",
"settings.experimental.speechToTextModel.description": "Sesli giriş için Kilo Gateway transkripsiyon modelini seçin.",
"settings.experimental.continueOnDeny.title": "Reddetme Durumunda Devam Et",
"settings.experimental.continueOnDeny.description": "Bir izin reddedildiğinde ajan döngüsüne devam et",
"settings.experimental.mcpTimeout.title": "MCP Zaman Aşımı (ms)",
+22
View File
@@ -282,6 +282,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"Кнопка 'Покращити запит' допомагає вдосконалити ваш запит, надаючи додатковий контекст, уточнення або перефразування. Введіть запит тут і натисніть кнопку ще раз, щоб побачити, як це працює.",
"speechToText.tooltip.start": "Почати голосове введення",
"speechToText.tooltip.stop": "Зупинити захоплення звуку",
"speechToText.tooltip.transcribing": "Транскрибування... Натисніть, щоб скасувати.",
"speechToText.tooltip.error": "Помилка голосового введення. Натисніть, щоб очистити.",
"speechToText.error.title": "Помилка голосового введення",
"speechToText.error.loginRequired": "Увійдіть до Kilo, щоб використовувати голосове введення.",
"speechToText.error.permission": "Доступ до мікрофона заборонено.",
"speechToText.error.microphone": "Не вдалося запустити мікрофон.",
"speechToText.error.recording": "Помилка запису.",
"speechToText.error.emptyRecording": "Аудіо не записано.",
"speechToText.error.emptyTranscript": "Мовлення не виявлено.",
"speechToText.error.encoding": "Не вдалося закодувати запис.",
"speechToText.toast.transcribed": "Транскрипцію вставлено",
"prompt.toast.pasteUnsupported.title": "Вставка не підтримується",
"prompt.toast.pasteUnsupported.description": "Сюди можна вставляти лише зображення або PDF.",
"prompt.toast.modelAgentRequired.title": "Виберіть агента і модель",
@@ -1233,6 +1247,14 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Інструмент Agent Manager",
"settings.experimental.agentManagerTool.description":
"Дозволити агентам запускати локальні сесії Agent Manager і сесії worktree через виклик інструмента",
"settings.experimental.speechToText.title": "Мовлення в текст",
"settings.experimental.speechToText.description":
"Увімкніть голосове введення в полях запитів, використовуючи ваш обліковий запис Kilo через Kilo Gateway.",
"settings.experimental.speechToText.disabledDescription":
"Увімкніть Kilo provider і увійдіть, щоб використовувати голосове введення в полях запитів.",
"settings.experimental.speechToTextModel.title": "Модель мовлення в текст",
"settings.experimental.speechToTextModel.description":
"Виберіть модель транскрипції Kilo Gateway для голосового введення.",
"settings.experimental.continueOnDeny.title": "Продовжувати при відхиленні",
"settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено",
"settings.experimental.mcpTimeout.title": "Тайм-аут MCP (мс)",
+19
View File
@@ -279,6 +279,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"'增强提示'按钮通过提供额外上下文、澄清或重新表述来帮助改进您的请求。尝试在此处输入请求,然后再次点击按钮查看其工作原理。",
"speechToText.tooltip.start": "开始语音输入",
"speechToText.tooltip.stop": "停止捕获音频",
"speechToText.tooltip.transcribing": "正在转录... 点击取消。",
"speechToText.tooltip.error": "语音输入失败。点击清除。",
"speechToText.error.title": "语音输入失败",
"speechToText.error.loginRequired": "登录 Kilo 以使用语音输入。",
"speechToText.error.permission": "麦克风权限被拒绝。",
"speechToText.error.microphone": "无法启动麦克风。",
"speechToText.error.recording": "录音失败。",
"speechToText.error.emptyRecording": "未录制任何音频。",
"speechToText.error.emptyTranscript": "未检测到语音。",
"speechToText.error.encoding": "无法编码录音。",
"speechToText.toast.transcribed": "已插入转录文本",
"prompt.toast.pasteUnsupported.title": "不支持的粘贴",
"prompt.toast.pasteUnsupported.description": "这里只能粘贴图片或 PDF 文件。",
"prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
@@ -1202,6 +1216,11 @@ export const dict = {
"settings.experimental.codebaseSearch.description": "启用 AI 驱动的自然语言代码库搜索",
"settings.experimental.agentManagerTool.title": "Agent Manager 工具",
"settings.experimental.agentManagerTool.description": "允许智能体通过工具调用启动 Agent Manager 本地会话和工作树会话",
"settings.experimental.speechToText.title": "语音转文本",
"settings.experimental.speechToText.description": "通过 Kilo Gateway 使用您的 Kilo 帐户在提示词字段中启用语音输入。",
"settings.experimental.speechToText.disabledDescription": "启用并登录 Kilo provider 以在提示词字段中使用语音输入。",
"settings.experimental.speechToTextModel.title": "语音转文本模型",
"settings.experimental.speechToTextModel.description": "选择用于语音输入的 Kilo Gateway 转录模型。",
"settings.experimental.continueOnDeny.title": "拒绝后继续",
"settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环",
"settings.experimental.mcpTimeout.title": "MCP 超时(毫秒)",
+19
View File
@@ -278,6 +278,20 @@ export const dict = {
"prompt.action.enhanceDescription":
"「強化提示詞」按鈕可透過提供額外內容、說明或改寫來協助改善提示詞。試著在這裡輸入提示詞,再點選一次按鈕以了解其運作方式。",
"speechToText.tooltip.start": "開始語音輸入",
"speechToText.tooltip.stop": "停止擷取音訊",
"speechToText.tooltip.transcribing": "正在轉錄... 點擊取消。",
"speechToText.tooltip.error": "語音輸入失敗。點擊清除。",
"speechToText.error.title": "語音輸入失敗",
"speechToText.error.loginRequired": "登入 Kilo 以使用語音輸入。",
"speechToText.error.permission": "麥克風權限被拒絕。",
"speechToText.error.microphone": "無法啟動麥克風。",
"speechToText.error.recording": "錄音失敗。",
"speechToText.error.emptyRecording": "未錄製任何音訊。",
"speechToText.error.emptyTranscript": "未偵測到語音。",
"speechToText.error.encoding": "無法編碼錄音。",
"speechToText.toast.transcribed": "已插入轉錄文字",
"prompt.toast.pasteUnsupported.title": "不支援的貼上",
"prompt.toast.pasteUnsupported.description": "這裡只能貼上圖片或 PDF 檔案。",
"prompt.toast.modelAgentRequired.title": "請選擇 Agent 和模型",
@@ -1171,6 +1185,11 @@ export const dict = {
"settings.experimental.agentManagerTool.title": "Agent Manager 工具",
"settings.experimental.agentManagerTool.description":
"允許 Agent 透過工具呼叫啟動 Agent Manager 本機工作階段和工作樹工作階段",
"settings.experimental.speechToText.title": "語音轉文字",
"settings.experimental.speechToText.description": "透過 Kilo Gateway 使用您的 Kilo 帳戶在提示詞欄位中啟用語音輸入。",
"settings.experimental.speechToText.disabledDescription": "啟用並登入 Kilo provider 以在提示詞欄位中使用語音輸入。",
"settings.experimental.speechToTextModel.title": "語音轉文字模型",
"settings.experimental.speechToTextModel.description": "選擇用於語音輸入的 Kilo Gateway 轉錄模型。",
"settings.experimental.continueOnDeny.title": "拒絕後繼續",
"settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈",
"settings.experimental.mcpTimeout.title": "MCP 逾時(毫秒)",
@@ -563,6 +563,54 @@
box-shadow: 0 0 0 1px var(--surface-base, var(--vscode-editor-background));
}
.prompt-speech-button {
position: relative;
}
.prompt-speech-button--recording {
color: var(--vscode-errorForeground, #f48771);
[data-slot="icon-svg"] {
color: currentColor !important;
}
.prompt-speech-icon {
animation: prompt-speech-recording-pulse 1.1s ease-in-out infinite;
}
}
.prompt-speech-button--recording::after {
content: "";
position: absolute;
right: 4px;
top: 4px;
width: 5px;
height: 5px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 0 2px var(--surface-base, var(--vscode-editor-background));
}
.prompt-speech-button--error {
color: var(--vscode-charts-yellow, #cca700);
[data-slot="icon-svg"] {
color: currentColor !important;
}
}
@keyframes prompt-speech-recording-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.72;
transform: scale(0.94);
}
}
/* ============================================
Enhance Prompt Spinner
============================================ */
@@ -243,6 +243,13 @@ export interface AppendReviewCommentsMessage {
autoSend?: boolean
}
export interface AppendReviewCommentsToTerminalMessage {
type: "appendReviewCommentsToTerminal"
comments: ReviewComment[]
autoSend?: boolean
targetTerminalId: string
}
export interface TriggerTaskMessage {
type: "triggerTask"
text: string
@@ -326,12 +333,43 @@ export interface AutocompleteSettingsLoadedMessage {
}
}
export interface SpeechToTextSettingsLoadedMessage {
type: "speechToTextSettingsLoaded"
settings: {
enabled: boolean
model: string
}
}
export interface ChatCompletionResultMessage {
type: "chatCompletionResult"
text: string
requestId: string
}
export interface SpeechToTextResultMessage {
type: "speechToTextResult"
text: string
requestId: string
}
export interface SpeechToTextStartedMessage {
type: "speechToTextStarted"
requestId: string
}
export interface SpeechToTextCancelledMessage {
type: "speechToTextCancelled"
requestId: string
}
export interface SpeechToTextErrorMessage {
type: "speechToTextError"
error: string
code?: string
requestId: string
}
export interface FileSearchItem {
path: string
type: "file" | "folder" | "opened-file"
@@ -922,7 +960,12 @@ export type ExtensionMessage =
| SkillsLoadedMessage
| CommandsLoadedMessage
| AutocompleteSettingsLoadedMessage
| SpeechToTextSettingsLoadedMessage
| ChatCompletionResultMessage
| SpeechToTextStartedMessage
| SpeechToTextCancelledMessage
| SpeechToTextResultMessage
| SpeechToTextErrorMessage
| FileSearchResultMessage
| TerminalContextResultMessage
| TerminalContextErrorMessage
@@ -958,6 +1001,7 @@ export type ExtensionMessage =
| SetChatBoxMessage
| AppendChatBoxMessage
| AppendReviewCommentsMessage
| AppendReviewCommentsToTerminalMessage
| TriggerTaskMessage
| VariantsLoadedMessage
| CloudSessionDataLoadedMessage
@@ -324,12 +324,33 @@ export interface RequestAutocompleteSettingsMessage {
type: "requestAutocompleteSettings"
}
export interface RequestSpeechToTextSettingsMessage {
type: "requestSpeechToTextSettings"
}
export interface RequestChatCompletionMessage {
type: "requestChatCompletion"
text: string
requestId: string
}
export interface SpeechToTextStartMessage {
type: "speechToTextStart"
requestId: string
model: string
language?: string
}
export interface SpeechToTextStopMessage {
type: "speechToTextStop"
requestId: string
}
export interface SpeechToTextCancelMessage {
type: "speechToTextCancel"
requestId: string
}
export interface RequestFileSearchMessage {
type: "requestFileSearch"
query: string
@@ -1080,7 +1101,11 @@ export type WebviewMessage =
| DeleteSessionRequest
| RenameSessionRequest
| RequestAutocompleteSettingsMessage
| RequestSpeechToTextSettingsMessage
| RequestChatCompletionMessage
| SpeechToTextStartMessage
| SpeechToTextStopMessage
| SpeechToTextCancelMessage
| RequestFileSearchMessage
| RequestTerminalContextMessage
| RequestGitChangesContextMessage
@@ -0,0 +1,354 @@
import { Effect } from "effect"
import type { Agent } from "@/agent/agent"
import type { Config } from "@/config/config"
import type { Provider } from "@/provider/provider"
import type { LLM } from "@/session/llm"
import { MessageV2 } from "@/session/message-v2"
import { usable } from "@/session/overflow"
import type { SessionProcessor } from "@/session/processor"
import { MessageID, PartID, type SessionID } from "@/session/schema"
import type { Session } from "@/session/session"
import { Token } from "@/util/token"
import * as Log from "@opencode-ai/core/util/log"
type Update = <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
type UpdateMessage = <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
const log = Log.create({ service: "kilocode.compaction.chunks" })
const TOOL_OUTPUT_MAX_CHARS = 2_000
const TRANSCRIPT_MAX_CHARS = 16_000
const RATIO = 0.6
const CONCURRENCY = 3
const DEPTH = 3
const OUTPUT = 2_048
export namespace KiloCompactionChunks {
type Chunk = {
index: number
messages: MessageV2.WithParts[]
}
type Output = {
result: SessionProcessor.Result
output: string | undefined
}
type Deps = {
processors: SessionProcessor.Interface
session: Pick<Session.Interface, "updateMessage" | "updatePart" | "removeMessage">
}
type Input = Deps & {
user: MessageV2.User
agent: Agent.Info
sessionID: SessionID
model: Provider.Model
cfg: Config.Info
messages: MessageV2.WithParts[]
prompt: string
target: MessageV2.Assistant
updateMessage: UpdateMessage
updatePart: Update
}
type Replay = {
info: MessageV2.User
parts: MessageV2.Part[]
}
export function eligible(input: { result: SessionProcessor.Result; error: MessageV2.Assistant["error"] }) {
if (input.result === "compact") return true
return input.result === "stop" && input.error?.name === "ContextOverflowError"
}
export function needed(input: { cfg: Config.Info; model: Provider.Model; tokens: number }) {
// Apply 1.3x multiplier to token estimate to compensate for Token.estimate
// under-counting actual provider tokenizer counts by ~15-30%.
return Math.ceil(input.tokens * 1.3) + model(input.model).limit.output > usable({ cfg: input.cfg, model: model(input.model) })
}
export function replay(input: Input & { replay: Replay }) {
return Effect.gen(function* () {
const chunk: Chunk = {
index: 0,
messages: [{ info: input.replay.info, parts: input.replay.parts }],
}
const size = budget({ cfg: input.cfg, model: input.model })
if (!(yield* large({ messages: chunk.messages, model: input.model, size }))) return input.replay
const result = yield* summarize({ ...input, chunk, total: 1 })
if (result.result !== "continue" || !result.output) return input.replay
return {
info: input.replay.info,
parts: [
{
id: PartID.ascending(),
messageID: input.replay.info.id,
sessionID: input.sessionID,
type: "text" as const,
synthetic: true,
text: [
"The original replayed request was too large to send after compaction.",
"Use this compacted representation of that request instead:",
result.output,
].join("\n\n"),
},
],
} satisfies Replay
})
}
function budget(input: { cfg: Config.Info; model: Provider.Model }) {
return Math.max(1_000, Math.floor(usable({ cfg: input.cfg, model: model(input.model) }) * RATIO))
}
function model(input: Provider.Model) {
return {
...input,
limit: {
...input.limit,
output: Math.min(input.limit.output, OUTPUT),
},
} satisfies Provider.Model
}
function large(input: { messages: MessageV2.WithParts[]; model: Provider.Model; size: number }) {
return estimate({ messages: input.messages, model: input.model }).pipe(Effect.map((count) => count > input.size))
}
function estimate(input: { messages: MessageV2.WithParts[]; model: Provider.Model }) {
return Effect.gen(function* () {
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
return Token.estimate(JSON.stringify(msgs))
})
}
export function split(input: { messages: MessageV2.WithParts[]; model: Provider.Model; size: number }) {
return Effect.gen(function* () {
const chunks: Chunk[] = []
let buf: MessageV2.WithParts[] = []
for (const msg of input.messages) {
const next = [...buf, msg]
const size = yield* estimate({ messages: next, model: input.model })
if (buf.length && size > input.size) {
chunks.push({ index: chunks.length, messages: buf })
buf = [msg]
continue
}
buf = next
}
if (buf.length) chunks.push({ index: chunks.length, messages: buf })
return chunks
})
}
function text(msg: MessageV2.Assistant, parts: MessageV2.Part[]) {
return parts
.filter((part): part is MessageV2.TextPart => part.type === "text" && part.messageID === msg.id)
.map((part) => part.text.trim())
.filter(Boolean)
.join("\n\n")
.trim()
}
function clip(input: { text: string; chars: number; label: string }) {
if (input.text.length <= input.chars) return input.text
const cut = input.text.length - input.chars
return `${input.text.slice(0, input.chars)}\n[${input.label} truncated for compaction: omitted ${cut} chars]`
}
function part(part: MessageV2.Part) {
if (part.type === "text") return clip({ text: part.text, chars: TRANSCRIPT_MAX_CHARS, label: "Text" })
if (part.type === "reasoning") return `[Reasoning]: ${clip({ text: part.text, chars: TRANSCRIPT_MAX_CHARS, label: "Reasoning" })}`
if (part.type === "file") return `[File attachment]: ${part.filename ?? part.url} (${part.mime})`
if (part.type === "agent") return `[Agent]: ${part.name}`
if (part.type === "subtask") return `[Subtask ${part.agent}]: ${part.description}\n${clip({ text: part.prompt, chars: TRANSCRIPT_MAX_CHARS, label: "Subtask prompt" })}`
if (part.type === "tool") {
const head = `[Tool ${part.tool} ${part.state.status}]`
if (part.state.status === "completed") {
return [
head,
`input: ${clip({ text: JSON.stringify(part.state.input), chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool input" })}`,
`output: ${clip({ text: part.state.output, chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool output" })}`,
].join("\n")
}
if (part.state.status === "error") return `${head}\n${clip({ text: part.state.error, chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool error" })}`
return `${head}\ninput: ${clip({ text: JSON.stringify(part.state.input), chars: TOOL_OUTPUT_MAX_CHARS, label: "Tool input" })}`
}
if (part.type === "step-finish") return `[Step finished]: ${part.reason}`
if (part.type === "compaction") return "[Compaction requested]"
return `[${part.type}]`
}
function transcript(input: { messages: MessageV2.WithParts[] }) {
return input.messages
.map((msg, index) => {
const body = msg.parts
.map(part)
.filter(Boolean)
.join("\n\n")
return [`<message index=\"${index + 1}\" role=\"${msg.info.role}\">`, body || "[no content]", "</message>"].join("\n")
})
.join("\n\n")
}
function prompt(input: { chunk: Chunk; total: number }) {
return [
`Summarize conversation chunk ${input.chunk.index + 1} of ${input.total}.`,
"Only summarize facts present in this chunk.",
"Preserve concrete file paths, commands, errors, decisions, and unresolved tasks.",
"Use terse Markdown bullets. Do not mention chunking or compaction.",
].join("\n")
}
function messages(input: { summaries: string[] }) {
return input.summaries.map((summary, index) => ({
role: "user" as const,
content: [
{
type: "text" as const,
text: [`<partial-summary index=\"${index + 1}\">`, summary, "</partial-summary>"].join("\n"),
},
],
}))
}
function assistant(input: { base: MessageV2.Assistant; sessionID: SessionID }) {
return {
...input.base,
id: MessageID.ascending(),
parentID: input.base.parentID,
sessionID: input.sessionID,
cost: 0,
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: Date.now() },
finish: undefined,
error: undefined,
} satisfies MessageV2.Assistant
}
function run(input: Input & { data: LLM.StreamInput["messages"]; text: string }) {
return Effect.gen(function* () {
const msg = yield* input.session.updateMessage(assistant({ base: input.target, sessionID: input.sessionID }))
const mdl = model(input.model)
const worker = yield* input.processors.create({ assistantMessage: msg, sessionID: input.sessionID, model: mdl })
const opts = input.agent.options
const agent = {
...input.agent,
options: {
...opts,
maxOutputTokens: Math.min(
OUTPUT,
typeof opts?.maxOutputTokens === "number" ? opts.maxOutputTokens : OUTPUT,
),
},
}
const out = yield* Effect.gen(function* () {
const result = yield* worker.process({
user: input.user,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [...input.data, { role: "user", content: [{ type: "text", text: input.text }] }],
model: mdl,
})
const parts = MessageV2.parts(worker.message.id)
return { result, output: text(worker.message, parts) }
}).pipe(
Effect.ensuring(
input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore),
),
)
const result = out.result
const output = out.output
if (result !== "continue") return { result, output: undefined }
if (!output) return { result: "stop" as const, output: undefined }
return { result, output }
})
}
function summarize(input: Input & { chunk: Chunk; total: number }) {
return Effect.gen(function* () {
const size = budget({ cfg: input.cfg, model: input.model })
const data = (yield* large({ messages: input.chunk.messages, model: input.model, size }))
? [
{
role: "user" as const,
content: [
{
type: "text" as const,
text: [
"The following compacted transcript represents an oversized conversation chunk.",
"Summarize only facts present in the transcript.",
"<conversation>",
transcript({ messages: input.chunk.messages }),
"</conversation>",
].join("\n"),
},
],
},
]
: yield* MessageV2.toModelMessagesEffect(input.chunk.messages, input.model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
return yield* run({ ...input, data, text: prompt({ chunk: input.chunk, total: input.total }) })
})
}
function reduce(input: Input & { summaries: string[]; depth: number }): Effect.Effect<Output> {
return Effect.gen(function* () {
const result = yield* run({ ...input, data: messages({ summaries: input.summaries }), text: input.prompt })
if (result.result === "continue") return result
if (input.depth >= DEPTH || input.summaries.length <= 1) return result
const size = Math.ceil(input.summaries.length / 2)
const groups = Array.from({ length: Math.ceil(input.summaries.length / size) }, (_, index) =>
input.summaries.slice(index * size, index * size + size),
)
const next: Output[] = yield* Effect.forEach(
groups,
(group) => reduce({ ...input, summaries: group, depth: input.depth + 1 }),
{ concurrency: 1 },
)
if (next.some((item) => item.result !== "continue" || !item.output)) return result
return yield* reduce({ ...input, summaries: next.map((item) => item.output!), depth: input.depth + 2 })
})
}
export function process(input: Input) {
return Effect.gen(function* () {
const size = budget({ cfg: input.cfg, model: input.model })
const chunks = yield* split({ messages: input.messages, model: input.model, size })
log.info("fallback", { chunks: chunks.length, concurrency: CONCURRENCY })
const partial = yield* Effect.forEach(
chunks,
(chunk) => summarize({ ...input, chunk, total: chunks.length }),
{ concurrency: Math.min(CONCURRENCY, chunks.length) },
)
if (partial.some((item) => item.result !== "continue" || !item.output)) return "compact" as const
const final = chunks.length === 1 && (yield* large({ messages: chunks[0].messages, model: input.model, size }))
? partial[0]
: yield* reduce({ ...input, summaries: partial.map((item) => item.output!), depth: 0 })
if (!final || final.result !== "continue" || !final.output) return "compact" as const
yield* input.updatePart({
id: PartID.ascending(),
messageID: input.target.id,
sessionID: input.sessionID,
type: "text",
text: final.output,
})
input.target.finish = "stop"
input.target.error = undefined
input.target.time.completed = Date.now()
yield* input.updateMessage(input.target)
return "continue" as const
})
}
}
@@ -0,0 +1,53 @@
import type { ModelMessage } from "ai"
import type { Provider } from "@/provider/provider"
import { Token } from "@/util/token"
// Token.estimate consistently under-counts by ~15-30% vs. actual provider tokenizers.
// Multiply all estimates by this factor and add a fixed safety margin to compensate.
const ESTIMATE_FACTOR = 1.3
const SAFETY = 2048
const MIN_OUTPUT = 1024
export namespace KiloLLM {
/**
* Caps `maxOutputTokens` to fit within the model's context window after
* accounting for the actual estimated input tokens (messages + tool schemas).
*
* Many small models (e.g. qwen 7B, 32K context) ship with a default
* max_output of 32K, leaving no room for input once tools are included.
* This prevents the provider from rejecting the request with a context
* overflow error.
*/
export function capOutputTokens(input: {
model: Provider.Model
messages: ModelMessage[]
tools: Record<string, { description?: string; inputSchema?: unknown }>
configured: number | undefined
}): number | undefined {
if (input.configured == null) return input.configured
if (input.configured <= 0) return undefined
const { context } = input.model.limit
if (!context) return input.configured
const msgTokens = Math.ceil(Token.estimate(JSON.stringify(input.messages)) * ESTIMATE_FACTOR)
const toolTokens = Math.ceil(
Token.estimate(
JSON.stringify(
Object.entries(input.tools).map(([name, t]) => ({
name,
description: t.description,
inputSchema: t.inputSchema,
})),
),
) * ESTIMATE_FACTOR,
)
const available = context - msgTokens - toolTokens - SAFETY
// If available is ≤0 the input alone exceeds context — return the original
// value so the provider returns a natural overflow error which triggers
// compaction (compactionAttempts guard stops the loop eventually).
if (available <= 0) return input.configured
if (available >= input.configured) return input.configured
return Math.max(MIN_OUTPUT, available)
}
}
+57 -16
View File
@@ -20,6 +20,7 @@ import { makeRuntime } from "@/effect/run-service"
import { fn } from "@/util/fn"
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change
import { KiloCompactionPayloadRecovery } from "@/kilocode/session/compaction-payload-recovery" // kilocode_change
import { KiloCompactionChunks } from "@/kilocode/session/compaction-chunks" // kilocode_change
const log = Log.create({ service: "session.compaction" })
@@ -416,6 +417,7 @@ export const layer: Layer.Layer<
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
const tokens = Token.estimate(JSON.stringify(modelMessages)) // kilocode_change
const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
@@ -450,21 +452,41 @@ export const layer: Layer.Layer<
model,
})
// kilocode_change start
const result = yield* KiloCompactionPayloadRecovery.process({
processor,
user: userMessage,
agent,
sessionID: input.sessionID,
model,
messages: modelMessages,
prompt: nextPrompt,
recovery: selected.head,
updateMessage: session.updateMessage,
updatePart: session.updatePart,
})
const result = KiloCompactionChunks.needed({ cfg, model, tokens })
? "compact"
: yield* KiloCompactionPayloadRecovery.process({
processor,
user: userMessage,
agent,
sessionID: input.sessionID,
model,
messages: modelMessages,
prompt: nextPrompt,
recovery: selected.head,
updateMessage: session.updateMessage,
updatePart: session.updatePart,
})
// kilocode_change end
if (result === "compact") {
// kilocode_change start - fallback to chunked compaction when the first summary overflows
const fallback = KiloCompactionChunks.eligible({ result, error: processor.message.error ?? processor.compactError?.() })
? yield* KiloCompactionChunks.process({
processors,
session,
user: userMessage,
agent,
sessionID: input.sessionID,
model,
cfg,
messages: selected.head,
prompt: nextPrompt,
target: processor.message,
updateMessage: session.updateMessage,
updatePart: session.updatePart,
})
: result
if (fallback === "compact") {
// kilocode_change end
processor.message.error = new MessageV2.ContextOverflowError({
message: replay
? "Conversation history too large to compact - exceeds model context limit"
@@ -482,8 +504,25 @@ export const layer: Layer.Layer<
})
}
if (result === "continue" && input.auto) {
if (fallback === "continue" && input.auto) { // kilocode_change
if (replay) {
// kilocode_change start - compact oversized replay turns instead of looping into replay overflow
replay = yield* KiloCompactionChunks.replay({
processors,
session,
user: userMessage,
agent,
sessionID: input.sessionID,
model,
cfg,
messages: selected.head,
prompt: nextPrompt,
target: processor.message,
updateMessage: session.updateMessage,
updatePart: session.updatePart,
replay,
})
// kilocode_change end
const original = replay.info
const replayMsg = yield* session.updateMessage({
id: MessageID.ascending(),
@@ -496,6 +535,7 @@ export const layer: Layer.Layer<
tools: original.tools,
system: original.system,
})
KiloSessionPromptQueue.retarget(input.sessionID, replayMsg.id) // kilocode_change - expose replay to scope()
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
@@ -539,6 +579,7 @@ export const layer: Layer.Layer<
agent: userMessage.agent,
model: userMessage.model,
})
KiloSessionPromptQueue.retarget(input.sessionID, continueMsg.id) // kilocode_change - expose auto-continue to scope()
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
@@ -566,12 +607,12 @@ export const layer: Layer.Layer<
// kilocode_change start - compaction already invalidates cache, so collapse stale tool outputs too
if (processor.message.error) return "stop"
if (result === "continue") {
if (fallback === "continue") { // kilocode_change
yield* prune({ sessionID: input.sessionID, reason: "post-compaction" })
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
}
// kilocode_change end
return result
return fallback // kilocode_change
})
const create = Effect.fn("SessionCompaction.create")(function* (input: {
+9
View File
@@ -25,6 +25,7 @@ import { getKiloProjectId } from "@/kilocode/project-id"
import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway"
import { Identity } from "@kilocode/kilo-telemetry"
import { makeRuntime } from "@/effect/run-service"
import { KiloLLM } from "@/kilocode/session/llm"
// kilocode_change end
import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -223,6 +224,14 @@ const live: Layer.Layer<
// kilocode_change end
const tools = resolveTools(input)
// kilocode_change start - cap maxOutputTokens to fit within context after estimating real input size
params.maxOutputTokens = KiloLLM.capOutputTokens({
model: input.model,
messages,
tools,
configured: params.maxOutputTokens,
})
// kilocode_change end
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
+1
View File
@@ -1387,6 +1387,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
if (task && !lastFinished) tasks.push(...task)
}
// kilocode_change end
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
@@ -0,0 +1,582 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Effect, Layer, ManagedRuntime } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Snapshot } from "../../src/snapshot"
import { KiloCompactionChunks } from "../../src/kilocode/session/compaction-chunks"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionCompaction } from "../../src/session/compaction"
import * as SessionProcessorModule from "../../src/session/processor"
import type { SessionProcessor } from "../../src/session/processor"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Session as SessionNs } from "../../src/session/session"
import { SessionStatus } from "../../src/session/status"
import { SessionSummary } from "../../src/session/summary"
import { ProviderTest } from "../fake/provider"
import { tmpdir } from "../fixture/fixture"
const providerID = ProviderID.make("test")
const modelID = ModelID.make("test-model")
const ref = { providerID, modelID }
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
messages(input: Parameters<SessionNs.Interface["messages"]>[0]) {
return run(SessionNs.Service.use((svc) => svc.messages(input)))
},
updateMessage<T extends MessageV2.Info>(msg: T) {
return run(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
},
updatePart<T extends MessageV2.Part>(part: T) {
return run(SessionNs.Service.use((svc) => svc.updatePart(part)))
},
}
const summary = Layer.succeed(
SessionSummary.Service,
SessionSummary.Service.of({
summarize: () => Effect.void,
diff: () => Effect.succeed([]),
computeDiff: () => Effect.succeed([]),
}),
)
async function user(sessionID: SessionID, text: string) {
const msg = await svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
})
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
async function assistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) {
const msg: MessageV2.Assistant = {
id: MessageID.ascending(),
role: "assistant",
sessionID,
mode: "build",
agent: "build",
path: { cwd: root, root },
cost: 0,
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID,
providerID,
parentID,
time: { created: Date.now() },
finish: "stop",
}
await svc.updateMessage(msg)
await svc.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID,
type: "text",
text,
})
return msg
}
function llm() {
const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
> = []
return {
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) {
queue.push(stream)
},
layer: Layer.succeed(
LLM.Service,
LLM.Service.of({
stream: (input) => {
const item = queue.shift() ?? Stream.empty
const stream = typeof item === "function" ? item(input) : item
return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event)))
},
raw: () => Effect.die("raw not implemented in test LLM"),
}),
),
}
}
function reply(text: string, capture?: (input: LLM.StreamInput) => void) {
return (input: LLM.StreamInput) => {
capture?.(input)
return Stream.make(
{ type: "start" } as LLM.Event,
{ type: "text-start", id: "txt-0" } as LLM.Event,
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event,
{ type: "text-end", id: "txt-0" } as LLM.Event,
{
type: "finish-step",
finishReason: "stop",
rawFinishReason: "stop",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
} as LLM.Event,
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
} as LLM.Event,
)
}
}
function overflow() {
return Stream.make(
{ type: "start" } as LLM.Event,
{
type: "finish-step",
finishReason: "stop",
rawFinishReason: "stop",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 20_000,
outputTokens: 1,
totalTokens: 20_001,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
} as LLM.Event,
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 20_000,
outputTokens: 1,
totalTokens: 20_001,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
} as LLM.Event,
)
}
function runtime(layer: Layer.Layer<LLM.Service>, context = 7_000) {
const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus))
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } })
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(ProviderTest.fake({ model }).layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(status),
Layer.provide(bus),
Layer.provide(Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }) })),
),
)
}
function fakeRuntime() {
const calls: string[] = []
const outputs: number[] = []
const bus = Bus.layer
const processor = Layer.effect(
SessionProcessorModule.SessionProcessor.Service,
Effect.gen(function* () {
const sessions = yield* SessionNs.Service
return SessionProcessorModule.SessionProcessor.Service.of({
create: Effect.fn("TestSessionProcessor.create")((input) =>
Effect.succeed({
get message() {
return input.assistantMessage
},
updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)),
completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void),
process: Effect.fn("TestSessionProcessor.process")((stream: LLM.StreamInput) =>
Effect.gen(function* () {
outputs.push(input.model.limit.output)
calls.push(JSON.stringify(stream.messages))
const text = stream.messages.some((msg) => JSON.stringify(msg).includes("Create a new anchored summary"))
? "final summary"
: calls.length === 1
? "chunk one"
: "chunk two"
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: input.assistantMessage.id,
sessionID: input.sessionID,
type: "text",
text,
})
input.assistantMessage.finish = "stop"
return "continue" as const
}),
),
} satisfies SessionProcessor.Handle),
),
})
}),
)
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 10_000, output: 1_000 } })
return {
calls,
outputs,
rt: ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus).pipe(
Layer.provide(ProviderTest.fake({ model }).layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(bus),
Layer.provide(
Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
}),
),
),
),
}
}
function liveRuntime(layer: Layer.Layer<LLM.Service>, context = 10_000) {
const bus = Bus.layer
const status = SessionStatus.layer.pipe(Layer.provide(bus))
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } })
return ManagedRuntime.make(
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
Layer.provide(ProviderTest.fake({ model }).layer),
Layer.provide(SessionNs.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(layer),
Layer.provide(Permission.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(status),
Layer.provide(bus),
Layer.provide(
Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...Config.Info.zod.parse({}), compaction: { reserved: 1_000 } }),
}),
),
),
)
}
afterEach(() => {
mock.restore()
})
describe("KiloCompactionChunks", () => {
test("splits oversized history into chronological chunks", async () => {
const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 7_000, output: 1_000 } })
const sessionID = SessionID.make("ses_chunks_split")
const messages: MessageV2.WithParts[] = Array.from({ length: 4 }, (_, index) => ({
info: {
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
},
parts: [
{
id: PartID.ascending(),
messageID: MessageID.ascending(),
sessionID,
type: "text",
text: `${index}: ${"x".repeat(8_000)}`,
},
],
}))
const chunks = await Effect.runPromise(KiloCompactionChunks.split({ messages, model, size: 2_000 }))
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.flatMap((chunk) => chunk.messages.map((msg) => msg.info.id))).toEqual(messages.map((msg) => msg.info.id))
})
test("falls back to chunk workers after the first compaction overflows", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(10_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000))
const second = await user(session.id, "second " + "c".repeat(10_000))
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
const parts = summaries.flatMap((msg) => msg.parts).filter((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(calls.at(-1)).toContain("Create a new anchored summary")
expect(summaries).toHaveLength(1)
expect(parts.map((part) => part.text)).toEqual(["final summary"])
} finally {
await rt.dispose()
}
},
})
})
test("uses chunk fallback before sending oversized normal compaction", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(10_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(10_000))
const second = await user(session.id, "second " + "c".repeat(10_000))
await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(10_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls[0]).toContain("Summarize conversation chunk")
expect(calls[0]).not.toContain("Create a new anchored summary")
} finally {
await rt.dispose()
}
},
})
})
test("uses a worker even when fallback selection produces one oversized chunk", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(20_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const summaries = all.filter((msg) => msg.info.role === "assistant" && msg.info.summary)
const parts = summaries.flatMap((msg) => msg.parts).filter((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThan(0)
expect(calls[0]).toContain("Summarize conversation chunk")
expect(summaries).toHaveLength(1)
expect(parts.map((part) => part.text)).toEqual(["final summary"])
} finally {
await rt.dispose()
}
},
})
})
test("serializes oversized fallback chunks before summarizing", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "single huge request " + "a".repeat(80_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
const { rt, calls } = fakeRuntime()
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls[0]).toContain("compacted transcript")
expect(calls[0]).toContain("Text truncated for compaction")
expect(calls[0]).toContain("Summarize conversation chunk")
} finally {
await rt.dispose()
}
},
})
})
test("caps worker output budget below oversized model output limit", async () => {
const { rt, calls, outputs } = fakeRuntime()
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const first = await user(session.id, "first " + "a".repeat(1_000))
await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(1_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: false })
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
}),
),
)
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThan(0)
expect(outputs.every((value) => value <= 2_048)).toBe(true)
} finally {
await rt.dispose()
}
},
})
})
test("compacts oversized replay turns after overflow compaction", async () => {
const stub = llm()
const calls: string[] = []
stub.push(reply("history summary"))
stub.push(reply("replay summary", (input) => calls.push(JSON.stringify(input.messages))))
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const old = await user(session.id, "old context")
await assistant(session.id, old.id, tmp.path, "old reply")
const large = await user(session.id, "large replay " + "x".repeat(40_000))
await SessionCompaction.create({ sessionID: session.id, agent: "build", model: ref, auto: true, overflow: true })
const rt = liveRuntime(stub.layer)
try {
const msgs = await svc.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
const result = await rt.runPromise(
SessionCompaction.Service.use((svc) =>
svc.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: true,
overflow: true,
}),
),
)
const all = await svc.messages({ sessionID: session.id })
const replay = all.findLast((msg) => msg.info.role === "user" && msg.info.id !== large.id)
const part = replay?.parts.find((part): part is MessageV2.TextPart => part.type === "text")
expect(result).toBe("continue")
expect(calls).toHaveLength(1)
expect(calls[0]).toContain("Summarize conversation chunk 1 of 1")
expect(part?.text).toContain("compacted representation")
expect(part?.text).toContain("replay summary")
} finally {
await rt.dispose()
}
},
})
})
})
+59
View File
@@ -62,6 +62,8 @@ import type {
GlobalUpgradeResponses,
IndexingStatusResponses,
InstanceDisposeResponses,
KiloAudioTranscriptionsErrors,
KiloAudioTranscriptionsResponses,
KiloClawChatCredentialsResponses,
KiloClawStatusResponses,
KiloCloudSessionGetErrors,
@@ -5745,6 +5747,58 @@ export class Organization extends HeyApiClient {
}
}
export class Audio extends HeyApiClient {
/**
* Speech to text transcription
*
* Proxy an audio transcription request to the Kilo Gateway
*/
public transcriptions<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
model?: string
input_audio?: {
data: string
format: string
}
language?: string
temperature?: number
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "model" },
{ in: "body", key: "input_audio" },
{ in: "body", key: "language" },
{ in: "body", key: "temperature" },
],
},
],
)
return (options?.client ?? this.client).post<
KiloAudioTranscriptionsResponses,
KiloAudioTranscriptionsErrors,
ThrowOnError
>({
url: "/kilo/audio/transcriptions",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Session3 extends HeyApiClient {
/**
* Get cloud session
@@ -6066,6 +6120,11 @@ export class Kilo extends HeyApiClient {
return (this._organization ??= new Organization({ client: this.client }))
}
private _audio?: Audio
get audio(): Audio {
return (this._audio ??= new Audio({ client: this.client }))
}
private _cloud?: Cloud
get cloud(): Cloud {
return (this._cloud ??= new Cloud({ client: this.client }))
+39
View File
@@ -7247,6 +7247,45 @@ export type KiloFimResponses = {
export type KiloFimResponse = KiloFimResponses[keyof KiloFimResponses]
export type KiloAudioTranscriptionsData = {
body?: {
model: string
input_audio: {
data: string
format: string
}
language?: string
temperature?: number
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilo/audio/transcriptions"
}
export type KiloAudioTranscriptionsErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type KiloAudioTranscriptionsError = KiloAudioTranscriptionsErrors[keyof KiloAudioTranscriptionsErrors]
export type KiloAudioTranscriptionsResponses = {
/**
* Transcription response
*/
200: {
text: string
usage?: unknown
}
}
export type KiloAudioTranscriptionsResponse = KiloAudioTranscriptionsResponses[keyof KiloAudioTranscriptionsResponses]
export type KiloNotificationsData = {
body?: never
path?: never
+1
View File
@@ -42,6 +42,7 @@ const active = new Set([
"generate.yml",
"nix-eval.yml",
"nix-hashes.yml",
"publish-jetbrains.yml",
"publish.yml",
"smoke-test.yml",
"source-check-links.yml",