mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge branch 'main' into rapid-empress
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Accept terminal input immediately while the Agent Manager shell starts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show file names before their containing folders in JetBrains `@file` suggestions.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent Agent Manager overview requests from timing out while refreshing Git statistics across many worktrees.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support model, agent, variant, and sandbox slash commands in Agent Manager worktree prompts.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+10
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -33415,6 +33415,9 @@
|
||||
"hide_prompt_training_models": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"privacy_mode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sandbox": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user