Merge remote-tracking branch 'origin/main' into optimize-message-submission-ux-transitions

# Conflicts:
#	packages/kilo-vscode/webview-ui/src/context/session-queue.ts
#	packages/kilo-vscode/webview-ui/src/context/session-utils.ts
#	packages/kilo-vscode/webview-ui/src/context/session.tsx
This commit is contained in:
marius-kilocode
2026-08-18 10:56:18 +02:00
154 changed files with 3774 additions and 1169 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Agent Manager project accordions that stayed on a loading indicator until you clicked into them. Expanded projects other than the active one now receive their worktrees, sessions, and git stats as soon as Agent Manager opens and again after the panel reloads. While a project is still loading, its local row and section headings stay in place and the rows below fade in as skeleton placeholders instead of showing a single centered spinner, so several projects loading at once look consistent.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep provider names out of the compact prompt model selector label while retaining them in the expanded picker.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Use NVIDIA Parakeet TDT 0.6B v3 as the default speech-to-text model while retaining dynamic model discovery and offline fallback support.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-ui": patch
---
Keep chat pinned to the latest streaming output through layout reflows and downward scrolling.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Restore incremental assistant text streaming across Kilo chat surfaces.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep file mention suggestions current and scoped to the active workspace while preserving instant cached results.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Use the full Codex context window for GPT-5.6 models authenticated through ChatGPT OAuth.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support setting default open or collapsed state for MCP and generic tool blocks in Display settings
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Optimize Agent Manager tab switching and context transition latency
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Optimize large session loading performance and reduce reactive re-render overhead in the webview.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Optimize TaskHeader and timeline bar calculation performance in the webview.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Switch to the code model when starting implementation after a planning session.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve cached reasoning variants when starting new VS Code sessions.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Restore live Git change statistics on the Agent Manager diff toggle for local and worktree selections.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prompt for explicit, one-shot approval before mutating Git commands run outside the sandbox.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix agent mode switch getting stuck on draft/cloud-import sessions: apply the explicitly selected agent instead of reverting to the stale session agent.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show the model provider next to every model in the model selector.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep queued prompts visible while the server confirms and loads their message parts.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show token throughput by default, with a Display setting to hide it when needed.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Open delegated subagent sessions in Agent Manager inspector tabs alongside terminals.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Open `/sessions` on a git worktree with the Worktree history filter selected by default.
+4
View File
@@ -129,6 +129,10 @@ export const Info = Schema.Struct({
description:
"Controls whether code edit and diff blocks are expanded or collapsed by default in the VS Code chat UI",
}),
mcp_tool_display: Schema.optional(Schema.Literals(["expanded", "collapsed"])).annotate({
description:
"Controls whether MCP and generic tool blocks are expanded or collapsed by default in the VS Code chat UI",
}),
hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({
description: "Hide Kilo Gateway models that may train on your prompts from model listings",
}),
+2
View File
@@ -57,6 +57,8 @@ exclude = [
'^https?://aistudio\.google\.com/api-keys/?$',
# Linear intermittently returns 503 to CI link checks.
'^https?://linear\.app/?$',
# These VS Code Marketplace pages intermittently return 503 to automated link checks.
'^https://marketplace\.visualstudio\.com/items\?itemName=(connor4312\.esbuild-problem-matchers|dbaeumer\.vscode-eslint|esbenp\.prettier-vscode|Requesty\.requesty)$',
# Consistently times out in CI
'^https?://opncd\.ai',
'^https?://zod\.dev/v4/changelog',
@@ -39,7 +39,6 @@ The GitHub App requests the following permissions:
- **Review Style** — Strict, Balanced, or Lenient
- **Repository Selection** — All repositories or select specific ones
- **Focus Areas** — Security, performance, bugs, style, testing, documentation
- **Max Review Time** — 5 to 30 minutes
- **Use REVIEW.md** — Load repository-specific review guidance, including sub-agent usage, from the base branch
4. Click **Save Configuration**
@@ -93,7 +92,7 @@ The repository list is synced from GitHub and can be refreshed from the configur
- Check the Code Reviews page for error details on specific reviews
- Ensure you have sufficient Kilo Code credits
- Very large PRs may time out — try increasing the max review time
- Very large PRs may time out — consider splitting the change into smaller PRs
### The GitHub App is missing permissions
@@ -36,7 +36,6 @@ Once connected, return here to configure the Review Agent.
- **Review Style** — Strict, Balanced, or Lenient
- **Repository Selection** — All repositories or select specific ones
- **Focus Areas** — Security, performance, bugs, style, testing, documentation
- **Max Review Time** — 5 to 30 minutes
- **Use REVIEW.md** — Load repository-specific review guidance, including sub-agent usage, from the base branch
4. Click **Save Configuration**
@@ -120,7 +119,7 @@ You need **Maintainer role** on the GitLab project. Both webhook creation and bo
- Check the Code Reviews page for error details
- Ensure you have sufficient Kilo Code credits
- Large MRs may time out — increase the max review time setting
- Large MRs may time out — consider splitting the change into smaller MRs
### No projects listed after connecting
@@ -45,8 +45,7 @@ Before enabling Code Reviews:
4. Select a **Review Style** — Strict, Balanced, or Lenient.
5. Choose which **repositories** should receive automatic reviews.
6. Optionally select **Focus Areas** such as security, performance, bugs, style, testing, or documentation.
7. Set a **maximum review time** (530 minutes).
8. Optionally enable **Use REVIEW.md** and add a `REVIEW.md` file at the repository root to shape how the agent reviews your code.
7. Optionally enable **Use REVIEW.md** and add a `REVIEW.md` file at the repository root to shape how the agent reviews your code.
Once configured, the Review Agent runs automatically on PR/MR events. For platform-specific setup, see:
@@ -154,8 +153,7 @@ When a pull request or merge request is opened or updated:
- Summary findings
- Suggested fixes
- Risk and severity tagging
5. Reviews respect the **maximum time limit** you set.
6. Only repositories youve selected will trigger automatic analysis.
5. Only repositories youve selected will trigger automatic analysis.
Reviews are posted directly in your platform (GitHub or GitLab) as if coming from a team reviewer.
@@ -235,7 +233,7 @@ The Review Agent is ideal for:
## Limitations and Guidance
- Reviews can run for **up to 30 minutes** depending on your setting.
- Reviews are subject to a fixed time limit — consider splitting very large PRs into smaller ones.
- The agent reviews **only the changed files**, not the entire repository.
- Some highly dynamic or domain-specific code may require additional context in `REVIEW.md`.
- The agent will only run on **selected repositories**.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b197d749f21df8d3061b93d13281a346b52514bec1b6855b474a03cfaeeb117b
size 2500
oid sha256:4b2de227517c16c9aa90af0245da92f8e532ad29e21de2142e50121dbebf55fc
size 1574
@@ -360,15 +360,15 @@ function renderable(part: PartType, showReasoningSummaries = true) {
return !!PART_MAPPING[part.type]
}
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash") return shell
if (tool === "edit" || tool === "write") return edit
if (tool === "apply_patch") return edit
function toolDefaultOpen(tool: string, shell = false, edit = false, mcp?: boolean) {
if (tool === "bash" || tool === "background_process") return shell
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
if (mcp !== undefined && !ToolRegistry.render(tool)) return mcp
}
function partDefaultOpen(part: PartType, shell = false, edit = false) {
function partDefaultOpen(part: PartType, shell = false, edit = false, mcp?: boolean) {
if (part.type !== "tool") return
return toolDefaultOpen(part.tool, shell, edit)
return toolDefaultOpen(part.tool, shell, edit, mcp)
}
function PartGrow(props: {
@@ -423,6 +423,7 @@ export function AssistantParts(props: {
reasoningAutoCollapse?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
mcpToolDefaultOpen?: boolean
animate?: boolean
}) {
const data = useData()
@@ -683,6 +684,7 @@ export function AssistantParts(props: {
entry().part,
props.shellToolDefaultOpen,
props.editToolDefaultOpen,
props.mcpToolDefaultOpen,
)}
reasoningAutoCollapse={props.reasoningAutoCollapse}
hideDetails={false}
@@ -203,6 +203,39 @@ describe("createAutoScroll non-scrollable layouts", () => {
ctx.dispose()
})
test("continues following streaming growth after a downward wheel at the bottom", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800
ctx.el.fire("wheel", new FakeWheelEvent(50, ctx.el) as unknown as Event)
ctx.el.scrollHeight = 1048
ctx.resize(0)
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(1048)
ctx.dispose()
})
test("continues following when streaming reflow emits scroll before resize", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800
ctx.el.scrollHeight = 1108
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
ctx.resize(0)
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(1108)
ctx.dispose()
})
test("follows when initially short content starts overflowing", () => {
const ctx = setup()
ctx.resize()
@@ -262,4 +295,23 @@ describe("createAutoScroll non-scrollable layouts", () => {
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
test("pauses when a native scrollbar drag changes scroll position without input events", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800
ctx.scroll.handleScroll()
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
ctx.el.scrollHeight = 1050
ctx.resize()
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
})
@@ -25,6 +25,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
let lastTop = 0
let lastHeight = 0
const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
@@ -99,8 +101,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
const handleScroll = () => {
if (!scroll) return
userActivity.consumeScroll()
const input = userActivity.consumeScroll()
const distance = distanceFromBottom(scroll)
const moved = Math.abs(scroll.scrollTop - lastTop) > 1 && Math.abs(scroll.scrollHeight - lastHeight) <= 1
lastTop = scroll.scrollTop
lastHeight = scroll.scrollHeight
if (!canScroll(scroll)) return
@@ -109,6 +114,10 @@ export function createAutoScroll(options: AutoScrollOptions) {
return
}
// Virtualizer and layout remeasurement can emit scroll before the
// ResizeObserver restores bottom-follow. Only user input should pause it.
if (!store.userScrolled && !input && !userActivity.isRecent() && !moved) return
stop()
}
@@ -200,6 +209,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (!el) return
lastTop = el.scrollTop
lastHeight = el.scrollHeight
updateOverflowAnchor(el)
cleanup = userActivity.listen(el)
}
@@ -26,8 +26,8 @@ export const createUserActivity = (options: UserActivityOptions) => {
const handleWheel = (event: WheelEvent) => {
if (!isPotentialScrollInput(event)) return
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
mark(event)
if (event.deltaY >= 0 || scroll.scrollTop <= 0) return
mark(event)
options.onWheelUp()
}
+1 -1
View File
@@ -1188,7 +1188,7 @@
},
"kilo-code.new.showTokenThroughput": {
"type": "boolean",
"default": false,
"default": true,
"description": "Show tokens-per-second (prompt-processing / text-generation) badges on assistant messages and the task header"
},
"kilo-code.new.showAutoApprovalReason": {
+64 -9
View File
@@ -379,6 +379,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private readonly openSessionIds = new Set<string>()
private modelUsageSessionIds: Set<string> = new Set()
private syncedChildSessions: Set<string> = new Set()
private readonly inspectorSessionIds = new Set<string>()
private readonly checkpoints = new Map<string, Promise<void>>()
private readonly sessionCreations = new Map<string, Promise<{ sid: string; dir: string } | undefined>>()
private readonly draftSessions = new Map<string, { sid: string; dir: string; expires: number }>()
@@ -1083,6 +1084,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (await this.handleModelSelectorExpandedMessage(message)) return
this.handleWebviewFocusMessage(message)
this.visibleTaskStreams.handle(message)
this.handleStreamVisibilityMessage(message)
if (this.handleChildSyncMessage(message)) return
if (await this.handleMemoryMessage(message)) return
if (this.handleLegacyMigrationMessage(message)) return
switch (message.type) {
@@ -1171,15 +1174,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// isn't blocked by slow responses for earlier sessions.
void this.handleLoadMessages(message.sessionID, {
mode: message.mode,
focus: message.focus,
before: message.before,
limit: message.limit,
})
break
case "syncSession":
this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) =>
console.error("[Kilo New] handleSyncSession failed:", e),
)
break
case "loadSessions":
this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e))
break
@@ -1572,6 +1571,33 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private handleChildSyncMessage(
message: TypedWebviewMessage & { sessionID?: unknown; parentSessionID?: unknown; scope?: unknown },
): boolean {
if (message.type !== "syncSession" && message.type !== "unsyncSession") return false
if (typeof message.sessionID !== "string") return true
if (message.type === "syncSession") {
if (message.scope === "inspector") this.inspectorSessionIds.add(message.sessionID)
const parent = typeof message.parentSessionID === "string" ? message.parentSessionID : undefined
this.handleSyncSession(message.sessionID, parent).catch((e) =>
console.error("[Kilo New] handleSyncSession failed:", e),
)
return true
}
if (message.scope === "inspector") this.inspectorSessionIds.delete(message.sessionID)
this.releaseChildSession(message.sessionID)
return true
}
private handleStreamVisibilityMessage(
message: TypedWebviewMessage & { sessionID?: unknown; visible?: unknown },
): void {
if (message.type !== "streamSessionVisible" || message.visible !== false || typeof message.sessionID !== "string") {
return
}
this.releaseChildSession(message.sessionID)
}
private handleEditorOpenMessage(message: Parameters<typeof handleEditorAction>[0]): boolean {
return handleEditorAction(message, {
// An explicit sessionID (e.g. from validateFiles) takes precedence over
@@ -1976,14 +2002,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleLoadMessages(
sessionID: string,
options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {},
options: {
mode?: MessageLoadMode
focus?: boolean
before?: string
limit?: number
preserveStream?: boolean
} = {},
): Promise<void> {
const mode = options.mode ?? "replace"
if (mode === "replace" || mode === "focus") {
this.stopCurrentSessionProcesses(sessionID)
this.trackedSessionIds.add(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
if (options.focus !== false) {
this.stopCurrentSessionProcesses(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
}
}
if (!this.client) {
this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID })
@@ -2119,6 +2153,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private releaseChildSession(sessionID: string): void {
if (
this.inspectorSessionIds.has(sessionID) ||
this.visibleTaskStreams.has(sessionID) ||
this.currentSession?.id === sessionID ||
this.openSessionIds.has(sessionID)
) {
return
}
if (!this.syncedChildSessions.delete(sessionID)) return
this.trackedSessionIds.delete(sessionID)
this.streams.drop(sessionID)
this.visibleTaskStreams.delete(sessionID)
this.sessionDirectories.delete(sessionID)
this.sessionGitDirectories.delete(sessionID)
this.sessionGitRecoveries.delete(sessionID)
this.connectionService.pruneSession(sessionID)
}
/**
* Build the context object used by the extracted session-refresh helpers.
*/
@@ -2252,6 +2305,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.streams.drop(sessionID)
this.visibleTaskStreams.delete(sessionID)
this.syncedChildSessions.delete(sessionID)
this.inspectorSessionIds.delete(sessionID)
this.sessionDirectories.delete(sessionID)
this.sessionGitDirectories.delete(sessionID)
this.sessionGitRecoveries.delete(sessionID)
@@ -5091,6 +5145,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.trackedSessionIds.clear()
this.openSessionIds.clear()
this.syncedChildSessions.clear()
this.inspectorSessionIds.clear()
this.draftSessions.clear()
this.sessionDirectories.clear()
this.anacondaDesktop.dispose()
@@ -69,6 +69,7 @@ import { PLATFORM } from "./constants"
import { ProjectRegistry } from "./project/registry"
import type { ProjectContext } from "./project/context"
import { ProjectContexts } from "./project/contexts"
import { hydrateExpanded } from "./project/hydrate"
import { createMultiVersion, type MultiVersionHost } from "./provider-multi-version"
import { handleProjectMessage, type ProjectMessageDeps } from "./project/messages"
import { createProjectWiring } from "./project/wiring"
@@ -1625,15 +1626,14 @@ export class AgentManagerProvider implements Disposable {
multiProject: this.host.multiProject(),
projects,
})
if (this.panel) {
for (const project of projects) {
if (project.active || !project.expanded || !project.trusted || project.missing) continue
if (this.contexts.get(project.id)) continue
const ctx = this.contexts.expand(project.id)
if (ctx) this.initExpanded(ctx)
}
}
if (this.panel)
hydrateExpanded(projects, {
expand: (id) => this.contexts.expand(id),
push: (ctx) => this.pushState(ctx),
init: (ctx) => this.initExpanded(ctx),
})
this.projectPollers.sync(this.contexts)
this.projectPollers.replay()
}
// Worktree file helpers
@@ -0,0 +1,37 @@
/**
* Background project hydration for the Agent Manager sidebar.
*
* An expanded project accordion renders entirely from a pushed
* agentManager.state payload, so a project without one stays on loading
* placeholders. For background (non-active) projects that payload is produced
* asynchronously after the context loads, which means it can be posted before
* the webview mounts and is then lost: the panel attaches and pushes the
* catalog first, and the webview only asks for the catalog again on mount.
*
* Re-pushing already loaded contexts on every catalog push closes that gap for
* both a freshly opened panel and a reloaded webview, instead of leaving the
* accordion on placeholders until the user clicks a row inside it.
*/
import type { ProjectContext } from "./context"
import type { ProjectSnapshot } from "./contexts"
interface Hooks {
/** Expand a project without activating it; undefined when not allowed. */
expand: (id: string) => ProjectContext | undefined
/** Re-post the state of an already loaded context. */
push: (ctx: ProjectContext) => void
/** Load a cold context and push its state once it is ready. */
init: (ctx: ProjectContext) => void
}
/** Ensure every expanded background project has current state in the webview. */
export function hydrateExpanded(projects: readonly ProjectSnapshot[], hooks: Hooks): void {
for (const project of projects) {
if (project.active || !project.expanded || !project.trusted || project.missing) continue
const ctx = hooks.expand(project.id)
if (!ctx) continue
if (ctx.lifecycle === "ready") hooks.push(ctx)
else hooks.init(ctx)
}
}
@@ -23,7 +23,10 @@ import type { WorktreeStateManager } from "../WorktreeStateManager"
export interface PollerPair {
stats: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void }
pr: { poller: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void } }
pr: {
poller: { setEnabled(enabled: boolean): void; setVisible(visible: boolean): void; stop(): void }
replay?(): void
}
}
export type StatsOutMessage =
@@ -82,12 +85,41 @@ function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair {
export class ProjectPollers {
private readonly pollers = new Map<string, PollerPair>()
private readonly cache = new Map<string, { worktrees?: StatsOutMessage; local?: StatsOutMessage }>()
constructor(
private readonly deps: PollerDeps,
private readonly create: (ctx: ProjectContext, deps: PollerDeps) => PollerPair = createPollerPair,
) {}
/**
* Record the latest stats per project on the way to the webview. A poller
* only emits when something changed, so a webview that mounts (or reloads)
* after an emit would otherwise keep its stats placeholders forever.
*/
private recording(id: string): PollerDeps {
return {
...this.deps,
post: (msg) => {
const entry = this.cache.get(id) ?? {}
if (msg.type === "agentManager.worktreeStats") entry.worktrees = msg
if (msg.type === "agentManager.localStats") entry.local = msg
this.cache.set(id, entry)
this.deps.post(msg)
},
}
}
/** Re-post the latest background project stats and PR statuses. */
replay(): void {
for (const [id, pair] of this.pollers) {
const entry = this.cache.get(id)
if (entry?.worktrees) this.deps.post(entry.worktrees)
if (entry?.local) this.deps.post(entry.local)
pair.pr.replay?.()
}
}
/**
* Reconcile pollers with the current expanded set: start pollers for
* expanded, trusted, non-active projects whose state is initialized, and
@@ -101,7 +133,7 @@ export class ProjectPollers {
if (!ctx?.peekState()) continue
wanted.add(snap.id)
if (this.pollers.has(snap.id)) continue
const pair = this.create(ctx, this.deps)
const pair = this.create(ctx, this.recording(snap.id))
pair.stats.setVisible(this.deps.visible())
pair.pr.poller.setVisible(this.deps.visible())
pair.stats.setEnabled(true)
@@ -113,6 +145,7 @@ export class ProjectPollers {
pair.stats.stop()
pair.pr.poller.stop()
this.pollers.delete(id)
this.cache.delete(id)
}
}
@@ -129,6 +162,7 @@ export class ProjectPollers {
pair.pr.poller.stop()
}
this.pollers.clear()
this.cache.clear()
}
}
@@ -817,6 +817,7 @@ interface LoadMessagesIn {
type: "loadMessages"
sessionID: string
mode?: "replace" | "prepend" | "focus"
focus?: boolean
before?: string
limit?: number
}
@@ -73,6 +73,7 @@ export function registerToggleAutoApprove(
const { data: pending } = await client.permission.list({ directory: dir }, { throwOnError: true })
for (const req of pending) {
if (generation !== snapshot) break
if (req.metadata?.["sandboxEscalation"] === true) continue
await client.permission
.reply({ requestID: req.id, directory: dir, reply: "once" }, { throwOnError: true })
.catch((err) => {
@@ -91,6 +92,7 @@ export function registerToggleAutoApprove(
if (!active) return false
const client = tryGetClient(connectionService)
if (!client) return false
if (event.properties.metadata?.["sandboxEscalation"] === true) return false
const dir =
directory ?? connectionService.getPermissionDirectory(event.properties.id) ?? resolve(event.properties.sessionID)
return client.permission
@@ -2,7 +2,7 @@ import * as path from "path"
import * as vscode from "vscode"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { mergeFileSearchResults } from "./file-search-results"
import { mergeFileSearchItems } from "./file-search-items"
import { mergeFileSearchItems, type FileSearchItem } from "./file-search-items"
type Message = {
query: string
@@ -20,6 +20,35 @@ type Input = {
post: (message: unknown) => void
}
async function fetchBackend(client: KiloClient, dir: string, query: string): Promise<[string[], string[]]> {
if (!client?.find?.files) return [[], []]
const [fileRes, folderRes] = await Promise.allSettled([
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
])
return [settled(fileRes, "file"), settled(folderRes, "folder")]
}
function assemble(
query: string,
dir: string,
files: string[],
folders: string[],
open: Set<string>,
): { paths: string[]; items: FileSearchItem[] } {
const uri = vscode.window.activeTextEditor?.document.uri
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
const paths = mergeFileSearchResults({ query, backend: files, open, active })
const items = mergeFileSearchItems({
query,
files: paths,
folders,
open: new Set(active ? [active, ...open] : open),
})
return { paths, items }
}
export async function handleFileSearch(input: Input): Promise<void> {
const client = input.client
if (!client) {
@@ -29,27 +58,11 @@ export async function handleFileSearch(input: Input): Promise<void> {
const id = input.message.sessionID ?? input.current ?? input.context
const dir = input.dir(id)
const open = dir ? await input.open(dir) : new Set<string>()
const query = input.message.query
void Promise.allSettled([
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
]).then(([fileRes, folderRes]) => {
const files = settled(fileRes, "file")
const folders = settled(folderRes, "folder")
const uri = vscode.window.activeTextEditor?.document.uri
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
const result = mergeFileSearchResults({ query, backend: files, open, active })
const items = mergeFileSearchItems({
query,
files: result,
folders,
open: new Set(active ? [active, ...open] : open),
})
input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId })
})
const [files, folders] = await fetchBackend(client, dir, query)
const open = dir ? await input.open(dir) : new Set<string>()
const { paths, items } = assemble(query, dir, files, folders, open)
input.post({ type: "fileSearchResult", paths, items, dir, requestId: input.message.requestId })
}
function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] {
@@ -218,6 +218,7 @@ const slimmers: Record<string, (state: Record<string, unknown>) => Record<string
multiedit: slimMultiedit,
write: slimWrite,
bash: slimBash,
task: slimOutput,
}
/** Strip provider metadata that the webview never reads from reasoning parts. */
@@ -6,7 +6,7 @@ export function buildThroughputSettingMessage() {
const config = vscode.workspace.getConfiguration("kilo-code.new")
return {
type: "throughputSettingLoaded" as const,
visible: config.get<boolean>("showTokenThroughput", false),
visible: config.get<boolean>("showTokenThroughput", true),
}
}
@@ -25,6 +25,10 @@ export class VisibleTaskStreams {
this.refs.delete(id)
}
has(id: string): boolean {
return this.refs.has(id)
}
setActive(active: boolean): void {
if (this.active === active) return
this.active = active
@@ -6,6 +6,11 @@ export interface SpeechToTextModelDef {
}
const models: SpeechToTextModelDef[] = [
{
id: "nvidia/parakeet-tdt-0.6b-v3",
label: "Parakeet TDT 0.6B v3",
provider: "NVIDIA",
},
{
id: "openai/whisper-large-v3-turbo",
label: "Whisper Large V3 Turbo",
@@ -38,11 +43,6 @@ const models: SpeechToTextModelDef[] = [
label: "Chirp 3",
provider: "Google",
},
{
id: "nvidia/parakeet-tdt-0.6b-v3",
label: "Parakeet TDT 0.6B v3",
provider: "NVIDIA",
},
]
export const SPEECH_TO_TEXT_MODELS: readonly SpeechToTextModelDef[] = models
@@ -105,6 +105,9 @@ test.describe("history session accessibility", () => {
const local = page.getByRole("tab", { name: "Local" })
const worktree = page.getByRole("tab", { name: "Worktree" })
await expect(worktree).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("tabpanel", { name: "Worktree" })).toBeVisible()
await local.focus()
await page.keyboard.press("End")
await expect(worktree).toBeFocused()
@@ -26,6 +26,8 @@ test("model selector exposes combobox relationships and active option movement",
await expect(combobox).toHaveAttribute("aria-controls", await tree.getAttribute("id"))
await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id"))
await expect(combobox).toHaveAccessibleDescription("Choose the model used for code review tasks.")
await expect(alpha.locator(".model-selector-item-provider-tag")).toHaveText("Kilo")
await expect(bravo.locator(".model-selector-item-provider-tag")).toHaveText("Kilo")
await expect(alpha.locator("button")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Add to favorites: Alpha" })).toBeVisible()
await expect(page.locator(".model-selector-group-label").nth(0)).toContainText("Auto Models")
@@ -114,7 +116,7 @@ test("provider groups collapse, expand, and skip their model rows", async ({ pag
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
const kilo = page.getByRole("treeitem", { name: "Kilo", exact: true })
const nvidia = page.getByRole("treeitem", { name: "NVIDIA" })
const nvidia = page.getByRole("treeitem", { name: "NVIDIA", exact: true })
await combobox.press("ArrowDown")
await combobox.press("ArrowLeft")
@@ -202,8 +204,8 @@ test("selected favorite remains selected when its duplicate group is collapsed",
test("large catalogs keep the rendered tree bounded and navigate to distant models", async ({ page }) => {
await load(page, "shared--model-selector-large-catalog")
await page.getByRole("button", { name: "Select model: Provider 0 / Model 300" }).click()
const combobox = page.getByRole("combobox", { name: "Select model: Provider 0 / Model 300. Search models" })
await page.getByRole("button", { name: "Select model: Model 300" }).click()
const combobox = page.getByRole("combobox", { name: "Select model: Model 300. Search models" })
const tree = page.getByRole("tree", { name: "Select model" })
// The window mounts before we measure it, yet stays far smaller than the catalog.
@@ -21,6 +21,7 @@ const CSS_FILES = [
]
const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SubagentPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"),
path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
@@ -49,7 +50,10 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/ProjectList.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectActions.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"),
path.join(ROOT, "webview-ui/agent-manager/Skeleton.tsx"),
path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ClosableTab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/InspectorTabStrip.tsx"),
path.join(ROOT, "webview-ui/agent-manager/ProjectBranchDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DefaultBaseBranchDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/tab-rendering.tsx"),
@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test"
import fs from "node:fs"
import path from "node:path"
const TAB_BAR = path.resolve(import.meta.dir, "../../webview-ui/agent-manager/TabBar.tsx")
describe("Agent Manager diff toggle", () => {
it("renders live Git stats rather than pull-request stats", () => {
const source = fs.readFileSync(TAB_BAR, "utf-8")
const start = source.indexOf('title={props.t("agentManager.diff.toggle")}')
const end = source.indexOf("</TooltipKeybind>", start)
const button = source.slice(start, end)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
expect(button).toContain("<Show when={hasChanges()}>")
expect(button).toContain("stats()!.files")
expect(button).toContain("stats()!.additions")
expect(button).toContain("stats()!.deletions")
expect(button).not.toContain("props.prStatus()")
expect(button).not.toContain("pr().additions")
expect(button).not.toContain("pr().deletions")
})
})
@@ -10,6 +10,7 @@ import {
const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8")
const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8")
const subagent = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/SubagentPanel.tsx"), "utf8")
const terminal = readFileSync(
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"),
"utf8",
@@ -25,13 +26,28 @@ test("xterm owns the padding used by FitAddon", () => {
expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/)
})
test("uses one persisted width for the diff and terminal inspector", () => {
test("uses one persisted width for every inspector panel", () => {
expect(app).toContain("persisted?.sidePanelWidth")
expect(app).toContain("createPanelResize(setPanelWidth")
expect(app).toContain("style={{ width: `${panelWidth()}px` }}")
expect(subagent).toContain("InspectorTabStrip")
expect(app).not.toContain("diffWidth")
expect(app).not.toContain("terminalWidth")
})
test("hides keyboard hints only in inspector tabs", () => {
const side = readFileSync(
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"),
"utf8",
)
expect(subagent).toContain("showKeybind={false}")
expect(side).toContain("showKeybind={false}")
expect(
readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/render.tsx"), "utf8"),
).not.toContain("showKeybind={false}")
})
test("limits inspector layout updates during resize", () => {
const frames: ((time: number) => void)[] = []
const widths: number[] = []
@@ -0,0 +1,116 @@
import { describe, expect, it } from "bun:test"
import { ProjectContext, type ProjectContextDeps } from "../../src/agent-manager/project/context"
import type { ProjectSnapshot } from "../../src/agent-manager/project/contexts"
import { hydrateExpanded } from "../../src/agent-manager/project/hydrate"
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
const deps: ProjectContextDeps = {
log: () => {},
state: () =>
({ getWorktrees: () => [], getSessions: () => [], flush: async () => {} }) as unknown as WorktreeStateManager,
}
function snapshot(id: string, over: Partial<ProjectSnapshot> = {}): ProjectSnapshot {
return {
id,
root: `/repo/${id}`,
label: id,
pinned: false,
active: false,
expanded: true,
initialized: false,
trusted: true,
missing: false,
...over,
}
}
/** Contexts keyed by id, created cold. */
function contexts(ids: string[]) {
const map = new Map(ids.map((id) => [id, new ProjectContext(id, `/repo/${id}`, false, deps)]))
return {
map,
hooks: () => {
const pushed: string[] = []
const inited: string[] = []
return {
pushed,
inited,
expand: (id: string) => map.get(id),
push: (ctx: ProjectContext) => pushed.push(ctx.id),
init: (ctx: ProjectContext) => inited.push(ctx.id),
}
},
}
}
describe("hydrateExpanded", () => {
it("loads a cold expanded background project", () => {
const all = contexts(["a"])
const hooks = all.hooks()
hydrateExpanded([snapshot("a")], hooks)
expect(hooks.inited).toEqual(["a"])
expect(hooks.pushed).toEqual([])
})
/**
* Regression: a background project loads asynchronously, so its state push can
* land before the webview mounts. The catalog push that follows the mount must
* re-push it, otherwise the accordion stays on loading placeholders until the
* user clicks a row inside the project.
*/
it("re-pushes state for an already loaded background project", async () => {
const all = contexts(["a"])
await all.map.get("a")!.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
const hooks = all.hooks()
hydrateExpanded([snapshot("a")], hooks)
expect(hooks.pushed).toEqual(["a"])
expect(hooks.inited).toEqual([])
})
it("skips the active project, which the provider pushes itself", async () => {
const all = contexts(["a"])
await all.map.get("a")!.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
const hooks = all.hooks()
hydrateExpanded([snapshot("a", { active: true })], hooks)
expect(hooks.pushed).toEqual([])
expect(hooks.inited).toEqual([])
})
it("skips collapsed, untrusted, and missing projects", () => {
const all = contexts(["collapsed", "untrusted", "missing"])
const hooks = all.hooks()
hydrateExpanded(
[
snapshot("collapsed", { expanded: false }),
snapshot("untrusted", { trusted: false }),
snapshot("missing", { missing: true }),
],
hooks,
)
expect(hooks.pushed).toEqual([])
expect(hooks.inited).toEqual([])
})
it("skips a project the coordinator refuses to expand", () => {
const hooks = {
pushed: [] as string[],
inited: [] as string[],
expand: () => undefined,
push: () => {},
init: () => {},
}
hydrateExpanded([snapshot("gone")], hooks)
expect(hooks.pushed).toEqual([])
expect(hooks.inited).toEqual([])
})
it("hydrates every expanded background project, not just the first", async () => {
const all = contexts(["a", "b", "c"])
await all.map.get("b")!.ensureReady(async () => ({ ok: true, refsFixed: 0 }))
const hooks = all.hooks()
hydrateExpanded([snapshot("a"), snapshot("b"), snapshot("c")], hooks)
expect(hooks.inited).toEqual(["a", "c"])
expect(hooks.pushed).toEqual(["b"])
})
})
@@ -173,3 +173,78 @@ describe("ProjectPollers", () => {
expect(made.get("prj-extra")!.stopped).toEqual({ stats: true, pr: true })
})
})
/** Poller pair that exposes the injected deps so a test can emit like a real poller. */
function recorder() {
const posted: StatsOutMessage[] = []
const emit = new Map<string, (msg: StatsOutMessage) => void>()
const replayed: string[] = []
const create = (ctx: { id: string }, injected: { post: (msg: StatsOutMessage) => void }): PollerPair => {
emit.set(ctx.id, injected.post)
return {
stats: { setEnabled: () => {}, setVisible: () => {}, stop: () => {} },
pr: {
poller: { setEnabled: () => {}, setVisible: () => {}, stop: () => {} },
replay: () => replayed.push(ctx.id),
},
}
}
const deps = {
git: {} as GitOps,
semaphore: undefined as never,
post: (msg: StatsOutMessage) => posted.push(msg),
openExternal: () => {},
visible: () => true,
log: () => {},
}
return { posted, emit, replayed, create, deps }
}
const LOCAL: StatsOutMessage = {
type: "agentManager.localStats",
projectId: "prj-extra",
stats: { branch: "main", files: 0, additions: 0, deletions: 0, ahead: 0, behind: 0 } as never,
}
describe("ProjectPollers.replay", () => {
/**
* Regression: a background poller only emits on change, so a webview that
* mounts or reloads after the emit would keep its stats placeholders forever.
*/
it("re-posts the latest stats for a background project", () => {
const contexts = setup([stored("prj-extra")])
expand(contexts, "prj-extra")
const rec = recorder()
const pollers = new ProjectPollers(rec.deps as never, rec.create as never)
pollers.sync(contexts)
rec.emit.get("prj-extra")!(LOCAL)
expect(rec.posted).toHaveLength(1)
pollers.replay()
expect(rec.posted).toHaveLength(2)
expect(rec.posted[1]).toBe(LOCAL)
expect(rec.replayed).toEqual(["prj-extra"])
})
it("posts nothing for a project that has not emitted yet", () => {
const contexts = setup([stored("prj-extra")])
expand(contexts, "prj-extra")
const rec = recorder()
const pollers = new ProjectPollers(rec.deps as never, rec.create as never)
pollers.sync(contexts)
pollers.replay()
expect(rec.posted).toEqual([])
})
it("drops cached stats for a collapsed project", () => {
const contexts = setup([stored("prj-extra")])
expand(contexts, "prj-extra")
const rec = recorder()
const pollers = new ProjectPollers(rec.deps as never, rec.create as never)
pollers.sync(contexts)
rec.emit.get("prj-extra")!(LOCAL)
contexts.collapse("prj-extra")
pollers.sync(contexts)
pollers.replay()
expect(rec.posted).toHaveLength(1)
})
})
@@ -0,0 +1,74 @@
import { describe, expect, it } from "bun:test"
import { handleFileSearch } from "../../src/kilo-provider/file-search"
type Query = { query: string; directory: string; type: "file" | "directory"; limit: number }
function client(data: { files: string[]; folders: string[] }) {
const calls: Query[] = []
return {
calls,
value: {
find: {
files: async (query: Query) => {
calls.push(query)
return { data: query.type === "file" ? data.files : data.folders }
},
},
},
}
}
describe("handleFileSearch", () => {
it("posts one fresh response for each request", async () => {
const api = client({ files: ["src/a.ts"], folders: ["src"] })
const posted: unknown[] = []
await handleFileSearch({
client: api.value as never,
message: { query: "", requestId: "request-1", sessionID: "session-1" },
dir: (id) => (id === "session-1" ? "/repo" : ""),
open: async () => new Set(["src/open.ts"]),
post: (message) => posted.push(message),
})
expect(api.calls).toEqual([
{ query: "", directory: "/repo", type: "file", limit: 50 },
{ query: "", directory: "/repo", type: "directory", limit: 50 },
])
expect(posted).toHaveLength(1)
expect(posted[0]).toEqual({
type: "fileSearchResult",
requestId: "request-1",
dir: "/repo",
paths: ["src/open.ts", "src/a.ts"],
items: [
{ path: "src/open.ts", type: "opened-file" },
{ path: "src/a.ts", type: "file" },
{ path: "src", type: "folder" },
],
})
})
it("returns an empty fresh response when files were deleted", async () => {
const api = client({ files: [], folders: [] })
const posted: unknown[] = []
await handleFileSearch({
client: api.value as never,
message: { query: "", requestId: "request-empty" },
dir: () => "/repo",
open: async () => new Set(),
post: (message) => posted.push(message),
})
expect(posted).toEqual([
{
type: "fileSearchResult",
requestId: "request-empty",
dir: "/repo",
paths: [],
items: [],
},
])
})
})
@@ -255,69 +255,67 @@ describe("hasByok", () => {
describe("buildTriggerLabel", () => {
it("returns resolved model name for non-kilo provider unchanged", () => {
expect(buildTriggerLabel("GPT-4o", "openai", undefined, null, false, "", true, labels)).toBe("GPT-4o")
expect(buildTriggerLabel("GPT-4o", "openai", null, false, "", true, labels)).toBe("GPT-4o")
})
it("strips sub-provider prefix from resolved name for kilo gateway models", () => {
expect(
buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, undefined, null, false, "", true, labels),
).toBe("Claude Sonnet")
expect(buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, null, false, "", true, labels)).toBe(
"Claude Sonnet",
)
})
it("does not strip prefix for non-kilo provider even if name contains ': '", () => {
expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", undefined, null, false, "", true, labels)).toBe(
expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", null, false, "", true, labels)).toBe(
"Anthropic: Claude Sonnet",
)
})
it("returns resolved name as-is when providerID is undefined", () => {
expect(buildTriggerLabel("GPT-4o", undefined, undefined, null, false, "", true, labels)).toBe("GPT-4o")
expect(buildTriggerLabel("GPT-4o", undefined, null, false, "", true, labels)).toBe("GPT-4o")
})
it("returns providerName / resolvedName for non-kilo provider with providerName", () => {
expect(buildTriggerLabel("GPT-4o", "openai", "OpenAI", null, false, "", true, labels)).toBe("OpenAI / GPT-4o")
it("does not add provider name to the compact label", () => {
expect(buildTriggerLabel("GPT-5.6 Luna", "openai", null, false, "", true, labels)).toBe("GPT-5.6 Luna")
})
it("returns modelID for kilo gateway raw selection", () => {
const raw = { providerID: "kilo", modelID: "kilo-auto/frontier" }
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier")
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier")
})
it("returns providerID / modelID for non-kilo raw selection", () => {
const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" }
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe(
"anthropic / claude-3-5-sonnet",
)
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("anthropic / claude-3-5-sonnet")
})
it("returns clearLabel when allowClear and no selection", () => {
expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "None", true, labels)).toBe("None")
expect(buildTriggerLabel(undefined, undefined, null, true, "None", true, labels)).toBe("None")
})
it("falls back to labels.notSet when allowClear and clearLabel is empty", () => {
expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "", true, labels)).toBe("Not set")
expect(buildTriggerLabel(undefined, undefined, null, true, "", true, labels)).toBe("Not set")
})
it("returns labels.select when providers exist and no selection", () => {
expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", true, labels)).toBe("Select model")
expect(buildTriggerLabel(undefined, undefined, null, false, "", true, labels)).toBe("Select model")
})
it("returns labels.noProviders when no providers available", () => {
expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", false, labels)).toBe("No providers")
expect(buildTriggerLabel(undefined, undefined, null, false, "", false, labels)).toBe("No providers")
})
it("prefers resolvedName over raw selection", () => {
const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" }
expect(buildTriggerLabel("Claude Sonnet", undefined, undefined, raw, false, "", true, labels)).toBe("Claude Sonnet")
expect(buildTriggerLabel("Claude Sonnet", undefined, raw, false, "", true, labels)).toBe("Claude Sonnet")
})
it("ignores partial raw selection (only providerID)", () => {
const raw = { providerID: "anthropic", modelID: "" }
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
})
it("ignores partial raw selection (only modelID)", () => {
const raw = { providerID: "", modelID: "claude-3-5-sonnet" }
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
})
})
@@ -89,6 +89,16 @@ describe("sendCommand dismisses pending tool requests", () => {
})
})
describe("confirmed queued prompts retain optimistic parts", () => {
const source = readFile(SESSION_FILE)
const body = extractFunctionBody(source, "handleMessageCreated")
it("does not clear optimistic parts before canonical part events arrive", () => {
expect(body).toContain("Keep placeholder parts until their canonical part.updated events arrive")
expect(body).not.toContain("delete p[message.id]")
})
})
describe("static command completion contract", () => {
const source = readFile(SESSION_FILE)
@@ -613,8 +623,8 @@ describe("Optimistic parts preservation and smooth status contract", () => {
it("handlePartUpdated replaces matching optimistic parts in place", () => {
const updated = extractFunctionBody(source, "handlePartUpdated")
expect(updated).toContain("pendingOptimisticParts.get(effectiveMessageID)")
expect(updated).toContain("optIds.delete")
expect(updated).toContain("optimisticParts.get(effectiveMessageID)")
expect(updated).toContain("mergeOptimisticPart")
})
it("statusText derives status only from the active turn after the last user message", () => {
@@ -146,6 +146,14 @@ describe("resolvePromptAgent", () => {
expect(resolvePromptAgent({ selections: {}, pending: "code" })).toBe("code")
})
it("honors an explicit pending selection for a draft scope with no per-session entry", () => {
expect(resolvePromptAgent({ sessionID: "draft-1", selections: {}, pending: "ask" })).toBe("ask")
})
it("does not fall back to pending for a real server session with no per-session entry", () => {
expect(resolvePromptAgent({ sessionID: "ses_1", selections: {}, pending: "ask" })).toBeUndefined()
})
it("omits the agent when there is no explicit selection", () => {
expect(resolvePromptAgent({ sessionID: "ses_1", selections: {}, pending: null })).toBeUndefined()
expect(resolvePromptAgent({ selections: {}, pending: null })).toBeUndefined()
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { mergeParts, sameParts } from "../../webview-ui/src/context/session-parts"
import { mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts"
import type { Part } from "../../webview-ui/src/types/messages"
function text(id: string, value: string, time: { start?: number; end?: number } = {}): Part {
@@ -10,6 +10,10 @@ function tool(id: string): Part {
return { id, messageID: "m1", type: "tool", tool: "bash", state: { status: "pending", input: {} } }
}
function file(id: string): Part {
return { id, messageID: "m1", type: "file", mime: "text/plain", url: "data:,file" }
}
function value(parts: Part[], id: string) {
const part = parts.find((item) => item.id === id)
if (!part || part.type !== "text") return
@@ -100,6 +104,24 @@ describe("mergeParts", () => {
})
})
describe("mergeOptimisticPart", () => {
it("replaces the optimistic user part when its canonical event arrives", () => {
const current = [text("client", "queued prompt")]
const result = mergeOptimisticPart(current, new Set(["client"]), text("server", "queued prompt"))
expect(result.parts).toEqual([text("server", "queued prompt")])
expect(result.replaced).toBe("client")
})
it("keeps unmatched optimistic parts while canonical attachments arrive", () => {
const current = [text("client-text", "queued prompt"), file("client-file")]
const result = mergeOptimisticPart(current, new Set(["client-text", "client-file"]), file("server-file"))
expect(result.parts.map((part) => part.id)).toEqual(["client-text", "server-file"])
expect(result.replaced).toBe("client-file")
})
})
describe("sameParts", () => {
it("accepts equal hydrated and snapshot parts", () => {
expect(sameParts([text("p1", "done", { end: 2 })], [text("p1", "done", { end: 2 })])).toBe(true)
@@ -50,7 +50,7 @@ describe("selectSession keeps the chat in sync with the selection while offline"
// Queue a replay unconditionally. The earlier `deferredFetch = ready ? undefined : id`
// form skipped cached sessions, so a reconnect never re-sent the focus load that
// re-focuses the backend (focusSession/contextSessionID/SSE tracking/reconcile).
expect(body).toContain("deferredFetch = id")
expect(body).toContain("deferredFetch = { id, focus }")
expect(body).not.toMatch(/deferredFetch\s*=\s*ready\s*\?/)
})
})
@@ -61,12 +61,15 @@ describe("a deferred fetch is replayed on reconnect", () => {
const effect = source.slice(source.indexOf("on(server.isConnected"))
expect(effect).toContain("deferredFetch")
// Replays with the focus/replace choice so cached sessions still re-focus the backend.
expect(effect).toMatch(/loadFocusedMessages\(\s*id,\s*loaded\(\)\.has\(id\)\s*\)/)
expect(effect).toMatch(
/loadFocusedMessages\(\s*pending\.id,\s*loaded\(\)\.has\(pending\.id\),\s*pending\.focus\s*\)/,
)
})
it("the focused load helper sends focus for cached sessions and replace otherwise", () => {
const helper = source.slice(source.indexOf("function loadFocusedMessages("))
expect(helper).toMatch(/mode: "focus"/)
expect(helper).toMatch(/mode: "replace"/)
expect(helper).toContain("focus: false")
})
})
@@ -66,15 +66,24 @@ describe("session variants", () => {
it("persists an explicit default selection", () => {
const state = setup()
state.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
state.variants.select(undefined)
expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" })
expect(state.variants.current()).toBeUndefined()
expect(state.messages).toEqual([{ type: "persistVariant", key: "agent/code/anthropic/claude-sonnet-4", value: "" }])
})
it("carries the model default across model changes", () => {
const state = setup()
state.variants.carry(model, undefined, "code")
expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" })
it("does not shadow a cached variant when carrying the model default", () => {
const global = setup()
global.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
global.variants.carry(model, undefined, "code")
expect(global.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" })
expect(global.messages).toEqual([])
const session = setup("session-a")
session.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
session.variants.carry(model, undefined, "code", "session-a")
expect(session.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" })
expect(session.variants.current()).toBe("high")
})
})
@@ -33,6 +33,6 @@ describe("speech-to-text discovery", () => {
it("rejects empty or malformed catalogs so callers can use the static fallback", () => {
expect(parseSpeechToTextCatalog([])).toBeUndefined()
expect(parseSpeechToTextCatalog({ data: [] })).toBeUndefined()
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("openai/whisper-large-v3-turbo")
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("nvidia/parakeet-tdt-0.6b-v3")
})
})
@@ -6,8 +6,8 @@ import {
} from "../../src/speech-to-text/models"
describe("speech-to-text model catalog", () => {
it("uses Whisper Large V3 Turbo as the fallback default", () => {
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("openai/whisper-large-v3-turbo")
it("uses NVIDIA Parakeet TDT 0.6B v3 as the fallback default", () => {
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("nvidia/parakeet-tdt-0.6b-v3")
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe(SPEECH_TO_TEXT_MODELS[0]?.id)
})
@@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSubagentTabs } from "../../webview-ui/agent-manager/subagent-tabs"
function scene() {
const [current] = createSignal<string | undefined>("parent")
const calls = {
synced: [] as Array<[string, string | undefined]>,
unsynced: [] as string[],
shown: 0,
hidden: 0,
}
const tabs = createSubagentTabs({
current,
sync: (id, parent) => calls.synced.push([id, parent]),
unsync: (id) => calls.unsynced.push(id),
show: () => calls.shown++,
hide: () => calls.hidden++,
})
return { tabs, calls }
}
describe("Agent Manager subagent tabs", () => {
it("opens multiple child sessions and syncs each to its parent", () => {
createRoot((dispose) => {
const item = scene()
item.tabs.open("child-1", "First", "parent-1")
item.tabs.open("child-2", "Second", "parent-2")
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["child-1", "child-2"])
expect(item.tabs.active()).toBe("child-2")
expect(item.calls.synced).toEqual([
["child-1", "parent-1"],
["child-2", "parent-2"],
])
expect(item.calls.shown).toBe(2)
dispose()
})
})
it("closes the active tab onto its nearest survivor and hides when empty", () => {
createRoot((dispose) => {
const item = scene()
item.tabs.open("one", "One")
item.tabs.open("two", "Two")
item.tabs.open("three", "Three")
item.tabs.close("two")
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one", "three"])
expect(item.tabs.active()).toBe("three")
item.tabs.close("three")
item.tabs.close("one")
expect(item.tabs.tabs()).toEqual([])
expect(item.tabs.active()).toBeUndefined()
expect(item.calls.unsynced).toEqual(["two", "three", "one"])
expect(item.calls.hidden).toBe(1)
dispose()
})
})
it("supports Close Others and preserves the selected child", () => {
createRoot((dispose) => {
const item = scene()
item.tabs.open("one")
item.tabs.open("two")
item.tabs.open("three")
item.tabs.closeOthers("one")
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["one"])
expect(item.tabs.active()).toBe("one")
expect(item.calls.unsynced).toEqual(["two", "three"])
expect(item.calls.shown).toBe(4)
dispose()
})
})
it("reorders tabs without changing the active child", () => {
createRoot((dispose) => {
const item = scene()
item.tabs.open("one")
item.tabs.open("two")
item.tabs.open("three")
item.tabs.select("two")
item.tabs.reorder("three", "one")
expect(item.tabs.tabs().map((tab) => tab.id)).toEqual(["three", "one", "two"])
expect(item.tabs.active()).toBe("two")
dispose()
})
})
})
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import * as vscode from "vscode"
import { buildThroughputSettingMessage } from "../../src/kilo-provider/throughput-settings"
type Stub = {
getConfiguration: (section?: string) => {
get: <T>(key: string, fallback?: T) => T | undefined
}
}
const original = vscode.workspace.getConfiguration
function stubConfig(state: Map<string, unknown>) {
;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => {
if (section !== "kilo-code.new") {
return { get: <T>(_key: string, fallback?: T) => fallback }
}
return {
get: <T>(key: string, fallback?: T) => (state.has(key) ? (state.get(key) as T) : fallback),
}
}
}
afterEach(() => {
;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"]
})
describe("buildThroughputSettingMessage", () => {
let state: Map<string, unknown>
beforeEach(() => {
state = new Map()
stubConfig(state)
})
it("shows throughput by default", () => {
expect(buildThroughputSettingMessage().visible).toBe(true)
})
it("returns the persisted visibility preference", () => {
state.set("showTokenThroughput", false)
expect(buildThroughputSettingMessage().visible).toBe(false)
})
})
@@ -10,15 +10,32 @@ describe("toolDefaultOpen", () => {
it.each(["bash", "background_process"])("uses the terminal preference for %s", (name) => {
expect(toolDefaultOpen(tool(name), false, true)).toBe(false)
expect(toolDefaultOpen(tool(name), true, false)).toBe(true)
expect(toolDefaultOpen(tool(name), true, false, false)).toBe(true)
})
it.each(["edit", "write", "apply_patch"])("uses the code edit preference for %s", (name) => {
expect(toolDefaultOpen(tool(name), true, false)).toBe(false)
expect(toolDefaultOpen(tool(name), false, true)).toBe(true)
expect(toolDefaultOpen(tool(name), false, true, false)).toBe(true)
})
it.each([
"slack_post_message",
"github_create_issue",
"discord_search_messages",
"list_mcp_resources",
"custom_mcp_tool",
])("uses the mcp preference for %s", (name) => {
expect(toolDefaultOpen(tool(name), false, false, true)).toBe(true)
expect(toolDefaultOpen(tool(name), true, true, false)).toBe(false)
expect(toolDefaultOpen(tool(name), true, true)).toBeUndefined()
})
it("leaves unrelated parts unchanged", () => {
expect(toolDefaultOpen(tool("read"), true, true)).toBeUndefined()
expect(toolDefaultOpen({ type: "text" } as Part, true, true)).toBeUndefined()
expect(toolDefaultOpen(tool("read"), true, true, true)).toBeUndefined()
expect(toolDefaultOpen(tool("glob"), true, true, true)).toBeUndefined()
expect(toolDefaultOpen(tool("grep"), true, true, true)).toBeUndefined()
expect(toolDefaultOpen(tool("task"), true, true, true)).toBeUndefined()
expect(toolDefaultOpen({ type: "text" } as Part, true, true, true)).toBeUndefined()
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { createRoot } from "solid-js"
import { createRoot, createSignal } from "solid-js"
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
import { FILE_PICKER_RESULT } from "../../webview-ui/src/hooks/file-mention-utils"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
@@ -750,4 +750,333 @@ describe("useFileMention", () => {
dispose.fn?.()
})
it("renders cached files instantly when opening @ with empty query", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@", 1)
const refresh = posted.at(-1)
expect(refresh?.type).toBe("requestFileSearch")
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
dir: "/repo",
paths: ["src/index.ts", "package.json"],
items: [
{ path: "src/index.ts", type: "opened-file" },
{ path: "package.json", type: "file" },
],
})
}
expect(mention.mentionResults()).toEqual([
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
{ type: "opened-file", value: "src/index.ts" },
{ type: "file", value: "package.json" },
FILE_PICKER_RESULT,
])
// Close mention and reopen @ - should still be instant
mention.closeMention()
expect(mention.mentionResults().length).toBe(0)
mention.onInput("@", 1)
expect(mention.mentionResults()).toEqual([
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
{ type: "opened-file", value: "src/index.ts" },
{ type: "file", value: "package.json" },
FILE_PICKER_RESULT,
])
dispose.fn?.()
})
it("replaces deleted cached files when an empty refresh completes", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@", 1)
const prewarm = posted.at(-1)
expect(prewarm?.type).toBe("requestFileSearch")
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: prewarm?.type === "requestFileSearch" ? prewarm.requestId : "",
dir: "/repo",
paths: ["deleted.ts"],
items: [{ path: "deleted.ts", type: "file" }],
})
}
expect(mention.mentionResults()).toContainEqual({ type: "file", value: "deleted.ts" })
mention.closeMention()
mention.onInput("@", 1)
expect(mention.mentionResults()).toContainEqual({ type: "file", value: "deleted.ts" })
const refresh = posted.at(-1)
expect(refresh?.type).toBe("requestFileSearch")
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
dir: "/repo",
paths: [],
items: [],
})
}
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "deleted.ts" })
mention.closeMention()
mention.onInput("@", 1)
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "deleted.ts" })
dispose.fn?.()
})
it("does not reuse cached files after switching sessions", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const state = createRoot((root) => {
dispose.fn = root
const [session, setSession] = createSignal("session-a")
return { mention: useFileMention(ctx, session, () => false), setSession }
})
state.mention.onInput("@", 1)
const first = posted.at(-1)
expect(first).toMatchObject({ type: "requestFileSearch", sessionID: "session-a" })
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: first?.type === "requestFileSearch" ? first.requestId : "",
dir: "/repo-a",
paths: ["only-a.ts"],
items: [{ path: "only-a.ts", type: "file" }],
})
}
state.mention.closeMention()
state.setSession("session-b")
state.mention.onInput("@", 1)
expect(state.mention.mentionResults()).not.toContainEqual({ type: "file", value: "only-a.ts" })
expect(posted.at(-1)).toMatchObject({ type: "requestFileSearch", sessionID: "session-b", query: "" })
dispose.fn?.()
})
it("bounds remembered session directories", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const state = createRoot((root) => {
dispose.fn = root
const [session, setSession] = createSignal("session-0")
return { mention: useFileMention(ctx, session, () => false), setSession }
})
const reply = (request: WebviewMessage | undefined, dir: string) => {
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: request?.type === "requestFileSearch" ? request.requestId : "",
dir,
paths: [],
items: [],
})
}
}
state.mention.onInput("@", 1)
reply(posted.at(-1), "/repo/0")
for (let index = 1; index <= 8; index++) {
state.mention.closeMention()
state.setSession(`session-${index}`)
state.mention.onInput("@", 1)
reply(posted.at(-1), `/repo/${index}`)
}
state.mention.closeMention()
state.setSession("session-0")
state.mention.onInput("@", 1)
expect(state.mention.mentionResults()).toEqual([
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
FILE_PICKER_RESULT,
])
dispose.fn?.()
})
it("preserves the highlighted file when fresh results replace cached results", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@", 1)
const prewarm = posted.at(-1)
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: prewarm?.type === "requestFileSearch" ? prewarm.requestId : "",
dir: "/repo",
paths: ["a.ts", "b.ts"],
items: [
{ path: "a.ts", type: "file" },
{ path: "b.ts", type: "file" },
],
})
}
mention.closeMention()
mention.onInput("@", 1)
const selected = mention.mentionResults().findIndex((item) => item.type === "file" && item.value === "b.ts")
mention.setMentionIndex(selected)
const refresh = posted.at(-1)
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
dir: "/repo",
paths: ["new.ts", "b.ts"],
items: [
{ path: "new.ts", type: "file" },
{ path: "b.ts", type: "file" },
],
})
}
expect(mention.mentionResults()[mention.mentionIndex()]).toEqual({ type: "file", value: "b.ts" })
dispose.fn?.()
})
it("ignores a response after the query changes", async () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@old", 4)
await wait(170)
const old = posted.at(-1)
expect(old).toMatchObject({ type: "requestFileSearch", query: "old" })
mention.onInput("@new", 4)
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: old?.type === "requestFileSearch" ? old.requestId : "",
dir: "/repo",
paths: ["old.ts"],
items: [{ path: "old.ts", type: "file" }],
})
}
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "old.ts" })
dispose.fn?.()
})
it("ignores a response after closing the mention menu", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@", 1)
const request = posted.at(-1)
mention.closeMention()
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: request?.type === "requestFileSearch" ? request.requestId : "",
dir: "/repo",
paths: ["late.ts"],
items: [{ path: "late.ts", type: "file" }],
})
}
mention.onInput("@", 1)
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "late.ts" })
dispose.fn?.()
})
})
@@ -1,6 +1,7 @@
/** @jsxImportSource solid-js */
import {
batch,
For,
Show,
createSignal,
@@ -177,6 +178,8 @@ import { initialMessage, seedInitialVariant } from "./initial-message"
import { SidebarToggleButton } from "./SidebarToggleButton"
import { setTabWidths } from "./tab-widths"
import { clampPanelWidth, createPanelResize, maxPanelWidth, minPanelWidth, SidePanel } from "./side-panel-layout"
import { SubagentPanel } from "./SubagentPanel"
import { createSubagentTabs } from "./subagent-tabs"
import { buildShortcutCategories } from "./shortcuts"
import { tracker } from "./telemetry"
import { createChatFocus, createPromptFocus, hasQuestionOption } from "./focus"
@@ -305,8 +308,8 @@ const AgentManagerContent: Component = () => {
const diffLoading = diffs.diffLoading
const setDiffLoading = diffs.setDiffLoading
const diffNotices = diffs.diffNotices
// Diff and terminal views share one inspector width, restored from webview
// state so the user's divider position survives panel reloads.
// Diff, PR, terminal, and subagent views share one inspector width, restored
// from webview state so the user's divider position survives panel reloads.
const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth))
const resizeSide = createPanelResize(setPanelWidth, () => window.innerWidth)
const showSideTerminal = () => {
@@ -320,6 +323,17 @@ const AgentManagerContent: Component = () => {
const reviewComposer = createReviewComposer()
const [reviewActive, setReviewActive] = createSignal(false)
const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified")
const subagents = createSubagentTabs({
current: session.currentSessionID,
sync: (id, parentID) => session.syncSession(id, parentID, "inspector"),
unsync: (id) => session.unsyncSession(id, "inspector"),
show: () => {
setHistory(false)
setReviewActive(false)
setSidePanel(SidePanel.Subagents)
},
hide: () => setSidePanel(null),
})
const markdown = createMarkdownRender(vscode)
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const worktreeStats = () => registry.active().worktreeStats()
@@ -1213,7 +1227,17 @@ const AgentManagerContent: Component = () => {
if (match) projectNav.jump(parseInt(match[1]!) - 1)
}
}
const subagent = (event: Event) => {
const detail = (event as CustomEvent<{ sessionID?: unknown; title?: unknown; parentSessionID?: unknown }>).detail
if (typeof detail?.sessionID !== "string") return
subagents.open(
detail.sessionID,
typeof detail.title === "string" ? detail.title : undefined,
typeof detail.parentSessionID === "string" ? detail.parentSessionID : undefined,
)
}
window.addEventListener("message", handler)
window.addEventListener("agentManager.openSubagent", subagent)
// Prevent Cmd/Ctrl shortcuts from triggering native browser actions
const preventDefaults = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return
@@ -1262,6 +1286,7 @@ const AgentManagerContent: Component = () => {
confirmDeleteWorktree(sel)
}
window.addEventListener("keydown", deleteKeyHandler)
onCleanup(() => window.removeEventListener("agentManager.openSubagent", subagent))
// Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held.
// Capture phase so the terminal's key handlers can't swallow them; blur resets state
@@ -1990,14 +2015,16 @@ const AgentManagerContent: Component = () => {
}
const selectSessionTab = (id: string, pending: boolean) => {
setReviewActive(false)
if (pending) {
setActivePendingId(id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(id)
}
batch(() => {
setReviewActive(false)
if (pending) {
setActivePendingId(id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(id)
}
})
}
const termHandlers = createTerminalHandlers({
state: terms,
@@ -2154,6 +2181,10 @@ const AgentManagerContent: Component = () => {
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
if (sidePanel() === SidePanel.Subagents && subagents.active()) {
subagents.close(subagents.active()!)
return
}
// A focused side terminal owns Cmd+W while its panel is visible.
// Closing a chat tab out from under the user's cursor would be surprising.
if (sidePanel() === SidePanel.Terminal && terms.sideFocusedId()) {
@@ -2512,81 +2543,79 @@ const AgentManagerContent: Component = () => {
</Show>
</div>
</Show>
<Show when={!contextEmpty()}>
<div class="am-chat-wrapper">
<ChatView
onSelectSession={(id) => {
if (addSessionToCurrentWorktree(id)) return
if (localSessionIDs().includes(id)) {
<div class="am-chat-wrapper" classList={{ "am-chat-wrapper-hidden": contextEmpty() }}>
<ChatView
onSelectSession={(id) => {
if (addSessionToCurrentWorktree(id)) return
if (localSessionIDs().includes(id)) {
session.selectSession(id)
if (selection() === null) setSelection(LOCAL)
requestChatFocus()
return
}
// Navigate to owning worktree instead of forcing into local mode
if (worktreeSessionIds().has(id)) {
const ms = managedSessions().find((s) => s.id === id)
if (ms?.worktreeId) {
selectWorktree(ms.worktreeId)
session.selectSession(id)
if (selection() === null) setSelection(LOCAL)
setReviewActive(false)
requestChatFocus()
return
}
// Navigate to owning worktree instead of forcing into local mode
if (worktreeSessionIds().has(id)) {
const ms = managedSessions().find((s) => s.id === id)
if (ms?.worktreeId) {
selectWorktree(ms.worktreeId)
session.selectSession(id)
setReviewActive(false)
requestChatFocus()
return
}
}
openLocally(id)
}}
onShowHistory={() => setHistory(true)}
onForkMessage={readOnly() ? undefined : handleForkSession}
onForkSession={readOnly() ? undefined : handleForkSession}
readonly={readOnly()}
continueInWorktree={selection() === LOCAL}
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
deferFocusToQuestion={hasQuestionOption}
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
focusOnDraftChange={focusOnDraftChange}
onFocusChange={rememberPromptFocus}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
<Icon name="branch" size="small" />
<span class="am-readonly-text">{t("agentManager.session.readonly")}</span>
<Button
variant="secondary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("open_session_locally", "readonly_banner")
openLocally(sid)
}}
>
{t("agentManager.session.openLocally")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("promote_session", "readonly_banner")
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
}}
>
{t("agentManager.session.openInWorktree")}
</Button>
</div>
</Show>
</div>
</Show>
}
openLocally(id)
}}
onShowHistory={() => setHistory(true)}
onForkMessage={readOnly() ? undefined : handleForkSession}
onForkSession={readOnly() ? undefined : handleForkSession}
readonly={readOnly()}
continueInWorktree={selection() === LOCAL}
promptBoxId={`agent-manager:${selection() ?? "unassigned"}`}
deferFocusToQuestion={hasQuestionOption}
pendingSessionID={selection() === LOCAL ? activePendingId() : undefined}
focusOnDraftChange={focusOnDraftChange}
onFocusChange={rememberPromptFocus}
/>
<Show when={readOnly()}>
<div class="am-readonly-banner">
<Icon name="branch" size="small" />
<span class="am-readonly-text">{t("agentManager.session.readonly")}</span>
<Button
variant="secondary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("open_session_locally", "readonly_banner")
openLocally(sid)
}}
>
{t("agentManager.session.openLocally")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => {
if (!loaded()) return
const sid = session.currentSessionID()
if (!sid) return
metrics.track("promote_session", "readonly_banner")
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
}}
>
{t("agentManager.session.openInWorktree")}
</Button>
</div>
</Show>
</div>
</div>
{/* One inspector host for all right-side modes. It stays
mounted while a side terminal is alive hidden via
.am-side-host-hidden (absolute + opacity), never
unmounted, so xterm render loops keep streaming. */}
<Show when={sidePanel() !== null || terms.sides().length > 0}>
<Show when={sidePanel() !== null || terms.sides().length > 0 || subagents.tabs().length > 0}>
<div
class={`am-diff-resize ${sidePanel() === null ? "am-side-host-hidden" : ""}`}
style={{ width: `${panelWidth()}px` }}
@@ -2653,6 +2682,20 @@ const AgentManagerContent: Component = () => {
}
/>
</Show>
<Show when={subagents.tabs().length > 0}>
<SubagentPanel
tabs={subagents.tabs}
active={subagents.active}
visible={() => sidePanel() === SidePanel.Subagents}
nextKeybind={kb().nextTab ?? ""}
closeKeybind={kb().closeTab ?? ""}
onSelect={subagents.select}
onClose={subagents.close}
onCloseOthers={subagents.closeOthers}
onReorder={subagents.reorder}
onClosePanel={() => setSidePanel(null)}
/>
</Show>
<SideTerminalPanel
state={terms}
contextKey={terms.sideKey}
@@ -0,0 +1,155 @@
import type { IconProps } from "@kilocode/kilo-ui/icon"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { Show, type Component, type JSX } from "solid-js"
import { SessionTabMenu } from "../src/components/chat/SessionTabMenu"
import { SortableTabContainer } from "../src/components/chat/TabDnd"
import { useLanguage } from "../src/context/language"
import { parseBindingTokens } from "./keybind-tokens"
type TabIcon = IconProps["name"] | "spinner"
type Value<T> = T | (() => T)
function value<T>(input: Value<T>): T {
return typeof input === "function" ? (input as () => T)() : input
}
export interface ClosableTabProps {
id?: string
label: Value<string>
tooltip: Value<string>
icon: Value<TabIcon>
iconStatus?: Value<"success" | "failure" | undefined>
class?: string
focused?: boolean
active: boolean
closeable?: boolean
showKeybind?: boolean
keybind?: string
closeKeybind?: string
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onSelect: () => void
onMiddleClick?: (event: MouseEvent) => void
onClose: () => void
trailing?: JSX.Element
}
export const ClosableTabChrome: Component<ClosableTabProps> = (props) => {
const { t } = useLanguage()
const label = () => value(props.label)
const tooltip = () => value(props.tooltip)
const icon = () => value(props.icon)
const status = () => (props.iconStatus ? value(props.iconStatus) : undefined)
const keybind = () => (props.showKeybind === false ? "" : (props.keybind ?? ""))
const closeKeybind = () => (props.showKeybind === false ? "" : (props.closeKeybind ?? ""))
return (
<div
class={`am-tab am-tab-closable ${props.active ? "am-tab-active" : ""} ${props.focused ? "am-tab-focused" : ""} ${props.class ?? ""}`}
>
<div
class="am-tab-target"
role={props.role}
aria-selected={props.selected}
aria-label={tooltip()}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
>
<TooltipKeybind
title={tooltip()}
keybind={keybind()}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
>
<span class="am-tab-title">
<span class="am-tab-icon" data-run-status={status()}>
<Show when={icon() === "spinner"} fallback={<Icon name={icon() as IconProps["name"]} size="small" />}>
<Spinner class="am-tab-spinner" />
</Show>
</span>
<span class="am-tab-label">{label()}</span>
</span>
</TooltipKeybind>
</div>
{props.trailing}
<Show when={props.closeable !== false}>
<TooltipKeybind
title={t("agentManager.tab.close")}
keybind={closeKeybind()}
placement="top"
gutter={8}
class="am-tab-close-wrap"
openDelay={0}
>
<IconButton
icon="close-small"
size="small"
variant="ghost"
aria-label={t("agentManager.tab.closeTab")}
tabIndex={props.active ? 0 : -1}
class="am-tab-close"
data-tab-close="true"
onClick={(event) => {
event.stopPropagation()
props.onClose()
}}
/>
</TooltipKeybind>
</Show>
</div>
)
}
export const SortableClosableTab: Component<
ClosableTabProps & {
id: string
onCloseOthers: () => void
}
> = (props) => (
<SortableTabContainer id={props.id}>
<SessionTabMenu
onClose={props.onClose}
onCloseOthers={props.onCloseOthers}
closeable={props.closeable}
closeShortcut={
props.closeKeybind ? (
<span class="am-menu-shortcut">
{parseBindingTokens(props.closeKeybind).map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
</span>
) : undefined
}
>
<ClosableTabChrome
label={props.label}
tooltip={props.tooltip}
icon={props.icon}
iconStatus={props.iconStatus}
class={props.class}
focused={props.focused}
active={props.active}
closeable={props.closeable}
showKeybind={props.showKeybind}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
role={props.role}
selected={props.selected}
tabIndex={props.tabIndex}
onKeyDown={props.onKeyDown}
onSelect={props.onSelect}
onMiddleClick={props.onMiddleClick}
onClose={props.onClose}
trailing={props.trailing}
/>
</SessionTabMenu>
</SortableTabContainer>
)
@@ -0,0 +1,108 @@
import {
DragDropProvider,
DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
import { For, Show, createSignal, type Accessor, type Component, type JSX } from "solid-js"
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
import { createTabFocus } from "../src/utils/tab-navigation"
import { useTabScroll } from "../src/utils/tab-scroll"
import { setTabWidths } from "../src/utils/tab-widths"
const TABLIST = ".am-inspector-tablist"
type InspectorTabFocus = ReturnType<typeof createTabFocus>
interface InspectorTabStripApi {
focus: InspectorTabFocus
freeze: () => void
release: () => void
}
interface Props {
ids: Accessor<readonly string[]>
active: Accessor<string | undefined>
label: string
renderTab: (id: string, api: InspectorTabStripApi) => JSX.Element
overlay: (id: string) => string
onSelect: (id: string) => void
onReorder: (from: string, to: string) => void
action?: (api: InspectorTabStripApi) => JSX.Element
}
export const InspectorTabStrip: Component<Props> = (props) => {
let host!: HTMLDivElement
const scroll = useTabScroll(props.ids, props.active)
const focus = createTabFocus({ ids: props.ids, select: props.onSelect, root: () => host })
const [dragging, setDragging] = createSignal<{ id: string; width: number }>()
const freeze = () => setTabWidths(true, host, TABLIST)
const release = () => setTabWidths(false, host, TABLIST)
const api = { focus, freeze, release }
const start = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id !== "string") return
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
freeze()
setDragging({ id, width })
}
const end = () => {
setDragging(undefined)
release()
}
const over = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
props.onReorder(from, to)
}
return (
<div
ref={host}
class="am-inspector-tabs"
onPointerDown={(event) => {
if (event.target instanceof Element && event.target.closest(".am-tab-close[data-tab-close]")) freeze()
}}
onPointerLeave={() => {
if (!dragging()) release()
}}
>
<DragDropProvider onDragStart={start} onDragEnd={end} onDragOver={over} collisionDetector={closestCenter}>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list-wrap">
<div
class="am-inspector-tablist"
ref={(el) => {
scroll.setRef(el)
}}
role={props.ids().length > 0 ? "tablist" : undefined}
aria-label={props.ids().length > 0 ? props.label : undefined}
style={{ "--tab-count": `${props.ids().length}` } as JSX.CSSProperties}
>
<SortableProvider ids={[...props.ids()]}>
<For each={props.ids()}>{(id) => props.renderTab(id, api)}</For>
</SortableProvider>
</div>
</div>
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
<DragOverlay>
<Show when={dragging()}>
{(tab) => (
<div class="am-tab am-tab-overlay" style={{ width: `${tab().width}px` }}>
<span class="am-tab-label">{props.overlay(tab().id)}</span>
</div>
)}
</Show>
</DragOverlay>
</DragDropProvider>
{props.action?.(api)}
</div>
)
}
@@ -25,6 +25,7 @@ import { SidebarSectionHeader } from "./SidebarSectionHeader"
import { WorktreeItem } from "./WorktreeItem"
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
import { ProjectActions } from "./ProjectActions"
import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton"
import { applyTabOrder, firstOrderedTitle, reorderTabs } from "./tab-order"
import { buildTopLevelItems, sortWorktrees, isGroupEnd, isGroupStart, isGrouped } from "./section-helpers"
import { sectionAwareDetector } from "./section-dnd"
@@ -290,89 +291,85 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
return (
<div class="am-project-body" data-project-body={props.project.id}>
<Show
when={state()}
fallback={
<div class="am-project-loading">
<Spinner class="am-worktree-spinner" />
</div>
}
<button
class="am-local-item"
classList={{ "am-local-item-active": active() && props.selection === "local" }}
data-sidebar-id={`${props.project.id}:local`}
onClick={() => props.onSelectLocal(props.project.id)}
>
<button
class="am-local-item"
classList={{ "am-local-item-active": active() && props.selection === "local" }}
data-sidebar-id={`${props.project.id}:local`}
onClick={() => props.onSelectLocal(props.project.id)}
>
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
</Show>
<div class="am-local-text">
<span class="am-local-label">{props.t("agentManager.local")}</span>
<Show when={props.local === undefined}>
<span class="am-local-branch-skeleton" />
</Show>
<div class="am-local-text">
<span class="am-local-label">{props.t("agentManager.local")}</span>
<Show when={props.local?.branch}>
<span class="am-local-branch">{props.local!.branch}</span>
</Show>
</div>
<div class="am-wt-actions-cell">
<Show
when={
props.local &&
(props.local.additions || props.local.deletions || props.local.ahead || props.local.behind)
}
>
<div class="am-worktree-stats">
<Show when={props.local!.behind}>
<span class="am-worktree-behind">{props.local!.behind}</span>
</Show>
<Show when={props.local!.ahead}>
<span class="am-worktree-commits">{props.local!.ahead}</span>
</Show>
<Show when={props.local!.additions}>
<span class="am-stat-additions">+{props.local!.additions}</span>
</Show>
<Show when={props.local!.deletions}>
<span class="am-stat-deletions">{props.local!.deletions}</span>
</Show>
</div>
</Show>
<div class="am-wt-hover-actions">
<Show when={props.shortcutMap?.().get(`${props.project.id}:local`)}>
{(shortcut) => (
<span class="am-shortcut-badge">
{isMac ? "⌘" : "Ctrl+"}
{shortcut()}
</span>
)}
<Show when={props.local?.branch}>
<span class="am-local-branch">{props.local!.branch}</span>
</Show>
</div>
<div class="am-wt-actions-cell">
<Show when={props.local === undefined}>
<StatsSkeleton />
</Show>
<Show
when={
props.local && (props.local.additions || props.local.deletions || props.local.ahead || props.local.behind)
}
>
<div class="am-worktree-stats">
<Show when={props.local!.behind}>
<span class="am-worktree-behind">{props.local!.behind}</span>
</Show>
<Show when={props.local!.ahead}>
<span class="am-worktree-commits">{props.local!.ahead}</span>
</Show>
<Show when={props.local!.additions}>
<span class="am-stat-additions">+{props.local!.additions}</span>
</Show>
<Show when={props.local!.deletions}>
<span class="am-stat-deletions">{props.local!.deletions}</span>
</Show>
</div>
</Show>
<div class="am-wt-hover-actions">
<Show when={props.shortcutMap?.().get(`${props.project.id}:local`)}>
{(shortcut) => (
<span class="am-shortcut-badge">
{isMac ? "⌘" : "Ctrl+"}
{shortcut()}
</span>
)}
</Show>
</div>
</button>
</div>
</button>
<div class="am-section">
<SidebarSectionHeader
class="am-section-header"
label={<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>}
actions={
<ProjectActions
branch={state()?.defaultBaseBranch ?? props.local?.branch ?? "main"}
bindings={props.bindings}
loaded={state() !== undefined}
t={props.t}
onCreate={() => post({ type: "agentManager.createWorktree" })}
onNew={() => props.onNewWorktree(props.project.id)}
onSection={() => createSection()}
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
onBranch={() =>
props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)
}
/>
}
/>
<div class="am-worktree-list">
<div class="am-section">
<SidebarSectionHeader
class="am-section-header"
label={<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>}
actions={
<ProjectActions
branch={state()?.defaultBaseBranch ?? props.local?.branch ?? "main"}
bindings={props.bindings}
loaded={state() !== undefined}
t={props.t}
onCreate={() => post({ type: "agentManager.createWorktree" })}
onNew={() => props.onNewWorktree(props.project.id)}
onSection={() => createSection()}
onSetup={() => post({ type: "agentManager.configureSetupScript" })}
onBranch={() => props.onDefaultBranch(props.project.id, state()?.defaultBaseBranch, props.local?.branch)}
/>
}
/>
<div class="am-worktree-list">
<Show when={state()} fallback={<WorktreeSkeleton />}>
<DragDropProvider
onDragStart={onDragStart}
onDragOver={onDragOver}
@@ -435,21 +432,26 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
})()}
</DragOverlay>
</DragDropProvider>
</div>
</Show>
</div>
</div>
<UnassignedSessionsSection
sessions={localSessions}
loaded={() => props.sessions !== undefined}
collapsed={() => state()!.sessionsCollapsed === true}
active={() => undefined}
onToggle={() => post({ type: "agentManager.setSessionsCollapsed", collapsed: !state()!.sessionsCollapsed })}
onSelect={(sessionId) => props.onSelectSession(props.project.id, sessionId)}
onPromote={(sessionId) => post({ type: "agentManager.promoteSession", sessionId })}
onOpen={(sessionId) => post({ type: "agentManager.openLocally", sessionId })}
sidebarId={(sessionId) => `${props.project.id}:sess:${sessionId}`}
/>
</Show>
<UnassignedSessionsSection
sessions={localSessions}
loaded={() => props.sessions !== undefined && state() !== undefined}
collapsed={() => state()?.sessionsCollapsed === true}
disabled={state() === undefined}
active={() => undefined}
onToggle={() => {
const current = state()
if (!current) return
post({ type: "agentManager.setSessionsCollapsed", collapsed: !current.sessionsCollapsed })
}}
onSelect={(sessionId) => props.onSelectSession(props.project.id, sessionId)}
onPromote={(sessionId) => post({ type: "agentManager.promoteSession", sessionId })}
onOpen={(sessionId) => post({ type: "agentManager.openLocally", sessionId })}
sidebarId={(sessionId) => `${props.project.id}:sess:${sessionId}`}
/>
</div>
)
}
@@ -32,6 +32,7 @@ import { SidebarSectionHeader } from "./SidebarSectionHeader"
import { WorktreeItem } from "./WorktreeItem"
import { WorktreeSectionActions } from "./WorktreeSectionActions"
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton"
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
@@ -126,10 +127,7 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
</div>
<div class="am-wt-actions-cell">
<Show when={props.localStats() === undefined}>
<div class="am-worktree-stats-skeleton">
<div class="am-worktree-stats-skeleton-row" />
<div class="am-worktree-stats-skeleton-row" style={{ width: "70%" }} />
</div>
<StatsSkeleton />
</Show>
<Show
when={
@@ -212,17 +210,7 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
}
/>
<div class="am-worktree-list">
<Show
when={props.worktreesLoaded() && props.sessionsLoaded()}
fallback={
<div class="am-skeleton-list">
<div class="am-skeleton-wt">
<div class="am-skeleton-wt-icon" />
<div class="am-skeleton-wt-text" style={{ width: "60%" }} />
</div>
</div>
}
>
<Show when={props.worktreesLoaded() && props.sessionsLoaded()} fallback={<WorktreeSkeleton />}>
<Show when={!props.isGitRepo()}>
<div class="am-not-git-notice">
<Icon name="warning" size="small" />
@@ -10,6 +10,7 @@ interface Props {
class?: string
title?: string
ariaLabel?: string
disabled?: boolean
}
/** Shared layout for sidebar headings with a fixed leading control column. */
@@ -19,7 +20,7 @@ export const SidebarSectionHeader: Component<Props> = (props) => {
class={`am-sidebar-header${props.onToggle ? " am-sidebar-header-toggleable" : ""}${props.class ? ` ${props.class}` : ""}`}
title={props.title}
onClick={(event) => {
if (event.button === 0) props.onToggle?.()
if (event.button === 0 && !props.disabled) props.onToggle?.()
}}
>
<div class="am-sidebar-header-main">
@@ -29,9 +30,10 @@ export const SidebarSectionHeader: Component<Props> = (props) => {
type="button"
aria-expanded={props.expanded}
aria-label={props.ariaLabel}
disabled={props.disabled}
onClick={(event) => {
event.stopPropagation()
props.onToggle?.()
if (!props.disabled) props.onToggle?.()
}}
>
<span class="am-sidebar-header-chevron" aria-hidden="true">
@@ -0,0 +1,59 @@
import { For, type Component } from "solid-js"
/** Staggered widths so stacked placeholder rows read as a list, not a striped block. */
const BRANCH = ["62%", "44%", "70%", "52%"]
const SUB = ["38%", "30%", "46%", "34%"]
const TITLE = ["70%", "55%", "65%", "48%"]
const rows = (count: number) => Array.from({ length: count }, (_, index) => index)
/** Offset each row's pulse so any row count keeps the wave effect. */
const delay = (index: number) => `${index * 0.12}s`
/** Placeholder worktree rows shown until a project's worktree list arrives. */
export const WorktreeSkeleton: Component<{ count?: number }> = (props) => (
<div class="am-skeleton-list">
<For each={rows(props.count ?? 3)}>
{(index) => (
<div class="am-skeleton-wt">
<div class="am-skeleton-wt-icon" style={{ "animation-delay": delay(index) }} />
<div class="am-skeleton-wt-lines">
<div
class="am-skeleton-wt-text"
style={{ width: BRANCH[index % BRANCH.length], "animation-delay": delay(index) }}
/>
<div
class="am-skeleton-wt-sub"
style={{ width: SUB[index % SUB.length], "animation-delay": delay(index) }}
/>
</div>
</div>
)}
</For>
</div>
)
/** Placeholder session rows shown until a project's session list arrives. */
export const SessionSkeleton: Component<{ count?: number }> = (props) => (
<div class="am-skeleton-list">
<For each={rows(props.count ?? 3)}>
{(index) => (
<div class="am-skeleton-session">
<div
class="am-skeleton-session-title"
style={{ width: TITLE[index % TITLE.length], "animation-delay": delay(index) }}
/>
<div class="am-skeleton-session-time" style={{ "animation-delay": delay(index) }} />
</div>
)}
</For>
</div>
)
/** Placeholder for the two-line git stats column on a local or worktree row. */
export const StatsSkeleton: Component = () => (
<div class="am-worktree-stats-skeleton">
<div class="am-worktree-stats-skeleton-row" />
<div class="am-worktree-stats-skeleton-row" style={{ width: "70%" }} />
</div>
)
@@ -0,0 +1,131 @@
/**
* Read-only subagent chats for the Agent Manager inspector.
*
* The nested session provider keeps the parent chat selection independent from
* the child transcript while still consuming the same webview event stream.
*/
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { createEffect, type Accessor, type Component } from "solid-js"
import { DataBridge } from "../src/App"
import { ChatView } from "../src/components/chat"
import { SessionProvider, useSession } from "../src/context/session"
import { SortableClosableTab } from "./ClosableTab"
import { InspectorTabStrip } from "./InspectorTabStrip"
import type { SubagentTab } from "./subagent-tabs"
interface Props {
tabs: Accessor<SubagentTab[]>
active: Accessor<string | undefined>
visible: Accessor<boolean>
nextKeybind: string
closeKeybind: string
onSelect: (id: string) => void
onClose: (id: string) => void
onCloseOthers: (id: string) => void
onReorder: (from: string, to: string) => void
onClosePanel: () => void
}
const SubagentChat: Component<{ active: Accessor<string | undefined> }> = (props) => {
const session = useSession()
createEffect(() => {
const id = props.active()
if (!id) return
session.selectSession(id, { focus: false })
})
return (
<DataBridge>
<ChatView readonly promptBoxId="agent-manager:subagent" />
</DataBridge>
)
}
const SubagentContent: Component<Props> = (props) => {
const session = useSession()
const ids = () => props.tabs().map((tab) => tab.id)
const title = (id: string) => props.tabs().find((tab) => tab.id === id)?.title ?? "Sub-agent"
const close = (id: string, focus: { restore: () => void }) => {
props.onClose(id)
session.releaseSession(id)
if (ids().length > 0) focus.restore()
}
const closeOthers = (id: string) => {
const gone = ids().filter((item) => item !== id)
props.onCloseOthers(id)
for (const item of gone) session.releaseSession(item)
}
return (
<section
class="am-subagent-panel"
classList={{ "am-subagent-panel-visible": props.visible() }}
aria-label="Subagents"
aria-hidden={!props.visible()}
inert={!props.visible()}
>
<header class="am-subagent-header">
<div class="am-subagent-heading">
<Icon name="task" size="small" />
<span>Subagents</span>
<span class="am-subagent-count">{props.tabs().length}</span>
</div>
<IconButton
icon="x"
size="small"
variant="ghost"
aria-label="Close subagents panel"
onClick={props.onClosePanel}
/>
</header>
<InspectorTabStrip
ids={ids}
active={props.active}
label="Subagent sessions"
overlay={title}
onSelect={props.onSelect}
onReorder={props.onReorder}
renderTab={(id, api) => {
const label = title(id)
return (
<SortableClosableTab
id={id}
label={label}
tooltip={label}
icon="task"
showKeybind={false}
keybind={props.active() === id ? "" : props.nextKeybind}
closeKeybind={props.closeKeybind}
active={props.active() === id}
role="tab"
selected={props.active() === id}
tabIndex={props.active() === id ? 0 : -1}
onKeyDown={(event) => api.focus.key(id, event)}
onSelect={() => props.onSelect(id)}
onMiddleClick={(event) => {
if (event.button !== 1) return
event.preventDefault()
event.stopPropagation()
close(id, api.focus)
}}
onClose={() => close(id, api.focus)}
onCloseOthers={() => closeOthers(id)}
/>
)
}}
/>
<div class="am-subagent-chat">
<SubagentChat active={props.active} />
</div>
</section>
)
}
export const SubagentPanel: Component<Props> = (props) => (
<SessionProvider>
<SubagentContent {...props} />
</SessionProvider>
)
@@ -243,19 +243,14 @@ export const TabBar: Component<TabBarProps> = (props) => (
title={props.t("agentManager.diff.toggle")}
>
<Icon name="layers" size="small" />
<Show when={props.prStatus()}>
{(pr) => (
<Show when={pr().additions > 0 || pr().deletions > 0}>
<span class="am-diff-toggle-stats">
<Show when={pr().additions > 0}>
<span class="am-stat-additions">+{pr().additions}</span>
</Show>
<Show when={pr().deletions > 0}>
<span class="am-stat-deletions">{pr().deletions}</span>
</Show>
</span>
<Show when={hasChanges()}>
<span class="am-diff-toggle-stats">
<Show when={stats()!.files > 0}>
<span class="am-stat-files">{stats()!.files}f</span>
</Show>
)}
<span class="am-stat-additions">+{stats()!.additions}</span>
<span class="am-stat-deletions">{stats()!.deletions}</span>
</span>
</Show>
</button>
</TooltipKeybind>
@@ -7,11 +7,13 @@ import type { SessionInfo } from "../src/types/messages"
import { useLanguage } from "../src/context/language"
import { formatRelativeDate } from "../src/utils/date"
import { SidebarSectionHeader } from "./SidebarSectionHeader"
import { SessionSkeleton } from "./Skeleton"
interface Props {
sessions: Accessor<SessionInfo[]>
loaded: Accessor<boolean>
collapsed: Accessor<boolean>
disabled?: boolean
active: Accessor<string | undefined>
onToggle: () => void
onSelect: (id: string) => void
@@ -33,31 +35,14 @@ export const UnassignedSessionsSection: Component<Props> = (props) => {
<SidebarSectionHeader
class="am-section-header am-section-toggle"
expanded={!props.collapsed()}
disabled={props.disabled}
ariaLabel={t("agentManager.section.sessions")}
label={<span class="am-section-label">{t("agentManager.section.sessions")}</span>}
onToggle={props.onToggle}
/>
<Show when={!props.collapsed()}>
<div class="am-list">
<Show
when={props.loaded()}
fallback={
<div class="am-skeleton-list">
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "70%" }} />
<div class="am-skeleton-session-time" />
</div>
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "55%" }} />
<div class="am-skeleton-session-time" />
</div>
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "65%" }} />
<div class="am-skeleton-session-time" />
</div>
</div>
}
>
<Show when={props.loaded()} fallback={<SessionSkeleton />}>
<For each={props.sessions()}>
{(session) => (
<ContextMenu>
@@ -237,6 +237,11 @@ html[data-theme="kilo-vscode"]
cursor: pointer;
}
.am-sidebar-header-toggle:disabled {
cursor: default;
opacity: 0.5;
}
.am-sidebar-header-toggle:hover {
color: var(--text-base);
background: var(--surface-inset-base-hover);
@@ -407,10 +412,16 @@ html[data-theme="kilo-vscode"]
max-height: none;
}
.am-project-loading {
display: flex;
justify-content: center;
padding: 8px 0;
/* Stands in for .am-local-branch so the local row keeps its two-line height
while a project's git state is still loading. */
.am-local-branch-skeleton {
width: 72px;
max-width: 100%;
height: 8px;
margin: 2px 0;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
/* Collapsible section toggle */
@@ -1503,7 +1514,7 @@ html[data-theme="kilo-vscode"]
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-terminal-tab-spinner {
.am-tab-spinner {
width: 12px;
height: 12px;
}
@@ -1771,6 +1782,10 @@ body.am-wt-dragging-active * {
position: relative;
}
.am-chat-wrapper.am-chat-wrapper-hidden {
display: none;
}
.am-readonly-banner {
display: flex;
align-items: center;
@@ -3593,33 +3608,48 @@ body.am-wt-dragging-active * {
animation: am-fade-in 0.2s ease;
}
/* Worktree skeleton — matches .am-worktree-item layout */
/* Worktree skeleton mirrors .am-worktree-item: icon column plus a label row
and a branch row, so settling into real rows costs no vertical shift. */
.am-skeleton-wt {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
padding: 10px 10px;
padding: 6px 6px;
min-height: 36px;
box-sizing: border-box;
}
.am-skeleton-wt-icon {
width: 16px;
height: 16px;
margin-top: 3px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
.am-skeleton-wt-lines {
display: flex;
flex-direction: column;
gap: 7px;
flex: 1;
min-width: 0;
padding-top: 5px;
}
.am-skeleton-wt-text {
height: 13px;
height: 10px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-icon,
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-text {
animation-delay: 0.15s;
.am-skeleton-wt-sub {
height: 7px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
/* Session skeleton — matches .am-item layout */
@@ -3647,16 +3677,6 @@ body.am-wt-dragging-active * {
flex-shrink: 0;
}
.am-skeleton-session:nth-child(2) .am-skeleton-session-title,
.am-skeleton-session:nth-child(2) .am-skeleton-session-time {
animation-delay: 0.15s;
}
.am-skeleton-session:nth-child(3) .am-skeleton-session-title,
.am-skeleton-session:nth-child(3) .am-skeleton-session-time {
animation-delay: 0.3s;
}
/* Tab switcher for New/Import tabs in the dialog */
.am-tab-switcher {
@@ -4821,8 +4841,9 @@ body.vscode-high-contrast-light {
}
}
/* Experimental terminal tabs (feature-flagged) */
/* Shared sortable inspector tabs. */
.am-tab-closable,
.am-tab-terminal {
display: flex;
align-items: center;
@@ -4830,12 +4851,13 @@ body.vscode-high-contrast-light {
border-left: 1px solid var(--border-weak-base);
}
.am-tab-closable [data-component="icon"],
.am-tab-terminal [data-component="icon"] {
flex-shrink: 0;
opacity: 0.7;
}
.am-tab-terminal-focused {
.am-tab-focused {
background: var(--surface-base-hover);
}
@@ -4958,15 +4980,14 @@ body.vscode-high-contrast-light {
color: var(--text-weak);
}
/* Side terminal tab strip one row of tabs reusing the top bar's
.am-tab chrome, plus the "+" action. Height matches .am-diff-header
/* Inspector tab strip one row of tabs reusing the top bar's
.am-tab chrome, plus an optional action. Height matches .am-diff-header
(32px) so switching inspector modes does not shift the panel chrome.
No vertical padding: tabs fill the strip like they fill .am-tab-bar,
which also keeps the "+" optically centered. The strip itself never
scrolls; the tab list does, so a narrow panel never pushes the "+"
action out of view (same split as .am-tab-list-wrap /
.am-tab-add-wrap). */
.am-side-terminal-tabs {
which also keeps actions optically centered. The strip itself never
scrolls; the tab list does, so a narrow panel never pushes an action out
of view (same split as .am-tab-list-wrap / .am-tab-add-wrap). */
.am-inspector-tabs {
display: flex;
align-items: stretch;
height: 32px;
@@ -4981,10 +5002,10 @@ body.vscode-high-contrast-light {
/* Same width model as .am-tab-list: tabs claim an equal share of the
strip up to a maximum, and the list itself only grows as wide as its
tabs, so the "+" action stays glued to the last tab instead of
tabs, so an action stays glued to the last tab instead of
drifting to the far edge of a wide panel. The cap is smaller than the
top bar's 240px because the panel is narrow. */
.am-side-terminal-tablist {
.am-inspector-tablist {
--am-tab-max-width: 180px;
--am-tab-width: clamp(72px, calc(100% / var(--tab-count, 1)), var(--am-tab-max-width));
display: flex;
@@ -5000,17 +5021,16 @@ body.vscode-high-contrast-light {
scrollbar-width: none;
}
.am-side-terminal-tablist::-webkit-scrollbar {
.am-inspector-tablist::-webkit-scrollbar {
display: none;
}
.am-side-terminal-tablist[data-tab-widths-frozen] .am-tab-sortable {
.am-inspector-tablist[data-tab-widths-frozen] .am-tab-sortable {
transition: none;
}
/* The left divider marks a terminal among session tabs in the top bar.
Every tab here is a terminal, so it would just be noise. */
.am-side-terminal-tablist .am-tab-terminal {
/* The left divider belongs to the top-level mixed tab bar. */
.am-inspector-tablist .am-tab-closable {
border-left-color: transparent;
}
@@ -5034,6 +5054,69 @@ body.vscode-high-contrast-light {
pointer-events: none;
}
/* Subagent inspector panel. It remains mounted while another inspector mode
is active so switching back keeps the child transcript and scroll position. */
.am-subagent-panel {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
opacity: 0;
pointer-events: none;
z-index: 1;
background: var(--surface-base);
will-change: opacity;
}
.am-subagent-panel-visible {
opacity: 1;
pointer-events: auto;
}
.am-subagent-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
height: 32px;
padding: 0 4px 0 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
background: var(--surface-base);
}
.am-subagent-heading {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
color: var(--text-base);
font-size: var(--font-size-small);
font-weight: 600;
}
.am-subagent-count {
color: var(--text-weak);
font-size: var(--kilo-font-size-10);
font-variant-numeric: tabular-nums;
}
.am-subagent-chat {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
}
.am-subagent-chat > [data-component="data-provider"],
.am-subagent-chat [class~="chat-view"] {
min-width: 0;
min-height: 0;
flex: 1;
}
.am-terminal-host {
flex: 1;
min-height: 0;
@@ -5,8 +5,13 @@
import { render } from "solid-js/web"
import "@kilocode/kilo-ui/styles"
import "../src/styles/chat.css"
import { registerExpandedTaskTool } from "../src/components/chat/TaskToolExpanded"
import { registerVscodeToolOverrides } from "../src/components/chat/VscodeToolOverrides"
import { AgentManagerApp } from "./AgentManagerApp"
registerExpandedTaskTool()
registerVscodeToolOverrides()
const root = document.getElementById("root")
if (root) {
render(() => <AgentManagerApp />, root)
@@ -6,6 +6,7 @@
* draft, terminal, or review) and falls back to the first available session.
*/
import { batch } from "solid-js"
import { LOCAL } from "./navigate"
interface TermState {
@@ -39,23 +40,28 @@ export interface SelectionActionDeps<T extends SessionLike> {
/** Select the Local context: restore its remembered tab or fall back to the first session/draft. */
export function selectLocalAction<T extends SessionLike>(deps: SelectionActionDeps<T>, locals: T[]): void {
deps.saveTabMemory()
deps.setReviewActive(false)
deps.setSelection(LOCAL)
deps.post({ type: "agentManager.requestRepoInfo" })
const remembered = deps.tabMemory()[LOCAL]
if (deps.terms.hasRemembered(deps.nsKey(LOCAL), remembered)) return deps.activateTerminal(remembered!)
deps.terms.setActiveId(undefined)
const target = remembered ? locals.find((s) => s.id === remembered) : undefined
const fallback = target ?? locals[0]
if (fallback && !deps.isPending(fallback.id)) {
deps.setActivePendingId(undefined)
deps.selectSession(fallback.id)
} else {
deps.setActivePendingId(fallback && deps.isPending(fallback.id) ? fallback.id : undefined)
deps.clearSession()
deps.post({ type: "agentManager.showExistingLocalTerminal" })
}
deps.setReviewActive(deps.isReviewTab(remembered, LOCAL))
batch(() => {
deps.setReviewActive(false)
deps.setSelection(LOCAL)
if (deps.terms.hasRemembered(deps.nsKey(LOCAL), remembered)) {
deps.activateTerminal(remembered!)
return
}
deps.terms.setActiveId(undefined)
const target = remembered ? locals.find((s) => s.id === remembered) : undefined
const fallback = target ?? locals[0]
if (fallback && !deps.isPending(fallback.id)) {
deps.setActivePendingId(undefined)
deps.selectSession(fallback.id)
} else {
deps.setActivePendingId(fallback && deps.isPending(fallback.id) ? fallback.id : undefined)
deps.clearSession()
deps.post({ type: "agentManager.showExistingLocalTerminal" })
}
deps.setReviewActive(deps.isReviewTab(remembered, LOCAL))
})
}
/** Select a worktree: restore its remembered tab or fall back to its first session. */
@@ -65,13 +71,18 @@ export function selectWorktreeAction<T extends SessionLike>(
sessions: T[],
): void {
deps.saveTabMemory()
deps.setSelection(worktreeId)
const remembered = deps.tabMemory()[worktreeId]
if (deps.terms.hasRemembered(deps.nsKey(worktreeId), remembered)) return deps.activateTerminal(remembered!)
deps.terms.setActiveId(undefined)
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
const fallback = target ?? sessions[0]
if (fallback) deps.selectSession(fallback.id)
else deps.resetSession()
deps.setReviewActive(deps.isReviewTab(remembered, worktreeId))
batch(() => {
deps.setSelection(worktreeId)
if (deps.terms.hasRemembered(deps.nsKey(worktreeId), remembered)) {
deps.activateTerminal(remembered!)
return
}
deps.terms.setActiveId(undefined)
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
const fallback = target ?? sessions[0]
if (fallback) deps.selectSession(fallback.id)
else deps.resetSession()
deps.setReviewActive(deps.isReviewTab(remembered, worktreeId))
})
}
@@ -9,6 +9,7 @@ export enum SidePanel {
Diff = "diff",
PR = "pr",
Terminal = "terminal",
Subagents = "subagents",
}
function viewportWidth(viewport: number): number {
@@ -0,0 +1,90 @@
import { batch, createSignal, type Accessor } from "solid-js"
import { reorderTabs } from "../src/utils/tab-order"
export interface SubagentTab {
id: string
title: string
}
interface Options {
current: Accessor<string | undefined>
sync: (id: string, parentID?: string) => void
unsync: (id: string) => void
show: () => void
hide: () => void
}
export function createSubagentTabs(opts: Options) {
const [tabs, setTabs] = createSignal<SubagentTab[]>([])
const [active, setActive] = createSignal<string>()
const open = (id: string, title?: string, parentID?: string) => {
if (!id) return
const label = title?.trim() || "Sub-agent"
const existing = tabs().some((tab) => tab.id === id)
batch(() => {
setTabs((prev) => {
const existing = prev.find((tab) => tab.id === id)
if (!existing) return [...prev, { id, title: label }]
if (title?.trim() && existing.title !== label) {
return prev.map((tab) => (tab.id === id ? { ...tab, title: label } : tab))
}
return prev
})
setActive(id)
opts.show()
})
if (!existing) opts.sync(id, parentID ?? opts.current())
}
const select = (id: string) => {
if (!tabs().some((tab) => tab.id === id)) return
setActive(id)
opts.show()
}
const close = (id: string) => {
const current = tabs()
const index = current.findIndex((tab) => tab.id === id)
if (index < 0) return
const next = current.filter((tab) => tab.id !== id)
opts.unsync(id)
setTabs(next)
if (active() !== id) return
const replacement = next[Math.min(index, next.length - 1)]
if (replacement) {
setActive(replacement.id)
return
}
setActive(undefined)
opts.hide()
}
const closeOthers = (id: string) => {
if (!tabs().some((tab) => tab.id === id)) return
for (const tab of tabs()) {
if (tab.id !== id) opts.unsync(tab.id)
}
setTabs((prev) => prev.filter((tab) => tab.id === id))
setActive(id)
opts.show()
}
const reorder = (from: string, to: string) => {
const order = reorderTabs(
tabs().map((tab) => tab.id),
from,
to,
)
if (!order) return
setTabs((prev) => {
const lookup = new Map(prev.map((tab) => [tab.id, tab]))
return order.flatMap((id) => {
const tab = lookup.get(id)
return tab ? [tab] : []
})
})
}
return { tabs, active, open, select, close, closeOthers, reorder }
}
@@ -1,48 +1,26 @@
/**
* Right-side terminal panel for the Agent Manager inspector.
*
* Lives inside the shared `.am-diff-panel-wrapper` host next to the diff
* and PR panels, so all three inspector modes share one resize handle
* and one width.
* Lives inside the shared inspector host next to diff, PR, and subagent
* panels, so every mode uses the same persisted resize width. The tab row is
* the shared inspector strip used by subagents as well.
*
* A context can own several side terminals. The header is a tab strip
* that reuses the top tab bar's whole chrome: `SortableTerminalTab`
* (icon, title, X close, right-click Close / Close Others), the same
* `@thisbeyond/solid-dnd` reorder stack, the same overflow scrolling
* with edge fades, the same width freeze while tabs close, and the same
* arrow-key tab navigation, so a terminal behaves identically in
* either surface. Reorder state lives in the terminal state, so it is
* preserved across sidebar context switches for the webview's lifetime.
*
* The `+` action sits directly after the last tab (outside the
* scrolling region, like the tab bar's `am-tab-add-wrap`), so it never
* scrolls away and never drifts to the far edge of a wide panel. The
* strip stays visible even when empty so `+` is always reachable.
*
* Visibility is opacity-based, never unmount: the xterm render loop
* dies when its subtree leaves the paint tree (see `render.tsx`).
* Visibility is opacity-based, never unmount: the xterm render loop dies when
* its subtree leaves the paint tree (see `render.tsx`).
*/
import type { Accessor, Component, JSX } from "solid-js"
import { For, Show, createEffect, createSignal } from "solid-js"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import type { Accessor, Component } from "solid-js"
import { Show, createEffect } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../../src/context/language"
import { ConstrainDragYAxis } from "../../src/components/chat/TabDnd"
import { useTabScroll } from "../../src/utils/tab-scroll"
import { setTabWidths } from "../../src/utils/tab-widths"
import { createTabFocus } from "../../src/utils/tab-navigation"
import { InspectorTabStrip } from "../InspectorTabStrip"
import { renderSideTerminalLayer } from "./render"
import { SortableTerminalTab } from "./SortableTerminalTab"
import type { TerminalStateControls } from "./state"
/** Only this strip's tabs freeze; the top tab bar keeps its own widths. */
const TABLIST = ".am-side-terminal-tablist"
interface Props {
state: TerminalStateControls
/** Context the panel currently shows (`state.sideKey`). */
@@ -67,65 +45,18 @@ interface Props {
export const SideTerminalPanel: Component<Props> = (props) => {
const { t } = useLanguage()
let panel!: HTMLElement
let strip!: HTMLDivElement
createEffect(() => {
panel.inert = !props.visible()
})
const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>()
const sides = () => props.state.sidesForContext(props.contextKey())
const ids = () => sides().map((term) => term.id)
const active = () => props.state.sideActiveFor(props.contextKey())
const pending = () => props.state.pendingSide(props.contextKey())
const scroll = useTabScroll(ids, active)
// Scoped to `strip` so arrow keys and focus restore never jump to a
// tab in the top bar, which uses the same role="tab" markup.
const focus = createTabFocus({ ids, select: props.onSelect, root: () => strip })
// Only freeze while the pointer is over the strip: the widths must
// survive until the pointer leaves, so the remaining X buttons stay
// put across repeated closes. Releasing on the next frame would undo
// the freeze before it is ever painted (rAF runs before paint).
// "Close others" needs none of this: its context menu is portaled, so
// the pointer is off the strip, and the survivor spans the strip anyway.
const freeze = () => {
if (strip.closest(".am-side-terminal-tabs")?.matches(":hover")) setTabWidths(true, document, TABLIST)
}
const release = () => setTabWidths(false, document, TABLIST)
const close = (id: string) => {
freeze()
const close = (id: string, focus: { restore: () => void }) => {
props.onClose(id)
// Restore focus inside the strip only while it still owns a tab.
// Falling through to `focusPrompt` would pull focus into the chat
// composer while the panel is still open on its empty state.
if (ids().length > 0) focus.restore()
}
// Adding a tab shrinks every tab's equal share, so any freeze left
// over from a close in the same hover has to go first. `+` lives
// inside the strip, so no pointerleave happens between the two
// clicks and the surviving tabs would keep their wider pixel widths.
const start = () => {
release()
props.onStart()
}
const onDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id !== "string") return
// Pin the overlay to the tab's width: the overlay container uses
// min-width, so a long OSC title would otherwise overflow it and
// shift the visual center off the cursor (the "drag offset" bug).
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
setTabWidths(true, document, TABLIST)
setDragging({ id, width })
}
const onDragEnd = () => {
setDragging(undefined)
release()
}
const onDragOver = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
props.state.reorderSideDrag(props.contextKey(), from, to)
}
return (
<section
ref={panel}
@@ -133,105 +64,64 @@ export const SideTerminalPanel: Component<Props> = (props) => {
aria-label={t("agentManager.tab.terminal")}
aria-hidden={!props.visible()}
>
<div
class="am-side-terminal-tabs"
onPointerLeave={() => {
if (!dragging()) release()
}}
>
<DragDropProvider
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
{/* Overflow chrome copied from the top tab bar: the list is the
only scrolling element, wrapped by a fade host, so the "+"
action stays pinned next to the last tab. */}
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${scroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list-wrap">
{/* role="tablist" only when tabs exist: axe
aria-required-children rejects an empty tablist. */}
<div
class="am-side-terminal-tablist"
ref={(el) => {
strip = el
scroll.setRef(el)
}}
role={sides().length > 0 ? "tablist" : undefined}
aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined}
style={{ "--tab-count": `${sides().length}` } as JSX.CSSProperties}
>
<SortableProvider ids={ids()}>
<For each={sides()}>
{(term) => (
<SortableTerminalTab
id={term.id}
label={props.state.title(term.id) ?? term.title}
tooltip={props.state.title(term.id) ?? term.title}
status={props.state.scriptStatus(term.id)}
keybind={active() === term.id ? "" : props.nextKeybind}
closeKeybind={props.closeKeybind}
active={active() === term.id}
focused={props.state.sideFocusedId() === term.id}
role="tab"
selected={active() === term.id}
tabIndex={active() === term.id ? 0 : -1}
onKeyDown={(event) => focus.key(term.id, event)}
onSelect={() => props.onSelect(term.id)}
onMiddleClick={(e: MouseEvent) => {
if (e.button !== 1) return
e.preventDefault()
e.stopPropagation()
close(term.id)
}}
onClose={(e: MouseEvent) => {
e.stopPropagation()
close(term.id)
}}
onCloseOthers={() => props.onCloseOthers(term.id)}
onStop={(e: MouseEvent) => {
e.stopPropagation()
props.onStop(term.id)
}}
/>
)}
</For>
</SortableProvider>
</div>
</div>
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
{/* Cursor-following clone of the dragged tab (same pattern as
the top tab bar). The overlay is what makes the in-list
original use solid-dnd's slot-compensated transform, so the
dragged tab tracks the cursor without a jump/offset. The
original stays dimmed in its slot via .am-tab-dragging. */}
<DragOverlay>
<Show when={dragging()}>
{(tab) => (
<div class="am-tab am-tab-overlay" style={{ width: `${tab().width}px` }}>
<span class="am-tab-label">{props.state.title(tab().id) ?? t("agentManager.tab.terminal")}</span>
</div>
)}
</Show>
</DragOverlay>
</DragDropProvider>
<div class="am-side-terminal-add">
<Tooltip value={t("agentManager.terminal.add")} placement="bottom">
<IconButton
icon="plus"
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.add")}
onClick={start}
<InspectorTabStrip
ids={ids}
active={active}
label={t("agentManager.tab.terminal")}
overlay={(id) => props.state.title(id) ?? t("agentManager.tab.terminal")}
onSelect={props.onSelect}
onReorder={(from, to) => props.state.reorderSideDrag(props.contextKey(), from, to)}
renderTab={(id, api) => {
const term = sides().find((item) => item.id === id)
if (!term) return null
return (
<SortableTerminalTab
id={term.id}
label={props.state.title(term.id) ?? term.title}
tooltip={props.state.title(term.id) ?? term.title}
status={props.state.scriptStatus(term.id)}
showKeybind={false}
keybind={active() === term.id ? "" : props.nextKeybind}
closeKeybind={props.closeKeybind}
active={active() === term.id}
focused={props.state.sideFocusedId() === term.id}
role="tab"
selected={active() === term.id}
tabIndex={active() === term.id ? 0 : -1}
onKeyDown={(event) => api.focus.key(term.id, event)}
onSelect={() => props.onSelect(term.id)}
onMiddleClick={(event) => {
if (event.button !== 1) return
event.preventDefault()
event.stopPropagation()
close(term.id, api.focus)
}}
onClose={() => close(term.id, api.focus)}
onCloseOthers={() => props.onCloseOthers(term.id)}
onStop={(event) => {
event.stopPropagation()
props.onStop(term.id)
}}
/>
</Tooltip>
</div>
</div>
)
}}
action={(api) => (
<div class="am-side-terminal-add">
<Tooltip value={t("agentManager.terminal.add")} placement="bottom">
<IconButton
icon="plus"
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.add")}
onClick={() => {
api.release()
props.onStart()
}}
/>
</Tooltip>
</div>
)}
/>
{renderSideTerminalLayer({
state: props.state,
contextKey: props.contextKey,
@@ -1,191 +1,99 @@
/**
* Tab chrome for xterm terminals.
* Terminal-specific adapter for the shared sortable inspector tab.
*
* `TerminalTabChrome` is the shared visual tab: console icon, title,
* tooltip/keybinding hints, and the X close button the same
* `am-tab*` structure the session tabs use. `SortableTerminalTab`
* wraps it with drag-and-drop and a right-click context menu; both the
* top tab bar and the side terminal panel render that wrapper, so a
* terminal tab behaves identically in either surface.
* PTY status determines the icon and whether a Setup tab can be closed. The
* tab chrome, drag wrapper, context menu, and close behavior are shared with
* subagent tabs.
*/
import { Component, Show, type JSX } from "solid-js"
import { Show, type Component } from "solid-js"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { useLanguage } from "../../src/context/language"
import { SortableTabContainer } from "../../src/components/chat/TabDnd"
import { parseBindingTokens } from "../keybind-tokens"
import { SortableClosableTab, type ClosableTabProps } from "../ClosableTab"
import { terminalChrome, terminalClosable, terminalStoppable } from "./chrome"
import type { ScriptTerminalStatus } from "./state"
export const TerminalTabChrome: Component<{
interface Props extends Omit<ClosableTabProps, "icon" | "onClose" | "trailing"> {
label: string
tooltip: string
status?: ScriptTerminalStatus
keybind?: string
closeKeybind?: string
focused?: boolean
active: boolean
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onSelect: () => void
onMiddleClick?: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
onStop?: (e: MouseEvent) => void
}> = (props) => {
onClose: () => void
onStop?: (event: MouseEvent) => void
}
const StopButton: Component<{ active: boolean; tabIndex: number; onStop?: (event: MouseEvent) => void }> = (props) => {
const { t } = useLanguage()
const chrome = () => terminalChrome(props.tooltip, props.status)
const icon = () => {
const kind = chrome().icon
if (kind === "success") return "check-small"
if (kind === "failure") return "warning"
return "console"
}
return (
<div
class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""} ${props.focused ? "am-tab-terminal-focused" : ""}`}
>
<div
class="am-tab-target"
role={props.role}
aria-selected={props.selected}
aria-label={chrome().tooltip}
tabIndex={props.tabIndex}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
onKeyDown={props.onKeyDown}
<Show when={props.active && props.onStop}>
<TooltipKeybind
title={t("agentManager.terminal.stopSetup")}
keybind=""
placement="top"
gutter={8}
class="am-tab-close-wrap"
openDelay={0}
>
<TooltipKeybind
title={chrome().tooltip}
keybind={props.keybind ?? ""}
placement="bottom"
gutter={8}
class="am-tab-tooltip"
openDelay={0}
>
<span class="am-tab-title">
<span class="am-tab-icon" data-run-status={chrome().icon}>
<Show when={chrome().icon === "spinner"} fallback={<Icon name={icon()} size="small" />}>
<Spinner class="am-terminal-tab-spinner" />
</Show>
</span>
<span class="am-tab-label">{props.label}</span>
</span>
</TooltipKeybind>
</div>
<Show when={terminalStoppable(props.status) && props.onStop}>
<TooltipKeybind
title={t("agentManager.terminal.stopSetup")}
keybind=""
placement="top"
gutter={8}
class="am-tab-close-wrap"
openDelay={0}
>
<IconButton
icon="stop"
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.stopSetup")}
tabIndex={props.active ? 0 : -1}
class="am-tab-close"
onClick={props.onStop}
/>
</TooltipKeybind>
</Show>
<Show when={terminalClosable(props.status)}>
<TooltipKeybind
title={t("agentManager.tab.close")}
keybind={props.closeKeybind ?? ""}
placement="top"
gutter={8}
class="am-tab-close-wrap"
openDelay={0}
>
<IconButton
icon="close-small"
size="small"
variant="ghost"
aria-label={t("agentManager.tab.closeTab")}
tabIndex={props.active ? 0 : -1}
class="am-tab-close"
onClick={props.onClose}
/>
</TooltipKeybind>
</Show>
</div>
<IconButton
icon="stop"
size="small"
variant="ghost"
aria-label={t("agentManager.terminal.stopSetup")}
tabIndex={props.tabIndex}
class="am-tab-close"
onClick={(event) => {
event.stopPropagation()
props.onStop?.(event)
}}
/>
</TooltipKeybind>
</Show>
)
}
export const SortableTerminalTab: Component<{
id: string
label: string
tooltip: string
status?: ScriptTerminalStatus
keybind?: string
closeKeybind?: string
focused?: boolean
active: boolean
role?: "tab"
selected?: boolean
tabIndex?: number
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
onCloseOthers: () => void
onStop?: (e: MouseEvent) => void
}> = (props) => {
const { t } = useLanguage()
return (
<SortableTabContainer id={props.id}>
<ContextMenu>
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
<TerminalTabChrome
label={props.label}
tooltip={props.tooltip}
status={props.status}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
focused={props.focused}
active={props.active}
role={props.role}
selected={props.selected}
tabIndex={props.tabIndex}
onKeyDown={props.onKeyDown}
onSelect={props.onSelect}
onMiddleClick={props.onMiddleClick}
onClose={props.onClose}
onStop={props.onStop}
/>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="am-ctx-menu">
<ContextMenu.Item
onSelect={() => props.onClose(new MouseEvent("click", { bubbles: true, cancelable: true }) as MouseEvent)}
>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
<Show when={props.closeKeybind}>
<span class="am-menu-shortcut">
{parseBindingTokens(props.closeKeybind ?? "").map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
</span>
</Show>
</ContextMenu.Item>
<ContextMenu.Item onSelect={props.onCloseOthers}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.closeOthers")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
</SortableTabContainer>
)
function icon(status: ScriptTerminalStatus | undefined) {
const value = terminalChrome("", status).icon
if (value === "success") return "check-small" as const
if (value === "failure") return "warning" as const
if (value === "spinner") return "spinner" as const
return "console" as const
}
function iconStatus(status: ScriptTerminalStatus | undefined) {
const value = terminalChrome("", status).icon
if (value === "success") return "success" as const
if (value === "failure") return "failure" as const
return undefined
}
export const SortableTerminalTab: Component<
Props & {
id: string
onCloseOthers: () => void
}
> = (props) => (
<SortableClosableTab
id={props.id}
label={props.label}
tooltip={terminalChrome(props.tooltip, props.status).tooltip}
icon={() => icon(props.status)}
iconStatus={() => iconStatus(props.status)}
class="am-tab-terminal"
focused={props.focused}
active={props.active}
closeable={terminalClosable(props.status)}
keybind={props.keybind}
closeKeybind={props.closeKeybind}
role={props.role}
selected={props.selected}
tabIndex={props.tabIndex}
onKeyDown={props.onKeyDown}
onSelect={props.onSelect}
onMiddleClick={props.onMiddleClick}
onClose={props.onClose}
onCloseOthers={props.onCloseOthers}
trailing={
<StopButton active={terminalStoppable(props.status)} tabIndex={props.active ? 0 : -1} onStop={props.onStop} />
}
/>
)
@@ -59,10 +59,7 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
onKeyDown={deps.onKeyDown}
onSelect={() => deps.onSelect(deps.id)}
onMiddleClick={(e: MouseEvent) => deps.onMiddleClick(deps.id, e)}
onClose={(e: MouseEvent) => {
e.stopPropagation()
deps.onClose(deps.id)
}}
onClose={() => deps.onClose(deps.id)}
onCloseOthers={() => deps.onCloseOthers(deps.id)}
/>
)
@@ -216,6 +216,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
const { config } = useConfig()
const open = createMemo(() => config().terminal_command_display !== "collapsed")
const edit = createMemo(() => config().code_edit_display === "expanded")
const mcp = createMemo(() => config().mcp_tool_display === "expanded")
// Throughput toggle lives on the shared DisplayProvider so every
// AssistantMessage renders against the same signal without posting its
@@ -331,7 +332,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
part={part}
message={props.message as SDKMessage}
showAssistantCopyPartID={props.showAssistantCopyPartID}
defaultOpen={toolDefaultOpen(part, open(), edit())}
defaultOpen={toolDefaultOpen(part, open(), edit(), mcp())}
forceOpen={forceOpen()}
forceOpenFile={forceOpen() ? props.forceOpenFile : undefined}
reasoningAutoCollapse={display.reasoningAutoCollapse()}
@@ -1234,26 +1234,21 @@ export const MessageList: Component<MessageListProps> = (props) => {
const id = pendingRestore()
if (!id || session.loading()) return
turns().length
// Double-rAF: the first frame lets the browser paint the new DOM from
// the messagesLoaded batch. The second frame restores scroll position
// without forcing a synchronous layout reflow mid-paint.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (pendingRestore() !== id) return
const el = scrollEl()
if (!el) return
const state = getScroll(id)
const anchor = resolveAnchor(state, keys())
const handle = virtualizer()
if (state?.type === "anchor" && anchor && handle) {
handle.scrollToIndex(anchor.index, { offset: anchor.offset })
autoScroll.pause()
maybeLoadOlder()
} else {
autoScroll.forceScrollToBottom()
}
setPendingRestore(undefined)
})
if (pendingRestore() !== id) return
const el = scrollEl()
if (!el) return
const state = getScroll(id)
const anchor = resolveAnchor(state, keys())
const handle = virtualizer()
if (state?.type === "anchor" && anchor && handle) {
handle.scrollToIndex(anchor.index, { offset: anchor.offset })
autoScroll.pause()
maybeLoadOlder()
} else {
autoScroll.forceScrollToBottom()
}
setPendingRestore(undefined)
})
})
@@ -62,6 +62,7 @@ export const PermissionDock: Component<{
}
const text = (rule: string) => (command() ? label(rule) : describeRule(props.request.toolName, rule, language.t))
const external = () => props.request.toolName === "external_directory"
const sandboxEscalation = () => props.request.toolName === "sandbox_escalation"
const cmdDescription = () => {
const val = props.request.args?.description
return typeof val === "string" && val.length > 0 ? val : undefined
@@ -129,6 +130,7 @@ export const PermissionDock: Component<{
}
const title = () => {
if (sandboxEscalation()) return language.t("notification.permission.titleSandboxEscalation")
const skill = props.request.args?.skill
if (skillShell() && typeof skill === "string" && skill.length > 0)
// Escape the untrusted skill name so bidi/control chars can't reorder the header text.
@@ -373,7 +373,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
createEffect(
on(draftKey, (key, prev) => {
if (prev !== undefined && prev !== key) {
saveDraft(prev, untrack(text), untrack(reviewComments), untrack(imageAttach.images))
const val = untrack(text)
const comments = untrack(reviewComments)
const imgs = untrack(imageAttach.images)
if (val || comments.length > 0 || imgs.length > 0 || drafts.has(prev)) {
saveDraft(prev, val, comments, imgs)
}
}
const draft = drafts.get(key) ?? ""
const pending = reviewDrafts.get(key) ?? []
@@ -406,14 +411,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
createEffect(() => {
const msgs = session.userMessages()
if (msgs.length === 0) return
const texts = msgs.map((m) => {
const parts = session.getParts(m.id)
return parts
.filter((part): part is TextPart => part.type === "text")
.map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, ""))
.join("")
})
history.seed(texts)
const timer = setTimeout(() => {
const texts = msgs.map((m) => {
const parts = session.getParts(m.id)
return parts
.filter((part): part is TextPart => part.type === "text")
.map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, ""))
.join("")
})
history.seed(texts)
}, 100)
onCleanup(() => clearTimeout(timer))
})
// Focus textarea when any part of the app requests it
@@ -8,6 +8,7 @@ export const SessionTabMenu: ParentComponent<{
onFork?: () => void
onClose: () => void
onCloseOthers?: () => void
closeable?: boolean
closeShortcut?: JSX.Element
}> = (props) => {
const { t } = useLanguage()
@@ -23,13 +24,17 @@ export const SessionTabMenu: ParentComponent<{
<Icon name="fork" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.forkSession")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<Show when={props.closeable !== false || props.onCloseOthers}>
<ContextMenu.Separator />
</Show>
</Show>
<Show when={props.closeable !== false}>
<ContextMenu.Item onSelect={props.onClose}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
{props.closeShortcut}
</ContextMenu.Item>
</Show>
<ContextMenu.Item onSelect={props.onClose}>
<Icon name="close" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.tab.close")}</ContextMenu.ItemLabel>
{props.closeShortcut}
</ContextMenu.Item>
<Show when={props.onCloseOthers}>
<ContextMenu.Item onSelect={() => props.onCloseOthers?.()}>
<Icon name="close" size="small" />
@@ -42,7 +42,8 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
const busy = createMemo(() => session.status() === "busy")
const canCompact = createMemo(() => !busy() && session.visibleMessages().length > 0 && !!session.selected())
const fmt = (n: number) => new Intl.NumberFormat(language.locale(), { style: "currency", currency: "USD" }).format(n)
const money = createMemo(() => new Intl.NumberFormat(language.locale(), { style: "currency", currency: "USD" }))
const fmt = (n: number) => money().format(n)
const breakdown = () => session.costBreakdown()
@@ -18,6 +18,7 @@ import { useI18n } from "@kilocode/kilo-ui/context/i18n"
import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
import { useSession } from "../../context/session"
import { useVSCode } from "../../context/vscode"
import { useWorktreeMode } from "../../context/worktree-mode"
import { childID } from "../../context/session-utils"
import { taskResult, taskRunning, taskVisible } from "./task-tool-state"
@@ -26,6 +27,7 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
const language = useLanguage()
const session = useSession()
const vscode = useVSCode()
const worktree = useWorktreeMode()
const childSessionId = () =>
childID({
@@ -50,11 +52,18 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
}),
)
let synced: string | undefined
createEffect(() => {
const id = taskVisible(open(), childSessionId())
if (synced === id) return
if (synced) session.unsyncSession(synced)
synced = id
if (!id) return
session.syncSession(id)
})
onCleanup(() => {
if (synced) session.unsyncSession(synced)
})
const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }))
@@ -115,7 +124,16 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
e.stopPropagation()
const id = childSessionId()
if (!id) return
vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title: description() })
const title = description()
if (worktree) {
window.dispatchEvent(
new CustomEvent("agentManager.openSubagent", {
detail: { sessionID: id, title, parentSessionID: session.currentSessionID() },
}),
)
return
}
vscode.postMessage({ type: "openSubAgentViewer", sessionID: id, title })
}
const trigger = () => (
@@ -138,7 +156,7 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
icon="square-arrow-top-right"
size="small"
variant="ghost"
aria-label="Open sub-agent in tab"
aria-label={worktree ? "Open sub-agent in panel" : "Open sub-agent in tab"}
onClick={openInTab}
/>
</Show>
@@ -35,7 +35,7 @@ export const TaskUsage: Component<TaskUsageProps> = (props) => {
if (value > 0 && value < 0.000001) return "<$0.000001"
return money().format(value)
}
const Summary = () => (
const renderSummary = () => (
<>
<span class="task-header-tokens-label">Tokens</span>
<Show when={props.tokens.input > 0}>
@@ -60,19 +60,10 @@ export const TaskUsage: Component<TaskUsageProps> = (props) => {
)
return (
<Show
when={props.usage?.models.length}
fallback={
<div class="task-header-tokens">
<Summary />
</div>
}
>
<Show when={props.usage?.models.length} fallback={<div class="task-header-tokens">{renderSummary()}</div>}>
<Collapsible variant="ghost" class="task-header-usage tool-collapsible" defaultOpen={props.defaultOpen}>
<Collapsible.Trigger class="task-header-usage-trigger">
<span class="task-header-tokens">
<Summary />
</span>
<span class="task-header-tokens">{renderSummary()}</span>
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
@@ -2,11 +2,32 @@ import type { Part as SDKPart, ToolPart } from "@kilocode/sdk/v2"
const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"])
const TERMINAL_TOOLS = new Set(["bash", "background_process"])
const BUILTIN_NON_MCP_TOOLS = new Set([
"read",
"list",
"glob",
"grep",
"webfetch",
"websearch",
"chart",
"codesearch",
"semantic_search",
"task",
"todowrite",
"todoread",
"question",
"suggest",
"skill",
"plan_enter",
"plan_exit",
])
export function toolDefaultOpen(part: SDKPart, terminal: boolean, edit: boolean) {
export function toolDefaultOpen(part: SDKPart, terminal: boolean, edit: boolean, mcp?: boolean) {
if (part.type !== "tool") return undefined
const tool = (part as unknown as ToolPart).tool
if (TERMINAL_TOOLS.has(tool)) return terminal
if (EDIT_TOOLS.has(tool)) return edit
if (BUILTIN_NON_MCP_TOOLS.has(tool)) return undefined
if (mcp !== undefined) return mcp
return undefined
}
@@ -29,7 +29,8 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
const dialog = useDialog()
const session = useSession()
const tabs = useLocalTabs()
const [tab, setTab] = createSignal<Source>("local")
const worktreeIds = () => props.worktreeSessionIds?.()
const [tab, setTab] = createSignal<Source>(worktreeIds() ? "worktree" : "local")
let local: HTMLButtonElement | undefined
let cloud: HTMLButtonElement | undefined
let worktree: HTMLButtonElement | undefined
@@ -37,8 +38,6 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
let cloudPanel: HTMLDivElement | undefined
let worktreePanel: HTMLDivElement | undefined
const worktreeIds = () => props.worktreeSessionIds?.()
createEffect(() => {
if (tab() === "worktree" && !worktreeIds()) setTab("local")
})
@@ -6,7 +6,7 @@ import { Switch } from "@kilocode/kilo-ui/switch"
import { useConfig } from "../../context/config"
import { useDisplay } from "../../context/display"
import { useLanguage } from "../../context/language"
import type { CodeEditDisplay, TerminalCommandDisplay } from "../../types/messages"
import type { CodeEditDisplay, McpToolDisplay, TerminalCommandDisplay } from "../../types/messages"
import SettingsRow from "./SettingsRow"
interface LayoutOption {
@@ -24,6 +24,11 @@ const CODE_EDIT_OPTIONS: LayoutOption[] = [
{ value: "collapsed", labelKey: "settings.display.codeEdit.collapsed" },
]
const MCP_OPTIONS: LayoutOption[] = [
{ value: "expanded", labelKey: "settings.display.mcpTool.expanded" },
{ value: "collapsed", labelKey: "settings.display.mcpTool.collapsed" },
]
const DisplayTab: Component = () => {
const { config, updateConfig, settings, updateSetting } = useConfig()
const display = useDisplay()
@@ -96,7 +101,7 @@ const DisplayTab: Component = () => {
description={language.t("settings.display.tokenThroughput.description")}
>
<Switch
checked={Boolean(settings()["showTokenThroughput"] ?? false)}
checked={Boolean(settings()["showTokenThroughput"] ?? true)}
onChange={(checked: boolean) => updateSetting("showTokenThroughput", checked)}
hideLabel
>
@@ -141,7 +146,6 @@ const DisplayTab: Component = () => {
<SettingsRow
title={language.t("settings.display.codeEdit.title")}
description={language.t("settings.display.codeEdit.description")}
last
>
<Select
options={CODE_EDIT_OPTIONS}
@@ -159,6 +163,28 @@ const DisplayTab: Component = () => {
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.display.mcpTool.title")}
description={language.t("settings.display.mcpTool.description")}
last
>
<Select
options={MCP_OPTIONS}
current={MCP_OPTIONS.find((o) => o.value === (config().mcp_tool_display ?? "collapsed"))}
value={(o) => o.value}
label={(o) => language.t(o.labelKey)}
onSelect={(o) => {
if (!o) return
const next = o.value as McpToolDisplay
if (next === (config().mcp_tool_display ?? "collapsed")) return
updateConfig({ mcp_tool_display: next })
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
</Card>
</div>
)
@@ -38,6 +38,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [
"auto_collapse_reasoning",
"terminal_command_display",
"code_edit_display",
"mcp_tool_display",
"hide_prompt_training_models",
"sandbox",
"indexing",
@@ -776,7 +776,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
buildTriggerLabel(
activeModel()?.name,
activeModel()?.providerID,
activeModel()?.providerName,
props.value,
props.allowClear ?? false,
props.clearLabel ?? "",
@@ -1011,7 +1010,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite" || hasSearch()
const showSelect = () => expanded() && preActive() && !isActive(model)
const starLabel = () =>
`${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}`
@@ -1082,9 +1080,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
</Show>
</span>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</Show>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</div>
</div>
<Show when={session && props.favorites !== false}>
@@ -216,7 +216,6 @@ export function stripSubProviderPrefix(name: string): string {
export function buildTriggerLabel(
resolvedName: string | undefined,
providerID: string | undefined,
providerName: string | undefined,
raw: ModelSelection | null,
allowClear: boolean,
clearLabel: string,
@@ -225,7 +224,6 @@ export function buildTriggerLabel(
): string {
if (resolvedName) {
if (providerID === KILO_GATEWAY_ID) return stripSubProviderPrefix(resolvedName)
if (providerName) return `${providerName} / ${resolvedName}`
return resolvedName
}
if (raw?.providerID && raw?.modelID) {
@@ -35,7 +35,7 @@ export const DisplayProvider: ParentComponent = (props) => {
const vscode = useVSCode()
const reasoningAutoCollapse = createMemo(() => config().auto_collapse_reasoning ?? false)
const [fontSize, setFontSizeSignal] = createSignal(readFontSize())
const [throughputVisible, setThroughputVisible] = createSignal(false)
const [throughputVisible, setThroughputVisible] = createSignal(true)
const [autoApprovalReasonVisible, setAutoApprovalReasonVisible] = createSignal(true)
// Request both toggles once on mount; the extension posts back
@@ -31,7 +31,17 @@ export function resolvePromptAgent(input: {
selections: Record<string, string>
pending: string | null
}) {
if (input.sessionID) return input.selections[input.sessionID]
if (input.sessionID) {
const sel = input.selections[input.sessionID]
if (sel) return sel
// Only fall back to the pending selection for draft scopes, not real server
// sessions. A server session with no stored selection must keep its own agent
// rather than be flipped to the stale/default pending agent.
if (!input.sessionID.startsWith("ses_")) {
return input.pending ?? undefined
}
return undefined
}
return input.pending ?? undefined
}
@@ -25,6 +25,15 @@ export function sameParts(local: Part[] = [], snapshot: Part[] = []): boolean {
return true
}
export function mergeOptimisticPart(current: Part[], ids: ReadonlySet<string>, part: Part) {
const index = current.findIndex((item) => ids.has(item.id) && item.type === part.type)
if (index < 0) return { parts: [...current, part] }
const old = current[index]!
const next = current.slice()
next[index] = part
return { parts: next, replaced: old.id }
}
/**
* Reconcile snapshots may be older than in-flight streaming deltas. Preserve
* only appended streamed tail parts and open prefix extensions while still
@@ -200,9 +200,9 @@ export function activeUserMessageID(
parts?: (msg: Message) => Message["parts"],
submitting?: boolean,
) {
if (status.type === "idle" && !submitting) return undefined
const id = active(messages, status, parts)
if (id) return id
if (status.type === "idle" && !submitting) return undefined
return pending(messages, parts)
}
@@ -1,6 +1,24 @@
import { reconcile } from "solid-js/store"
import type { Message, MessageLoadMode, Part, ToolPart } from "../types/messages"
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
export type MessageMutation = Exclude<MessageLoadMode, "focus"> | "append" | "update"
export interface MessagePageState {
loadingInitial: boolean
loadingOlder: boolean
before?: string
hasMore: boolean
lastMutation?: MessageMutation
}
export const emptyPageState: MessagePageState = {
loadingInitial: false,
loadingOlder: false,
hasMore: false,
}
/** Remove ids from a Set immutably, returning the original when nothing changed. */
export function dropSet(prev: Set<string>, ids: Iterable<string>): Set<string> {
const next = new Set(prev)
@@ -8,7 +26,13 @@ export function dropSet(prev: Set<string>, ids: Iterable<string>): Set<string> {
return next.size === prev.size ? prev : next
}
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
export function messageParts(messages: Message[]): Record<string, Part[]> {
const parts: Record<string, Part[]> = {}
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
}
return parts
}
type SnapshotPart = {
type?: string
@@ -481,11 +505,3 @@ export function mergeMessages(current: Message[], incoming: Message[], mode: Exc
return true
})
}
export function messageParts(messages: Message[]): Record<string, Part[]> {
const parts: Record<string, Part[]> = {}
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
}
return parts
}

Some files were not shown because too many files have changed in this diff Show More