mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
Merge branch 'main' into kirillk/jetbrains-2
This commit is contained in:
@@ -23,7 +23,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
pre_release:
|
||||
description: "Publish as pre-release (VS Code marketplace)"
|
||||
description: "Publish as pre-release (VS Code marketplace + npm rc channel)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
@@ -58,6 +58,7 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
KILO_BUMP: ${{ inputs.bump }}
|
||||
KILO_VERSION: ${{ inputs.version }}
|
||||
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
|
||||
outputs:
|
||||
@@ -83,6 +84,7 @@ jobs:
|
||||
env:
|
||||
KILO_VERSION: ${{ needs.version.outputs.version }}
|
||||
KILO_RELEASE: ${{ needs.version.outputs.release }}
|
||||
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
cleanup = undefined
|
||||
}
|
||||
|
||||
lastScrollTop = undefined
|
||||
scroll = el
|
||||
|
||||
if (!el) return
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
"taskDefinitions": [
|
||||
{
|
||||
"type": "kilo-worktree-setup",
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "The setup script command to execute"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
|
||||
@@ -36,6 +36,7 @@ import { GitOps } from "./agent-manager/GitOps"
|
||||
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
|
||||
import { getWorkspaceRoot } from "./review-utils"
|
||||
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
|
||||
import type { RemoteStatusService } from "./services/RemoteStatusService"
|
||||
import { resolveProjectDirectory } from "./project-directory"
|
||||
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
|
||||
import { retry } from "./services/cli-backend/retry"
|
||||
@@ -112,6 +113,7 @@ const mapAgent = (a: Agent) => ({
|
||||
|
||||
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
|
||||
public static readonly viewType = "kilo-code.SidebarProvider"
|
||||
private readonly instanceId = crypto.randomUUID()
|
||||
|
||||
private webview: vscode.Webview | null = null
|
||||
private currentSession: Session | null = null
|
||||
@@ -145,6 +147,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private cachedNotificationsMessage: unknown = null
|
||||
private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = []
|
||||
private readyResolvers: (() => void)[] = []
|
||||
private promptRecoveryQueued = false
|
||||
private promptRecovery: Promise<void> | null = null
|
||||
private trackedSessionIds: Set<string> = new Set()
|
||||
private syncedChildSessions: Set<string> = new Set()
|
||||
/** Tracks the latest status for each session, used to warn before destructive config operations. */
|
||||
@@ -173,6 +177,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private unsubscribeDirectoryProvider: (() => void) | null = null
|
||||
private initConnectionPromise: Promise<void> | null = null
|
||||
private webviewMessageDisposable: vscode.Disposable | null = null
|
||||
private viewStateDisposable: vscode.Disposable | null = null
|
||||
private visibilityDisposable: vscode.Disposable | null = null
|
||||
|
||||
/** Lazily initialized ignore controller for .kilocodeignore filtering */
|
||||
private ignoreController: FileIgnoreController | null = null
|
||||
@@ -198,6 +204,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
| null = null
|
||||
|
||||
private diffVirtualProvider: import("./DiffVirtualProvider").DiffVirtualProvider | undefined
|
||||
private remoteService: RemoteStatusService | null = null
|
||||
private unsubscribeRemote: (() => void) | null = null
|
||||
|
||||
constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
@@ -211,6 +219,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
TelemetryProxy.getInstance().setProvider(this)
|
||||
}
|
||||
|
||||
setRemoteService(service: RemoteStatusService): void {
|
||||
this.remoteService = service
|
||||
this.unsubscribeRemote = service.onChange(() => this.sendRemoteStatus())
|
||||
}
|
||||
private sendRemoteStatus(): void {
|
||||
const s = this.remoteService?.getState()
|
||||
if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected })
|
||||
}
|
||||
private focusSession(id?: string): void {
|
||||
if (id) this.connectionService.registerFocused(this.instanceId, id)
|
||||
else this.connectionService.unregisterFocused(this.instanceId)
|
||||
}
|
||||
|
||||
public setProjectDirectory(directory: string | null): void {
|
||||
if (this.projectDirectory === directory) return
|
||||
this.projectDirectory = directory
|
||||
@@ -324,6 +345,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// authoritative and reconciliation risks race-resetting busy sessions.
|
||||
const reconcile = this.sessionStatusMap.size === 0
|
||||
void this.seedSessionStatusMap(reconcile)
|
||||
|
||||
this.sendRemoteStatus()
|
||||
}
|
||||
|
||||
// legacy-migration start
|
||||
@@ -354,20 +377,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
localResourceRoots: [this.extensionUri],
|
||||
}
|
||||
|
||||
// Set HTML content
|
||||
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
|
||||
|
||||
// Handle messages from webview (shared handler)
|
||||
this.setupWebviewMessageHandler(webviewView.webview)
|
||||
|
||||
// Track sidebar visibility for keybinding when-clauses and stats polling
|
||||
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
|
||||
webviewView.onDidChangeVisibility(() => {
|
||||
this.visibilityDisposable?.dispose()
|
||||
this.visibilityDisposable = webviewView.onDidChangeVisibility(() => {
|
||||
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
|
||||
this.statsPoller?.setEnabled(webviewView.visible)
|
||||
this.focusSession(webviewView.visible ? this.currentSession?.id : undefined)
|
||||
})
|
||||
|
||||
// Initialize connection to CLI backend
|
||||
this.initializeConnection()
|
||||
}
|
||||
|
||||
@@ -386,9 +405,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
panel.webview.html = this._getHtmlForWebview(panel.webview)
|
||||
|
||||
// Handle messages from webview (shared handler)
|
||||
this.setupWebviewMessageHandler(panel.webview)
|
||||
|
||||
this.viewStateDisposable?.dispose()
|
||||
this.viewStateDisposable = panel.onDidChangeViewState(() =>
|
||||
this.focusSession(panel.active ? this.currentSession?.id : undefined),
|
||||
)
|
||||
this.initializeConnection()
|
||||
}
|
||||
|
||||
@@ -444,6 +465,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
void this.handleLoadSessions()
|
||||
}
|
||||
|
||||
/** Recover permission/question prompts after sessions and directories are tracked. */
|
||||
public recoverPendingPrompts(): void {
|
||||
this.promptRecoveryQueued = true
|
||||
if (!this.isWebviewReady) return
|
||||
if (!this.client) return
|
||||
if (this.promptRecovery) return
|
||||
|
||||
this.promptRecovery = this.flushPendingPrompts().finally(() => {
|
||||
this.promptRecovery = null
|
||||
if (this.promptRecoveryQueued && this.isWebviewReady && this.client) this.recoverPendingPrompts()
|
||||
})
|
||||
}
|
||||
|
||||
private async flushPendingPrompts(): Promise<void> {
|
||||
while (this.promptRecoveryQueued && this.isWebviewReady) {
|
||||
if (!this.client) return
|
||||
this.promptRecoveryQueued = false
|
||||
await Promise.all([
|
||||
fetchAndSendPendingPermissions(this.permissionCtx),
|
||||
fetchAndSendPendingQuestions(this.questionCtx),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
public openCloudSession(sessionId: string): void {
|
||||
this.postMessage({ type: "openCloudSession", sessionId })
|
||||
}
|
||||
@@ -499,6 +544,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.isWebviewReady = true
|
||||
await this.syncWebviewState("webviewReady")
|
||||
this.flushPendingReviewComments()
|
||||
this.recoverPendingPrompts()
|
||||
this.readyResolvers.splice(0).forEach((r) => r())
|
||||
break
|
||||
case "sendMessage": {
|
||||
@@ -582,6 +628,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
case "clearSession":
|
||||
this.contextSessionID = this.currentSession?.id ?? this.contextSessionID
|
||||
this.currentSession = null
|
||||
this.focusSession()
|
||||
break
|
||||
case "loadMessages":
|
||||
// Don't await: allow parallel loads so rapid session switching
|
||||
@@ -817,6 +864,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
case "renameSession":
|
||||
await this.handleRenameSession(message.sessionID, message.title)
|
||||
break
|
||||
case "toggleRemote":
|
||||
case "setRemoteEnabled":
|
||||
case "requestRemoteStatus":
|
||||
this.remoteService
|
||||
?.handleMessage(message.type, message.enabled)
|
||||
.then((s) => {
|
||||
if (s) this.sendRemoteStatus()
|
||||
})
|
||||
.catch((err) => console.error("[Kilo New] remote message failed:", err))
|
||||
break
|
||||
case "updateSetting":
|
||||
await this.handleUpdateSetting(message.key, message.value)
|
||||
break
|
||||
@@ -1048,6 +1105,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// Subscribe to SSE events for this webview (filtered by tracked sessions)
|
||||
this.unsubscribeEvent = this.connectionService.onEventFiltered(
|
||||
(event) => {
|
||||
// Remote status events are global and should always pass through
|
||||
if (event.type === "kilo-sessions.remote-status-changed") return true
|
||||
const sessionId = this.connectionService.resolveEventSessionId(event)
|
||||
|
||||
// message.part.updated and message.part.delta are always session-scoped; drop if session unknown.
|
||||
@@ -1090,8 +1149,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
await this.syncWebviewState("sse-connected")
|
||||
await this.flushPendingSessionRefresh("sse-connected")
|
||||
await fetchAndSendPendingPermissions(this.permissionCtx)
|
||||
await fetchAndSendPendingQuestions(this.questionCtx)
|
||||
this.recoverPendingPrompts()
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error)
|
||||
this.postMessage({
|
||||
@@ -1166,6 +1224,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
await this.syncWebviewState("initializeConnection")
|
||||
await this.flushPendingSessionRefresh("initializeConnection")
|
||||
this.recoverPendingPrompts()
|
||||
|
||||
// Fetch providers, agents, skills, config, notifications, and session statuses in parallel
|
||||
await Promise.all([
|
||||
@@ -1244,6 +1303,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private async handleLoadMessages(sessionID: string): Promise<void> {
|
||||
// Track the session so we receive its SSE events
|
||||
this.trackedSessionIds.add(sessionID)
|
||||
this.focusSession(sessionID)
|
||||
this.contextSessionID = sessionID
|
||||
|
||||
if (!this.client) {
|
||||
@@ -1329,9 +1389,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
messages,
|
||||
})
|
||||
|
||||
// Recover any permission.asked events that were missed while the webview
|
||||
// was loading or during an SSE reconnection (fire-and-forget).
|
||||
void fetchAndSendPendingPermissions(this.permissionCtx)
|
||||
// Recover any prompts missed while the webview was loading or during an SSE reconnection.
|
||||
this.recoverPendingPrompts()
|
||||
} catch (error) {
|
||||
// Silently ignore aborted requests — the user switched to a different session
|
||||
if (abort.signal.aborted) return
|
||||
@@ -1388,11 +1447,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
messages,
|
||||
})
|
||||
|
||||
// Recover any missed permission/question prompts emitted by the child before
|
||||
// we started tracking it. Both run fire-and-forget after messagesLoaded so
|
||||
// the webview isn't blocked.
|
||||
void fetchAndSendPendingPermissions(this.permissionCtx)
|
||||
void fetchAndSendPendingQuestions(this.questionCtx)
|
||||
// Recover any prompts emitted by the child before we started tracking it.
|
||||
this.recoverPendingPrompts()
|
||||
} catch (err) {
|
||||
this.syncedChildSessions.delete(sessionID)
|
||||
console.error("[Kilo New] KiloProvider: Failed to sync child session:", err)
|
||||
@@ -2822,6 +2878,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
* Filters events by project ID and tracked session IDs so each webview only sees its own sessions.
|
||||
*/
|
||||
private handleEvent(event: Event): void {
|
||||
if (event.type === "kilo-sessions.remote-status-changed") {
|
||||
this.remoteService?.updateFromEvent({ enabled: event.properties.enabled, connected: event.properties.connected })
|
||||
return
|
||||
}
|
||||
|
||||
// Drop session events from other projects before any tracking logic.
|
||||
// This must come first: the trackedSessionIds guard below would otherwise
|
||||
// let a foreign session through if it was accidentally tracked.
|
||||
@@ -3233,6 +3294,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
* Does NOT kill the server — that's the connection service's job.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.unsubscribeRemote?.()
|
||||
this.focusSession()
|
||||
this.statsPoller?.stop()
|
||||
this.statsGitOps?.dispose()
|
||||
this.unsubscribeEvent?.()
|
||||
@@ -3244,7 +3307,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.unsubscribeMigrationComplete?.()
|
||||
this.unsubscribeClearPendingPrompts?.()
|
||||
this.unsubscribeDirectoryProvider?.()
|
||||
this.viewStateDisposable?.dispose()
|
||||
this.visibilityDisposable?.dispose()
|
||||
this.webviewMessageDisposable?.dispose()
|
||||
this.isWebviewReady = false
|
||||
this.promptRecoveryQueued = false
|
||||
this.trackedSessionIds.clear()
|
||||
this.syncedChildSessions.clear()
|
||||
this.sessionDirectories.clear()
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
|
||||
import { KiloProvider } from "./KiloProvider"
|
||||
import { resolvePanelProjectDirectory } from "./project-directory"
|
||||
import type { KiloConnectionService } from "./services/cli-backend"
|
||||
import type { RemoteStatusService } from "./services/RemoteStatusService"
|
||||
|
||||
type PanelView = "settings" | "profile" | "marketplace"
|
||||
|
||||
@@ -26,6 +27,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
|
||||
private panels = new Map<PanelView, vscode.WebviewPanel>()
|
||||
private providers = new Map<PanelView, KiloProvider>()
|
||||
private tabs = new Map<PanelView, string>()
|
||||
private remoteService: RemoteStatusService | null = null
|
||||
|
||||
constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
@@ -101,6 +103,9 @@ export class SettingsEditorProvider implements vscode.Disposable {
|
||||
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
|
||||
projectDirectory,
|
||||
})
|
||||
if (this.remoteService) {
|
||||
provider.setRemoteService(this.remoteService)
|
||||
}
|
||||
provider.resolveWebviewPanel(panel)
|
||||
|
||||
// Listen for closePanel from the webview (back button in panel mode)
|
||||
@@ -144,6 +149,14 @@ export class SettingsEditorProvider implements vscode.Disposable {
|
||||
})
|
||||
}
|
||||
|
||||
setRemoteService(service: RemoteStatusService): void {
|
||||
this.remoteService = service
|
||||
// Apply to any existing providers
|
||||
for (const [, provider] of this.providers) {
|
||||
provider.setRemoteService(service)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const [, panel] of this.panels) {
|
||||
panel.dispose()
|
||||
|
||||
@@ -25,6 +25,7 @@ import { continueInWorktree } from "./continue-in-worktree"
|
||||
import { shouldStopDiffPolling } from "./delete-worktree"
|
||||
import { buildKeybindingMap } from "./format-keybinding"
|
||||
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
|
||||
import { Semaphore } from "./semaphore"
|
||||
import { PLATFORM } from "./constants"
|
||||
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
|
||||
import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils"
|
||||
@@ -76,11 +77,13 @@ export class AgentManagerProvider implements Disposable {
|
||||
(msg) => this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
|
||||
createTerminalHost(),
|
||||
)
|
||||
this.gitOps = new GitOps({ log: (...args) => this.log(...args) })
|
||||
const semaphore = new Semaphore(3)
|
||||
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
|
||||
this.statsPoller = new GitStatsPoller({
|
||||
getWorktrees: () => this.state?.getWorktrees() ?? [],
|
||||
getWorkspaceRoot: () => this.getRoot(),
|
||||
getClient: () => this.connectionService.getClient(),
|
||||
semaphore,
|
||||
onStats: (stats) => {
|
||||
const msg = { type: "agentManager.worktreeStats" as const, stats }
|
||||
this.cachedWorktreeStats = msg
|
||||
@@ -105,6 +108,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber,
|
||||
openExternal: (u) => this.host.openExternal(u),
|
||||
log: (...a) => this.log(...a),
|
||||
semaphore,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -197,24 +201,25 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.host.refreshGit()
|
||||
}
|
||||
|
||||
// Do not auto-remove stale worktrees on load.
|
||||
// Presence checks run in the shared poller and require explicit user cleanup.
|
||||
|
||||
// Register all worktree sessions with the session provider
|
||||
for (const worktree of state.getWorktrees()) {
|
||||
for (const session of state.getSessions(worktree.id)) {
|
||||
this.panel?.sessions.setSessionDirectory(session.id, worktree.path)
|
||||
this.panel?.sessions.trackSession(session.id)
|
||||
for (const wt of state.getWorktrees()) {
|
||||
for (const s of state.getSessions(wt.id)) {
|
||||
this.panel?.sessions.setSessionDirectory(s.id, wt.path)
|
||||
this.panel?.sessions.trackSession(s.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Push full state to webview
|
||||
for (const s of state.getSessions()) if (!s.worktreeId) this.panel?.sessions.trackSession(s.id)
|
||||
this.pushState()
|
||||
|
||||
// Refresh sessions so worktree sessions appear in the list
|
||||
if (state.getSessions().length > 0) {
|
||||
this.panel?.sessions.refreshSessions()
|
||||
}
|
||||
|
||||
// Recover any pending permission/question prompts that were missed during
|
||||
// panel recreation or SSE reconnection. Must run after all worktree sessions
|
||||
// are registered with their directory overrides so the recovery queries the
|
||||
// correct CLI backend Instances.
|
||||
this.panel?.sessions.recoverPendingPrompts()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,8 +237,12 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId)
|
||||
if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId)
|
||||
if (m.type === "agentManager.openLocally") {
|
||||
if (!this.panel) return null
|
||||
this.panel.sessions.clearSessionDirectory(m.sessionId)
|
||||
this.panel?.sessions.clearSessionDirectory(m.sessionId)
|
||||
const st = this.getStateManager()
|
||||
if (st?.getSession(m.sessionId)) {
|
||||
st.moveSession(m.sessionId, null)
|
||||
this.pushState()
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (m.type === "continueInWorktree") {
|
||||
@@ -245,6 +254,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId)
|
||||
if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId)
|
||||
if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId)
|
||||
if (m.type === "agentManager.persistSession" || m.type === "agentManager.forgetSession") {
|
||||
const persist = m.type === "agentManager.persistSession"
|
||||
void this.stateReady?.then(() => {
|
||||
const st = this.getStateManager()
|
||||
if (st)
|
||||
persist ? !st.getSession(m.sessionId) && st.addSession(m.sessionId, null) : st.removeSession(m.sessionId)
|
||||
})
|
||||
return null
|
||||
}
|
||||
if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) {
|
||||
this.activeSessionId = m.draftID
|
||||
}
|
||||
@@ -400,6 +418,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.startDiffPolling(m.sessionId)
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.openSessions") {
|
||||
this.connectionService.registerOpen("agent-manager", m.sessionIDs)
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.stopDiffWatch") {
|
||||
this.stopDiffPolling()
|
||||
return null
|
||||
@@ -427,6 +449,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
// uses the correct session even before the session provider's async session.get completes.
|
||||
if (m.type === "loadMessages") {
|
||||
this.activeSessionId = m.sessionID
|
||||
this.connectionService.registerFocused("agent-manager", m.sessionID)
|
||||
this.terminalManager.syncOnSessionSwitch(m.sessionID)
|
||||
this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined)
|
||||
}
|
||||
@@ -434,6 +457,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
// After clearSession, clear active tracking and re-register worktree sessions
|
||||
if (m.type === "clearSession") {
|
||||
this.activeSessionId = undefined
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
void Promise.resolve().then(() => {
|
||||
if (!this.panel || !this.state) return
|
||||
for (const id of this.state.worktreeSessionIds()) {
|
||||
@@ -911,9 +935,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
continue
|
||||
}
|
||||
|
||||
await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch)
|
||||
await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch, wt.worktree.id)
|
||||
|
||||
const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch)
|
||||
const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch, wt.worktree.id)
|
||||
if (!session) {
|
||||
const state = this.getStateManager()
|
||||
const manager = this.getWorktreeManager()
|
||||
@@ -926,7 +950,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
const state = this.getStateManager()!
|
||||
state.addSession(session.id, wt.worktree.id)
|
||||
this.registerWorktreeSession(session.id, wt.result.path)
|
||||
this.notifyWorktreeReady(session.id, wt.result)
|
||||
this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id)
|
||||
|
||||
// Set the per-version model immediately so the UI selector reflects
|
||||
// the correct model as soon as the worktree appears, before Phase 2.
|
||||
@@ -1404,6 +1428,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
status: "error",
|
||||
message: `Setup script failed: ${msg}`,
|
||||
branch,
|
||||
worktreeId,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1432,6 +1457,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (!this.panel) return
|
||||
this.panel.sessions.setSessionDirectory(sessionId, directory)
|
||||
this.panel.sessions.trackSession(sessionId)
|
||||
// Recover any permission/question prompts that arrived before the session
|
||||
// was tracked. The CLI backend may have emitted permission.asked between
|
||||
// session.create() returning and this registration completing.
|
||||
this.panel.sessions.recoverPendingPrompts()
|
||||
}
|
||||
|
||||
private onWorktreePresence(result: WorktreePresenceResult): void {
|
||||
@@ -1987,6 +2016,8 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
this.connectionService.registerOpen("agent-manager", [])
|
||||
this.stopDiffPolling()
|
||||
this.statsPoller.stop()
|
||||
this.gitOps.dispose()
|
||||
|
||||
@@ -4,11 +4,14 @@ import * as fs from "fs/promises"
|
||||
import { spawn } from "../util/process"
|
||||
import simpleGit from "simple-git"
|
||||
import { parseWorktreeList, normalizePath } from "./git-import"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
|
||||
interface GitOpsOptions {
|
||||
log: (...args: unknown[]) => void
|
||||
/** Override git command execution for testing. */
|
||||
runGit?: (args: string[], cwd: string) => Promise<string>
|
||||
/** Shared concurrency gate for child process spawning. */
|
||||
semaphore?: Semaphore
|
||||
}
|
||||
|
||||
export interface ApplyConflict {
|
||||
@@ -63,6 +66,7 @@ export class GitOps {
|
||||
private readonly log: (...args: unknown[]) => void
|
||||
private readonly runGit: (args: string[], cwd: string) => Promise<string>
|
||||
private readonly controller = new AbortController()
|
||||
private readonly semaphore: Semaphore | undefined
|
||||
|
||||
get disposed(): boolean {
|
||||
return this.controller.signal.aborted
|
||||
@@ -70,6 +74,7 @@ export class GitOps {
|
||||
|
||||
constructor(options: GitOpsOptions) {
|
||||
this.log = options.log
|
||||
this.semaphore = options.semaphore
|
||||
this.runGit =
|
||||
options.runGit ??
|
||||
((args, cwd) =>
|
||||
@@ -87,20 +92,22 @@ export class GitOps {
|
||||
private raw(args: string[], cwd: string): Promise<string> {
|
||||
const signal = this.controller.signal
|
||||
if (signal.aborted) return Promise.reject(new Error("GitOps disposed"))
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const onAbort = () => reject(new Error("GitOps disposed"))
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
this.runGit(args, cwd).then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(err) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
reject(err)
|
||||
},
|
||||
)
|
||||
})
|
||||
const invoke = () =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const onAbort = () => reject(new Error("GitOps disposed"))
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
this.runGit(args, cwd).then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(err) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
reject(err)
|
||||
},
|
||||
)
|
||||
})
|
||||
return this.semaphore ? this.semaphore.run(invoke) : invoke()
|
||||
}
|
||||
|
||||
/** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */
|
||||
@@ -413,37 +420,39 @@ export class GitOps {
|
||||
if (this.controller.signal.aborted) {
|
||||
return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" })
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", args, {
|
||||
cwd,
|
||||
env: options?.env,
|
||||
signal: this.controller.signal,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})
|
||||
const invoke = () =>
|
||||
new Promise<ExecResult>((resolve) => {
|
||||
const child = spawn("git", args, {
|
||||
cwd,
|
||||
env: options?.env,
|
||||
signal: this.controller.signal,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
if (options?.stdin !== undefined) {
|
||||
if (!child.stdin) {
|
||||
resolve({ code: 1, stdout: "", stderr: "stdin not available for git process" })
|
||||
return
|
||||
if (options?.stdin !== undefined) {
|
||||
if (!child.stdin) {
|
||||
resolve({ code: 1, stdout: "", stderr: "stdin not available for git process" })
|
||||
return
|
||||
}
|
||||
child.stdin.end(options.stdin)
|
||||
}
|
||||
child.stdin.end(options.stdin)
|
||||
}
|
||||
|
||||
const out: Buffer[] = []
|
||||
const err: Buffer[] = []
|
||||
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
|
||||
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
|
||||
const out: Buffer[] = []
|
||||
const err: Buffer[] = []
|
||||
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
|
||||
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
|
||||
|
||||
child.on("error", (error) => {
|
||||
resolve({ code: 1, stdout: "", stderr: error.message })
|
||||
})
|
||||
child.on("close", (code) => {
|
||||
resolve({
|
||||
code: code ?? 1,
|
||||
stdout: Buffer.concat(out).toString("utf8"),
|
||||
stderr: Buffer.concat(err).toString("utf8"),
|
||||
child.on("error", (error) => {
|
||||
resolve({ code: 1, stdout: "", stderr: error.message })
|
||||
})
|
||||
child.on("close", (code) => {
|
||||
resolve({
|
||||
code: code ?? 1,
|
||||
stdout: Buffer.concat(out).toString("utf8"),
|
||||
stderr: Buffer.concat(err).toString("utf8"),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
return this.semaphore ? this.semaphore.run(invoke) : invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as path from "path"
|
||||
import type { KiloClient, FileDiff } from "@kilocode/sdk/v2/client"
|
||||
import { remoteRef, type Worktree } from "./WorktreeStateManager"
|
||||
import type { GitOps } from "./GitOps"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
import { normalizePath } from "./git-import"
|
||||
|
||||
export interface WorktreeStats {
|
||||
@@ -45,6 +46,8 @@ interface GitStatsPollerOptions {
|
||||
onWorktreePresence?: (result: WorktreePresenceResult) => void
|
||||
log: (...args: unknown[]) => void
|
||||
intervalMs?: number
|
||||
/** Shared concurrency gate for child process spawning. */
|
||||
semaphore?: Semaphore
|
||||
}
|
||||
|
||||
export class GitStatsPoller {
|
||||
@@ -154,15 +157,20 @@ export class GitStatsPoller {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate the HTTP diffSummary call through the semaphore but NOT the
|
||||
// aheadBehind call — that goes through GitOps.raw() which already
|
||||
// acquires the same semaphore. Wrapping both would deadlock.
|
||||
const gate = this.options.semaphore
|
||||
const diff = (dir: string, base: string) => {
|
||||
const invoke = () => client.worktree.diffSummary({ directory: dir, base }, { throwOnError: true })
|
||||
return gate ? gate.run(invoke) : invoke()
|
||||
}
|
||||
const stats = (
|
||||
await Promise.all(
|
||||
active.map(async (wt) => {
|
||||
try {
|
||||
const base = remoteRef(wt)
|
||||
const [{ data: diffs }, ab] = await Promise.all([
|
||||
client.worktree.diffSummary({ directory: wt.path, base }, { throwOnError: true }),
|
||||
this.git.aheadBehind(wt.path, base),
|
||||
])
|
||||
const [{ data: diffs }, ab] = await Promise.all([diff(wt.path, base), this.git.aheadBehind(wt.path, base)])
|
||||
const files = diffs.length
|
||||
const additions = diffs.reduce((sum: number, diff: FileDiff) => sum + diff.additions, 0)
|
||||
const deletions = diffs.reduce((sum: number, diff: FileDiff) => sum + diff.deletions, 0)
|
||||
@@ -260,8 +268,10 @@ export class GitStatsPoller {
|
||||
try {
|
||||
if (base && client) {
|
||||
this.options.log(`Local stats: using HTTP client with base=${base}`)
|
||||
const gate = this.options.semaphore
|
||||
const invoke = () => client.worktree.diffSummary({ directory: root, base }, { throwOnError: true })
|
||||
const [{ data: diffs }, ab] = await Promise.all([
|
||||
client.worktree.diffSummary({ directory: root, base }, { throwOnError: true }),
|
||||
gate ? gate.run(invoke) : invoke(),
|
||||
this.git.aheadBehind(root, base),
|
||||
])
|
||||
files = diffs.length
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ExecFileOptionsWithStringEncoding } from "child_process"
|
||||
import type { Worktree } from "./WorktreeStateManager"
|
||||
import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types"
|
||||
import { execWithShellEnv } from "./shell-env"
|
||||
import { classifyPRError } from "./git-import"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
|
||||
interface PRStatusPollerOptions {
|
||||
getWorktrees: () => Worktree[]
|
||||
@@ -9,6 +11,8 @@ interface PRStatusPollerOptions {
|
||||
onStatus: (worktreeId: string, pr: PRStatus | null, error?: "gh_missing" | "gh_auth" | "fetch_failed") => void
|
||||
log: (...args: unknown[]) => void
|
||||
intervalMs?: number
|
||||
/** Shared concurrency gate for child process spawning. */
|
||||
semaphore?: Semaphore
|
||||
}
|
||||
|
||||
const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
|
||||
@@ -33,9 +37,21 @@ export class PRStatusPoller {
|
||||
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
|
||||
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
|
||||
private readonly intervalMs: number
|
||||
private readonly semaphore: Semaphore | undefined
|
||||
|
||||
constructor(private readonly options: PRStatusPollerOptions) {
|
||||
this.intervalMs = options.intervalMs ?? 15_000
|
||||
this.semaphore = options.semaphore
|
||||
}
|
||||
|
||||
/** Run a command through the shared concurrency gate (when configured). */
|
||||
private shell(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const invoke = () => execWithShellEnv(cmd, args, options)
|
||||
return this.semaphore ? this.semaphore.run(invoke) : invoke()
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
@@ -140,7 +156,7 @@ export class PRStatusPoller {
|
||||
return this.ghAvailable
|
||||
}
|
||||
try {
|
||||
await execWithShellEnv("gh", ["--version"], { timeout: 5_000 })
|
||||
await this.shell("gh", ["--version"], { timeout: 5_000 })
|
||||
this.ghAvailable = true
|
||||
} catch {
|
||||
this.ghAvailable = false
|
||||
@@ -279,7 +295,7 @@ export class PRStatusPoller {
|
||||
if (branch) args.push(branch)
|
||||
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)
|
||||
|
||||
const { stdout } = await execWithShellEnv("gh", args, { cwd, timeout: 15_000 })
|
||||
const { stdout } = await this.shell("gh", args, { cwd, timeout: 15_000 })
|
||||
return parsePRResult(stdout)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
@@ -291,11 +307,11 @@ export class PRStatusPoller {
|
||||
/** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */
|
||||
private async ghPRListBySHA(cwd: string): Promise<PRResult | null> {
|
||||
try {
|
||||
const { stdout: sha } = await execWithShellEnv("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 })
|
||||
const { stdout: sha } = await this.shell("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 })
|
||||
const head = sha.trim()
|
||||
if (!head) return null
|
||||
|
||||
const { stdout } = await execWithShellEnv(
|
||||
const { stdout } = await this.shell(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
@@ -337,7 +353,7 @@ export class PRStatusPoller {
|
||||
items: PRCheck[]
|
||||
}> {
|
||||
try {
|
||||
const { stdout } = await execWithShellEnv(
|
||||
const { stdout } = await this.shell(
|
||||
"gh",
|
||||
["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"],
|
||||
{ cwd, timeout: 15_000 },
|
||||
@@ -375,7 +391,7 @@ export class PRStatusPoller {
|
||||
if (this.cachedRepo && this.cachedRepo.cwd === cwd) {
|
||||
return this.cachedRepo
|
||||
}
|
||||
const { stdout } = await execWithShellEnv("gh", ["repo", "view", "--json", "owner,name"], {
|
||||
const { stdout } = await this.shell("gh", ["repo", "view", "--json", "owner,name"], {
|
||||
cwd,
|
||||
timeout: 10_000,
|
||||
})
|
||||
@@ -415,7 +431,7 @@ export class PRStatusPoller {
|
||||
}
|
||||
}`
|
||||
|
||||
const { stdout } = await execWithShellEnv(
|
||||
const { stdout } = await this.shell(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
|
||||
@@ -858,6 +858,15 @@ export class WorktreeManager {
|
||||
this.log(`defaultBranch: branchLocal failed: ${e}`)
|
||||
}
|
||||
|
||||
// Check if this is an empty repo with no commits (unborn branch).
|
||||
// rev-parse --verify HEAD exits non-zero only when HEAD has no target
|
||||
// commit, which is the definitive test for an unborn branch.
|
||||
try {
|
||||
await this.git.raw(["rev-parse", "--verify", "HEAD"])
|
||||
} catch {
|
||||
throw new Error("This repository has no commits yet. Create an initial commit before using worktrees.")
|
||||
}
|
||||
|
||||
throw new Error("Could not determine default branch")
|
||||
}
|
||||
|
||||
|
||||
@@ -247,12 +247,12 @@ export class WorktreeStateManager {
|
||||
return session
|
||||
}
|
||||
|
||||
/** Move an existing session to a worktree (promotion). */
|
||||
moveSession(sessionId: string, worktreeId: string): void {
|
||||
/** Move an existing session to a worktree (or back to local when null). */
|
||||
moveSession(sessionId: string, worktreeId: string | null): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
session.worktreeId = worktreeId
|
||||
this.log(`Moved session ${sessionId} to worktree ${worktreeId}`)
|
||||
this.log(`Moved session ${sessionId} to ${worktreeId ?? "local"}`)
|
||||
void this.save()
|
||||
}
|
||||
|
||||
@@ -304,6 +304,11 @@ export class WorktreeStateManager {
|
||||
if (!wt.sectionId) top.add(wt.id)
|
||||
}
|
||||
this.worktreeOrder = order.filter((id) => top.has(id))
|
||||
// Append any sections/ungrouped worktrees missing from the incoming order
|
||||
const present = new Set(this.worktreeOrder)
|
||||
for (const id of top) {
|
||||
if (!present.has(id)) this.worktreeOrder.push(id)
|
||||
}
|
||||
void this.save()
|
||||
}
|
||||
|
||||
@@ -380,6 +385,11 @@ export class WorktreeStateManager {
|
||||
}
|
||||
|
||||
moveSection(id: string, dir: -1 | 1): void {
|
||||
// Ensure the section is in worktreeOrder (it may be missing if drag-and-drop
|
||||
// overwrote the order before this section was tracked)
|
||||
if (this.sections.has(id) && !this.worktreeOrder.includes(id)) {
|
||||
this.worktreeOrder.push(id)
|
||||
}
|
||||
const top = this.worktreeOrder.filter((item) => {
|
||||
if (this.sections.has(item)) return true
|
||||
const wt = this.worktrees.get(item)
|
||||
|
||||
@@ -29,7 +29,7 @@ interface WorktreeEntry {
|
||||
|
||||
type PRErrorKind = "not_found" | "gh_missing" | "gh_auth" | "unknown"
|
||||
|
||||
export type WorktreeSetupErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing"
|
||||
export type WorktreeSetupErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing" | "no_commits"
|
||||
|
||||
export function parsePRUrl(url: string): PRUrlParts | null {
|
||||
let normalized = url.trim()
|
||||
@@ -158,5 +158,6 @@ export function classifyWorktreeError(msg: string): WorktreeSetupErrorCode | und
|
||||
if (msg.includes("ENOENT") || msg.includes("not found in PATH")) return "git_not_found"
|
||||
if (msg.includes("not a git repository")) return "not_git_repo"
|
||||
if (msg.includes("Git LFS") && msg.includes("not found")) return "lfs_missing"
|
||||
if (msg.includes("no commits yet")) return "no_commits"
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface SessionProvider {
|
||||
trackSession(id: string): void
|
||||
refreshSessions(): void
|
||||
registerSession(session: Session): void
|
||||
/** Recover any pending permission/question prompts for tracked sessions. */
|
||||
recoverPendingPrompts(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { Worktree } from "./WorktreeStateManager"
|
||||
import type { AgentManagerOutMessage, PRStatus } from "./types"
|
||||
import type { Disposable } from "./host"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
import { PRStatusPoller } from "./PRStatusPoller"
|
||||
|
||||
interface PRBridgeHost {
|
||||
@@ -17,6 +18,7 @@ interface PRBridgeHost {
|
||||
hasPersistedPR(id: string): boolean
|
||||
openExternal(url: string): void
|
||||
log(...args: unknown[]): void
|
||||
semaphore?: Semaphore
|
||||
}
|
||||
|
||||
/** Minimal panel surface needed by the bridge (subset of PanelContext). */
|
||||
@@ -43,6 +45,7 @@ export class PRStatusBridge {
|
||||
hasPersistedPR: (id: string) => boolean
|
||||
openExternal: (url: string) => void
|
||||
log: (...args: unknown[]) => void
|
||||
semaphore?: Semaphore
|
||||
}): PRStatusBridge {
|
||||
return new PRStatusBridge(opts)
|
||||
}
|
||||
@@ -85,6 +88,7 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) {
|
||||
return {
|
||||
getWorktrees: () => host.getWorktrees(),
|
||||
getWorkspaceRoot: () => host.getWorkspaceRoot(),
|
||||
semaphore: host.semaphore,
|
||||
onStatus: (id: string, pr: PRStatus | null, err?: "gh_missing" | "gh_auth" | "fetch_failed") => {
|
||||
if (err) {
|
||||
// Don't forward errors to the webview when we have prior PR data
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Bounded-concurrency gate for git/gh child processes.
|
||||
*
|
||||
* Shared across GitOps and PRStatusPoller so that all polling loops
|
||||
* (GitStatsPoller, PRStatusPoller, diff watcher) compete for the same
|
||||
* slots. Prevents process storms when many worktrees are active.
|
||||
*/
|
||||
export class Semaphore {
|
||||
private running = 0
|
||||
private readonly pending: (() => void)[] = []
|
||||
|
||||
constructor(private readonly limit: number) {}
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await this.acquire()
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
this.release()
|
||||
}
|
||||
}
|
||||
|
||||
private acquire(): Promise<void> {
|
||||
if (this.running < this.limit) {
|
||||
this.running++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.pending.push(() => {
|
||||
this.running++
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
this.running--
|
||||
const next = this.pending.shift()
|
||||
if (next) next()
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,16 @@ export async function executeVscodeTask(config: SetupTaskConfig): Promise<number
|
||||
showReuseMessage: false,
|
||||
}
|
||||
|
||||
const execution = await vscode.tasks.executeTask(task)
|
||||
let execution: vscode.TaskExecution
|
||||
try {
|
||||
execution = await vscode.tasks.executeTask(task)
|
||||
} catch {
|
||||
// Task type may not be registered in certain VS Code environments
|
||||
// (e.g. remote, codespaces, or if package.json contribution is not loaded yet).
|
||||
// Return undefined so SetupScriptRunner treats it as a non-fatal skip
|
||||
// rather than VS Code surfacing its own error notification.
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false
|
||||
|
||||
@@ -314,6 +314,18 @@ interface CloseSessionIn {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/** Persist a non-worktree session to agent-manager.json (worktreeId = null). */
|
||||
interface PersistSessionIn {
|
||||
type: "agentManager.persistSession"
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/** Remove a non-worktree session from agent-manager.json. */
|
||||
interface ForgetSessionIn {
|
||||
type: "agentManager.forgetSession"
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
interface ConfigureSetupScriptIn {
|
||||
type: "agentManager.configureSetupScript"
|
||||
}
|
||||
@@ -465,6 +477,11 @@ interface OpenPRIn {
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
interface OpenSessionsIn {
|
||||
type: "agentManager.openSessions"
|
||||
sessionIDs: string[]
|
||||
}
|
||||
|
||||
interface OpenFileIn {
|
||||
type: "agentManager.openFile"
|
||||
sessionId: string
|
||||
@@ -589,6 +606,8 @@ export type AgentManagerInMessage =
|
||||
| OpenLocallyIn
|
||||
| AddSessionToWorktreeIn
|
||||
| CloseSessionIn
|
||||
| PersistSessionIn
|
||||
| ForgetSessionIn
|
||||
| ForkSessionIn
|
||||
| ConfigureSetupScriptIn
|
||||
| ShowTerminalIn
|
||||
@@ -619,6 +638,7 @@ export type AgentManagerInMessage =
|
||||
| RevertWorktreeFileIn
|
||||
| RefreshPRIn
|
||||
| OpenPRIn
|
||||
| OpenSessionsIn
|
||||
| OpenFileIn
|
||||
| GenericOpenFileIn
|
||||
| PreviewImageIn
|
||||
|
||||
@@ -95,6 +95,7 @@ export class VscodeHost implements Host {
|
||||
trackSession: (id) => provider.trackSession(id),
|
||||
refreshSessions: () => provider.refreshSessions(),
|
||||
registerSession: (s) => provider.registerSession(s),
|
||||
recoverPendingPrompts: () => provider.recoverPendingPrompts(),
|
||||
dispose: () => provider.dispose(),
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TelemetryProxy } from "./services/telemetry"
|
||||
import { registerCommitMessageService } from "./services/commit-message"
|
||||
import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions"
|
||||
import { registerToggleAutoApprove } from "./commands/toggle-auto-approve"
|
||||
import { RemoteStatusService } from "./services/RemoteStatusService"
|
||||
|
||||
// Activated via "onStartupFinished" (package.json) so that commands, code actions, keybindings,
|
||||
// autocomplete, commit-message generation, and URI deep links all work immediately — without
|
||||
@@ -33,8 +34,13 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
const browserAutomationService = new BrowserAutomationService(connectionService)
|
||||
browserAutomationService.syncWithSettings()
|
||||
|
||||
// Create remote status service (one status bar item for all webviews)
|
||||
const remoteService = new RemoteStatusService()
|
||||
context.subscriptions.push(remoteService)
|
||||
connectionService.setRemoteService(remoteService)
|
||||
|
||||
// Re-register browser automation MCP server on CLI backend reconnect, configure telemetry,
|
||||
// and reload autocomplete so it picks up the now-available backend connection.
|
||||
// set remote service client, and reload autocomplete so it picks up the now-available backend connection.
|
||||
const unsubscribeStateChange = connectionService.onStateChange((state) => {
|
||||
if (state === "connected") {
|
||||
browserAutomationService.reregisterIfEnabled()
|
||||
@@ -42,7 +48,17 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
if (config) {
|
||||
telemetry.configure(config.baseUrl, config.password)
|
||||
}
|
||||
try {
|
||||
remoteService.setClient(connectionService.getClient())
|
||||
console.log("[Kilo New] CLI connected, calling remoteService.refresh()")
|
||||
remoteService.refresh().catch((err) => console.warn("[Kilo New] initial remote refresh failed:", err))
|
||||
} catch {
|
||||
remoteService.setClient(null)
|
||||
}
|
||||
AutocompleteServiceManager.getInstance()?.load()
|
||||
} else {
|
||||
remoteService.clearState()
|
||||
remoteService.setClient(null)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -60,6 +76,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
// Create the provider with shared service
|
||||
const provider = new KiloProvider(context.extensionUri, connectionService, context)
|
||||
provider.setRemoteService(remoteService)
|
||||
|
||||
// Register the webview view provider for the sidebar.
|
||||
// retainContextWhenHidden keeps the webview alive when switching to other sidebar panels.
|
||||
@@ -103,6 +120,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
vscode.window.registerWebviewPanelSerializer("kilo-code.new.TabPanel", {
|
||||
deserializeWebviewPanel(panel: vscode.WebviewPanel) {
|
||||
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
|
||||
tabProvider.setRemoteService(remoteService)
|
||||
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
|
||||
agentManagerProvider.continueFromSidebar(sessionId, progress),
|
||||
)
|
||||
@@ -138,6 +156,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
// Create settings/profile editor provider (opens in editor area, not sidebar)
|
||||
const settingsEditorProvider = new SettingsEditorProvider(context.extensionUri, connectionService, context)
|
||||
settingsEditorProvider.setRemoteService(remoteService)
|
||||
context.subscriptions.push(settingsEditorProvider)
|
||||
|
||||
// Create sub-agent viewer provider (read-only editor panel for sub-agent sessions)
|
||||
@@ -228,8 +247,18 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
await provider.waitForReady()
|
||||
provider.postMessage({ type: "triggerTask", text: `Generate a terminal command: ${input}` })
|
||||
}),
|
||||
vscode.commands.registerCommand("kilo-code.new.toggleRemote", () => {
|
||||
remoteService.toggle().catch((err) => console.error("[Kilo New] toggleRemote command failed:", err))
|
||||
}),
|
||||
vscode.commands.registerCommand("kilo-code.new.openInTab", () => {
|
||||
return openKiloInNewTab(context, connectionService, agentManagerProvider, tabPanels, diffVirtualProvider)
|
||||
return openKiloInNewTab(
|
||||
context,
|
||||
connectionService,
|
||||
agentManagerProvider,
|
||||
tabPanels,
|
||||
diffVirtualProvider,
|
||||
remoteService,
|
||||
)
|
||||
}),
|
||||
vscode.commands.registerCommand("kilo-code.new.showChanges", () => {
|
||||
diffViewerProvider.openPanel()
|
||||
@@ -362,6 +391,7 @@ async function openKiloInNewTab(
|
||||
agentManagerProvider: AgentManagerProvider,
|
||||
tabPanels: Map<vscode.WebviewPanel, KiloProvider>,
|
||||
diffVirtualProvider: DiffVirtualProvider,
|
||||
remoteService: RemoteStatusService,
|
||||
) {
|
||||
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0)
|
||||
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
|
||||
@@ -384,6 +414,7 @@ async function openKiloInNewTab(
|
||||
}
|
||||
|
||||
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
|
||||
tabProvider.setRemoteService(remoteService)
|
||||
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
|
||||
agentManagerProvider.continueFromSidebar(sessionId, progress),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { t } from "./cli-backend/i18n"
|
||||
|
||||
export type RemoteState = { enabled: boolean; connected: boolean }
|
||||
|
||||
type Listener = (state: RemoteState) => void
|
||||
|
||||
/**
|
||||
* Singleton service that owns all remote-control state and the VS Code status bar item.
|
||||
* Replaces the per-webview polling in RemoteIndicator.tsx and ExperimentalTab.tsx
|
||||
* with a push-based model: one status bar item, zero recurring cost for non-remote users.
|
||||
*/
|
||||
export class RemoteStatusService implements vscode.Disposable {
|
||||
private state: RemoteState = { enabled: false, connected: false }
|
||||
private bar: vscode.StatusBarItem
|
||||
private listeners = new Set<Listener>()
|
||||
private client: KiloClient | null = null
|
||||
|
||||
constructor() {
|
||||
this.bar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99)
|
||||
this.bar.command = "kilo-code.new.toggleRemote"
|
||||
this.sync()
|
||||
}
|
||||
|
||||
setClient(c: KiloClient | null): void {
|
||||
this.client = c
|
||||
}
|
||||
|
||||
/** Get current state synchronously. */
|
||||
getState(): RemoteState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
updateFromEvent(state: RemoteState): void {
|
||||
this.update(state)
|
||||
}
|
||||
|
||||
/** Subscribe to state changes. Returns an unsubscribe function. */
|
||||
onChange(cb: Listener): () => void {
|
||||
this.listeners.add(cb)
|
||||
return () => this.listeners.delete(cb)
|
||||
}
|
||||
|
||||
clearState(): void {
|
||||
this.update({ enabled: false, connected: false })
|
||||
}
|
||||
|
||||
/** One-shot status fetch — broadcasts via onChange if state changed. */
|
||||
async refresh(): Promise<void> {
|
||||
if (!this.client) return
|
||||
const res = await this.client.remote.status().catch((err: unknown) => {
|
||||
console.warn("[Kilo] remote status refresh failed:", err)
|
||||
return undefined
|
||||
})
|
||||
if (!res?.data) return
|
||||
this.update({ enabled: res.data.enabled, connected: res.data.connected })
|
||||
}
|
||||
|
||||
/** Toggle remote on/off based on current state. */
|
||||
async toggle(): Promise<void> {
|
||||
if (!this.client) return
|
||||
const { data } = await this.client.remote.status(undefined, { throwOnError: true })
|
||||
await this.setEnabled(!data.enabled)
|
||||
}
|
||||
|
||||
/** Enable or disable remote. State updates are pushed via events. */
|
||||
async setEnabled(enabled: boolean): Promise<void> {
|
||||
if (!this.client) return
|
||||
if (enabled) {
|
||||
await this.client.remote.enable(undefined, { throwOnError: true })
|
||||
} else {
|
||||
await this.client.remote.disable(undefined, { throwOnError: true })
|
||||
}
|
||||
this.update({ enabled, connected: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a remote-related webview message.
|
||||
* Returns a response message to post back to the webview, or null.
|
||||
*/
|
||||
async handleMessage(type: string, enabled?: boolean): Promise<RemoteState | null> {
|
||||
switch (type) {
|
||||
case "toggleRemote":
|
||||
await this.toggle()
|
||||
return null
|
||||
case "setRemoteEnabled":
|
||||
if (enabled === undefined) return null
|
||||
await this.setEnabled(enabled)
|
||||
return null
|
||||
case "requestRemoteStatus":
|
||||
void this.refresh()
|
||||
return this.state
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.listeners.clear()
|
||||
this.bar.dispose()
|
||||
}
|
||||
|
||||
// -- internal ---------------------------------------------------------------
|
||||
|
||||
private update(next: RemoteState): void {
|
||||
if (this.state.enabled === next.enabled && this.state.connected === next.connected) return
|
||||
this.state = next
|
||||
this.sync()
|
||||
for (const cb of this.listeners) cb(next)
|
||||
}
|
||||
|
||||
/** Sync status bar appearance to current state. */
|
||||
private sync(): void {
|
||||
if (!this.state.enabled) {
|
||||
this.bar.hide()
|
||||
return
|
||||
}
|
||||
if (this.state.connected) {
|
||||
this.bar.text = "$(radio-tower) Kilo Remote"
|
||||
this.bar.tooltip = t("remote.connected")
|
||||
this.bar.color = new vscode.ThemeColor("testing.iconPassed")
|
||||
} else {
|
||||
this.bar.text = "$(radio-tower) Kilo Remote \u2026"
|
||||
this.bar.tooltip = t("remote.connecting")
|
||||
this.bar.color = new vscode.ThemeColor("editorWarning.foreground")
|
||||
}
|
||||
this.bar.show()
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export class KiloConnectionService {
|
||||
private state: ConnectionState = "disconnected"
|
||||
private connectPromise: Promise<void> | null = null
|
||||
private healthPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null
|
||||
|
||||
private readonly eventListeners: Set<SSEEventListener> = new Set()
|
||||
private readonly stateListeners: Set<StateListener> = new Set()
|
||||
@@ -51,6 +52,13 @@ export class KiloConnectionService {
|
||||
*/
|
||||
private readonly messageSessionIdsByMessageId: Map<string, string> = new Map()
|
||||
|
||||
/** Provider key → single focused session ID. */
|
||||
private readonly focused: Map<string, string> = new Map()
|
||||
/** Provider key → all open (background) session IDs. */
|
||||
private readonly opened: Map<string, string[]> = new Map()
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private unsubRemote: (() => void) | null = null
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.serverManager = new ServerManager(context)
|
||||
}
|
||||
@@ -106,6 +114,27 @@ export class KiloConnectionService {
|
||||
return this.config
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the remote status service. When remote is disabled, flushViewed()
|
||||
* is a no-op. When remote becomes enabled (startup refresh, user toggle,
|
||||
* or SSE event), the accumulated focused/opened state is automatically
|
||||
* flushed so the server is never left unaware of already-open sessions.
|
||||
*/
|
||||
setRemoteService(service: import("../RemoteStatusService").RemoteStatusService | null): void {
|
||||
this.unsubRemote?.()
|
||||
this.unsubRemote = null
|
||||
this.remoteService = service
|
||||
if (service) {
|
||||
this.unsubRemote = service.onChange((state) => {
|
||||
if (state.enabled) this.flushViewed()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private isRemoteEnabled(): boolean {
|
||||
return this.remoteService?.getState().enabled ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Current connection state.
|
||||
*/
|
||||
@@ -345,6 +374,55 @@ export class KiloConnectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the session a provider is actively viewing (focused).
|
||||
* After any change the aggregated set is sent to the server (debounced).
|
||||
*/
|
||||
registerFocused(key: string, sessionID: string): void {
|
||||
if (this.focused.get(key) === sessionID) return
|
||||
this.focused.set(key, sessionID)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a provider's focused session (e.g. on dispose, hidden, or clearSession).
|
||||
*/
|
||||
unregisterFocused(key: string): void {
|
||||
if (!this.focused.has(key)) return
|
||||
this.focused.delete(key)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the open (background tab) session IDs for a provider.
|
||||
* Sessions that appear in both focused and open are reported as focused only.
|
||||
*/
|
||||
registerOpen(key: string, ids: string[]): void {
|
||||
const prev = this.opened.get(key)
|
||||
if (prev && prev.length === ids.length && prev.every((v, i) => v === ids[i])) return
|
||||
this.opened.set(key, ids)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/** Debounced: send the aggregated focused + open session IDs to the server. */
|
||||
flushViewed(): void {
|
||||
if (!this.isRemoteEnabled()) return
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null
|
||||
const focus = new Set(this.focused.values())
|
||||
const open = new Set<string>()
|
||||
for (const ids of this.opened.values()) {
|
||||
for (const id of ids) {
|
||||
if (!focus.has(id)) open.add(id)
|
||||
}
|
||||
}
|
||||
this.client?.session
|
||||
.viewed({ focused: [...focus], open: [...open] })
|
||||
.catch((err) => console.warn("[Kilo New] ConnectionService: viewed flush failed:", err))
|
||||
}, 150)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up everything: kill server, close SSE, clear listeners.
|
||||
*/
|
||||
@@ -361,6 +439,14 @@ export class KiloConnectionService {
|
||||
this.clearPendingPromptsListeners.clear()
|
||||
this.directoryProviders.clear()
|
||||
this.messageSessionIdsByMessageId.clear()
|
||||
this.focused.clear()
|
||||
this.opened.clear()
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
this.unsubRemote?.()
|
||||
this.unsubRemote = null
|
||||
this.client = null
|
||||
this.sseClient = null
|
||||
this.config = null
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "انتهت عملية CLI بالرمز {{code}} قبل بدء الخادم",
|
||||
"server.startupTimeout": "انتهت مهلة بدء تشغيل الخادم بعد {{seconds}} ثانية",
|
||||
"remote.connected": "Kilo Remote: متصل",
|
||||
"remote.connecting": "Kilo Remote: جارٍ الاتصال\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "O processo da CLI foi encerrado com o código {{code}} antes que o servidor fosse iniciado",
|
||||
"server.startupTimeout": "Tempo limite de inicialização do servidor esgotado após {{seconds}} segundos",
|
||||
"remote.connected": "Kilo Remote: Conectado",
|
||||
"remote.connecting": "Kilo Remote: Conectando\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI proces je izašao sa kodom {{code}} prije nego što se server pokrenuo",
|
||||
"server.startupTimeout": "Vrijeme pokretanja servera je isteklo nakon {{seconds}} sekundi",
|
||||
"remote.connected": "Kilo Remote: Povezano",
|
||||
"remote.connecting": "Kilo Remote: Povezivanje\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI-processen afsluttede med kode {{code}} før serveren startede",
|
||||
"server.startupTimeout": "Serverens opstartstid udløb efter {{seconds}} sekunder",
|
||||
"remote.connected": "Kilo Remote: Forbundet",
|
||||
"remote.connecting": "Kilo Remote: Forbinder\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Der CLI-Prozess wurde mit dem Code {{code}} beendet, bevor der Server gestartet wurde",
|
||||
"server.startupTimeout": "Zeitüberschreitung beim Serverstart nach {{seconds}} Sekunden",
|
||||
"remote.connected": "Kilo Remote: Verbunden",
|
||||
"remote.connecting": "Kilo Remote: Verbindung wird hergestellt\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI process exited with code {{code}} before server started",
|
||||
"server.startupTimeout": "Server startup timeout after {{seconds}} seconds",
|
||||
"remote.connected": "Kilo Remote: Connected",
|
||||
"remote.connecting": "Kilo Remote: Connecting\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "El proceso de la CLI finalizó con el código {{code}} antes de que se iniciara el servidor",
|
||||
"server.startupTimeout": "Tiempo de espera de inicio del servidor agotado después de {{seconds}} segundos",
|
||||
"remote.connected": "Kilo Remote: Conectado",
|
||||
"remote.connecting": "Kilo Remote: Conectando\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Le processus CLI s'est terminé avec le code {{code}} avant le démarrage du serveur",
|
||||
"server.startupTimeout": "Délai de démarrage du serveur dépassé après {{seconds}} secondes",
|
||||
"remote.connected": "Kilo Remote\u00a0: Connecté",
|
||||
"remote.connecting": "Kilo Remote\u00a0: Connexion\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "サーバーが起動する前に、CLI プロセスがコード {{code}} で終了しました",
|
||||
"server.startupTimeout": "サーバーの起動が {{seconds}} 秒後にタイムアウトしました",
|
||||
"remote.connected": "Kilo Remote: 接続済み",
|
||||
"remote.connecting": "Kilo Remote: 接続中\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "서버가 시작되기 전에 CLI 프로세스가 코드 {{code}}로 종료되었습니다",
|
||||
"server.startupTimeout": "{{seconds}}초 후 서버 시작 시간이 초과되었습니다",
|
||||
"remote.connected": "Kilo Remote: 연결됨",
|
||||
"remote.connecting": "Kilo Remote: 연결 중\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI-proces is afgesloten met code {{code}} voordat de server is gestart",
|
||||
"server.startupTimeout": "Time-out bij opstarten van server na {{seconds}} seconden",
|
||||
"remote.connected": "Kilo Remote: Verbonden",
|
||||
"remote.connecting": "Kilo Remote: Verbinden\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI-prosessen avsluttet med kode {{code}} før serveren startet",
|
||||
"server.startupTimeout": "Tidsavbrudd for serveroppstart etter {{seconds}} sekunder",
|
||||
"remote.connected": "Kilo Remote: Tilkoblet",
|
||||
"remote.connecting": "Kilo Remote: Kobler til\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Proces CLI zakończył się z kodem {{code}} przed uruchomieniem serwera",
|
||||
"server.startupTimeout": "Przekroczono limit czasu uruchamiania serwera po {{seconds}} sekundach",
|
||||
"remote.connected": "Kilo Remote: Połączono",
|
||||
"remote.connecting": "Kilo Remote: Łączenie\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Процесс CLI завершился с кодом {{code}} до запуска сервера",
|
||||
"server.startupTimeout": "Время ожидания запуска сервера истекло через {{seconds}} секунд",
|
||||
"remote.connected": "Kilo Remote: Подключено",
|
||||
"remote.connecting": "Kilo Remote: Подключение\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "กระบวนการ CLI ออกด้วยรหัส {{code}} ก่อนที่เซิร์ฟเวอร์จะเริ่มทำงาน",
|
||||
"server.startupTimeout": "หมดเวลาการเริ่มต้นเซิร์ฟเวอร์หลังจาก {{seconds}} วินาที",
|
||||
"remote.connected": "Kilo Remote: เชื่อมต่อแล้ว",
|
||||
"remote.connecting": "Kilo Remote: กำลังเชื่อมต่อ\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "CLI işlemi sunucu başlamadan önce {{code}} koduyla çıktı",
|
||||
"server.startupTimeout": "{{seconds}} saniye sonra sunucu başlatma zaman aşımı",
|
||||
"remote.connected": "Kilo Remote: Bağlandı",
|
||||
"remote.connecting": "Kilo Remote: Bağlanıyor\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "Процес CLI завершився з кодом {{code}} до запуску сервера",
|
||||
"server.startupTimeout": "Час очікування запуску сервера вичерпано після {{seconds}} секунд",
|
||||
"remote.connected": "Kilo Remote: Підключено",
|
||||
"remote.connecting": "Kilo Remote: Підключення\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "在服务器启动之前,CLI 进程已退出,代码为 {{code}}",
|
||||
"server.startupTimeout": "服务器启动在 {{seconds}} 秒后超时",
|
||||
"remote.connected": "Kilo Remote: 已连接",
|
||||
"remote.connecting": "Kilo Remote: 正在连接\u2026",
|
||||
} as const
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const dict = {
|
||||
"server.processExited": "在伺服器啟動之前,CLI 處理程序已退出,代碼為 {{code}}",
|
||||
"server.startupTimeout": "伺服器啟動在 {{seconds}} 秒後逾時",
|
||||
"remote.connected": "Kilo Remote: 已連線",
|
||||
"remote.connecting": "Kilo Remote: 正在連線\u2026",
|
||||
} as const
|
||||
|
||||
@@ -60,6 +60,10 @@ const mockVscode = {
|
||||
stat: async () => ({ type: 1, ctime: 0, mtime: 0, size: 0 }),
|
||||
},
|
||||
},
|
||||
StatusBarAlignment: { Left: 1, Right: 2 },
|
||||
ThemeColor: class {
|
||||
constructor(public id: string) {}
|
||||
},
|
||||
window: {
|
||||
activeTextEditor: undefined,
|
||||
visibleTextEditors: [],
|
||||
@@ -67,6 +71,15 @@ const mockVscode = {
|
||||
showTextDocument: async () => {},
|
||||
showWarningMessage: async () => undefined,
|
||||
createTerminal: () => ({ show: noop, sendText: noop, dispose: noop }),
|
||||
createStatusBarItem: () => ({
|
||||
text: "",
|
||||
tooltip: "",
|
||||
color: undefined as unknown,
|
||||
command: undefined as string | undefined,
|
||||
show: noop,
|
||||
hide: noop,
|
||||
dispose: noop,
|
||||
}),
|
||||
},
|
||||
commands: {
|
||||
registerCommand: () => ({ dispose: noop }),
|
||||
|
||||
@@ -179,6 +179,8 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
"agentManager.addSessionToWorktree",
|
||||
"agentManager.forkSession",
|
||||
"agentManager.closeSession",
|
||||
"agentManager.persistSession",
|
||||
"agentManager.forgetSession",
|
||||
"agentManager.configureSetupScript",
|
||||
"agentManager.showTerminal",
|
||||
"agentManager.showLocalTerminal",
|
||||
@@ -534,8 +536,8 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
|
||||
*/
|
||||
const MAX_LINES: Record<string, { maxLines: number; note: string }> = {
|
||||
"AgentManagerProvider.ts": {
|
||||
maxLines: 2000,
|
||||
note: "primary extraction target: break into smaller orchestrators",
|
||||
maxLines: 2050,
|
||||
note: "permission recovery wiring is interleaved with panel/session lifecycle; extract more orchestrators next",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as nodePath from "path"
|
||||
import { GitOps } from "../../src/agent-manager/GitOps"
|
||||
import { Semaphore } from "../../src/agent-manager/semaphore"
|
||||
|
||||
function ops(handler: (args: string[], cwd: string) => Promise<string>): GitOps {
|
||||
return new GitOps({ log: () => undefined, runGit: handler })
|
||||
function ops(handler: (args: string[], cwd: string) => Promise<string>, semaphore?: Semaphore): GitOps {
|
||||
return new GitOps({ log: () => undefined, runGit: handler, semaphore })
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
@@ -556,4 +557,37 @@ describe("GitOps", () => {
|
||||
expect(git.disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("semaphore integration", () => {
|
||||
it("limits concurrent raw() calls", async () => {
|
||||
let running = 0
|
||||
let peak = 0
|
||||
const sem = new Semaphore(2)
|
||||
const git = ops(async () => {
|
||||
running++
|
||||
peak = Math.max(peak, running)
|
||||
await sleep(10)
|
||||
running--
|
||||
return "ok"
|
||||
}, sem)
|
||||
|
||||
await Promise.all(Array.from({ length: 6 }, () => git.currentBranch("/repo")))
|
||||
expect(peak).toBe(2)
|
||||
})
|
||||
|
||||
it("works without a semaphore (no gating)", async () => {
|
||||
let running = 0
|
||||
let peak = 0
|
||||
const git = ops(async () => {
|
||||
running++
|
||||
peak = Math.max(peak, running)
|
||||
await sleep(10)
|
||||
running--
|
||||
return "ok"
|
||||
})
|
||||
|
||||
await Promise.all(Array.from({ length: 4 }, () => git.currentBranch("/repo")))
|
||||
expect(peak).toBe(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from "path"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller"
|
||||
import { GitOps } from "../../src/agent-manager/GitOps"
|
||||
import { Semaphore } from "../../src/agent-manager/semaphore"
|
||||
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
@@ -430,4 +431,55 @@ describe("GitStatsPoller", () => {
|
||||
const fetches = commands.filter((cmd) => cmd[0] === "fetch")
|
||||
expect(fetches.length).toBe(0)
|
||||
})
|
||||
|
||||
it("limits concurrent diffSummary calls when semaphore is provided", async () => {
|
||||
let running = 0
|
||||
let peak = 0
|
||||
let ticks = 0
|
||||
const sem = new Semaphore(2)
|
||||
|
||||
const client = {
|
||||
worktree: {
|
||||
diffSummary: async () => {
|
||||
running++
|
||||
peak = Math.max(peak, running)
|
||||
await sleep(20)
|
||||
running--
|
||||
return { data: diff(1, 0) }
|
||||
},
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
|
||||
// Wire the SAME semaphore into GitOps to prove there's no deadlock —
|
||||
// aheadBehind acquires the semaphore independently, not nested inside
|
||||
// the diffSummary gate.
|
||||
const wts = Array.from({ length: 5 }, (_, i) => worktree(String(i)))
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => wts,
|
||||
getWorkspaceRoot: () => undefined,
|
||||
getClient: () => client,
|
||||
onStats: () => {
|
||||
ticks++
|
||||
},
|
||||
onLocalStats: () => undefined,
|
||||
log: () => undefined,
|
||||
intervalMs: 5,
|
||||
semaphore: sem,
|
||||
git: new GitOps({
|
||||
log: () => undefined,
|
||||
semaphore: sem,
|
||||
runGit: async (args) => {
|
||||
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t0"
|
||||
return ""
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
poller.setEnabled(true)
|
||||
await waitFor(() => ticks >= 1)
|
||||
poller.stop()
|
||||
|
||||
// Only diffSummary calls are tracked — they should be bounded.
|
||||
expect(peak).toBeLessThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { resolveNavigation, validateLocalSession, adjacentHint, LOCAL } from "../../webview-ui/agent-manager/navigate"
|
||||
import {
|
||||
resolveNavigation,
|
||||
validateLocalSession,
|
||||
adjacentHint,
|
||||
restoreLocalSessions,
|
||||
LOCAL,
|
||||
} from "../../webview-ui/agent-manager/navigate"
|
||||
|
||||
const ids = ["a", "b", "c", "d"]
|
||||
|
||||
@@ -180,3 +186,99 @@ describe("adjacentHint", () => {
|
||||
expect(adjacentHint("b", "a", ["a", "b"], "prev", "next")).toBe("next")
|
||||
})
|
||||
})
|
||||
|
||||
describe("restoreLocalSessions", () => {
|
||||
const identity = (items: { id: string }[], _order: string[]) => items
|
||||
const isPending = (id: string) => id.startsWith("pending-")
|
||||
|
||||
// Simulates applyTabOrder: reorders items to match the order array
|
||||
const reorder = (items: { id: string }[], order: string[]) => {
|
||||
const lookup = new Map(items.map((item) => [item.id, item]))
|
||||
const result: { id: string }[] = []
|
||||
for (const id of order) {
|
||||
const item = lookup.get(id)
|
||||
if (item) {
|
||||
result.push(item)
|
||||
lookup.delete(id)
|
||||
}
|
||||
}
|
||||
for (const item of lookup.values()) result.push(item)
|
||||
return result
|
||||
}
|
||||
|
||||
it("restores local sessions when current list is empty", () => {
|
||||
const sessions = [
|
||||
{ id: "s1", worktreeId: null },
|
||||
{ id: "s2", worktreeId: null },
|
||||
]
|
||||
const result = restoreLocalSessions(sessions, [], undefined, isPending, identity)
|
||||
expect(result).toEqual(["s1", "s2"])
|
||||
})
|
||||
|
||||
it("skips worktree-bound sessions", () => {
|
||||
const sessions = [
|
||||
{ id: "s1", worktreeId: "wt-1" },
|
||||
{ id: "s2", worktreeId: null },
|
||||
{ id: "s3", worktreeId: "wt-2" },
|
||||
]
|
||||
const result = restoreLocalSessions(sessions, [], undefined, isPending, identity)
|
||||
expect(result).toEqual(["s2"])
|
||||
})
|
||||
|
||||
it("applies tab order on restore", () => {
|
||||
const sessions = [
|
||||
{ id: "s1", worktreeId: null },
|
||||
{ id: "s2", worktreeId: null },
|
||||
{ id: "s3", worktreeId: null },
|
||||
]
|
||||
const result = restoreLocalSessions(sessions, [], ["s3", "s1", "s2"], isPending, reorder)
|
||||
expect(result).toEqual(["s3", "s1", "s2"])
|
||||
})
|
||||
|
||||
it("does not overwrite existing real sessions", () => {
|
||||
const sessions = [
|
||||
{ id: "s1", worktreeId: null },
|
||||
{ id: "s2", worktreeId: null },
|
||||
]
|
||||
// Current already has real sessions — don't replace
|
||||
const result = restoreLocalSessions(sessions, ["s1", "s2"], undefined, isPending, identity)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does restore when current only has pending tabs", () => {
|
||||
const sessions = [
|
||||
{ id: "s1", worktreeId: null },
|
||||
{ id: "s2", worktreeId: null },
|
||||
]
|
||||
const result = restoreLocalSessions(sessions, ["pending-1"], undefined, isPending, identity)
|
||||
expect(result).toEqual(["s1", "s2"])
|
||||
})
|
||||
|
||||
it("returns undefined when no local sessions and no tab order", () => {
|
||||
const sessions = [{ id: "s1", worktreeId: "wt-1" }]
|
||||
const result = restoreLocalSessions(sessions, [], undefined, isPending, identity)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("applies tab order to existing sessions", () => {
|
||||
const sessions = [{ id: "s1", worktreeId: null }]
|
||||
const result = restoreLocalSessions(sessions, ["s2", "s1"], ["s1", "s2"], isPending, reorder)
|
||||
expect(result).toEqual(["s1", "s2"])
|
||||
})
|
||||
|
||||
it("merges disk session missing from stale webview state", () => {
|
||||
const sessions = [
|
||||
{ id: "s1", worktreeId: null },
|
||||
{ id: "s2", worktreeId: null },
|
||||
{ id: "s3", worktreeId: null },
|
||||
]
|
||||
// webview state is stale: has s1, s2 but not s3 (debounce didn't fire)
|
||||
const result = restoreLocalSessions(sessions, ["s1", "s2"], undefined, isPending, identity)
|
||||
expect(result).toEqual(["s1", "s2", "s3"])
|
||||
})
|
||||
|
||||
it("returns undefined when no disk sessions and no tab order", () => {
|
||||
const result = restoreLocalSessions([], [], undefined, isPending, identity)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, spyOn } from "bun:test"
|
||||
import { RemoteStatusService, type RemoteState } from "../../src/services/RemoteStatusService"
|
||||
|
||||
type StatusResponse = { enabled: boolean; connected: boolean }
|
||||
|
||||
function client(opts: { status?: StatusResponse | (() => StatusResponse); fail?: boolean }) {
|
||||
return {
|
||||
remote: {
|
||||
status: async (_body?: unknown, _opts?: unknown) => {
|
||||
if (opts.fail) throw new Error("connection refused")
|
||||
const data =
|
||||
typeof opts.status === "function" ? opts.status() : (opts.status ?? { enabled: false, connected: false })
|
||||
return { data }
|
||||
},
|
||||
enable: async (_body?: unknown, _opts?: unknown) => {
|
||||
if (opts.fail) throw new Error("enable failed")
|
||||
return { data: true }
|
||||
},
|
||||
disable: async (_body?: unknown, _opts?: unknown) => {
|
||||
if (opts.fail) throw new Error("disable failed")
|
||||
return { data: true }
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function service() {
|
||||
return new RemoteStatusService()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Listener management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RemoteStatusService", () => {
|
||||
describe("onChange", () => {
|
||||
it("listener called on state change", async () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
|
||||
await svc.refresh()
|
||||
expect(states).toEqual([{ enabled: true, connected: true }])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("listener not called after unsubscribe", async () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
const unsub = svc.onChange((s) => states.push(s))
|
||||
unsub()
|
||||
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
|
||||
await svc.refresh()
|
||||
expect(states).toEqual([])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("multiple listeners all notified", async () => {
|
||||
const svc = service()
|
||||
const a: RemoteState[] = []
|
||||
const b: RemoteState[] = []
|
||||
svc.onChange((s) => a.push(s))
|
||||
svc.onChange((s) => b.push(s))
|
||||
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
|
||||
await svc.refresh()
|
||||
expect(a).toEqual([{ enabled: true, connected: false }])
|
||||
expect(b).toEqual([{ enabled: true, connected: false }])
|
||||
svc.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// refresh()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("refresh", () => {
|
||||
it("fetches status and notifies listeners", async () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
|
||||
await svc.refresh()
|
||||
expect(states).toEqual([{ enabled: true, connected: false }])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("without client is a no-op", async () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
await svc.refresh() // no client set
|
||||
expect(states).toEqual([])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("does not notify if state unchanged", async () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
// initial state is { enabled: false, connected: false }, same as client returns
|
||||
svc.setClient(client({ status: { enabled: false, connected: false } }) as never)
|
||||
await svc.refresh()
|
||||
expect(states).toEqual([]) // no change from initial
|
||||
svc.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setEnabled()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("setEnabled", () => {
|
||||
it("setEnabled(true) calls enable and broadcasts enabled state", async () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
|
||||
await svc.setEnabled(true)
|
||||
expect(states).toEqual([{ enabled: true, connected: false }])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("setEnabled(false) calls disable and broadcasts disabled", async () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
|
||||
// First get to enabled state
|
||||
await svc.refresh()
|
||||
states.length = 0 // reset
|
||||
await svc.setEnabled(false)
|
||||
expect(states).toEqual([{ enabled: false, connected: false }])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("setEnabled(false) after enable broadcasts disabled", async () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
|
||||
await svc.setEnabled(true)
|
||||
states.length = 0
|
||||
await svc.setEnabled(false)
|
||||
expect(states).toEqual([{ enabled: false, connected: false }])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("setEnabled(true) error is surfaced", async () => {
|
||||
const svc = service()
|
||||
svc.setClient(client({ fail: true }) as never)
|
||||
await expect(svc.setEnabled(true)).rejects.toThrow("enable failed")
|
||||
svc.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("toggle", () => {
|
||||
it("toggle when disabled calls enable", async () => {
|
||||
const svc = service()
|
||||
let enabled = false
|
||||
const c = {
|
||||
remote: {
|
||||
status: async (_b?: unknown, _o?: unknown) => ({ data: { enabled: false, connected: false } }),
|
||||
enable: async (_b?: unknown, _o?: unknown) => {
|
||||
enabled = true
|
||||
return { data: true }
|
||||
},
|
||||
disable: async (_b?: unknown, _o?: unknown) => ({ data: true }),
|
||||
},
|
||||
}
|
||||
svc.setClient(c as never)
|
||||
await svc.toggle()
|
||||
expect(enabled).toBe(true)
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("toggle when enabled calls disable", async () => {
|
||||
const svc = service()
|
||||
let disabled = false
|
||||
const c = {
|
||||
remote: {
|
||||
status: async (_b?: unknown, _o?: unknown) => ({ data: { enabled: true, connected: true } }),
|
||||
enable: async (_b?: unknown, _o?: unknown) => ({ data: true }),
|
||||
disable: async (_b?: unknown, _o?: unknown) => {
|
||||
disabled = true
|
||||
return { data: true }
|
||||
},
|
||||
},
|
||||
}
|
||||
svc.setClient(c as never)
|
||||
await svc.toggle()
|
||||
expect(disabled).toBe(true)
|
||||
svc.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Push-based updates via updateFromEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("updateFromEvent", () => {
|
||||
it("broadcasts state when pushed via event", () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.updateFromEvent({ enabled: true, connected: true })
|
||||
expect(states).toEqual([{ enabled: true, connected: true }])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("does not notify if event state matches current", () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
// initial state is { enabled: false, connected: false }
|
||||
svc.updateFromEvent({ enabled: false, connected: false })
|
||||
expect(states).toEqual([])
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("tracks successive event-driven transitions", () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.updateFromEvent({ enabled: true, connected: false })
|
||||
svc.updateFromEvent({ enabled: true, connected: true })
|
||||
expect(states).toEqual([
|
||||
{ enabled: true, connected: false },
|
||||
{ enabled: true, connected: true },
|
||||
])
|
||||
svc.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clearState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("clearState", () => {
|
||||
it("resets to disabled state", () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.updateFromEvent({ enabled: true, connected: true })
|
||||
states.length = 0
|
||||
svc.clearState()
|
||||
expect(states).toEqual([{ enabled: false, connected: false }])
|
||||
expect(svc.getState()).toEqual({ enabled: false, connected: false })
|
||||
svc.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("status bar", () => {
|
||||
it("status bar hidden when remote disabled", async () => {
|
||||
const svc = service()
|
||||
svc.setClient(client({ status: { enabled: false, connected: false } }) as never)
|
||||
await svc.refresh() // no state change from initial, bar should stay hidden
|
||||
// Dispose checks bar was never shown — no direct assertion on mock, just no crash
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("status bar shown with correct text when connected", async () => {
|
||||
const svc = service()
|
||||
svc.setClient(client({ status: { enabled: true, connected: true } }) as never)
|
||||
await svc.refresh()
|
||||
// Service is functional — status bar is managed internally. We verify no errors.
|
||||
svc.dispose()
|
||||
})
|
||||
|
||||
it("status bar shown with connecting text when enabled but not connected", async () => {
|
||||
const svc = service()
|
||||
svc.setClient(client({ status: { enabled: true, connected: false } }) as never)
|
||||
await svc.refresh()
|
||||
svc.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dispose()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("dispose", () => {
|
||||
it("dispose clears listeners", () => {
|
||||
const svc = service()
|
||||
const states: RemoteState[] = []
|
||||
svc.onChange((s) => states.push(s))
|
||||
svc.updateFromEvent({ enabled: true, connected: false })
|
||||
svc.dispose()
|
||||
// No further notifications after dispose
|
||||
svc.updateFromEvent({ enabled: true, connected: true })
|
||||
expect(states).toEqual([{ enabled: true, connected: false }])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { Semaphore } from "../../src/agent-manager/semaphore"
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
describe("Semaphore", () => {
|
||||
it("runs tasks up to the concurrency limit", async () => {
|
||||
const sem = new Semaphore(2)
|
||||
let running = 0
|
||||
let peak = 0
|
||||
|
||||
const task = () =>
|
||||
sem.run(async () => {
|
||||
running++
|
||||
peak = Math.max(peak, running)
|
||||
await delay(50)
|
||||
running--
|
||||
})
|
||||
|
||||
await Promise.all([task(), task(), task(), task(), task()])
|
||||
expect(peak).toBe(2)
|
||||
expect(running).toBe(0)
|
||||
})
|
||||
|
||||
it("returns the value produced by the function", async () => {
|
||||
const sem = new Semaphore(1)
|
||||
const result = await sem.run(async () => 42)
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
it("propagates rejections without blocking the queue", async () => {
|
||||
const sem = new Semaphore(1)
|
||||
const order: string[] = []
|
||||
|
||||
const failing = sem.run(async () => {
|
||||
order.push("fail-start")
|
||||
throw new Error("boom")
|
||||
})
|
||||
|
||||
const passing = sem.run(async () => {
|
||||
order.push("pass-start")
|
||||
return "ok"
|
||||
})
|
||||
|
||||
await expect(failing).rejects.toThrow("boom")
|
||||
expect(await passing).toBe("ok")
|
||||
expect(order).toEqual(["fail-start", "pass-start"])
|
||||
})
|
||||
|
||||
it("processes queued tasks in FIFO order", async () => {
|
||||
const sem = new Semaphore(1)
|
||||
const order: number[] = []
|
||||
|
||||
// First task holds the slot while 2 and 3 queue
|
||||
const t1 = sem.run(async () => {
|
||||
order.push(1)
|
||||
await delay(50)
|
||||
})
|
||||
const t2 = sem.run(async () => {
|
||||
order.push(2)
|
||||
})
|
||||
const t3 = sem.run(async () => {
|
||||
order.push(3)
|
||||
})
|
||||
|
||||
await Promise.all([t1, t2, t3])
|
||||
expect(order).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it("allows full concurrency when limit exceeds task count", async () => {
|
||||
const sem = new Semaphore(10)
|
||||
let running = 0
|
||||
let peak = 0
|
||||
|
||||
const task = () =>
|
||||
sem.run(async () => {
|
||||
running++
|
||||
peak = Math.max(peak, running)
|
||||
await delay(30)
|
||||
running--
|
||||
})
|
||||
|
||||
await Promise.all([task(), task(), task()])
|
||||
expect(peak).toBe(3)
|
||||
})
|
||||
|
||||
it("releases the slot on synchronous throw", async () => {
|
||||
const sem = new Semaphore(1)
|
||||
|
||||
await expect(
|
||||
sem.run(() => {
|
||||
throw new Error("sync")
|
||||
}),
|
||||
).rejects.toThrow("sync")
|
||||
|
||||
// Slot is free — next task should run immediately
|
||||
const result = await sem.run(async () => "recovered")
|
||||
expect(result).toBe("recovered")
|
||||
})
|
||||
})
|
||||
@@ -96,6 +96,15 @@ describe("WorktreeStateManager", () => {
|
||||
expect(manager.getSession("s1")?.worktreeId).toBe(wt2.id)
|
||||
})
|
||||
|
||||
it("moves session back to local (null worktreeId)", () => {
|
||||
const wt = manager.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
manager.addSession("s1", wt.id)
|
||||
expect(manager.getSession("s1")?.worktreeId).toBe(wt.id)
|
||||
|
||||
manager.moveSession("s1", null)
|
||||
expect(manager.getSession("s1")?.worktreeId).toBeNull()
|
||||
})
|
||||
|
||||
it("moveSession is a no-op for nonexistent session", () => {
|
||||
manager.moveSession("nonexistent", "wt-1")
|
||||
expect(manager.getSessions()).toHaveLength(0)
|
||||
|
||||
@@ -122,6 +122,17 @@ describe("WorktreeStateManager sections", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("setWorktreeOrder", () => {
|
||||
it("preserves sections missing from incoming order", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
const a = mgr.addSection("A", null)
|
||||
const b = mgr.addSection("B", null)
|
||||
// Simulate webview sending an order that omits section B
|
||||
mgr.setWorktreeOrder([wt.id, a.id])
|
||||
expect(mgr.getWorktreeOrder()).toContain(b.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("moveToSection", () => {
|
||||
it("sets sectionId and removes from worktreeOrder", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
@@ -220,6 +231,16 @@ describe("WorktreeStateManager sections", () => {
|
||||
expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(a.id)
|
||||
})
|
||||
|
||||
it("moves a section that is missing from worktreeOrder", () => {
|
||||
const a = mgr.addSection("A", null)
|
||||
const b = mgr.addSection("B", null)
|
||||
// Simulate a drag-and-drop that lost section B from the order
|
||||
mgr.setWorktreeOrder([a.id])
|
||||
expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id])
|
||||
mgr.moveSection(b.id, -1)
|
||||
expect(mgr.getWorktreeOrder()).toEqual([b.id, a.id])
|
||||
})
|
||||
|
||||
it("persists reordered sections across save/load", async () => {
|
||||
const a = mgr.addSection("A", null)
|
||||
const b = mgr.addSection("B", null)
|
||||
|
||||
@@ -81,7 +81,7 @@ import { NewWorktreeDialog } from "./NewWorktreeDialog"
|
||||
import { LanguageBridge, DataBridge } from "../src/App"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate"
|
||||
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, restoreLocalSessions, LOCAL } from "./navigate"
|
||||
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
|
||||
import { ConstrainDragYAxis, SortableReviewTab, SortableTab } from "./sortable-tab"
|
||||
import { DiffPanel } from "./DiffPanel"
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
import { sectionAwareDetector } from "./section-dnd"
|
||||
import { ConstrainDragXAxis } from "./constrain-drag-x"
|
||||
import { mergeWorktreeDiffs } from "./diff-state"
|
||||
import { trackOpenSessions } from "./open-sessions"
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
|
||||
@@ -671,11 +672,17 @@ const AgentManagerContent: Component = () => {
|
||||
const all = session.sessions()
|
||||
if (all.length === 0) return // sessions not loaded yet
|
||||
const ids = all.map((s) => s.id)
|
||||
const valid = localSessionIDs().filter((lid) => isPending(lid) || validateLocalSession(lid, ids))
|
||||
if (valid.length !== localSessionIDs().length) {
|
||||
const prev = localSessionIDs()
|
||||
const valid = prev.filter((lid) => isPending(lid) || validateLocalSession(lid, ids))
|
||||
if (valid.length !== prev.length) {
|
||||
const removed = prev.filter((lid) => !isPending(lid) && !valid.includes(lid))
|
||||
for (const id of removed) {
|
||||
vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id })
|
||||
}
|
||||
setLocalSessionIDs(valid)
|
||||
}
|
||||
})
|
||||
trackOpenSessions(localSessionIDs, isPending, managedSessions, vscode.postMessage)
|
||||
|
||||
// Drop in-memory review state for worktrees that no longer exist.
|
||||
createEffect(() => {
|
||||
@@ -1118,6 +1125,7 @@ const AgentManagerContent: Component = () => {
|
||||
setLocalSessionIDs((prev) => [...prev, created.session.id])
|
||||
setSelection(LOCAL)
|
||||
}
|
||||
vscode.postMessage({ type: "agentManager.persistSession", sessionId: created.session.id })
|
||||
session.selectSession(created.session.id)
|
||||
})
|
||||
|
||||
@@ -1192,6 +1200,7 @@ const AgentManagerContent: Component = () => {
|
||||
if (idx >= 0) return [...prev.slice(0, idx + 1), ev.sessionId, ...prev.slice(idx + 1)]
|
||||
return [...prev, ev.sessionId]
|
||||
})
|
||||
vscode.postMessage({ type: "agentManager.persistSession", sessionId: ev.sessionId })
|
||||
}
|
||||
session.selectSession(ev.sessionId)
|
||||
}
|
||||
@@ -1230,15 +1239,15 @@ const AgentManagerContent: Component = () => {
|
||||
const ms = state.sessions.find((s) => s.id === current)
|
||||
if (ms?.worktreeId) setSelection(ms.worktreeId)
|
||||
}
|
||||
// Recover local tab order from persisted state
|
||||
const localOrder = state.tabOrder?.[LOCAL]
|
||||
if (localOrder && localSessionIDs().length > 0) {
|
||||
const reordered = applyTabOrder(
|
||||
localSessionIDs().map((id) => ({ id })),
|
||||
localOrder,
|
||||
).map((item) => item.id)
|
||||
setLocalSessionIDs(reordered)
|
||||
}
|
||||
// Restore local session IDs from persisted state (sessions with no worktreeId)
|
||||
const restored = restoreLocalSessions(
|
||||
state.sessions,
|
||||
localSessionIDs(),
|
||||
state.tabOrder?.[LOCAL],
|
||||
isPending,
|
||||
applyTabOrder,
|
||||
)
|
||||
if (restored) setLocalSessionIDs(restored)
|
||||
// Recover sessions collapsed state from extension-persisted state
|
||||
if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed)
|
||||
// Clear busy state for worktrees that have been removed
|
||||
@@ -1889,6 +1898,9 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
if (pending || localSet().has(sessionId)) {
|
||||
setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId))
|
||||
if (!pending) {
|
||||
vscode.postMessage({ type: "agentManager.forgetSession", sessionId })
|
||||
}
|
||||
} else {
|
||||
vscode.postMessage({ type: "agentManager.closeSession", sessionId })
|
||||
}
|
||||
@@ -2416,7 +2428,9 @@ const AgentManagerContent: Component = () => {
|
||||
</SectionHeader>
|
||||
)
|
||||
}
|
||||
return renderWt(item.wt, idx)
|
||||
const ug = ungrouped()
|
||||
const wtIdx = () => ug.indexOf(item.wt)
|
||||
return renderWt(item.wt, wtIdx, ug)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
|
||||
@@ -54,6 +54,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "افتح مجلدًا يحتوي على مستودع git لاستخدام مساحات العمل (worktrees).",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"يستخدم هذا المستودع Git LFS، ولكن لم يتم العثور على git-lfs. يرجى تثبيت Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"هذا المستودع لا يحتوي على أي التزامات (commits) بعد. قم بإنشاء التزام أولي قبل استخدام مساحات العمل (worktrees).",
|
||||
"agentManager.shortcuts.title": "اختصارات لوحة المفاتيح",
|
||||
"agentManager.shortcuts.category.sidebar": "الشريط الجانبي",
|
||||
"agentManager.shortcuts.category.tabs": "علامات التبويب",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Abra uma pasta que contém um repositório git para usar worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Este repositório usa Git LFS, mas o git-lfs não foi encontrado. Instale o Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Este repositório ainda não possui commits. Crie um commit inicial antes de usar worktrees.",
|
||||
"agentManager.shortcuts.title": "Atalhos de Teclado",
|
||||
"agentManager.shortcuts.category.sidebar": "Barra lateral",
|
||||
"agentManager.shortcuts.category.tabs": "Abas",
|
||||
|
||||
@@ -56,6 +56,8 @@ export const dict = {
|
||||
"Otvorite fasciklu koja sadrži git repozitorijum da biste koristili worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Ovaj repozitorijum koristi Git LFS, ali git-lfs nije pronađen. Molimo instalirajte Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Ovaj repozitorijum još uvek nema commit-ova. Napravite početni commit pre korišćenja worktrees.",
|
||||
"agentManager.shortcuts.title": "Prečice na tastaturi",
|
||||
"agentManager.shortcuts.category.sidebar": "Bočna traka",
|
||||
"agentManager.shortcuts.category.tabs": "Kartice",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Åbn en mappe, der indeholder et git-repository for at bruge worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Dette repository bruger Git LFS, men git-lfs blev ikke fundet. Installer venligst Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Dette repository har ingen commits endnu. Opret et indledende commit før du bruger worktrees.",
|
||||
"agentManager.shortcuts.title": "Tastaturgenveje",
|
||||
"agentManager.shortcuts.category.sidebar": "Sidebjælke",
|
||||
"agentManager.shortcuts.category.tabs": "Faner",
|
||||
|
||||
@@ -56,6 +56,8 @@ export const dict = {
|
||||
"Öffnen Sie einen Ordner, der ein Git-Repository enthält, um Worktrees zu verwenden.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Dieses Repository verwendet Git LFS, aber git-lfs wurde nicht gefunden. Bitte installieren Sie Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Dieses Repository hat noch keine Commits. Erstellen Sie einen initialen Commit, bevor Sie Worktrees verwenden.",
|
||||
"agentManager.shortcuts.title": "Tastenkombinationen",
|
||||
"agentManager.shortcuts.category.sidebar": "Seitenleiste",
|
||||
"agentManager.shortcuts.category.tabs": "Tabs",
|
||||
|
||||
@@ -60,6 +60,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Open a folder that contains a git repository to use worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"This repository uses Git LFS, but git-lfs was not found. Please install Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"This repository has no commits yet. Create an initial commit before using worktrees.",
|
||||
"agentManager.shortcuts.title": "Keyboard Shortcuts",
|
||||
"agentManager.shortcuts.category.sidebar": "Sidebar",
|
||||
"agentManager.shortcuts.category.tabs": "Tabs",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Abra una carpeta que contenga un repositorio git para usar worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Este repositorio usa Git LFS, pero no se encontró git-lfs. Por favor instale Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Este repositorio aún no tiene commits. Cree un commit inicial antes de usar worktrees.",
|
||||
"agentManager.shortcuts.title": "Atajos de teclado",
|
||||
"agentManager.shortcuts.category.sidebar": "Barra lateral",
|
||||
"agentManager.shortcuts.category.tabs": "Pestañas",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Ouvrez un dossier contenant un dépôt git pour utiliser les worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Ce dépôt utilise Git LFS, mais git-lfs n'a pas été trouvé. Veuillez installer Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Ce dépôt n'a pas encore de commits. Créez un commit initial avant d'utiliser les worktrees.",
|
||||
"agentManager.shortcuts.title": "Raccourcis clavier",
|
||||
"agentManager.shortcuts.category.sidebar": "Barre latérale",
|
||||
"agentManager.shortcuts.category.tabs": "Onglets",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "worktreesを使用するには、gitリポジトリを含むフォルダーを開いてください。",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"このリポジトリはGit LFSを使用していますが、git-lfsが見つかりませんでした。Git LFSをインストールしてください。",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"このリポジトリにはまだコミットがありません。worktreesを使用する前に最初のコミットを作成してください。",
|
||||
"agentManager.shortcuts.title": "キーボードショートカット",
|
||||
"agentManager.shortcuts.category.sidebar": "サイドバー",
|
||||
"agentManager.shortcuts.category.tabs": "タブ",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "worktrees를 사용하려면 git 리포지토리가 포함된 폴더를 여세요.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"이 리포지토리는 Git LFS를 사용하지만 git-lfs를 찾을 수 없습니다. Git LFS를 설치하세요.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"이 리포지토리에는 아직 커밋이 없습니다. worktrees를 사용하기 전에 초기 커밋을 생성하세요.",
|
||||
"agentManager.shortcuts.title": "키보드 단축키",
|
||||
"agentManager.shortcuts.category.sidebar": "사이드바",
|
||||
"agentManager.shortcuts.category.tabs": "탭",
|
||||
|
||||
@@ -60,6 +60,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Open een map die een git repository bevat om worktrees te gebruiken.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Deze repository gebruikt Git LFS, maar git-lfs is niet gevonden. Installeer Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Deze repository heeft nog geen commits. Maak een initiële commit voordat je worktrees gebruikt.",
|
||||
"agentManager.shortcuts.title": "Sneltoetsen",
|
||||
"agentManager.shortcuts.category.sidebar": "Zijbalk",
|
||||
"agentManager.shortcuts.category.tabs": "Tabbladen",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Åpne en mappe som inneholder et git-repositorium for å bruke worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Dette repositoriet bruker Git LFS, men git-lfs ble ikke funnet. Vennligst installer Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Dette repositoriet har ingen commits ennå. Opprett en første commit før du bruker worktrees.",
|
||||
"agentManager.shortcuts.title": "Tastatursnarveier",
|
||||
"agentManager.shortcuts.category.sidebar": "Sidepanel",
|
||||
"agentManager.shortcuts.category.tabs": "Faner",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Otwórz folder zawierający repozytorium git, aby używać worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"To repozytorium używa Git LFS, ale nie znaleziono git-lfs. Zainstaluj Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"To repozytorium nie ma jeszcze commitów. Utwórz początkowy commit przed użyciem worktrees.",
|
||||
"agentManager.shortcuts.title": "Skróty klawiszowe",
|
||||
"agentManager.shortcuts.category.sidebar": "Pasek boczny",
|
||||
"agentManager.shortcuts.category.tabs": "Karty",
|
||||
|
||||
@@ -55,6 +55,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Откройте папку, содержащую репозиторий git, чтобы использовать worktrees.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Этот репозиторий использует Git LFS, но git-lfs не найден. Пожалуйста, установите Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"В этом репозитории еще нет коммитов. Создайте начальный коммит перед использованием worktrees.",
|
||||
"agentManager.shortcuts.title": "Сочетания клавиш",
|
||||
"agentManager.shortcuts.category.sidebar": "Боковая панель",
|
||||
"agentManager.shortcuts.category.tabs": "Вкладки",
|
||||
|
||||
@@ -53,6 +53,7 @@ export const dict = {
|
||||
"agentManager.setup.error.git_not_found": "ไม่ได้ติดตั้ง Git หรือไม่พบใน PATH โปรดติดตั้ง Git และรีสตาร์ท VS Code",
|
||||
"agentManager.setup.error.not_git_repo": "เปิดโฟลเดอร์ที่มีที่เก็บ git เพื่อใช้ worktrees",
|
||||
"agentManager.setup.error.lfs_missing": "ที่เก็บนี้ใช้ Git LFS แต่ไม่พบ git-lfs โปรดติดตั้ง Git LFS",
|
||||
"agentManager.setup.error.no_commits": "ที่เก็บนี้ยังไม่มีการคอมมิต สร้างการคอมมิตเริ่มต้นก่อนใช้ worktrees",
|
||||
"agentManager.shortcuts.title": "ปุ่มลัดแป้นพิมพ์",
|
||||
"agentManager.shortcuts.category.sidebar": "แถบด้านข้าง",
|
||||
"agentManager.shortcuts.category.tabs": "แท็บ",
|
||||
|
||||
@@ -60,6 +60,8 @@ export const dict = {
|
||||
"agentManager.setup.error.not_git_repo": "Worktree'leri kullanmak için bir git deposu içeren bir klasör açın.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Bu depo Git LFS kullanıyor, ancak git-lfs bulunamadı. Lütfen Git LFS'yi yükleyin.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"Bu depoda henüz commit bulunmuyor. Worktree'leri kullanmadan önce bir başlangıç commit'i oluşturun.",
|
||||
"agentManager.shortcuts.title": "Klavye Kısayolları",
|
||||
"agentManager.shortcuts.category.sidebar": "Kenar Çubuğu",
|
||||
"agentManager.shortcuts.category.tabs": "Sekmeler",
|
||||
|
||||
@@ -61,6 +61,8 @@ export const dict = {
|
||||
"Відкрийте папку, що містить git-репозиторій, щоб використовувати робочі дерева.",
|
||||
"agentManager.setup.error.lfs_missing":
|
||||
"Цей репозиторій використовує Git LFS, але git-lfs не знайдено. Будь ласка, встановіть Git LFS.",
|
||||
"agentManager.setup.error.no_commits":
|
||||
"У цьому репозиторії ще немає коммітів. Створіть початковий комміт перед використанням worktrees.",
|
||||
"agentManager.shortcuts.title": "Клавіатурні скорочення",
|
||||
"agentManager.shortcuts.category.sidebar": "Бічна панель",
|
||||
"agentManager.shortcuts.category.tabs": "Вкладки",
|
||||
|
||||
@@ -53,6 +53,7 @@ export const dict = {
|
||||
"agentManager.setup.error.git_not_found": "未安装 Git 或在 PATH 中找不到 Git。请安装 Git 并重新启动 VS Code。",
|
||||
"agentManager.setup.error.not_git_repo": "打开一个包含 git 存储库的文件夹以使用 worktrees。",
|
||||
"agentManager.setup.error.lfs_missing": "此存储库使用 Git LFS,但找不到 git-lfs。请安装 Git LFS。",
|
||||
"agentManager.setup.error.no_commits": "此存储库尚无提交。在使用 worktrees 之前,请创建一个初始提交。",
|
||||
"agentManager.shortcuts.title": "键盘快捷键",
|
||||
"agentManager.shortcuts.category.sidebar": "侧边栏",
|
||||
"agentManager.shortcuts.category.tabs": "标签页",
|
||||
|
||||
@@ -53,6 +53,7 @@ export const dict = {
|
||||
"agentManager.setup.error.git_not_found": "未安裝 Git 或在 PATH 中找不到 Git。請安裝 Git 並重新啟動 VS Code。",
|
||||
"agentManager.setup.error.not_git_repo": "開啟一個包含 git 儲存庫的資料夾以使用 worktrees。",
|
||||
"agentManager.setup.error.lfs_missing": "此儲存庫使用 Git LFS,但找不到 git-lfs。請安裝 Git LFS。",
|
||||
"agentManager.setup.error.no_commits": "此儲存庫尚無提交。在使用 worktrees 之前,請建立一個初始提交。",
|
||||
"agentManager.shortcuts.title": "鍵盤快捷鍵",
|
||||
"agentManager.shortcuts.category.sidebar": "側邊欄",
|
||||
"agentManager.shortcuts.category.tabs": "分頁",
|
||||
|
||||
@@ -74,6 +74,54 @@ export function adjacentHint(
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute which session IDs should populate the "local" tab on state restore.
|
||||
*
|
||||
* Managed sessions with `worktreeId === null` are non-worktree sessions that
|
||||
* were persisted to agent-manager.json. On restore we use them as the local
|
||||
* tab list, optionally applying a persisted tab order.
|
||||
*
|
||||
* @param sessions - All managed sessions from agent-manager.json
|
||||
* @param current - The webview's current localSessionIDs (may contain pending tabs)
|
||||
* @param tabOrder - Persisted tab order for the "local" key, if any
|
||||
* @param isPending - Predicate to identify pending (not-yet-created) tab IDs
|
||||
* @param applyOrder - Reorder helper: (items, order) → ordered items
|
||||
*/
|
||||
export function restoreLocalSessions(
|
||||
sessions: { id: string; worktreeId: string | null }[],
|
||||
current: string[],
|
||||
tabOrder: string[] | undefined,
|
||||
isPending: (id: string) => boolean,
|
||||
applyOrder: (items: { id: string }[], order: string[]) => { id: string }[],
|
||||
): string[] | undefined {
|
||||
const locals = sessions.filter((s) => !s.worktreeId).map((s) => s.id)
|
||||
const real = current.filter((id) => !isPending(id))
|
||||
|
||||
// First restore: current has no real sessions but disk has some
|
||||
if (locals.length > 0 && real.length === 0) {
|
||||
if (!tabOrder) return locals
|
||||
return applyOrder(
|
||||
locals.map((id) => ({ id })),
|
||||
tabOrder,
|
||||
).map((item) => item.id)
|
||||
}
|
||||
|
||||
// Merge any disk-persisted sessions missing from current (e.g. vscode.setState
|
||||
// debounce didn't fire before close, but persistSession already wrote to disk)
|
||||
const missing = locals.filter((id) => !current.includes(id))
|
||||
const merged = missing.length > 0 ? [...current, ...missing] : current
|
||||
|
||||
// Apply tab order if present
|
||||
if (tabOrder && merged.length > 0) {
|
||||
return applyOrder(
|
||||
merged.map((id) => ({ id })),
|
||||
tabOrder,
|
||||
).map((item) => item.id)
|
||||
}
|
||||
|
||||
return missing.length > 0 ? merged : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* After removing a worktree, pick the nearest remaining sidebar neighbor.
|
||||
* Order: the worktree just below → the one above → LOCAL.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createEffect } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
/** Reactive effect: reports open (non-pending) session IDs to the extension for heartbeat. */
|
||||
export function trackOpenSessions(
|
||||
local: Accessor<string[]>,
|
||||
pending: (id: string) => boolean,
|
||||
managed: Accessor<Array<{ id: string }>>,
|
||||
post: (msg: { type: "agentManager.openSessions"; sessionIDs: string[] }) => void,
|
||||
): void {
|
||||
createEffect(() => {
|
||||
const ids = [...new Set([...local().filter((id) => !pending(id)), ...managed().map((s) => s.id)])]
|
||||
post({ type: "agentManager.openSessions", sessionIDs: ids })
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { Switch } from "@kilocode/kilo-ui/switch"
|
||||
import { Select } from "@kilocode/kilo-ui/select"
|
||||
import { TextField } from "@kilocode/kilo-ui/text-field"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import type { ExtensionMessage } from "../../types/messages"
|
||||
import SettingsRow from "./SettingsRow"
|
||||
|
||||
interface ShareOption {
|
||||
@@ -21,6 +23,20 @@ const SHARE_OPTIONS: ShareOption[] = [
|
||||
const ExperimentalTab: Component = () => {
|
||||
const { config, updateConfig } = useConfig()
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
const [active, setActive] = createSignal(false)
|
||||
|
||||
const handler = (msg: ExtensionMessage) => {
|
||||
if (msg.type === "remoteStatus") {
|
||||
setActive(msg.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsub = vscode.onMessage(handler)
|
||||
vscode.postMessage({ type: "requestRemoteStatus" })
|
||||
onCleanup(unsub)
|
||||
})
|
||||
|
||||
const experimental = createMemo(() => config().experimental ?? {})
|
||||
|
||||
@@ -33,6 +49,37 @@ const ExperimentalTab: Component = () => {
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
{/* Remote control */}
|
||||
<div data-component="remote-settings">
|
||||
<div data-slot="remote-settings-header">
|
||||
<div data-slot="settings-row-label-title">{language.t("settings.experimental.remote.title")}</div>
|
||||
<div data-slot="settings-row-label-subtitle">{language.t("settings.experimental.remote.description")}</div>
|
||||
</div>
|
||||
<div data-slot="remote-settings-block">
|
||||
<div data-slot="remote-settings-row">
|
||||
<span data-slot="remote-settings-label">{language.t("settings.experimental.remote.current")}</span>
|
||||
<span data-slot="remote-settings-status" data-active={active()}>
|
||||
{active()
|
||||
? language.t("settings.experimental.remote.active")
|
||||
: language.t("settings.experimental.remote.inactive")}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="remote-settings-hint">{language.t("settings.experimental.remote.hint")}</div>
|
||||
</div>
|
||||
<div data-slot="remote-settings-row">
|
||||
<span data-slot="remote-settings-label">{language.t("settings.experimental.remote.startup")}</span>
|
||||
<Switch
|
||||
checked={config().remote_control ?? false}
|
||||
onChange={(checked) => {
|
||||
updateConfig({ remote_control: checked })
|
||||
}}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.experimental.remote.startup")}
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share mode */}
|
||||
<SettingsRow
|
||||
title={language.t("settings.experimental.share.title")}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const KNOWN_KEYS: ReadonlyArray<string> = [
|
||||
"instructions",
|
||||
"skills",
|
||||
"snapshot",
|
||||
"remote_control",
|
||||
"share",
|
||||
"username",
|
||||
"watcher",
|
||||
|
||||
@@ -98,6 +98,14 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
|
||||
vscode.postMessage({ type: "openSettingsPanel" })
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote",
|
||||
description: "Toggle remote control",
|
||||
hints: [],
|
||||
action: () => {
|
||||
vscode.postMessage({ type: "toggleRemote" })
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const client = exclude ? all.filter((c) => !exclude.has(c.name)) : all
|
||||
|
||||
+8
@@ -1056,6 +1056,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن",
|
||||
"settings.experimental.mcpTimeout.title": "مهلة MCP (مللي ثانية)",
|
||||
"settings.experimental.mcpTimeout.description": "مهلة طلبات خادم MCP بالمللي ثانية",
|
||||
"settings.experimental.remote.title": "التحكم Remote",
|
||||
"settings.experimental.remote.description":
|
||||
"قم بتمكين التحكم Remote في الجلسات عبر Kilo Cloud. سيؤثر هذا أيضًا على واجهات سطر الأوامر (CLIs) على هذا الجهاز.",
|
||||
"settings.experimental.remote.current": "الحالة الحالية:",
|
||||
"settings.experimental.remote.startup": "التفعيل التلقائي عند بدء التشغيل:",
|
||||
"settings.experimental.remote.active": "نشط",
|
||||
"settings.experimental.remote.inactive": "غير نشط",
|
||||
"settings.experimental.remote.hint": "استخدم /remote في الدردشة للتبديل",
|
||||
"settings.experimental.toolToggles": "مفاتيح الأدوات",
|
||||
"settings.agentBehaviour.defaultAgent.title": "الوكيل الافتراضي",
|
||||
"settings.agentBehaviour.defaultAgent.description": "الوكيل المستخدم عند عدم التحديد",
|
||||
|
||||
+8
@@ -1073,6 +1073,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada",
|
||||
"settings.experimental.mcpTimeout.title": "Tempo limite MCP (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Tempo limite para solicitações do servidor MCP em milissegundos",
|
||||
"settings.experimental.remote.title": "Controle Remote",
|
||||
"settings.experimental.remote.description":
|
||||
"Habilite o controle Remote de sessões via Kilo Cloud. Isso também afetará as CLIs nesta máquina.",
|
||||
"settings.experimental.remote.current": "Estado atual:",
|
||||
"settings.experimental.remote.startup": "Ativar automaticamente na inicialização:",
|
||||
"settings.experimental.remote.active": "Ativo",
|
||||
"settings.experimental.remote.inactive": "Inativo",
|
||||
"settings.experimental.remote.hint": "Use /remote no chat para alternar",
|
||||
"settings.experimental.toolToggles": "Alternadores de ferramentas",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Agente padrão",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Agente a usar quando nenhum é especificado",
|
||||
|
||||
+8
@@ -1072,6 +1072,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Nastavi petlju agenta kada je dozvola odbijena",
|
||||
"settings.experimental.mcpTimeout.title": "MCP istek vremena (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Istek vremena za MCP server zahtjeve u milisekundama",
|
||||
"settings.experimental.remote.title": "Remote kontrola",
|
||||
"settings.experimental.remote.description":
|
||||
"Omogućite Remote kontrolu sesija putem Kilo Cloud. Ovo će također utjecati na CLI-jeve na ovoj mašini.",
|
||||
"settings.experimental.remote.current": "Trenutno stanje:",
|
||||
"settings.experimental.remote.startup": "Automatsko uključivanje pri pokretanju:",
|
||||
"settings.experimental.remote.active": "Aktivno",
|
||||
"settings.experimental.remote.inactive": "Neaktivno",
|
||||
"settings.experimental.remote.hint": "Koristite /remote u chatu za prebacivanje",
|
||||
"settings.experimental.toolToggles": "Prekidači alata",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Zadani agent",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Agent koji se koristi kada nijedan nije naveden",
|
||||
|
||||
+8
@@ -1066,6 +1066,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Fortsæt agentløkken, når en tilladelse afvises",
|
||||
"settings.experimental.mcpTimeout.title": "MCP-timeout (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Timeout for MCP-serveranmodninger i millisekunder",
|
||||
"settings.experimental.remote.title": "Remote-styring",
|
||||
"settings.experimental.remote.description":
|
||||
"Aktivér Remote-styring af sessioner via Kilo Cloud. Dette vil også påvirke CLI'er på denne maskine.",
|
||||
"settings.experimental.remote.current": "Nuværende status:",
|
||||
"settings.experimental.remote.startup": "Aktivér automatisk ved opstart:",
|
||||
"settings.experimental.remote.active": "Aktiv",
|
||||
"settings.experimental.remote.inactive": "Inaktiv",
|
||||
"settings.experimental.remote.hint": "Brug /remote i chatten for at skifte",
|
||||
"settings.experimental.toolToggles": "Værktøjsskift",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Standardagent",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Agent til brug, når ingen er angivet",
|
||||
|
||||
+8
@@ -1086,6 +1086,14 @@ export const dict = {
|
||||
"Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird",
|
||||
"settings.experimental.mcpTimeout.title": "MCP-Zeitlimit (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Zeitlimit für MCP-Server-Anfragen in Millisekunden",
|
||||
"settings.experimental.remote.title": "Remote-Steuerung",
|
||||
"settings.experimental.remote.description":
|
||||
"Aktivieren Sie die Remote-Steuerung von Sitzungen über Kilo Cloud. Dies betrifft auch CLIs auf diesem Computer.",
|
||||
"settings.experimental.remote.current": "Aktueller Status:",
|
||||
"settings.experimental.remote.startup": "Automatisch beim Start aktivieren:",
|
||||
"settings.experimental.remote.active": "Aktiv",
|
||||
"settings.experimental.remote.inactive": "Inaktiv",
|
||||
"settings.experimental.remote.hint": "Verwende /remote im Chat zum Umschalten",
|
||||
"settings.experimental.toolToggles": "Werkzeug-Schalter",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Standard-Agent",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Agent, der verwendet wird, wenn keiner angegeben ist",
|
||||
|
||||
@@ -1067,6 +1067,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied",
|
||||
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Timeout for MCP server requests in milliseconds",
|
||||
"settings.experimental.remote.title": "Remote Control",
|
||||
"settings.experimental.remote.description":
|
||||
"Enable remote control of sessions via Kilo Cloud. This will also affect CLIs on this machine.",
|
||||
"settings.experimental.remote.current": "Current state:",
|
||||
"settings.experimental.remote.startup": "Auto-enable on startup:",
|
||||
"settings.experimental.remote.active": "Active",
|
||||
"settings.experimental.remote.inactive": "Inactive",
|
||||
"settings.experimental.remote.hint": "Use /remote in chat to toggle",
|
||||
"settings.experimental.toolToggles": "Tool Toggles",
|
||||
|
||||
"settings.agentBehaviour.defaultAgent.title": "Default Agent",
|
||||
|
||||
+8
@@ -1077,6 +1077,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso",
|
||||
"settings.experimental.mcpTimeout.title": "Tiempo de espera MCP (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Tiempo de espera para solicitudes del servidor MCP en milisegundos",
|
||||
"settings.experimental.remote.title": "Control Remote",
|
||||
"settings.experimental.remote.description":
|
||||
"Habilite el control Remote de las sesiones a través de Kilo Cloud. Esto también afectará a las CLI de este equipo.",
|
||||
"settings.experimental.remote.current": "Estado actual:",
|
||||
"settings.experimental.remote.startup": "Activar automáticamente al inicio:",
|
||||
"settings.experimental.remote.active": "Activo",
|
||||
"settings.experimental.remote.inactive": "Inactivo",
|
||||
"settings.experimental.remote.hint": "Usa /remote en el chat para alternar",
|
||||
"settings.experimental.toolToggles": "Interruptores de herramientas",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Agente predeterminado",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Agente a usar cuando no se especifica ninguno",
|
||||
|
||||
+8
@@ -1088,6 +1088,14 @@ export const dict = {
|
||||
"Continuer la boucle de l'agent lorsqu'une autorisation est refusée",
|
||||
"settings.experimental.mcpTimeout.title": "Délai MCP (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Délai des requêtes du serveur MCP en millisecondes",
|
||||
"settings.experimental.remote.title": "Contrôle Remote",
|
||||
"settings.experimental.remote.description":
|
||||
"Activez le contrôle Remote des sessions via Kilo Cloud. Cela affectera également les CLI sur cette machine.",
|
||||
"settings.experimental.remote.current": "État actuel :",
|
||||
"settings.experimental.remote.startup": "Activation automatique au démarrage :",
|
||||
"settings.experimental.remote.active": "Actif",
|
||||
"settings.experimental.remote.inactive": "Inactif",
|
||||
"settings.experimental.remote.hint": "Utilisez /remote dans le chat pour basculer",
|
||||
"settings.experimental.toolToggles": "Commutateurs d'outils",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Agent par défaut",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Agent à utiliser lorsqu'aucun n'est spécifié",
|
||||
|
||||
+8
@@ -1066,6 +1066,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行",
|
||||
"settings.experimental.mcpTimeout.title": "MCPタイムアウト(ミリ秒)",
|
||||
"settings.experimental.mcpTimeout.description": "MCPサーバーリクエストのタイムアウト(ミリ秒)",
|
||||
"settings.experimental.remote.title": "Remote コントロール",
|
||||
"settings.experimental.remote.description":
|
||||
"Kilo Cloud 経由でのセッションの Remote コントロールを有効にします。これはこのマシンの CLI にも影響します。",
|
||||
"settings.experimental.remote.current": "現在の状態:",
|
||||
"settings.experimental.remote.startup": "起動時の自動有効化:",
|
||||
"settings.experimental.remote.active": "アクティブ",
|
||||
"settings.experimental.remote.inactive": "非アクティブ",
|
||||
"settings.experimental.remote.hint": "チャットで /remote を使用して切り替えます",
|
||||
"settings.experimental.toolToggles": "ツールトグル",
|
||||
"settings.agentBehaviour.defaultAgent.title": "デフォルトエージェント",
|
||||
"settings.agentBehaviour.defaultAgent.description": "指定されていない場合に使用するエージェント",
|
||||
|
||||
+8
@@ -1063,6 +1063,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속",
|
||||
"settings.experimental.mcpTimeout.title": "MCP 타임아웃 (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "MCP 서버 요청의 타임아웃 시간 (밀리초)",
|
||||
"settings.experimental.remote.title": "Remote 제어",
|
||||
"settings.experimental.remote.description":
|
||||
"Kilo Cloud를 통한 세션의 Remote 제어를 활성화합니다. 이는 이 컴퓨터의 CLI에도 영향을 미칩니다.",
|
||||
"settings.experimental.remote.current": "현재 상태:",
|
||||
"settings.experimental.remote.startup": "시작 시 자동 활성화:",
|
||||
"settings.experimental.remote.active": "활성",
|
||||
"settings.experimental.remote.inactive": "비활성",
|
||||
"settings.experimental.remote.hint": "채팅에서 /remote를 사용하여 전환하세요",
|
||||
"settings.experimental.toolToggles": "도구 토글",
|
||||
"settings.agentBehaviour.defaultAgent.title": "기본 에이전트",
|
||||
"settings.agentBehaviour.defaultAgent.description": "지정되지 않은 경우 사용할 에이전트",
|
||||
|
||||
+8
@@ -1076,6 +1076,14 @@ export const dict = {
|
||||
"Ga door met de agent loop wanneer een toestemming wordt geweigerd",
|
||||
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Timeout voor MCP-serververzoeken in milliseconden",
|
||||
"settings.experimental.remote.title": "Remote-bediening",
|
||||
"settings.experimental.remote.description":
|
||||
"Schakel Remote-bediening van sessies in via Kilo Cloud. Dit heeft ook invloed op CLI's op deze machine.",
|
||||
"settings.experimental.remote.current": "Huidige status:",
|
||||
"settings.experimental.remote.startup": "Automatisch inschakelen bij opstarten:",
|
||||
"settings.experimental.remote.active": "Actief",
|
||||
"settings.experimental.remote.inactive": "Inactief",
|
||||
"settings.experimental.remote.hint": "Gebruik /remote in de chat om te schakelen",
|
||||
"settings.experimental.toolToggles": "Tool Schakelaars",
|
||||
|
||||
"settings.agentBehaviour.defaultAgent.title": "Standaard Agent",
|
||||
|
||||
+8
@@ -1069,6 +1069,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Fortsett agentløkken når en tillatelse avvises",
|
||||
"settings.experimental.mcpTimeout.title": "MCP-tidsavbrudd (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Tidsavbrudd for MCP-serverforespørsler i millisekunder",
|
||||
"settings.experimental.remote.title": "Remote-kontroll",
|
||||
"settings.experimental.remote.description":
|
||||
"Aktiver Remote-kontroll av økter via Kilo Cloud. Dette vil også påvirke CLI-er på denne maskinen.",
|
||||
"settings.experimental.remote.current": "Nåværende status:",
|
||||
"settings.experimental.remote.startup": "Aktiver automatisk ved oppstart:",
|
||||
"settings.experimental.remote.active": "Aktiv",
|
||||
"settings.experimental.remote.inactive": "Inaktiv",
|
||||
"settings.experimental.remote.hint": "Bruk /remote i chatten for å veksle",
|
||||
"settings.experimental.toolToggles": "Verktøybrytere",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Standardagent",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Agent å bruke når ingen er angitt",
|
||||
|
||||
+8
@@ -1070,6 +1070,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Kontynuuj pętlę agenta po odmowie uprawnienia",
|
||||
"settings.experimental.mcpTimeout.title": "Limit czasu MCP (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "Limit czasu żądań serwera MCP w milisekundach",
|
||||
"settings.experimental.remote.title": "Sterowanie Remote",
|
||||
"settings.experimental.remote.description":
|
||||
"Włącz sterowanie Remote sesjami za pośrednictwem Kilo Cloud. Wpłynie to również na CLI na tej maszynie.",
|
||||
"settings.experimental.remote.current": "Aktualny stan:",
|
||||
"settings.experimental.remote.startup": "Automatyczne włączanie przy starcie:",
|
||||
"settings.experimental.remote.active": "Aktywny",
|
||||
"settings.experimental.remote.inactive": "Nieaktywny",
|
||||
"settings.experimental.remote.hint": "Użyj /remote na czacie, aby przełączyć",
|
||||
"settings.experimental.toolToggles": "Przełączniki narzędzi",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Domyślny agent",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Agent używany, gdy żaden nie jest określony",
|
||||
|
||||
+8
@@ -1072,6 +1072,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении",
|
||||
"settings.experimental.mcpTimeout.title": "Таймаут MCP (мс)",
|
||||
"settings.experimental.mcpTimeout.description": "Таймаут запросов MCP-сервера в миллисекундах",
|
||||
"settings.experimental.remote.title": "Управление Remote",
|
||||
"settings.experimental.remote.description":
|
||||
"Включите управление Remote сеансами через Kilo Cloud. Это также повлияет на CLI на этом компьютере.",
|
||||
"settings.experimental.remote.current": "Текущее состояние:",
|
||||
"settings.experimental.remote.startup": "Автоматически включать при запуске:",
|
||||
"settings.experimental.remote.active": "Активно",
|
||||
"settings.experimental.remote.inactive": "Неактивно",
|
||||
"settings.experimental.remote.hint": "Используйте /remote в чате для переключения",
|
||||
"settings.experimental.toolToggles": "Переключатели инструментов",
|
||||
"settings.agentBehaviour.defaultAgent.title": "Агент по умолчанию",
|
||||
"settings.agentBehaviour.defaultAgent.description": "Агент при отсутствии указания",
|
||||
|
||||
+8
@@ -1058,6 +1058,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ",
|
||||
"settings.experimental.mcpTimeout.title": "หมดเวลา MCP (มิลลิวินาที)",
|
||||
"settings.experimental.mcpTimeout.description": "หมดเวลาสำหรับคำขอเซิร์ฟเวอร์ MCP เป็นมิลลิวินาที",
|
||||
"settings.experimental.remote.title": "การควบคุม Remote",
|
||||
"settings.experimental.remote.description":
|
||||
"เปิดใช้งานการควบคุม Remote ของเซสชันผ่าน Kilo Cloud ซึ่งจะส่งผลต่อ CLI บนเครื่องนี้ด้วย",
|
||||
"settings.experimental.remote.current": "สถานะปัจจุบัน:",
|
||||
"settings.experimental.remote.startup": "เปิดใช้งานอัตโนมัติเมื่อเริ่มต้น:",
|
||||
"settings.experimental.remote.active": "เปิดใช้งาน",
|
||||
"settings.experimental.remote.inactive": "ปิดใช้งาน",
|
||||
"settings.experimental.remote.hint": "ใช้ /remote ในแชทเพื่อสลับสถานะ",
|
||||
"settings.experimental.toolToggles": "สวิตช์เครื่องมือ",
|
||||
"settings.agentBehaviour.defaultAgent.title": "เอเจนต์เริ่มต้น",
|
||||
"settings.agentBehaviour.defaultAgent.description": "เอเจนต์ที่ใช้เมื่อไม่ได้ระบุ",
|
||||
|
||||
+8
@@ -1072,6 +1072,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Bir izin reddedildiğinde ajan döngüsüne devam et",
|
||||
"settings.experimental.mcpTimeout.title": "MCP Zaman Aşımı (ms)",
|
||||
"settings.experimental.mcpTimeout.description": "MCP sunucu istekleri için milisaniye cinsinden zaman aşımı",
|
||||
"settings.experimental.remote.title": "Remote Kontrolü",
|
||||
"settings.experimental.remote.description":
|
||||
"Kilo Cloud üzerinden oturumların Remote kontrolünü etkinleştirin. Bu, bu makinedeki CLI'leri de etkileyecektir.",
|
||||
"settings.experimental.remote.current": "Mevcut durum:",
|
||||
"settings.experimental.remote.startup": "Başlangıçta otomatik etkinleştir:",
|
||||
"settings.experimental.remote.active": "Aktif",
|
||||
"settings.experimental.remote.inactive": "Pasif",
|
||||
"settings.experimental.remote.hint": "Geçiş yapmak için sohbette /remote kullanın",
|
||||
"settings.experimental.toolToggles": "Araç Açma/Kapatma",
|
||||
|
||||
"settings.agentBehaviour.defaultAgent.title": "Varsayılan Ajan",
|
||||
|
||||
+8
@@ -1075,6 +1075,14 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено",
|
||||
"settings.experimental.mcpTimeout.title": "Тайм-аут MCP (мс)",
|
||||
"settings.experimental.mcpTimeout.description": "Тайм-аут у мілісекундах для запитів до MCP-сервера",
|
||||
"settings.experimental.remote.title": "Керування Remote",
|
||||
"settings.experimental.remote.description":
|
||||
"Увімкніть керування Remote сеансами через Kilo Cloud. Це також вплине на CLI на цьому комп'ютері.",
|
||||
"settings.experimental.remote.current": "Поточний стан:",
|
||||
"settings.experimental.remote.startup": "Автоматичне ввімкнення під час запуску:",
|
||||
"settings.experimental.remote.active": "Активний",
|
||||
"settings.experimental.remote.inactive": "Неактивний",
|
||||
"settings.experimental.remote.hint": "Використовуйте /remote у чаті для перемикання",
|
||||
"settings.experimental.toolToggles": "Перемикачі інструментів",
|
||||
|
||||
"settings.agentBehaviour.defaultAgent.title": "Агент за замовчуванням",
|
||||
|
||||
+7
@@ -1048,6 +1048,13 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环",
|
||||
"settings.experimental.mcpTimeout.title": "MCP 超时(毫秒)",
|
||||
"settings.experimental.mcpTimeout.description": "MCP 服务器请求的超时时间(毫秒)",
|
||||
"settings.experimental.remote.title": "Remote 控制",
|
||||
"settings.experimental.remote.description": "通过 Kilo Cloud 启用会话的 Remote 控制。这也会影响此计算机上的 CLI。",
|
||||
"settings.experimental.remote.current": "当前状态:",
|
||||
"settings.experimental.remote.startup": "启动时自动启用:",
|
||||
"settings.experimental.remote.active": "已启用",
|
||||
"settings.experimental.remote.inactive": "未启用",
|
||||
"settings.experimental.remote.hint": "在聊天中使用 /remote 进行切换",
|
||||
"settings.experimental.toolToggles": "工具开关",
|
||||
"settings.agentBehaviour.defaultAgent.title": "默认智能体",
|
||||
"settings.agentBehaviour.defaultAgent.description": "未指定时使用的智能体",
|
||||
|
||||
+7
@@ -1050,6 +1050,13 @@ export const dict = {
|
||||
"settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈",
|
||||
"settings.experimental.mcpTimeout.title": "MCP 逾時(毫秒)",
|
||||
"settings.experimental.mcpTimeout.description": "MCP 伺服器請求的逾時時間(毫秒)",
|
||||
"settings.experimental.remote.title": "Remote 控制",
|
||||
"settings.experimental.remote.description": "透過 Kilo Cloud 啟用工作階段的 Remote 控制。這也會影響此電腦上的 CLI。",
|
||||
"settings.experimental.remote.current": "目前狀態:",
|
||||
"settings.experimental.remote.startup": "啟動時自動啟用:",
|
||||
"settings.experimental.remote.active": "已啟用",
|
||||
"settings.experimental.remote.inactive": "已停用",
|
||||
"settings.experimental.remote.hint": "在聊天中使用 /remote 來切換",
|
||||
"settings.experimental.toolToggles": "工具開關",
|
||||
"settings.agentBehaviour.defaultAgent.title": "預設 Agent",
|
||||
"settings.agentBehaviour.defaultAgent.description": "未指定時使用的 Agent",
|
||||
|
||||
@@ -1072,6 +1072,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Remote Settings */
|
||||
[data-component="remote-settings"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="remote-settings-header"] {
|
||||
margin-bottom: 4px;
|
||||
|
||||
[data-slot="settings-row-label-title"] {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
[data-slot="settings-row-label-subtitle"] {
|
||||
font-size: 12px;
|
||||
color: var(--text-weak-base, var(--vscode-descriptionForeground));
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="remote-settings-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="remote-settings-label"] {
|
||||
font-size: 12px;
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="remote-settings-block"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
[data-slot="remote-settings-status"] {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="remote-settings-status"][data-active="true"] {
|
||||
color: var(--vscode-testing-iconPassed, #5cb85c);
|
||||
}
|
||||
|
||||
[data-slot="remote-settings-hint"] {
|
||||
font-size: 11px;
|
||||
color: var(--text-weak);
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prompt-input-hint-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -421,6 +421,7 @@ export interface Config {
|
||||
instructions?: string[]
|
||||
skills?: SkillsConfig
|
||||
snapshot?: boolean
|
||||
remote_control?: boolean
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
username?: string
|
||||
watcher?: WatcherConfig
|
||||
@@ -1503,6 +1504,7 @@ export type ExtensionMessage =
|
||||
| McpStatusLoadedMessage
|
||||
| ClearPendingPromptsMessage
|
||||
| ExtensionDataReadyMessage
|
||||
| RemoteStatusMessage
|
||||
|
||||
// ============================================
|
||||
// Messages FROM webview TO extension
|
||||
@@ -1911,6 +1913,18 @@ export interface CloseSessionRequest {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/** Persist a non-worktree session to agent-manager.json (worktreeId = null). */
|
||||
export interface PersistSessionRequest {
|
||||
type: "agentManager.persistSession"
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/** Remove a non-worktree session from agent-manager.json. */
|
||||
export interface ForgetSessionRequest {
|
||||
type: "agentManager.forgetSession"
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
// Rename a worktree's display label
|
||||
export interface RenameWorktreeRequest {
|
||||
type: "agentManager.renameWorktree"
|
||||
@@ -2153,6 +2167,31 @@ export interface SetDefaultBaseBranchRequest {
|
||||
branch?: string
|
||||
}
|
||||
|
||||
// Report all open session IDs to extension for heartbeat (webview → extension)
|
||||
export interface AgentManagerOpenSessionsMessage {
|
||||
type: "agentManager.openSessions"
|
||||
sessionIDs: string[]
|
||||
}
|
||||
|
||||
export interface RemoteStatusMessage {
|
||||
type: "remoteStatus"
|
||||
enabled: boolean
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface ToggleRemoteMessage {
|
||||
type: "toggleRemote"
|
||||
}
|
||||
|
||||
export interface SetRemoteEnabledMessage {
|
||||
type: "setRemoteEnabled"
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface RequestRemoteStatusMessage {
|
||||
type: "requestRemoteStatus"
|
||||
}
|
||||
|
||||
export interface ConnectProviderMessage {
|
||||
type: "connectProvider"
|
||||
requestId: string
|
||||
@@ -2350,6 +2389,8 @@ export type WebviewMessage =
|
||||
| AddSessionToWorktreeRequest
|
||||
| ForkSessionRequest
|
||||
| CloseSessionRequest
|
||||
| PersistSessionRequest
|
||||
| ForgetSessionRequest
|
||||
| RenameWorktreeRequest
|
||||
| TelemetryRequest
|
||||
| RequestRepoInfoMessage
|
||||
@@ -2398,6 +2439,7 @@ export type WebviewMessage =
|
||||
| OpenSubAgentViewerRequest
|
||||
| PreviewImageRequest
|
||||
| SetDefaultBaseBranchRequest
|
||||
| AgentManagerOpenSessionsMessage
|
||||
| FetchMarketplaceDataMessage
|
||||
| FilterMarketplaceItemsMessage
|
||||
| InstallMarketplaceItemMessage
|
||||
@@ -2412,6 +2454,9 @@ export type WebviewMessage =
|
||||
| RequestRecentsMessage
|
||||
| ToggleFavoriteRequest
|
||||
| RequestFavoritesMessage
|
||||
| ToggleRemoteMessage
|
||||
| SetRemoteEnabledMessage
|
||||
| RequestRemoteStatusMessage
|
||||
| ContinueInWorktreeRequest
|
||||
| CreateSectionRequest
|
||||
| RenameSectionRequest
|
||||
|
||||
@@ -478,6 +478,8 @@ export const RunCommand = cmd({
|
||||
|
||||
async function loop() {
|
||||
const toggles = new Map<string, boolean>()
|
||||
const MAX_RETRIES = 3 // kilocode_change
|
||||
let retries = 0 // kilocode_change
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (
|
||||
@@ -568,6 +570,16 @@ export const RunCommand = cmd({
|
||||
UI.error(err)
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
if (
|
||||
event.type === "session.status" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
event.properties.status.type === "busy"
|
||||
) {
|
||||
retries = 0
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
if (
|
||||
event.type === "session.status" &&
|
||||
event.properties.sessionID === sessionID &&
|
||||
@@ -599,8 +611,28 @@ export const RunCommand = cmd({
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
}) // kilocode_change
|
||||
} // kilocode_change
|
||||
// kilocode_change start - network retry handling
|
||||
if (event.type === "session.network.asked") {
|
||||
const request = event.properties
|
||||
if (request.sessionID !== sessionID) continue
|
||||
retries++
|
||||
if (retries > MAX_RETRIES) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL + `network retry limit reached (${MAX_RETRIES}); rejecting`,
|
||||
)
|
||||
await sdk.network.reject({ requestID: request.id })
|
||||
continue
|
||||
}
|
||||
const delay = Math.min(5000 * Math.pow(2, retries - 1), 60000)
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
await sdk.network.reply({
|
||||
requestID: request.id,
|
||||
})
|
||||
}
|
||||
// kilocode_change end
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ function App() {
|
||||
// kilocode_change start — notify server which session the user is viewing (for live session indicators)
|
||||
createEffect(() => {
|
||||
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
|
||||
sdk.client.session.viewed({ sessionID }).catch(() => {})
|
||||
sdk.client.session.viewed({ focused: sessionID ? [sessionID] : [] }).catch(() => {})
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
Command,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionNetworkWait, // kilocode_change
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
McpResource,
|
||||
@@ -48,6 +49,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
// kilocode_change start
|
||||
network: {
|
||||
[sessionID: string]: SessionNetworkWait[]
|
||||
}
|
||||
// kilocode_change end
|
||||
config: Config
|
||||
session: Session[]
|
||||
session_status: {
|
||||
@@ -88,6 +94,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
agent: [],
|
||||
permission: {},
|
||||
question: {},
|
||||
// kilocode_change start
|
||||
network: {},
|
||||
// kilocode_change end
|
||||
command: [],
|
||||
provider: [],
|
||||
provider_default: {},
|
||||
@@ -131,6 +140,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
delete draft.session_diff[sessionID]
|
||||
delete draft.session_status[sessionID]
|
||||
delete draft.todo[sessionID]
|
||||
delete draft.network[sessionID]
|
||||
}),
|
||||
)
|
||||
fullSyncedSessions.delete(sessionID)
|
||||
@@ -224,8 +234,58 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}),
|
||||
)
|
||||
break
|
||||
} // kilocode_change
|
||||
|
||||
// kilocode_change start
|
||||
case "session.network.replied":
|
||||
case "session.network.rejected": {
|
||||
const requests = store.network[event.properties.sessionID]
|
||||
if (!requests) break
|
||||
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
|
||||
if (!match.found) break
|
||||
setStore(
|
||||
"network",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "session.network.restored": {
|
||||
const requests = store.network[event.properties.sessionID]
|
||||
if (!requests) break
|
||||
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
|
||||
if (match.found) {
|
||||
setStore("network", event.properties.sessionID, match.index, "restored", true)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "session.network.asked": {
|
||||
const request = event.properties
|
||||
const requests = store.network[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("network", request.sessionID, [request])
|
||||
break
|
||||
}
|
||||
const match = Binary.search(requests, request.id, (r) => r.id)
|
||||
if (match.found) {
|
||||
setStore("network", request.sessionID, match.index, reconcile(request))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"network",
|
||||
request.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 0, request)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||
break
|
||||
@@ -467,7 +527,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
||||
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
||||
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), // kilocode_change
|
||||
// kilocode_change start
|
||||
sdk.client.network.list().then((x) => {
|
||||
const next: Record<string, SessionNetworkWait[]> = {}
|
||||
for (const item of x.data ?? []) {
|
||||
if (!next[item.sessionID]) next[item.sessionID] = []
|
||||
next[item.sessionID].push(item)
|
||||
}
|
||||
setStore("network", reconcile(next))
|
||||
}),
|
||||
// kilocode_change end
|
||||
sdk.client.session.status().then((x) => {
|
||||
setStore("session_status", reconcile(x.data!))
|
||||
}),
|
||||
|
||||
@@ -78,6 +78,7 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Global } from "@/global"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { NetworkPrompt } from "./network" // kilocode_change
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import { formatTranscript } from "../../util/transcript"
|
||||
import { UI } from "@/cli/ui.ts"
|
||||
@@ -141,6 +142,12 @@ export function Session() {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.question[x.id] ?? [])
|
||||
})
|
||||
// kilocode_change start
|
||||
const network = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return children().flatMap((x) => sync.data.network[x.id] ?? [])
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
const pending = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
|
||||
@@ -181,6 +188,15 @@ export function Session() {
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => [route.sessionID, network().length] as const,
|
||||
([id, len], prev) => {
|
||||
if (!prev || prev[0] !== id) return
|
||||
if (len > prev[1] && bellEnabled()) bell()
|
||||
},
|
||||
),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -1203,8 +1219,19 @@ export function Session() {
|
||||
<Show when={permissions().length === 0 && questions().length > 0}>
|
||||
<QuestionPrompt request={questions()[0]} />
|
||||
</Show>
|
||||
{/* kilocode_change start */}
|
||||
<Show when={permissions().length === 0 && questions().length === 0 && network().length > 0}>
|
||||
<NetworkPrompt request={network()[0]} />
|
||||
</Show>
|
||||
{/* kilocode_change end */}
|
||||
{/* kilocode_change start */}
|
||||
<Prompt
|
||||
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
|
||||
visible={
|
||||
!session()?.parentID &&
|
||||
permissions().length === 0 &&
|
||||
questions().length === 0 &&
|
||||
network().length === 0
|
||||
} // kilocode_change end
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
@@ -1213,7 +1240,7 @@ export function Session() {
|
||||
r.set(route.initialPrompt)
|
||||
}
|
||||
}}
|
||||
disabled={permissions().length > 0 || questions().length > 0}
|
||||
disabled={permissions().length > 0 || questions().length > 0 || network().length > 0} // kilocode_change
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// kilocode_change - new file
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import type { SessionNetworkWait } from "@kilocode/sdk/v2"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
|
||||
export function NetworkPrompt(props: { request: SessionNetworkWait }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const dialog = useDialog()
|
||||
|
||||
function reply() {
|
||||
void sdk.client.network.reply({ requestID: props.request.id }).catch(() => {})
|
||||
}
|
||||
|
||||
function reject() {
|
||||
void sdk.client.network.reject({ requestID: props.request.id }).catch(() => {})
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (dialog.stack.length > 0) return
|
||||
if (evt.name === "return" && props.request.restored) {
|
||||
evt.preventDefault()
|
||||
reply()
|
||||
return
|
||||
}
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
evt.preventDefault()
|
||||
reject()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.accent}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box flexDirection="column" gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<Show
|
||||
when={props.request.restored}
|
||||
fallback={
|
||||
<>
|
||||
<text fg={theme.warning}>Network disconnected</text>
|
||||
<text fg={theme.text}>{props.request.message}</text>
|
||||
<text fg={theme.textMuted}>Waiting for network...</text>
|
||||
<text fg={theme.textMuted}>Press Esc to stop this turn.</text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<text fg={theme.success}>Network reconnected</text>
|
||||
<text fg={theme.text}>Connection restored.</text>
|
||||
<text fg={theme.textMuted}>Press Enter to resume this turn.</text>
|
||||
<text fg={theme.textMuted}>Press Esc to stop.</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user