Merge branch 'main' into rapid-empress

This commit is contained in:
Kirill Kalishev
2026-08-05 11:27:20 -04:00
committed by GitHub
56 changed files with 874 additions and 135 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Accept terminal input immediately while the Agent Manager shell starts.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show file names before their containing folders in JetBrains `@file` suggestions.
+3 -7
View File
@@ -5,11 +5,7 @@
Changes from opencode v1.17.9 to v1.17.13 upstream:
- Core Improvements: Sessions gain a snapshot and revert system for staging, clearing and committing file reverts.
- Core Improvements: Durable session history is served in finite pages and exposed through the SDK.
- Core Improvements: MCP servers can append their instructions to the model context, and MCP resources are available as tools with template listing.
- Core Improvements: MCP tools use the `mcp__server__tool` naming convention, with legacy names still accepted.
- Core Improvements: Plugins can use the v2 effect host and a namespaced hook API.
- Core Improvements: Model variants are generated from models.dev data, including modes exposed as models.
- Core Improvements: Tool definitions pass `strict` through for Codex parity, and Gemini requests support video and audio media.
- Core Bugfixes: Interrupted assistant steps settle instead of leaving sessions stuck busy.
@@ -18,8 +14,8 @@ Changes from opencode v1.17.9 to v1.17.13 upstream:
- Core Bugfixes: Stale GitHub Copilot Responses item IDs are no longer replayed, and OpenAI reasoning variants are forced where required.
- Core Bugfixes: Adaptive thinking is enabled for Claude Sonnet 5, and expired promos were removed from the zen catalog.
- Core Bugfixes: Preserve released prompt history during database replay and keep native event streams connected for all supported Kilo events.
- Core Bugfixes: Remote skills refresh atomically with version pinning, and skill base directories are emitted as filesystem paths.
- CLI Improvements: `kilo run --mini` provides a compact interactive mode, and ports increment from the default when busy.
- Core Bugfixes: Remote skill manifests support optional per-skill versions; changing a version refreshes the cached skill atomically, and skill base directories are emitted as filesystem paths.
- CLI Improvements: Ports increment from the default when busy.
- CLI Improvements: Use `--auto` to start the TUI in a run-scoped auto-approve mode, and leave the mode mid-session from the command palette.
- TUI Improvements: Redesigned crash screen, model picker sorted by release date, a diff viewer keybind, main-branch diff source, bindable move-session command, and inline skill load errors.
- TUI Improvements: Redesigned crash screen, model picker sorted by release date, bindable diff viewer and Move Session commands, main-branch diff source, and inline skill load errors.
- TUI Bugfixes: File autocomplete is scoped to the session, multi-day durations format correctly, and root sessions load in the session switcher.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Add a privacy mode that blurs PII in the TUI (personal balance, Kilo Pass usage, etc.) and requires confirmation before `/profile` reveals email, name, balance, and team. Toggle with the new `/privacy` command or by setting `privacy_mode` in `kilo.json`. The `kilo profile` CLI command is unaffected.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Move the prompt navigator rail to the panel edge so it is harder to open by accident, and keep it clear of the pane splitter while resizing.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix high CPU and runaway memory growth in the JetBrains background `kilo serve` process on macOS by no longer eagerly starting native file watchers, matching the VS Code backend.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Show a concise retryable message when concurrent Kilo processes temporarily lock the SQLite database instead of printing the full server error trace.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent Agent Manager overview requests from timing out while refreshing Git statistics across many worktrees.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support model, agent, variant, and sandbox slash commands in Agent Manager worktree prompts.
+4
View File
@@ -129,6 +129,10 @@ export const Info = Schema.Struct({
hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({
description: "Hide Kilo Gateway models that may train on your prompts from model listings",
}),
privacy_mode: Schema.optional(Schema.Boolean).annotate({
description:
"Blur personally identifiable information (account email, balance, team name, etc.) in the TUI and require confirmation before showing profile details",
}),
sandbox: Schema.optional(
Schema.Struct({
enabled: Schema.optional(
@@ -165,6 +165,8 @@ MCP tools use the same permission system as built-in tools (`allow`, `ask`, `den
For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#auto-approve-tools).
Connected servers can also add usage instructions to the model context and expose resources, including parameterized resource templates. See [Server instructions and resources](/docs/automate/mcp/using-in-kilo-code#server-instructions-and-resources).
## Environment Variables
Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables:
@@ -513,6 +513,12 @@ After configuring an MCP server, Kilo Code will automatically detect available t
Example: "Analyze the performance of my API" might use an MCP tool that tests API endpoints.
### Server instructions and resources
When a connected MCP server provides instructions, Kilo adds them to the model context so the agent can follow the server's usage guidance. Kilo omits those instructions when every tool from that server is denied.
Resource-capable servers also make the `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` tools available to the agent. Resource templates describe parameterized URIs; the agent fills in a template, then reads the resulting resource URI. Resource listing and reads use Kilo's normal read approval flow.
## Troubleshooting MCP Servers
{% tabs %}
@@ -97,6 +97,8 @@ The `kilo console` command and its browser interface are deprecated and will be
| `/copy` | - | Copy latest agent response |
| `/copy-session` | - | Copy session transcript |
| `/export` | - | Export session transcript |
| `/move` | - | Move the current session to another project directory |
| `/diff` | - | Open the diff viewer |
| `/timestamps` | `/toggle-timestamps` | Show/hide timestamps |
| `/thinking` | `/toggle-thinking` | Show/hide thinking blocks |
@@ -219,6 +221,19 @@ There is no notification slash command or command-palette toggle. Use `tui.json`
The CLI's interactive mode supports slash commands for common operations. The main commands are documented above in the [Interactive Slash Commands](#interactive-slash-commands) section.
Use `/diff` to review working-tree changes. From the diff viewer, switch the source to the current branch compared with the main branch or to changes from the last assistant turn. Use `/move` to move the current session to another project directory.
The `diff_open` and `session_move` TUI keybindings run the same actions and are unbound by default. Set them under `keybinds` in `tui.jsonc`:
```jsonc
{
"keybinds": {
"diff_open": "<leader>d",
"session_move": "<leader>o",
},
}
```
## Permissions
Kilo Code uses the permission config to decide whether a given action should run automatically, prompt you, or be blocked.
+8 -2
View File
@@ -105,17 +105,20 @@ The remote server must serve an `index.json` file at the URL path with the follo
```json
{
"skills": [
{ "name": "skill-name", "files": ["SKILL.md", "references/file.md"] }
{ "name": "skill-name", "version": "2", "files": ["SKILL.md", "references/file.md"] }
]
}
```
Each skill object contains:
- `name`: The skill name (must match the directory name)
- `version`: Optional version string for refreshing cached skill files
- `files`: Array of files to fetch for this skill (must include `SKILL.md`)
Files are downloaded from `{url}/{skill-name}/{file}` paths.
When you change a remote skill's contents or file list, also change its `version`. On the next skill rediscovery (`/reload` or a new session), Kilo downloads the complete new version before atomically replacing the cached directory. If any download fails, Kilo keeps the previous cached version.
{% /tab %}
{% tab label="CLI" %}
@@ -174,17 +177,20 @@ The remote server must serve an `index.json` file at the URL path with the follo
```json
{
"skills": [
{ "name": "skill-name", "files": ["SKILL.md", "references/file.md"] }
{ "name": "skill-name", "version": "2", "files": ["SKILL.md", "references/file.md"] }
]
}
```
Each skill object contains:
- `name`: The skill name (must match the directory name)
- `version`: Optional version string for refreshing cached skill files
- `files`: Array of files to fetch for this skill (must include `SKILL.md`)
Files are downloaded from `{url}/{skill-name}/{file}` paths.
When you change a remote skill's contents or file list, also change its `version`. On the next skill rediscovery (`/reload` or a new session), Kilo downloads the complete new version before atomically replacing the cached directory. If any download fails, Kilo keeps the previous cached version.
{% /tab %}
{% /tabs %}
@@ -252,7 +252,7 @@ class KiloPromptCompletionProvider(
PrioritizedLookupElement.withGrouping(PrioritizedLookupElement.withPriority(element, 100.0), 100)
private fun file(file: WorkspaceFileDto): LookupElement = LookupElementBuilder.create(file.path)
.withPresentableText("@${file.path}")
.withPresentableText("@${file.name}")
.withTailText(parent(file.path), true)
.withIcon(icon(file))
.withLookupString(file.name)
@@ -211,6 +211,16 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
assertSame(AllIcons.Nodes.Folder, icon("src"))
}
fun `test mention completion renders filename before parent path`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/foo/Bar.kt")))
complete("@bar<caret>")
val view = LookupElementPresentation().also { item("src/foo/Bar.kt").renderElement(it) }
assertEquals("@Bar.kt", view.itemText)
assertEquals(" src/foo", view.tailText)
}
fun `test highlights known slash command at start`() {
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(0, 4, KiloPromptCompletionProvider.HighlightKind.COMMAND)),
@@ -240,7 +240,7 @@ export class AgentManagerProvider implements Disposable {
await this.stateReady
return this.state
},
stats: (refresh) => this.statsPoller.snapshot(refresh),
stats: () => this.statsPoller.snapshot(),
prs: () => this.prBridge.snapshot(),
push: () => this.pushState(),
managed: (id) => this.panelSessions.has(id) || !!this.state?.getSession(id),
@@ -1382,6 +1382,7 @@ export class AgentManagerProvider implements Disposable {
reviewDiffStyle: state.getReviewDiffStyle(),
reviewMarkdownRender: getDiffMarkdownRender(),
terminalDestination: this.destination.value(),
terminalFont: readTerminalFont(),
isGitRepo: true,
defaultBaseBranch: state.getDefaultBaseBranch(),
activeTarget: state.getActiveTarget(),
@@ -1408,6 +1409,7 @@ export class AgentManagerProvider implements Disposable {
reviewDiffStyle: "unified",
reviewMarkdownRender: getDiffMarkdownRender(),
terminalDestination: this.destination.value(),
terminalFont: readTerminalFont(),
isGitRepo: false,
runStatuses: [],
runScriptConfigured: false,
@@ -131,13 +131,7 @@ export class GitStatsPoller {
this.lastStats = {}
}
async snapshot(refresh = false): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> {
if (refresh && !this.busy) {
this.busy = true
await Promise.all([this.fetchWorktreeStats(true), this.fetchLocalStats()]).finally(() => {
this.busy = false
})
}
async snapshot(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> {
return {
worktrees: Object.values(this.lastStats),
...(this.lastLocalStats ? { local: this.lastLocalStats } : {}),
@@ -170,10 +164,10 @@ export class GitStatsPoller {
}
private async fetch(generation = this.generation): Promise<void> {
await Promise.all([this.fetchWorktreeStats(false, generation), this.fetchLocalStats(generation)])
await Promise.all([this.fetchWorktreeStats(generation), this.fetchLocalStats(generation)])
}
private async fetchWorktreeStats(includeSkipped = false, generation = this.generation): Promise<void> {
private async fetchWorktreeStats(generation = this.generation): Promise<void> {
const worktrees = this.options.getWorktrees()
if (worktrees.length === 0) return
@@ -189,7 +183,7 @@ export class GitStatsPoller {
for (const id of Object.keys(this.lastStats)) {
if (!ids.has(id)) delete this.lastStats[id]
}
const active = includeSkipped ? available : available.filter((wt) => !this.skipWorktreeIds.has(wt.id))
const active = available.filter((wt) => !this.skipWorktreeIds.has(wt.id))
if (active.length === 0) {
if (available.length > 0) return
if (this.lastHash === "") return
@@ -44,7 +44,7 @@ interface Options {
root(): string | undefined
ready(): Promise<WorktreeStateManager | undefined>
state(): WorktreeStateManager | undefined
stats(refresh?: boolean): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
stats(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }>
prs(): Map<string, PRStatus>
push(): void
managed(sessionID: string): boolean
@@ -257,7 +257,10 @@ export class AgentManagerOrchestrationBridge {
if (this.disposed || active.cancelled) return
const client = this.connection.getClient()
if (request.operation === "overview") {
const stats = await this.options.stats(true)
// Git stats are refreshed by the poller independently. A forced refresh
// here can spawn one diff/ahead-behind pair per worktree and exceed the
// host request timeout before the overview can return its IDs.
const stats = await this.options.stats()
if (this.disposed || active.cancelled) return
const result = await overview({
client,
@@ -46,15 +46,6 @@ interface Entry {
title: string
}
/** Stable prefix used for terminal tab IDs in the webview (e.g. `terminal:abc123`). */
export const TERMINAL_PREFIX = "terminal:"
/** Generate a reasonably unique terminal ID without bringing in a uuid dep. */
function makeTerminalId(): string {
const rand = Math.random().toString(36).slice(2, 8)
return `${TERMINAL_PREFIX}${Date.now().toString(36)}-${rand}`
}
export class TerminalManager {
private readonly entries = new Map<string, Entry>()
private readonly restarts = new Map<string, Promise<void>>()
@@ -70,6 +61,7 @@ export class TerminalManager {
* tab back into the correct sidebar context.
*/
async create(params: {
terminalId: string
worktreeId: string | null
cwd: string
title: string
@@ -84,18 +76,17 @@ export class TerminalManager {
const err = error instanceof Error ? error.message : String(error ?? "unknown error")
throw new Error(`Failed to create PTY: ${err}`)
}
const terminalId = makeTerminalId()
const entry: Entry = {
terminalId,
terminalId: params.terminalId,
ptyID: data.id,
worktreeId: params.worktreeId,
cwd: params.cwd,
title: data.title ?? params.title,
}
this.entries.set(terminalId, entry)
this.entries.set(params.terminalId, entry)
const wsUrl = this.deps.buildWsUrl(entry.ptyID, entry.cwd)
this.deps.log(`Terminal created: ${terminalId} -> pty ${entry.ptyID} cwd=${entry.cwd}`)
return { terminalId, worktreeId: entry.worktreeId, title: entry.title, wsUrl }
this.deps.log(`Terminal created: ${params.terminalId} -> pty ${entry.ptyID} cwd=${entry.cwd}`)
return { terminalId: params.terminalId, worktreeId: entry.worktreeId, title: entry.title, wsUrl }
}
/** Forward a resize event to the backend PTY. Missing terminals are a no-op. */
@@ -155,7 +155,7 @@ export class TerminalRouter {
// Join the shared backend connection instead of racing its synchronous
// client accessor when this is the first Kilo action in the window.
await this.deps.getClientAsync()
const created = await manager.create({ worktreeId, cwd, title })
const created = await manager.create({ terminalId: createId, worktreeId, cwd, title })
if (generation !== this.generation) {
await manager.close(created.terminalId)
return
@@ -147,6 +147,7 @@ interface StateMessage {
/** Last selected sidebar target for seamless project-switch restore. */
activeTarget?: SidebarTarget
terminalDestination?: TerminalDestination
terminalFont?: TerminalFont
}
/** Project catalog pushed to the webview after registry or context changes. */
@@ -939,7 +940,7 @@ interface MoveSectionIn {
interface TerminalCreateIn {
type: "agentManager.terminal.create"
/** Webview-generated correlation id, echoed back in created/error. */
/** Webview-generated logical terminal id, echoed back in created/error. */
createId: string
placement: TerminalPlacement
/** null for LOCAL, worktree id otherwise */
@@ -37,6 +37,7 @@ describe("AgentManagerOrchestrationBridge", () => {
const replies: unknown[] = []
const rejections: unknown[] = []
const lists = new Map<string, AgentManagerRequest[]>()
const statsCalls: number[] = []
const handlers: {
event?: (event: SSEPayload, directory?: string) => void
state?: (state: "connecting" | "connected" | "disconnected" | "error") => void
@@ -54,6 +55,12 @@ describe("AgentManagerOrchestrationBridge", () => {
status: mock(async () => ({ data: {} })),
promptAsync,
},
permission: {
list: mock(async () => ({ data: [] })),
},
question: {
list: mock(async () => ({ data: [] })),
},
kilocode: {
agentManager: {
list: mock(async ({ directory }: { directory?: string }) => {
@@ -96,7 +103,10 @@ describe("AgentManagerOrchestrationBridge", () => {
root: () => root,
ready: async () => state,
state: () => state,
stats: async () => ({ worktrees: [] }),
stats: async () => {
statsCalls.push(1)
return { worktrees: [] }
},
prs: () => new Map(),
push,
managed: (id) => managed.has(id),
@@ -108,7 +118,21 @@ describe("AgentManagerOrchestrationBridge", () => {
{ id: `event-${value.id}`, type: "kilocode.agent_manager.requested", properties: value } as SSEPayload,
directory,
)
return { bridge, client, close, handlers, lists, managed, promptAsync, push, rejections, replies, request, status }
return {
bridge,
client,
close,
handlers,
lists,
managed,
promptAsync,
push,
rejections,
replies,
request,
statsCalls,
status,
}
}
const request: AgentManagerRequest = {
@@ -233,6 +257,30 @@ describe("AgentManagerOrchestrationBridge", () => {
test.bridge.dispose()
})
it("returns an overview with cached git stats", async () => {
const test = harness()
test.request({
id: "amr_overview",
sessionID: "ses_caller",
operation: "overview",
})
await waitFor(() => test.replies.length === 1)
expect(test.statsCalls).toEqual([1])
expect(test.replies[0]).toEqual({
requestID: "amr_overview",
directory: root,
result: {
operation: "overview",
overview: expect.objectContaining({
ungrouped: [expect.objectContaining({ id: expect.any(String) })],
sections: [],
}),
},
})
test.bridge.dispose()
})
it("stops a live panel session before it is persisted", async () => {
const test = harness()
test.managed.add("ses_live")
@@ -0,0 +1,146 @@
import { describe, expect, it } from "bun:test"
import { createInputBuffer, createReplayGate } from "../../webview-ui/agent-manager/terminal/replay"
describe("Agent Manager terminal input buffer", () => {
it("sends parser replies first while preserving user input order", () => {
const input = createInputBuffer()
input.add("early ")
input.add("reply", true)
input.add("command\r")
expect(input.take()).toBe("replyearly command\r")
expect(input.take()).toBe("")
})
it("caps user input and protocol replies independently", () => {
const input = createInputBuffer(4)
input.add("12345")
input.add("abcde", true)
expect(input.take()).toBe("bcde2345")
})
})
describe("Agent Manager terminal replay gate", () => {
it("flushes initial input only after replay parsing completes", () => {
const events: string[] = []
let complete: (() => void) | undefined
const gate = createReplayGate({
write: (data, callback) => {
events.push(typeof data === "string" ? data : `bytes:${data.join(",")}`)
if (callback) complete = callback
},
flush: () => events.push("flush"),
})
gate.attach(false)
expect(gate.blocked()).toBe(true)
gate.output("replay")
gate.output(new Uint8Array([1, 2, 3]))
expect(events).toEqual([])
expect(gate.frame(new Uint8Array([0, 123, 125]))).toBe(true)
expect(gate.blocked()).toBe(true)
expect(gate.draining()).toBe(true)
expect(events).toEqual(["replay", "bytes:1,2,3", ""])
complete?.()
expect(gate.blocked()).toBe(false)
expect(gate.draining()).toBe(false)
expect(events).toEqual(["replay", "bytes:1,2,3", "", "flush"])
})
it("leaves reconnect input on the output-settle path", () => {
const events: string[] = []
const gate = createReplayGate({
write: () => events.push("write"),
flush: () => events.push("flush"),
})
gate.attach(true)
expect(gate.blocked()).toBe(false)
expect(gate.draining()).toBe(false)
gate.output("live")
expect(gate.frame(new Uint8Array([0]))).toBe(true)
expect(events).toEqual(["write"])
})
it("consumes only one initial replay boundary", () => {
let drains = 0
const gate = createReplayGate({
write: (_data, callback) => {
if (callback) drains++
},
flush: () => undefined,
})
gate.attach(false)
expect(gate.frame(new Uint8Array())).toBe(false)
expect(gate.frame(new Uint8Array([0]))).toBe(true)
expect(gate.frame(new Uint8Array([0]))).toBe(true)
expect(drains).toBe(1)
})
it("ignores an initial parse callback after reconnect starts", () => {
let complete: (() => void) | undefined
let flushed = 0
const gate = createReplayGate({
write: (_data, callback) => {
if (callback) complete = callback
},
flush: () => flushed++,
})
gate.attach(false)
gate.frame(new Uint8Array([0]))
gate.attach(true)
complete?.()
expect(gate.blocked()).toBe(false)
expect(flushed).toBe(0)
})
it("lets terminal replies pass while queued replay parses before user input flushes", () => {
const events: string[] = []
let complete: (() => void) | undefined
const gate = createReplayGate({
write: (data, callback) => {
events.push(String(data))
if (callback) complete = callback
},
flush: () => events.push("flush"),
})
gate.attach(false)
expect(gate.blocked()).toBe(true)
gate.output("replay")
gate.frame(new Uint8Array([0]))
expect(gate.blocked()).toBe(true)
expect(gate.draining()).toBe(true)
gate.output("terminal-reply")
expect(events).toEqual(["replay", "", "terminal-reply"])
expect(complete).toBeFunction()
complete?.()
expect(gate.blocked()).toBe(false)
expect(gate.draining()).toBe(false)
expect(events).toEqual(["replay", "", "terminal-reply", "flush"])
})
it("keeps user input blocked for the complete parser-drain window", () => {
let complete: (() => void) | undefined
const gate = createReplayGate({
write: (_data, callback) => {
if (callback) complete = callback
},
flush: () => undefined,
})
gate.attach(false)
gate.frame(new Uint8Array([0]))
expect(gate.blocked()).toBe(true)
expect(gate.draining()).toBe(true)
complete?.()
expect(gate.blocked()).toBe(false)
expect(gate.draining()).toBe(false)
})
})
@@ -41,6 +41,7 @@ describe("Agent Manager terminal routing", () => {
expect(messages[0]).toMatchObject({
type: "agentManager.terminal.created",
createId: "side-1",
terminalId: "side-1",
placement: "side",
worktreeId: "wt-1",
projectId: "prj-1",
@@ -22,7 +22,6 @@ function scene(initial: string | null = LOCAL) {
shown: [] as string[],
errors: 0,
running: [] as Array<{ contextKey: string; terminalId: string }>,
sideFocus: [] as boolean[],
}
const tabs = () => state.current().map((term) => term.id)
const handlers = createTerminalHandlers({
@@ -39,6 +38,7 @@ function scene(initial: string | null = LOCAL) {
getSelection: selection,
LOCAL,
REVIEW_TAB_ID: "review",
getFont: () => font,
})
const dispatch = createTerminalMessageHandler({
state,
@@ -50,7 +50,6 @@ function scene(initial: string | null = LOCAL) {
},
showError: () => events.errors++,
postMessage: (message) => posted.push(message as Record<string, unknown>),
onSideCreated: (_contextKey, _terminalId, focus) => events.sideFocus.push(focus),
onScriptRunning: (contextKey, terminalId) => events.running.push({ contextKey, terminalId }),
})
return { state, selection, setSelection, posted, events, handlers, dispatch }
@@ -317,18 +316,23 @@ describe("Agent Manager terminal state", () => {
item.handlers.requestSide()
expect(item.posted).toHaveLength(1)
expect(item.state.sidesForContext(LOCAL)).toHaveLength(1)
const request = item.posted[0]!
expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null })
const createId = String(request.createId)
expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side")
expect(createId).toStartWith("terminal:")
const optimistic = item.state.sidesForContext(LOCAL)[0]
expect(item.dispatch(createdSide(createId, createId))).toBe(true)
expect(item.state.sidesForContext(LOCAL)[0]).toBe(optimistic)
expect(optimistic?.wsUrl).toBe(`ws://${createId}`)
expect(item.state.sideActiveFor(LOCAL)).toBe(createId)
expect(item.events.activated).toEqual([])
expect(item.events.selected).toEqual([])
expect(item.events.saved).toBe(0)
item.handlers.requestSide()
expect(item.posted).toHaveLength(1)
expect(item.state.focusRequest()?.id).toBe("terminal:side")
expect(item.state.focusRequest()?.id).toBe(createId)
dispose()
})
})
@@ -347,8 +351,8 @@ describe("Agent Manager terminal state", () => {
worktreeId: "wt-1",
})
const createId = String(item.posted[0]!.createId)
expect(item.dispatch(createdSide(createId, "terminal:side", "Terminal 1", "wt-1"))).toBe(true)
expect(item.events.sideFocus).toEqual([false])
expect(item.dispatch(createdSide(createId, createId, "Terminal 1", "wt-1"))).toBe(true)
expect(item.state.sidesForContext("wt-1")[0]).toMatchObject({ id: createId, title: "Terminal 1" })
dispose()
})
})
@@ -358,8 +362,8 @@ describe("Agent Manager terminal state", () => {
const item = scene()
item.handlers.addSide()
const createId = String(item.posted[0]!.createId)
expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true)
expect(item.events.sideFocus).toEqual([true])
expect(item.dispatch(createdSide(createId, createId))).toBe(true)
expect(item.state.focusRequest()?.id).toBe(createId)
dispose()
})
})
@@ -373,13 +377,12 @@ describe("Agent Manager terminal state", () => {
const first = String(item.posted[0]!.createId)
const second = String(item.posted[1]!.createId)
item.dispatch(createdSide(first, "terminal:one", "Terminal 1"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"])
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
item.dispatch(createdSide(first, first, "Terminal 1"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([first, second])
item.dispatch(createdSide(second, "terminal:two", "Terminal 2"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"])
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
item.dispatch(createdSide(second, second, "Terminal 2"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([first, second])
expect(item.state.sideActiveFor(LOCAL)).toBe(second)
dispose()
})
})
@@ -606,6 +609,7 @@ describe("Agent Manager terminal state", () => {
getSelection: selection,
LOCAL,
REVIEW_TAB_ID: "review",
getFont: () => font,
})
const dispatch = createTerminalMessageHandler({
state,
@@ -627,12 +631,11 @@ describe("Agent Manager terminal state", () => {
expect(item.posted).toHaveLength(1)
const request = item.posted[0]!
expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null })
expect(item.dispatch({ ...createdSide(String(request.createId), "terminal:side"), projectId: "prj-1" })).toBe(
true,
)
const id = String(request.createId)
expect(item.dispatch({ ...createdSide(id, id), projectId: "prj-1" })).toBe(true)
expect(item.state.sideKey()).toBe("prj-1:local")
expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"])
expect(item.state.sideActiveFor("prj-1:local")).toBe("terminal:side")
expect(item.state.sides().map((term) => term.id)).toEqual([id])
expect(item.state.sideActiveFor("prj-1:local")).toBe(id)
// A worktree context sends its plain worktree id, not "prj-1:wt-1".
const wt = nsScene("wt-1")
@@ -3,7 +3,10 @@ import { createRoot } from "solid-js"
import { useSlashCommand } from "../../webview-ui/src/hooks/useSlashCommand"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
function setup(sandbox: () => void, options: { enabled?: () => boolean; exclude?: () => Set<string> } = {}) {
function setup(
sandbox: () => void,
options: { enabled?: () => boolean; exclude?: () => Set<string>; include?: Set<string> } = {},
) {
const sent: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const root = createRoot((dispose) => ({
@@ -18,6 +21,7 @@ function setup(sandbox: () => void, options: { enabled?: () => boolean; exclude?
},
{ action: sandbox, enabled: options.enabled ?? (() => true) },
options.exclude,
options.include,
),
}))
const fire = (message: ExtensionMessage) => {
@@ -27,6 +31,33 @@ function setup(sandbox: () => void, options: { enabled?: () => boolean; exclude?
}
describe("useSlashCommand sandbox action", () => {
it("supports the singular model alias", () => {
const ctx = setup(() => {})
ctx.slash.onInput("/model", 6)
expect(ctx.slash.results()[0]).toEqual(expect.objectContaining({ name: "models", hints: ["model"] }))
ctx.dispose()
})
it("can restrict the menu to worktree configuration commands", () => {
const ctx = setup(() => {}, { include: new Set(["models", "agents", "variant", "sandbox"]) })
ctx.fire({
type: "commandsLoaded",
commands: [
{ name: "merge", description: "Merge changes", hints: [] },
{ name: "models", description: "Server model command", hints: [] },
],
})
ctx.slash.onInput("/merge", 6)
expect(ctx.slash.results()).toEqual([])
ctx.slash.onInput("/models", 7)
expect(ctx.slash.results().map((command) => command.name)).toEqual(["models"])
ctx.dispose()
})
it("opens project memory actions from the top-level command", () => {
const ctx = setup(() => {})
const state = { text: "/memory" }
@@ -45,7 +45,9 @@ import type {
SessionCreatedMessage,
BranchInfo,
TerminalDestination,
TerminalFont,
} from "../src/types/messages"
import { readFontSize } from "../src/font-size"
import { IndexingProvider } from "../src/context/indexing"
import {} from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
@@ -361,6 +363,10 @@ const AgentManagerContent: Component = () => {
const PENDING_PREFIX = "pending:"
const closedDrafts = new Set<string>()
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
const [terminalFont, setTerminalFont] = createSignal<TerminalFont>({
fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--vscode-editor-font-family").trim(),
fontSize: readFontSize(),
})
/** Namespace key so worktree/local ids from different projects never collide. */
const nsKey = (sel: string) => `${currentProjectId() ?? "single"}:${sel}`
@@ -1067,6 +1073,7 @@ const AgentManagerContent: Component = () => {
const applyState = (msg: ExtensionMessage) => {
if (msg.type !== "agentManager.state") return
const state = msg as AgentManagerStateMessage
if (state.terminalFont) setTerminalFont(state.terminalFont)
const pid = state.projectId
if (pid) setProjectStates((prev) => ({ ...prev, [pid]: state }))
const store = pid ? registry.ensure(pid) : registry.active()
@@ -1333,13 +1340,6 @@ const AgentManagerContent: Component = () => {
showToast({ variant: "error", title: t("agentManager.terminal.errorTitle"), description: message }),
postMessage: (message) => vscode.postMessage(message as never),
onCreated: (contextKey, terminalId) => appendToTabOrder(contextKey, terminalId),
onSideCreated: (contextKey, terminalId, focus) => {
// Focus only when the user is still looking at this panel —
// a slow create landing after a mode switch must not steal it.
if (focus && sidePanel() === "terminal" && !history() && !reviewActive() && terms.sideKey() === contextKey) {
terms.requestFocus(terminalId)
}
},
onSideClosed: (_contextKey, terminalId) => forgetTerminalFocus(terminalId),
onScriptRunning: (contextKey, terminalId) => {
if (terms.sideKey() !== contextKey) return
@@ -1358,6 +1358,7 @@ const AgentManagerContent: Component = () => {
onDestinationChanged: (destination) => sideCtl.syncDefault(destination),
})
const unsubTerminals = vscode.onMessage((msg) => {
if (msg.type === "agentManager.terminal.fontChanged") setTerminalFont(msg.font)
terminalDispatch(msg)
})
@@ -2072,6 +2073,7 @@ const AgentManagerContent: Component = () => {
getSelection: selection,
LOCAL,
REVIEW_TAB_ID,
getFont: terminalFont,
})
const sideCtl = createSideTerminal({
@@ -43,6 +43,7 @@ import { useSpeechToTextModels } from "../src/context/speech-to-text-models"
import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut"
import { convertToMentionPath } from "../src/utils/path-mentions"
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import { useSlashCommand } from "../src/hooks/useSlashCommand"
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect"
import { tracker } from "./telemetry"
@@ -51,6 +52,8 @@ import type { ModeRouter } from "./mode-router"
type VersionCount = 1 | 2 | 3 | 4
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox"])
const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt"
type DialogTab = "new" | "import"
@@ -288,6 +291,31 @@ export const NewWorktreeDialog: Component<{
let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined
const setPromptValue = (value: string) => {
setPrompt(value)
persistPrompt(value)
adjustHeight()
}
const restorePrompt = () => {
requestAnimationFrame(() => textareaRef?.focus({ preventScroll: true }))
}
const slash = useSlashCommand(
vscode,
{ action: toggleSandbox, enabled: () => sandboxVisible() && sandbox() !== undefined && sandboxAvailable() },
() => {
const hidden = new Set<string>()
if (session.agents().length < 2) hidden.add("agents")
if (variants().length === 0) hidden.add("variant")
if (!sandboxVisible()) hidden.add("sandbox")
return hidden
},
WORKTREE_PROMPT_COMMANDS,
WORKTREE_PROMPT_SCOPE,
)
const onFocusPrompt = () => restorePrompt()
window.addEventListener("focusPrompt", onFocusPrompt)
onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt))
onMount(() => {
setBranchesLoading(true)
vscode.postMessage({ type: "agentManager.requestBranches", projectId: props.projectId })
@@ -389,6 +417,11 @@ export const NewWorktreeDialog: Component<{
return
}
if (slash.onKeyDown(e, textareaRef, setPromptValue, restorePrompt)) {
e.stopPropagation()
return
}
// Shift+Tab cycles reasoning effort variants (setting: chat.shiftTabCyclesVariant).
// When disabled or no variants exist, fall through to default focus navigation.
if (e.key === "Tab" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
@@ -583,6 +616,33 @@ export const NewWorktreeDialog: Component<{
onDragLeave={imageAttach.handleDragLeave}
onDrop={imageAttach.handleDrop}
>
<Show when={slash.show()}>
<div class="slash-command-dropdown am-slash-command-dropdown" data-component="popover-content">
<Show
when={slash.results().length > 0}
fallback={<div class="slash-command-empty">No commands found</div>}
>
<For each={slash.results()}>
{(cmd, index) => (
<div
class="slash-command-item"
classList={{ "slash-command-item--active": index() === slash.index() }}
onMouseDown={(e) => {
e.preventDefault()
if (textareaRef) slash.select(cmd, textareaRef, setPromptValue, restorePrompt)
}}
onMouseEnter={() => slash.setIndex(index())}
>
<span class="slash-command-name">/{cmd.name}</span>
<Show when={cmd.description}>
<span class="slash-command-desc">{cmd.description}</span>
</Show>
</div>
)}
</For>
</Show>
</div>
</Show>
<Show when={imageAttach.images().length > 0}>
<div class="image-attachments">
<For each={imageAttach.images()}>
@@ -626,6 +686,7 @@ export const NewWorktreeDialog: Component<{
setPrompt(val)
persistPrompt(val)
adjustHeight()
slash.onInput(val, e.currentTarget.selectionStart ?? val.length)
}}
onKeyDown={onKey}
onKeyUp={speechUp}
@@ -642,6 +703,7 @@ export const NewWorktreeDialog: Component<{
agents={session.agents()}
value={agent()}
onSelect={selectAgent}
trigger={WORKTREE_PROMPT_SCOPE}
portal={false}
deferDismiss
/>
@@ -652,6 +714,9 @@ export const NewWorktreeDialog: Component<{
onSelect={(pid, mid) => {
if (pid && mid) setModel({ providerID: pid, modelID: mid })
}}
onPick={restorePrompt}
onCancel={restorePrompt}
trigger={WORKTREE_PROMPT_SCOPE}
placement="top-start"
portal={false}
deferDismiss
@@ -660,6 +725,7 @@ export const NewWorktreeDialog: Component<{
variants={variants()}
value={effectiveVariant()}
onSelect={setVariant}
trigger={WORKTREE_PROMPT_SCOPE}
portal={false}
deferDismiss
cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false}
@@ -2820,11 +2820,19 @@ body.am-wt-dragging-active * {
/* While any inline (non-portaled) selector popover is open, let it escape the
dialog's scroll containers. Covers model, thinking-variant, and mode pickers. */
.am-nv-dialog .am-prompt-input-container:has([data-component="popover-content"]),
.am-nv-dialog .am-prompt-input-container:has(.am-slash-command-dropdown),
.am-nv-dialog-content:has([data-component="popover-content"]),
[data-component="dialog"]:has(.am-nv-dialog [data-component="popover-content"]) [data-slot="dialog-body"] {
.am-nv-dialog-content:has(.am-slash-command-dropdown),
[data-component="dialog"]:has(.am-nv-dialog [data-component="popover-content"]) [data-slot="dialog-body"],
[data-component="dialog"]:has(.am-nv-dialog .am-slash-command-dropdown) [data-slot="dialog-body"] {
overflow: visible;
}
.am-slash-command-dropdown {
min-width: 0;
max-width: none;
}
.am-nv-dialog .am-prompt-input-ghost-wrapper {
height: 100%;
}
@@ -21,6 +21,7 @@ import { useVSCode } from "../../src/context/vscode"
import { useLanguage } from "../../src/context/language"
import { formatReviewCommentsMarkdown } from "../../src/utils/review-comment-markdown"
import type { ScriptTerminalStatus, TerminalFont } from "./state"
import { createInputBuffer, createReplayGate } from "./replay"
interface Props {
terminalId: string
@@ -192,7 +193,8 @@ export const TerminalTab: Component<Props> = (props) => {
let ws: WebSocket | undefined
let closed = false
let pending = ""
const input = createInputBuffer()
let user = false
let restartRequested = false
let disconnected = false
let readyTimer: ReturnType<typeof setTimeout> | undefined
@@ -234,11 +236,18 @@ export const TerminalTab: Component<Props> = (props) => {
rows: term.rows,
})
}
const markUser = () => {
user = true
queueMicrotask(() => {
user = false
})
}
const send = (data: string) => {
if (disconnected && props.restartable) {
pending += data
if (pending.length > 256 * 1024) pending = pending.slice(-256 * 1024)
requestRestart()
const reply = replay.draining() && !user
user = false
if (props.restartable && (replay.blocked() || disconnected || ws?.readyState !== WebSocket.OPEN)) {
input.add(data, reply)
if (disconnected) requestRestart()
return
}
if (ws?.readyState === WebSocket.OPEN) {
@@ -246,7 +255,7 @@ export const TerminalTab: Component<Props> = (props) => {
return
}
}
const flush = () => {
const flush = (all = false) => {
if (ws?.readyState !== WebSocket.OPEN) return
if (readyTimer) {
clearTimeout(readyTimer)
@@ -256,9 +265,8 @@ export const TerminalTab: Component<Props> = (props) => {
clearTimeout(fallbackTimer)
fallbackTimer = undefined
}
const data = pending
pending = ""
if (data && /[^\r\n]/.test(data)) ws.send(data)
const data = input.take()
if (data && (all || /[^\r\n]/.test(data))) ws.send(data)
disconnected = false
restartRequested = false
}
@@ -270,8 +278,17 @@ export const TerminalTab: Component<Props> = (props) => {
flush()
}, 100)
}
const replay = createReplayGate({
write: (data, callback) => term.write(data, callback),
flush: () => flush(true),
})
const disposeKey = term.onKey(markUser)
for (const event of ["input", "paste", "compositionend", "mousedown", "wheel"]) {
host.addEventListener(event, markUser, true)
}
const open = (url: string) => {
if (closed || !url) return
replay.attach(disconnected)
const next = new WebSocket(url)
next.binaryType = "arraybuffer"
ws = next
@@ -289,14 +306,14 @@ export const TerminalTab: Component<Props> = (props) => {
if (closed || ws !== next) return
streamed = true
if (typeof event.data === "string") {
term.write(event.data)
replay.output(event.data)
scheduleFlush()
return
}
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes.length > 0 && bytes[0] === 0x00) return
term.write(bytes)
if (replay.frame(bytes)) return
replay.output(bytes)
scheduleFlush()
}
}
@@ -326,6 +343,7 @@ export const TerminalTab: Component<Props> = (props) => {
}
}
const disposeData = term.onData(send)
const disposeBinary = term.onBinary(send)
open(props.wsUrl)
// These addons are not needed to paint the initial prompt. Defer them
@@ -440,6 +458,7 @@ export const TerminalTab: Component<Props> = (props) => {
if (message.targetTerminalId !== props.terminalId) return
const comments = message.comments
if (!Array.isArray(comments) || comments.length === 0) return
markUser()
term.paste(`${formatReviewCommentsMarkdown(comments)}\n`)
return
}
@@ -449,6 +468,16 @@ export const TerminalTab: Component<Props> = (props) => {
return
}
if (message.type === "agentManager.terminal.created") {
if (message.terminalId === props.terminalId && !ws) {
term.options.fontFamily = message.font.fontFamily
term.options.fontSize = message.font.fontSize
scheduleRepaint()
open(message.wsUrl)
}
return
}
if (message.type === "agentManager.terminal.error" && message.terminalId === props.terminalId) {
restartRequested = false
return
@@ -479,8 +508,14 @@ export const TerminalTab: Component<Props> = (props) => {
createEffect(() => {
const now = props.active
const serial = props.focusSerial ?? 0
if (now && (!wasActive || serial !== focusSerial))
scheduleRepaint((serial > 0 && serial !== focusSerial) || props.focusOnActivate !== false)
if (now && (!wasActive || serial !== focusSerial)) {
const focus = (serial > 0 && serial !== focusSerial) || props.focusOnActivate !== false
// xterm creates its textarea synchronously in term.open(). Focus it
// now so a freshly revealed terminal accepts input in this event
// turn; the queued repaint below still refits and retries next frame.
if (focus && document.hasFocus()) term.focus()
scheduleRepaint(focus)
}
if (!now && wasActive) term.blur()
wasActive = now
focusSerial = serial
@@ -528,11 +563,16 @@ export const TerminalTab: Component<Props> = (props) => {
window.removeEventListener("focus", onWindowFocus)
host.removeEventListener("focusin", onFocusIn)
host.removeEventListener("focusout", onFocusOut)
for (const event of ["input", "paste", "compositionend", "mousedown", "wheel"]) {
host.removeEventListener(event, markUser, true)
}
disposeKey.dispose()
fontSub()
themeObserver.disconnect()
clearTimeout(resizeTimer)
ro.disconnect()
disposeData.dispose()
disposeBinary.dispose()
disposeTitle.dispose()
try {
ws?.close()
@@ -0,0 +1,90 @@
interface ReplayGateDeps {
/** Write one output chunk to xterm, optionally observing parser completion. */
write(data: string | Uint8Array, callback?: () => void): void
/** Release input buffered before the initial PTY attachment. */
flush(): void
}
/** Keep terminal protocol replies ahead of user input without reordering the
* user's bytes when both arrive while initial replay is being parsed. */
export function createInputBuffer(limit = 256 * 1024) {
let input = ""
let replies = ""
const add = (data: string, reply = false) => {
if (reply) {
replies += data
if (replies.length > limit) replies = replies.slice(-limit)
return
}
input += data
if (input.length > limit) input = input.slice(-limit)
}
const take = () => {
const data = replies + input
replies = ""
input = ""
return data
}
return { add, take }
}
/**
* Gate initial user input on the PTY replay boundary. The backend sends a
* binary 0x00 metadata frame after retained output; waiting for xterm to parse
* everything queued before that frame keeps shell capability replies ahead of
* the command the user typed while the PTY was starting.
*
* Reconnects keep their existing output-settle timer instead. Their buffered
* input belongs to an exited shell recovery flow, not the initial attachment.
*/
export function createReplayGate(deps: ReplayGateDeps) {
let blocked = false
let boundary = false
let draining = false
let serial = 0
let pending: Array<string | Uint8Array> = []
const attach = (reconnecting: boolean) => {
serial++
blocked = !reconnecting
boundary = false
draining = false
pending = []
}
const output = (data: string | Uint8Array) => {
if (blocked && !boundary) {
pending.push(data)
return
}
deps.write(data)
}
const frame = (data: Uint8Array) => {
if (data.length === 0 || data[0] !== 0x00) return false
if (blocked && !boundary) {
boundary = true
// Match OpenCode's transport ordering: once the server says replay is
// complete, xterm-generated replies from parsing those queued chunks
// must precede the command typed while the PTY was starting. Keep user
// input blocked until the parser-drain callback below; TerminalTab puts
// parser-generated replies in its separate priority buffer meanwhile.
draining = true
const current = serial
for (const chunk of pending) deps.write(chunk)
pending = []
deps.write("", () => {
if (serial !== current) return
draining = false
blocked = false
deps.flush()
})
}
return true
}
return { attach, blocked: () => blocked, draining: () => draining, frame, output }
}
@@ -64,12 +64,13 @@ export interface TerminalFocusRequest {
* Multiple creates can be in flight for the same context at once. */
interface SideRequest {
contextKey: string
focus: boolean
}
export interface TerminalStateControls {
/** Record received from `terminal.created`. */
/** Add a terminal record to one context. */
add(worktreeId: string | null, term: TerminalTabState): void
/** Fill an optimistic terminal record without replacing its xterm-owning object. */
attach(terminalId: string, input: Pick<TerminalTabState, "title" | "wsUrl" | "font">): boolean
/** Drop a terminal from its context (location resolved automatically).
* Returns the removed record so callers can react to placement. */
remove(terminalId: string): TerminalTabStateWithContext | undefined
@@ -145,7 +146,7 @@ export interface TerminalStateControls {
/** Request ids of the in-flight side-terminal creates for a context. */
pendingSide(contextKey: string): boolean
/** Mark a side-terminal create as in flight for a context. */
beginSide(contextKey: string, createId: string, focus?: boolean): void
beginSide(contextKey: string, createId: string): void
/** Settle a create request; returns it so the caller can validate. */
completeSide(createId: string): SideRequest | undefined
}
@@ -299,6 +300,19 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
})
}
const attach = (terminalId: string, input: Pick<TerminalTabState, "title" | "wsUrl" | "font">) => {
const key = contextFor(terminalId)
const term = terminalsByContext()[key ?? ""]?.find((item) => item.id === terminalId)
if (!term) return false
// Keep the record reference stable so Solid's <For> never remounts the
// xterm that already owns focus and buffered input.
term.title = input.title
term.wsUrl = input.wsUrl
term.font = input.font
setTitle(terminalId, input.title)
return true
}
const remove = (terminalId: string): TerminalTabStateWithContext | undefined => {
const key = contextFor(terminalId)
if (!key) return undefined
@@ -528,8 +542,8 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0
const beginSide = (key: string, createId: string, focus = false) => {
requests.set(createId, { contextKey: key, focus })
const beginSide = (key: string, createId: string) => {
requests.set(createId, { contextKey: key })
setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] }))
}
@@ -550,6 +564,7 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
return {
add,
attach,
remove,
contextFor,
isScript,
@@ -604,12 +619,12 @@ export interface TerminalHandlerDeps {
/** Sentinel value for the LOCAL sidebar selection. */
LOCAL: string
REVIEW_TAB_ID: string
getFont: () => TerminalFont
}
/** Correlation ids for terminal create requests. */
function newId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID()
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
return `${TERMINAL_PREFIX}${crypto.randomUUID()}`
}
/**
@@ -644,7 +659,16 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
// project-namespaced state key and must not leak into the message.
const sel = deps.getSelection()
const id = newId()
deps.state.beginSide(key, id, focus)
deps.state.beginSide(key, id)
deps.state.add(key === deps.LOCAL ? null : key, {
id,
title: "Terminal",
wsUrl: "",
font: deps.getFont(),
placement: "side",
})
deps.state.setSideActive(key, id)
if (focus) deps.state.requestFocus(id)
deps.postMessage({
type: "agentManager.terminal.create",
createId: id,
@@ -744,6 +768,7 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
return true
}
deps.state.completeSide(terminalId)
deps.state.remove(terminalId)
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
return true
@@ -827,8 +852,6 @@ export interface TerminalMessageHandlerDeps {
* than wherever `tabIds()`'s base composition happens to put it.
*/
onCreated?: (contextKey: string, terminalId: string) => void
/** Side terminal for a context finished creating. */
onSideCreated?: (contextKey: string, terminalId: string, focus: boolean) => void
/** Side terminal create failed for a context. */
onSideError?: (contextKey: string) => void
/** Side terminal was closed (locally or by the extension). */
@@ -860,14 +883,16 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
// reloaded (or the context is gone) — close the PTY again instead
// of leaking it.
const request = deps.state.completeSide(msg.createId)
if (!request || request.contextKey !== key) {
if (!request || request.contextKey !== key || msg.terminalId !== msg.createId) {
deps.state.remove(msg.createId)
deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId })
return
}
deps.state.add(key === LOCAL ? null : key, term)
// The newest terminal becomes the visible one in its panel.
deps.state.setSideActive(key, msg.terminalId)
deps.onSideCreated?.(key, msg.terminalId, request.focus)
// The user may have closed the optimistic terminal while the PTY was
// starting. The request was completed above, but its record is gone.
if (!deps.state.attach(msg.terminalId, term)) {
deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId })
}
return
}
deps.state.add(key === LOCAL ? null : key, term)
@@ -907,7 +932,9 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) {
return true
}
if (msg.type === "agentManager.terminal.error") {
const context = msg.createId ? deps.state.contextFor(msg.createId) : undefined
const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined
if (msg.createId && context) deps.state.remove(msg.createId)
if (request) deps.onSideError?.(request.contextKey)
deps.showError(msg.message)
return true
@@ -128,6 +128,12 @@ export function PromptRail(props: PromptRailProps) {
return items()[entry.type === "overflow" ? entry.index : 0]
}
// Dragging the pane splitter, or selecting transcript text, sweeps the pointer
// across the rail with a button held. The splitter tracks the drag on the
// document, so those moves still reach the ticks; treating them as hover would
// pop the navigator open in the middle of a resize.
const dragging = (event: MouseEvent) => event.buttons !== 0
const openCard = (index: number) => {
cancelClose()
const entry = entries()[index]
@@ -299,7 +305,10 @@ export function PromptRail(props: PromptRailProps) {
data-queued={(entry.type === "prompt" && entry.item.queued) || undefined}
aria-label={entryLabel(entry)}
tabIndex={index() === (focused() ?? 0) ? 0 : -1}
onMouseEnter={() => openCard(index())}
onMouseEnter={(event) => {
if (dragging(event)) return
openCard(index())
}}
onFocus={() => openCard(index())}
onClick={() => {
if (entry.type === "prompt") props.onSelect(entry.item)
@@ -39,6 +39,8 @@ export interface ModeSwitcherBaseProps {
portal?: boolean
/** Delay outside dismissal while the popover opens inside a dialog. */
deferDismiss?: boolean
/** Only respond to picker events from this prompt scope. */
trigger?: string
}
export const ModeSwitcherBase: Component<ModeSwitcherBaseProps> = (props) => {
@@ -51,7 +53,9 @@ export const ModeSwitcherBase: Component<ModeSwitcherBaseProps> = (props) => {
let slash = false
// Listen for slash command trigger
const onTrigger = () => {
const onTrigger = (event: Event) => {
const source = (event as CustomEvent<{ source?: string }>).detail?.source
if (source !== props.trigger) return
slash = true
openSelected()
}
@@ -134,6 +134,8 @@ export interface ModelSelectorBaseProps {
label?: string
/** Additional accessible context for this model setting. */
description?: string
/** Only respond to picker events from this prompt scope. */
trigger?: string
}
export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
@@ -512,7 +514,11 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
// Register before the popover mounts so programmatic slash-command opens
// always restore the prompt before the popover's own Escape handler runs.
const onTrigger = () => setOpen(true)
const onTrigger = (event: Event) => {
const source = (event as CustomEvent<{ source?: string }>).detail?.source
if (source !== props.trigger) return
setOpen(true)
}
const onEscape = (e: KeyboardEvent) => {
if (!open() || e.key !== "Escape") return
e.preventDefault()
@@ -41,6 +41,8 @@ export interface ThinkingSelectorBaseProps {
deferDismiss?: boolean
/** Listen for the global prompt trigger event. Defaults to true. */
globalTrigger?: boolean
/** Only respond to picker events from this prompt scope. */
trigger?: string
/** Show the Shift+Tab cycle hint in the trigger tooltip. */
cycleHint?: boolean
/** Accessible name for the selector trigger. */
@@ -85,7 +87,9 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props
refocus()
}
const onTrigger = () => {
const onTrigger = (event: Event) => {
const source = (event as CustomEvent<{ source?: string }>).detail?.source
if (source !== props.trigger) return
if (rows().length === 0) return
onOpen(true)
}
@@ -56,11 +56,16 @@ export function useSlashCommand(
vscode: VSCodeContext,
sandbox: { action: () => void; enabled: Accessor<boolean> },
exclude?: Set<string> | Accessor<Set<string>>,
include?: Set<string> | Accessor<Set<string>>,
scope?: string,
): SlashCommand {
const [server, setServer] = createSignal<SlashCommandInfo[]>([])
const [query, setQuery] = createSignal<string | null>(null)
const [index, setIndex] = createSignal(0)
const [requested, setRequested] = createSignal(false)
const open = (name: string) => {
window.dispatchEvent(new CustomEvent(name, { detail: { source: scope } }))
}
const all: SlashCommandEntry[] = [
{
@@ -83,9 +88,9 @@ export function useSlashCommand(
{
name: "models",
description: "Switch the AI model",
hints: [],
hints: ["model"],
action: () => {
window.dispatchEvent(new CustomEvent("openModelPicker"))
open("openModelPicker")
},
},
{
@@ -93,7 +98,7 @@ export function useSlashCommand(
description: "Switch the agent mode",
hints: ["modes"],
action: () => {
window.dispatchEvent(new CustomEvent("openModePicker"))
open("openModePicker")
},
},
{
@@ -101,7 +106,7 @@ export function useSlashCommand(
description: "Switch the reasoning effort",
hints: ["variants", "reasoning", "thinking"],
action: () => {
window.dispatchEvent(new CustomEvent("openVariantPicker"))
open("openVariantPicker")
},
},
{
@@ -192,17 +197,23 @@ export function useSlashCommand(
return exclude
}
const included = () => {
if (typeof include === "function") return include()
return include
}
const client = () => {
const set = excluded()
if (!set) return all
return all.filter((c) => !set.has(c.name))
const only = included()
return all.filter((c) => !set?.has(c.name) && (!only || only.has(c.name)))
}
const commands = (): SlashCommandEntry[] => {
const list = client()
const names = new Set(list.map((c) => c.name))
const set = excluded()
const filtered = server().filter((c) => !names.has(c.name) && !set?.has(c.name))
const only = included()
const filtered = server().filter((c) => !names.has(c.name) && !set?.has(c.name) && (!only || only.has(c.name)))
return [...list, ...filtered]
}
@@ -4,15 +4,21 @@
.prompt-rail {
--prompt-rail-width: 16px;
--prompt-rail-gap: 8px;
--prompt-rail-step: 14px;
--prompt-rail-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Clears the pane splitter. Agent Manager's sidebar resize handle is 8px wide
and straddles the boundary, so it reaches 4px into the transcript; without
this gutter, aiming slightly wide of the splitter lands on a tick instead of
starting a drag. */
--prompt-rail-edge: 8px;
position: absolute;
inset-inline-start: max(
0px,
calc(50% - var(--chat-readable-width) / 2 - var(--prompt-rail-width) - var(--prompt-rail-gap))
);
/* Against the panel edge rather than tracking the readable lane: on a wide
editor tab the lane is centered, so a lane-hugging rail would sit right
next to the prose the pointer travels over and open on the way past. The
far edge is somewhere the pointer only goes deliberately. */
inset-inline-start: var(--prompt-rail-edge);
top: 0;
bottom: 0;
width: var(--prompt-rail-width);
@@ -51,15 +57,21 @@
outline: none;
}
/* Ticks grow by transform, not by width and height: a rail can hold a hundred
of them, and a compositor-only transition keeps the hover response smooth
instead of laying out the line on every frame. The box is the full 16px x 2px
target and the resting scale draws it as the 9px x 1.5px hairline. */
.prompt-rail-tick-line {
display: block;
height: 1.5px;
width: 9px;
height: 2px;
width: var(--prompt-rail-width);
border-radius: 1px;
background: var(--icon-base);
opacity: 0.7;
transform: scale(0.5625, 0.75);
transform-origin: left center;
transition:
width 0.24s var(--prompt-rail-ease),
transform 0.24s var(--prompt-rail-ease),
opacity 0.2s var(--prompt-rail-ease),
background-color 0.2s var(--prompt-rail-ease),
box-shadow 0.2s var(--prompt-rail-ease);
@@ -70,8 +82,18 @@
opacity: 0.5;
}
/* The summary tick keeps a real width: scaling would squash its dash pattern
along with it. At most a couple of these exist, so the width transition is
free here. */
.prompt-rail-tick--overflow .prompt-rail-tick-line {
width: 6px;
transform: scale(1, 0.75);
transition:
width 0.24s var(--prompt-rail-ease),
transform 0.24s var(--prompt-rail-ease),
opacity 0.2s var(--prompt-rail-ease),
background-color 0.2s var(--prompt-rail-ease),
box-shadow 0.2s var(--prompt-rail-ease);
background: repeating-linear-gradient(
to right,
var(--icon-weaker) 0,
@@ -83,23 +105,32 @@
/* Scroll position: subtle, always-on cue. */
.prompt-rail-tick--active .prompt-rail-tick-line {
width: 13px;
transform: scale(0.8125, 0.75);
opacity: 1;
background: var(--icon-strong-base);
}
.prompt-rail-tick--overflow.prompt-rail-tick--active .prompt-rail-tick-line {
width: 13px;
transform: scale(1, 0.75);
}
/* Pointer or keyboard target: the loud state, full width. */
.prompt-rail-tick:hover .prompt-rail-tick-line,
.prompt-rail-tick--open .prompt-rail-tick-line {
width: 100%;
height: 2px;
transform: none;
opacity: 1;
background: var(--icon-strong-base);
}
.prompt-rail-tick--overflow:hover .prompt-rail-tick-line,
.prompt-rail-tick--overflow.prompt-rail-tick--open .prompt-rail-tick-line,
.prompt-rail-tick--overflow:focus-visible .prompt-rail-tick-line {
width: var(--prompt-rail-width);
}
.prompt-rail-tick:focus-visible .prompt-rail-tick-line {
width: 100%;
height: 2px;
transform: none;
opacity: 1;
background: var(--icon-strong-base);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder) 50%, transparent);
@@ -745,6 +745,7 @@ export interface AgentManagerStateMessage {
/** Last selected sidebar target for seamless project-switch restore. */
activeTarget?: AgentManagerSidebarTarget
terminalDestination?: TerminalDestination
terminalFont?: TerminalFont
}
// A registered Agent Manager project as shown in the sidebar
@@ -784,9 +785,9 @@ export interface AgentManagerProjectSessionsMessage {
export interface AgentManagerTerminalCreatedMessage {
type: "agentManager.terminal.created"
/** Correlates with the create request; lets the webview spot stale
* creates. Deliberately not named `requestId`: that field name is the
* generic webview request/response correlation channel. */
/** Logical terminal id selected by the webview before PTY startup.
* Deliberately not named `requestId`: that field name is the generic
* webview request/response correlation channel. */
createId: string
placement: TerminalPlacement
/** null for LOCAL, worktree id otherwise */
@@ -801,7 +801,7 @@ export interface ShowExistingLocalTerminalRequest {
// Create a new xterm terminal in the given worktree context (null = workspace root)
export interface AgentManagerTerminalCreateRequest {
type: "agentManager.terminal.create"
/** Webview-generated correlation id, echoed back in created/error. */
/** Webview-generated logical terminal id, echoed back in created/error. */
createId: string
placement: TerminalPlacement
worktreeId: string | null
@@ -88,6 +88,7 @@ export namespace KilocodeConfigOverlay {
["model"],
["small_model"],
["hide_prompt_training_models"],
["privacy_mode"],
["default_agent"],
["snapshot"],
["share"],
@@ -0,0 +1,7 @@
import { isSqlError } from "effect/unstable/sql/SqlError"
export const busyMessage = "Database is busy. Please try again in a moment."
export function isBusy(error: unknown) {
return isSqlError(error) && error.reason._tag === "LockTimeoutError"
}
@@ -11,6 +11,8 @@ import { useRoute } from "@tui/context/route"
import { useDialog } from "@tui/ui/dialog"
import { useToast } from "@tui/ui/toast"
import { DialogAlert } from "@tui/ui/dialog-alert"
import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { reconcile } from "solid-js/store"
import type { Organization } from "@kilocode/kilo-gateway"
import type { ClawStatus } from "./claw/types.js"
import { DialogKiloTeamSelect } from "./components/dialog-kilo-team-select.js"
@@ -137,6 +139,15 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
hidden: !isKiloConnected(),
run: async () => {
try {
if (sync.data.globalConfig.privacy_mode === true) {
const confirmed = await DialogConfirm.show(
dialog,
"Privacy Mode Enabled",
"Privacy mode is on. Revealing your profile will display your email, name, balance, and team on screen.",
)
if (confirmed !== true) return
}
// Fetch profile and balance using server endpoint
const response = await sdk.client.kilo.profile()
@@ -176,6 +187,39 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
]
: []),
// /privacy command
{
name: "kilo.privacy",
get title() {
return sync.data.globalConfig.privacy_mode === true ? "Disable privacy mode" : "Enable privacy mode"
},
desc: "Blur PII (balance, email, etc.) and confirm before showing profile",
category: "Kilo",
slashName: "privacy",
run: async () => {
const next = sync.data.globalConfig.privacy_mode !== true
const response = await sdk.client.config.overlayUpdate({
scope: "global",
set: { privacy_mode: next },
})
if (response.error) {
const status = response.response?.status ?? "?"
toast.show({ message: `Failed to update privacy mode (${status})`, variant: "error" })
return
}
const [cfg, global] = await Promise.all([
sdk.client.config.get({}),
sdk.client.global.config.get({}),
])
if (cfg.data) sync.set("config", reconcile(cfg.data))
if (global.data) sync.set("globalConfig", reconcile(global.data))
toast.show({
message: next ? "Privacy mode enabled" : "Privacy mode disabled",
variant: "success",
})
},
},
// /teams command
{
name: "kilo.teams",
+1
View File
@@ -0,0 +1 @@
export const REDACTED_BALANCE = "•••"
@@ -5,6 +5,7 @@ import * as Log from "@opencode-ai/core/util/log"
import type { KiloPassState } from "@kilocode/kilo-gateway"
import type { Message } from "@kilocode/sdk/v2"
import { onBalanceRefresh } from "../balance-refresh"
import { REDACTED_BALANCE } from "../pii"
const id = "internal:kilo-sidebar-footer"
const TEAM_POLL_MS = 5 * 60_000
@@ -37,8 +38,9 @@ export function scope(org: string | null | undefined, list?: readonly { id: stri
}
}
export function creditLabel(value: ReturnType<typeof scope>) {
export function creditLabel(value: ReturnType<typeof scope>, masked = false) {
if (value.kind === "Personal") return "Personal credits"
if (masked) return "Team credits"
return value.name ? `${value.name} team` : "Team credits"
}
@@ -98,6 +100,9 @@ function View(props: { api: TuiPluginApi }) {
name: list.at(-1) ?? "",
}
})
const privacyMode = createMemo(() => props.api.state.globalConfig.privacy_mode === true)
const balanceText = createMemo(() => (privacyMode() ? REDACTED_BALANCE : null))
const mutedColor = createMemo(() => (privacyMode() ? theme().textMuted : tone()))
const refresh = () => {
const id = ++seq
// Cancel any prior request and time this one out — the client path has no fetch timeout,
@@ -167,19 +172,20 @@ function View(props: { api: TuiPluginApi }) {
{(() => {
const balance = data().balance
if (balance === undefined) return null
const masked = balanceText()
return (
<box flexDirection="row" justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={tone()}></text>
<text fg={mutedColor()}></text>
<text fg={theme().text}>
<b>{creditLabel(data().scope)}</b>
<b>{creditLabel(data().scope, privacyMode())}</b>
</text>
</box>
<text fg={tone()}>{format(balance)}</text>
<text fg={mutedColor()}>{masked ?? format(balance)}</text>
</box>
)
})()}
<Show when={data().scope.kind === "Personal" ? data().pass : null}>
<Show when={privacyMode() ? null : data().scope.kind === "Personal" ? data().pass : null}>
{(pass) => (
<box gap={0}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
+10 -1
View File
@@ -15,8 +15,17 @@ export namespace KilocodeWatcher {
export class Service extends Context.Service<Service, Interface>()("@kilocode/Watcher") {}
// Embedded editor clients (VS Code, JetBrains) have their own file watching
// and git integration and do not consume the CLI's vcs.branch.updated event,
// so they must not eagerly warm the location stack — that starts a native
// @parcel/watcher subscription per instance that lives for the whole session.
// On macOS FSEvents watches the entire subtree recursively (the ignore list
// is only a userspace filter), so an always-on, consumer-less watcher on a
// churny workspace burns CPU and leaks native memory while idle. The
// standalone CLI/TUI stays eager because its sidebar branch label is the only
// consumer and no request-driven route would otherwise build the stack.
export function eager(client = Flag.KILO_CLIENT) {
return client !== "vscode"
return client !== "vscode" && client !== "jetbrains"
}
export const layer = Layer.effect(
@@ -1,4 +1,5 @@
import { Image } from "@/image/image" // kilocode_change - classify user image validation defects
import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" // kilocode_change
import { KiloSessionHttpApi } from "@/kilocode/server/httpapi/session-fork" // kilocode_change
import { BlockedError as AgentRequirementError } from "@/kilocode/agent-requirements" // kilocode_change
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change
@@ -322,13 +323,21 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.void // kilocode_change - Stop is not an error
return Effect.gen(function* () {
yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
const error = Cause.squash(cause)
// kilocode_change start - keep SQLite lock failures out of local CLI logs
const busy = isBusy(error)
if (busy) {
yield* Effect.logWarning("prompt_async database busy", { sessionID: ctx.params.sessionID })
}
if (!busy) yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
// kilocode_change end
yield* events.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: AgentRequirementError.isInstance(error)
? error.toObject()
: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
: busy // kilocode_change
? new NamedError.Unknown({ message: busyMessage }).toObject() // kilocode_change
: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), // kilocode_change
})
})
}),
@@ -1,5 +1,6 @@
import { NamedError } from "@opencode-ai/core/util/error"
import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error"
import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" // kilocode_change
import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
@@ -16,6 +17,19 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
if (!defect) return Effect.failCause(cause)
const error = defect.defect
// kilocode_change start - SQLite lock contention is expected with multiple local clients
if (isBusy(error)) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logWarning("database busy", { ref }).pipe(
Effect.as(
HttpServerResponse.jsonUnsafe(
new NamedError.Unknown({ message: busyMessage, ref }).toObject(),
{ status: 503 },
),
),
)
}
// kilocode_change end
if (
ConfigErrorV1.JsonError.isInstance(error) ||
ConfigErrorV1.InvalidError.isInstance(error) ||
@@ -97,6 +97,7 @@ type Opts = {
state?: {
ready?: HostPluginApi["state"]["ready"]
config?: HostPluginApi["state"]["config"]
globalConfig?: HostPluginApi["state"]["globalConfig"] // kilocode_change
provider?: HostPluginApi["state"]["provider"]
path?: HostPluginApi["state"]["path"]
vcs?: HostPluginApi["state"]["vcs"]
@@ -303,6 +304,11 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
get config() {
return opts.state?.config ?? {}
},
// kilocode_change start
get globalConfig() {
return opts.state?.globalConfig ?? {}
},
// kilocode_change end
get provider() {
return opts.state?.provider ?? []
},
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test"
import { LockTimeoutError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error"
describe("SQLite errors", () => {
test("recognizes lock timeouts as busy database errors", () => {
const error = new SqlError({
reason: new LockTimeoutError({
cause: new Error("database is locked"),
message: "Failed to execute statement",
operation: "execute",
}),
})
expect(isBusy(error)).toBe(true)
expect(busyMessage).toBe("Database is busy. Please try again in a moment.")
})
test("does not classify other SQLite errors as busy", () => {
const error = new SqlError({
reason: new UnknownError({
cause: new Error("constraint failed"),
message: "Failed to execute statement",
operation: "execute",
}),
})
expect(isBusy(error)).toBe(false)
})
})
@@ -34,6 +34,10 @@ describe("KilocodeWatcher.eager", () => {
expect(KilocodeWatcher.eager("vscode")).toBe(false)
})
test("skips eager location watchers for JetBrains", () => {
expect(KilocodeWatcher.eager("jetbrains")).toBe(false)
})
test("keeps eager location watchers for the standalone CLI", () => {
expect(KilocodeWatcher.eager("cli")).toBe(true)
expect(KilocodeWatcher.eager(undefined)).toBe(true)
+1
View File
@@ -376,6 +376,7 @@ export type TuiKV = {
export type TuiState = {
readonly ready: boolean
readonly config: SdkConfig
readonly globalConfig: SdkConfig
readonly provider: ReadonlyArray<Provider>
readonly path: {
state: string
+1
View File
@@ -2555,6 +2555,7 @@ export type Config = {
terminal_command_display?: "expanded" | "collapsed"
code_edit_display?: "expanded" | "collapsed"
hide_prompt_training_models?: boolean
privacy_mode?: boolean
/**
* Sandbox configuration for agent tools
*/
+3
View File
@@ -33415,6 +33415,9 @@
"hide_prompt_training_models": {
"type": "boolean"
},
"privacy_mode": {
"type": "boolean"
},
"sandbox": {
"type": "object",
"properties": {
+5
View File
@@ -107,6 +107,11 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
get config() {
return sync.data.config
},
// kilocode_change start
get globalConfig() {
return sync.data.globalConfig
},
// kilocode_change end
get provider() {
return sync.data.provider
},