mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-30 19:32:39 +08:00
Merge pull request #8209 from Kilo-Org/eshurakov/puddle-beret
VSCode - Remote control and focused/open session tracking in heartbeat
This commit is contained in:
@@ -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
|
||||
@@ -173,6 +175,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 +202,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 +217,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 +343,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 +375,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 +403,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()
|
||||
}
|
||||
|
||||
@@ -582,6 +601,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 +837,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 +1078,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.
|
||||
@@ -1244,6 +1276,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) {
|
||||
@@ -2822,6 +2855,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 +3271,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,6 +3284,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.unsubscribeMigrationComplete?.()
|
||||
this.unsubscribeClearPendingPrompts?.()
|
||||
this.unsubscribeDirectoryProvider?.()
|
||||
this.viewStateDisposable?.dispose()
|
||||
this.visibilityDisposable?.dispose()
|
||||
this.webviewMessageDisposable?.dispose()
|
||||
this.trackedSessionIds.clear()
|
||||
this.syncedChildSessions.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()
|
||||
|
||||
@@ -400,6 +400,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 +431,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 +439,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()) {
|
||||
@@ -1987,6 +1993,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()
|
||||
|
||||
@@ -465,6 +465,11 @@ interface OpenPRIn {
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
interface OpenSessionsIn {
|
||||
type: "agentManager.openSessions"
|
||||
sessionIDs: string[]
|
||||
}
|
||||
|
||||
interface OpenFileIn {
|
||||
type: "agentManager.openFile"
|
||||
sessionId: string
|
||||
@@ -619,6 +624,7 @@ export type AgentManagerInMessage =
|
||||
| RevertWorktreeFileIn
|
||||
| RefreshPRIn
|
||||
| OpenPRIn
|
||||
| OpenSessionsIn
|
||||
| OpenFileIn
|
||||
| GenericOpenFileIn
|
||||
| PreviewImageIn
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -676,6 +677,7 @@ const AgentManagerContent: Component = () => {
|
||||
setLocalSessionIDs(valid)
|
||||
}
|
||||
})
|
||||
trackOpenSessions(localSessionIDs, isPending, managedSessions, vscode.postMessage)
|
||||
|
||||
// Drop in-memory review state for worktrees that no longer exist.
|
||||
createEffect(() => {
|
||||
|
||||
@@ -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
|
||||
@@ -2153,6 +2155,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
|
||||
@@ -2398,6 +2425,7 @@ export type WebviewMessage =
|
||||
| OpenSubAgentViewerRequest
|
||||
| PreviewImageRequest
|
||||
| SetDefaultBaseBranchRequest
|
||||
| AgentManagerOpenSessionsMessage
|
||||
| FetchMarketplaceDataMessage
|
||||
| FilterMarketplaceItemsMessage
|
||||
| InstallMarketplaceItemMessage
|
||||
@@ -2412,6 +2440,9 @@ export type WebviewMessage =
|
||||
| RequestRecentsMessage
|
||||
| ToggleFavoriteRequest
|
||||
| RequestFavoritesMessage
|
||||
| ToggleRemoteMessage
|
||||
| SetRemoteEnabledMessage
|
||||
| RequestRemoteStatusMessage
|
||||
| ContinueInWorktreeRequest
|
||||
| CreateSectionRequest
|
||||
| RenameSectionRequest
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
@@ -20,6 +21,16 @@ import { SessionStatus } from "@/session/status"
|
||||
import { Telemetry } from "@kilocode/kilo-telemetry"
|
||||
|
||||
export namespace KiloSessions {
|
||||
export const Event = {
|
||||
RemoteStatusChanged: BusEvent.define(
|
||||
"kilo-sessions.remote-status-changed",
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
connected: z.boolean(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "kilo-sessions" })
|
||||
|
||||
const Uuid = z.uuid()
|
||||
@@ -136,7 +147,8 @@ export namespace KiloSessions {
|
||||
let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender; heartbeat: () => Promise<void> } | undefined
|
||||
let enabling: Promise<void> | undefined
|
||||
let remoteSeq = 0
|
||||
let viewedSessionId: string | undefined
|
||||
const focused = new Set<string>()
|
||||
const opened = new Set<string>()
|
||||
|
||||
export async function init() {
|
||||
if (ingestDisabled) return
|
||||
@@ -210,7 +222,11 @@ export namespace KiloSessions {
|
||||
const cfg = await Config.getGlobal()
|
||||
if (remoteEnabled || cfg.remote_control)
|
||||
enableRemote().catch((err) => log.warn("remote not enabled", { error: String(err) }))
|
||||
Bus.subscribe(Bus.InstanceDisposed, () => disableRemote())
|
||||
// Use wildcard subscription so the dispose handler actually fires —
|
||||
// Bus.state dispose only notifies "*" subscribers, not event-type ones.
|
||||
Bus.subscribeAll((evt) => {
|
||||
if (evt.type === Bus.InstanceDisposed.type) disableRemote()
|
||||
})
|
||||
}
|
||||
|
||||
export async function enableRemote() {
|
||||
@@ -244,7 +260,8 @@ export namespace KiloSessions {
|
||||
])
|
||||
const statuses = SessionStatus.list()
|
||||
const ids = new Set(Object.keys(statuses))
|
||||
if (viewedSessionId) ids.add(viewedSessionId)
|
||||
for (const id of focused) ids.add(id)
|
||||
for (const id of opened) ids.add(id)
|
||||
const results = await Promise.all(
|
||||
[...ids].map(async (id) => {
|
||||
const session = await Session.get(id).catch(() => undefined)
|
||||
@@ -259,7 +276,12 @@ export namespace KiloSessions {
|
||||
}
|
||||
}),
|
||||
)
|
||||
return results.filter((r): r is NonNullable<typeof r> => !!r)
|
||||
const sessions = results.filter((r): r is NonNullable<typeof r> => !!r)
|
||||
return {
|
||||
sessions,
|
||||
focused: focused.size > 0 ? [...focused] : undefined,
|
||||
open: opened.size > 0 ? [...opened] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const conn = RemoteWS.connect({
|
||||
@@ -268,6 +290,12 @@ export namespace KiloSessions {
|
||||
withContext: (fn) => Instance.provide({ directory, fn }),
|
||||
getSessions,
|
||||
log,
|
||||
onOpen: () => {
|
||||
void Bus.publish(Event.RemoteStatusChanged, { enabled: true, connected: true })
|
||||
},
|
||||
onDisconnect: () => {
|
||||
void Bus.publish(Event.RemoteStatusChanged, { enabled: !!remote, connected: false })
|
||||
},
|
||||
onMessage: (msg) => {
|
||||
// Must run inside Instance.provide so Bus.subscribeAll can access
|
||||
// the instance-scoped subscription map via Instance.state().
|
||||
@@ -283,7 +311,7 @@ export namespace KiloSessions {
|
||||
})
|
||||
|
||||
const heartbeat = async () => {
|
||||
conn.send({ type: "heartbeat", sessions: await getSessions() })
|
||||
conn.send({ type: "heartbeat", ...(await getSessions()) })
|
||||
}
|
||||
|
||||
if (seq !== remoteSeq) {
|
||||
@@ -293,8 +321,9 @@ export namespace KiloSessions {
|
||||
}
|
||||
|
||||
remote = { conn, sender, heartbeat }
|
||||
log.info("remote connection enabled")
|
||||
log.info("remote connection enabled", { connected: conn.connected })
|
||||
Telemetry.trackRemoteConnectionOpened()
|
||||
void Bus.publish(Event.RemoteStatusChanged, { enabled: true, connected: conn.connected })
|
||||
})().finally(() => {
|
||||
if (remoteSeq === seq) enabling = undefined
|
||||
})
|
||||
@@ -310,6 +339,7 @@ export namespace KiloSessions {
|
||||
remote.conn.close()
|
||||
remote = undefined
|
||||
log.info("remote connection disabled")
|
||||
void Bus.publish(Event.RemoteStatusChanged, { enabled: false, connected: false })
|
||||
}
|
||||
|
||||
export function remoteStatus() {
|
||||
@@ -318,8 +348,15 @@ export namespace KiloSessions {
|
||||
connected: remote?.conn.connected ?? false,
|
||||
}
|
||||
}
|
||||
export function setViewedSession(sessionID: string | undefined) {
|
||||
viewedSessionId = sessionID
|
||||
export function setViewedSessions(input: { focused: string[]; open?: string[] }) {
|
||||
focused.clear()
|
||||
opened.clear()
|
||||
for (const id of input.focused) {
|
||||
focused.add(id)
|
||||
}
|
||||
for (const id of input.open ?? []) {
|
||||
opened.add(id)
|
||||
}
|
||||
if (remote) void remote.heartbeat().catch((err) => log.warn("heartbeat failed", { error: String(err) }))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export namespace RemoteProtocol {
|
||||
export const Heartbeat = z.object({
|
||||
type: z.literal("heartbeat"),
|
||||
sessions: z.array(SessionInfo),
|
||||
focused: z.array(z.string()).optional(),
|
||||
open: z.array(z.string()).optional(),
|
||||
})
|
||||
export type Heartbeat = z.infer<typeof Heartbeat>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
|
||||
import type { RemoteWS } from "@/kilo-sessions/remote-ws"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Session } from "@/session"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
@@ -48,7 +48,7 @@ export namespace RemoteSender {
|
||||
error: (...args: any[]) => void
|
||||
warn: (...args: any[]) => void
|
||||
}
|
||||
subscribe?: typeof Bus.subscribeAll
|
||||
subscribe?: (callback: (event: any) => void) => () => void
|
||||
provide?: typeof Instance.provide
|
||||
}
|
||||
|
||||
@@ -62,7 +62,20 @@ export namespace RemoteSender {
|
||||
const children = new Map<string, string>() // childId → parentId
|
||||
let unsub: (() => void) | undefined
|
||||
|
||||
const sub = options.subscribe ?? Bus.subscribeAll
|
||||
const sub =
|
||||
options.subscribe ??
|
||||
((callback: (event: any) => void) => {
|
||||
const handler = (event: { directory?: string; payload: any }) => callback(event.payload)
|
||||
GlobalBus.on("event", handler)
|
||||
return () => {
|
||||
GlobalBus.off("event", handler)
|
||||
}
|
||||
})
|
||||
|
||||
async function directoryFor(sid: string): Promise<string> {
|
||||
const info = await Session.get(sid).catch(() => undefined)
|
||||
return info?.directory ?? options.directory
|
||||
}
|
||||
|
||||
function subscribed(sid: string) {
|
||||
if (sessions.has(sid)) return true
|
||||
@@ -79,8 +92,9 @@ export namespace RemoteSender {
|
||||
async function backfillChildren(parentId: string) {
|
||||
const provide = options.provide ?? Instance.provide
|
||||
try {
|
||||
const dir = await directoryFor(parentId)
|
||||
await provide({
|
||||
directory: options.directory,
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
await discoverChildren(parentId)
|
||||
},
|
||||
@@ -118,8 +132,9 @@ export namespace RemoteSender {
|
||||
async function backfillPendingState(sessionId: string) {
|
||||
const provide = options.provide ?? Instance.provide
|
||||
try {
|
||||
const dir = await directoryFor(sessionId)
|
||||
await provide({
|
||||
directory: options.directory,
|
||||
directory: dir,
|
||||
fn: () => replay(sessionId),
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -179,12 +194,12 @@ export namespace RemoteSender {
|
||||
})
|
||||
}
|
||||
|
||||
function dispatchLongRunning(msg: RemoteProtocol.Command, work: () => Promise<void>) {
|
||||
function dispatchLongRunning(msg: RemoteProtocol.Command, dir: Promise<string>, work: () => Promise<void>) {
|
||||
const provide = options.provide ?? Instance.provide
|
||||
options.conn.send({ type: "response", id: msg.id, result: {} })
|
||||
void (async () => {
|
||||
try {
|
||||
await provide({ directory: options.directory, fn: work })
|
||||
await provide({ directory: await dir, fn: work })
|
||||
} catch (e) {
|
||||
options.log.error("long-running command failed after ACK", {
|
||||
id: msg.id,
|
||||
@@ -195,11 +210,11 @@ export namespace RemoteSender {
|
||||
})()
|
||||
}
|
||||
|
||||
function dispatchQuick(msg: RemoteProtocol.Command, work: () => Promise<void>) {
|
||||
function dispatchQuick(msg: RemoteProtocol.Command, dir: Promise<string>, work: () => Promise<void>) {
|
||||
const provide = options.provide ?? Instance.provide
|
||||
void (async () => {
|
||||
try {
|
||||
await provide({ directory: options.directory, fn: work })
|
||||
await provide({ directory: await dir, fn: work })
|
||||
options.conn.send({ type: "response", id: msg.id, result: {} })
|
||||
} catch (e) {
|
||||
options.conn.send({ type: "response", id: msg.id, error: String(e) })
|
||||
@@ -227,7 +242,7 @@ export namespace RemoteSender {
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatchLongRunning(msg, async () => {
|
||||
dispatchLongRunning(msg, directoryFor(input.data.sessionID), async () => {
|
||||
await SessionPrompt.prompt(input.data)
|
||||
})
|
||||
return
|
||||
@@ -242,7 +257,8 @@ export namespace RemoteSender {
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatchQuick(msg, () => Question.reply(parsed.data))
|
||||
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
|
||||
dispatchQuick(msg, dir, () => Question.reply(parsed.data))
|
||||
return
|
||||
}
|
||||
if (msg.command === "question_reject") {
|
||||
@@ -255,7 +271,8 @@ export namespace RemoteSender {
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatchQuick(msg, () => Question.reject(parsed.data.requestID))
|
||||
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
|
||||
dispatchQuick(msg, dir, () => Question.reject(parsed.data.requestID))
|
||||
return
|
||||
}
|
||||
if (msg.command === "permission_respond") {
|
||||
@@ -268,7 +285,8 @@ export namespace RemoteSender {
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatchQuick(msg, () => PermissionNext.reply(parsed.data))
|
||||
const dir = msg.sessionId ? directoryFor(msg.sessionId) : Promise.resolve(options.directory)
|
||||
dispatchQuick(msg, dir, () => PermissionNext.reply(parsed.data))
|
||||
return
|
||||
}
|
||||
options.conn.send({
|
||||
|
||||
@@ -6,13 +6,15 @@ export namespace RemoteWS {
|
||||
export type Options = {
|
||||
url: string
|
||||
getToken: () => Promise<string | undefined>
|
||||
getSessions: () => SessionInfo[] | Promise<SessionInfo[]>
|
||||
getSessions: () => Promise<{ sessions: SessionInfo[]; focused?: string[]; open?: string[] }>
|
||||
log: {
|
||||
info: (...args: any[]) => void
|
||||
error: (...args: any[]) => void
|
||||
warn: (...args: any[]) => void
|
||||
}
|
||||
onMessage?: (msg: RemoteProtocol.Inbound) => void
|
||||
onOpen?: () => void
|
||||
onDisconnect?: () => void
|
||||
heartbeat?: number
|
||||
/** Wraps callbacks that need to run in a specific async context (e.g. Instance.provide) */
|
||||
withContext?: <R>(fn: () => R) => Promise<R> | R
|
||||
@@ -46,7 +48,7 @@ export namespace RemoteWS {
|
||||
stopHeartbeat()
|
||||
beat = setInterval(() => {
|
||||
void withContext(async () => {
|
||||
send({ type: "heartbeat", sessions: await options.getSessions() })
|
||||
send({ type: "heartbeat", ...(await options.getSessions()) })
|
||||
}).catch((err) => {
|
||||
options.log.error("remote-ws heartbeat failed", { error: String(err) })
|
||||
})
|
||||
@@ -95,6 +97,7 @@ export namespace RemoteWS {
|
||||
|
||||
ws.onopen = () => {
|
||||
options.log.info("remote-ws connected", { buffered: buffer.length })
|
||||
void withContext(() => options.onOpen?.())
|
||||
backoff = 1000
|
||||
for (const msg of buffer) ws!.send(msg)
|
||||
buffer.length = 0
|
||||
@@ -128,14 +131,16 @@ export namespace RemoteWS {
|
||||
ws = undefined
|
||||
stopHeartbeat()
|
||||
stopWatchdog()
|
||||
if (closed) return
|
||||
if (event.code === 4401 || event.code === 4403 || event.code === 4409) {
|
||||
options.log.warn("remote-ws closed permanently", {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
})
|
||||
options.onClose?.(event.code, event.reason)
|
||||
void withContext(() => options.onClose?.(event.code, event.reason))
|
||||
return
|
||||
}
|
||||
void withContext(() => options.onDisconnect?.())
|
||||
schedule()
|
||||
}
|
||||
|
||||
|
||||
@@ -971,21 +971,30 @@ export const SessionRoutes = lazy(() =>
|
||||
)
|
||||
.post(
|
||||
"/viewed",
|
||||
// kilocode_change start
|
||||
describeRoute({
|
||||
summary: "Set viewed session",
|
||||
description: "Notify the server which session the user is currently viewing, or clear it.",
|
||||
summary: "Set viewed sessions",
|
||||
description: "Notify the server which sessions the user is currently viewing, or clear all.",
|
||||
operationId: "session.viewed",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Viewed session updated",
|
||||
description: "Viewed sessions updated",
|
||||
content: { "application/json": { schema: resolver(z.boolean()) } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", z.object({ sessionID: z.string().optional() })),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
focused: z.array(z.string()).optional(),
|
||||
open: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { KiloSessions } = await import("../../kilo-sessions/kilo-sessions")
|
||||
await KiloSessions.setViewedSession(c.req.valid("json").sessionID)
|
||||
const body = c.req.valid("json")
|
||||
KiloSessions.setViewedSessions({ focused: body.focused ?? [], open: body.open ?? [] })
|
||||
// kilocode_change end
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [{ id: "s1", status: "active", title: "Test" }],
|
||||
getSessions: async () => ({ sessions: [{ id: "s1", status: "active", title: "Test" }] }),
|
||||
log: nolog(),
|
||||
heartbeat: 100,
|
||||
})
|
||||
@@ -116,7 +116,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [],
|
||||
getSessions: async () => ({ sessions: [] }),
|
||||
log: nolog(),
|
||||
heartbeat: 60_000,
|
||||
})
|
||||
@@ -149,7 +149,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [],
|
||||
getSessions: async () => ({ sessions: [] }),
|
||||
log: nolog(),
|
||||
heartbeat: 60_000,
|
||||
})
|
||||
@@ -175,7 +175,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [],
|
||||
getSessions: async () => ({ sessions: [] }),
|
||||
log: nolog(),
|
||||
heartbeat: 60_000,
|
||||
})
|
||||
@@ -198,7 +198,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [],
|
||||
getSessions: async () => ({ sessions: [] }),
|
||||
log: nolog(),
|
||||
heartbeat: 60_000,
|
||||
onClose: (code) => codes.push(code),
|
||||
@@ -223,7 +223,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [],
|
||||
getSessions: async () => ({ sessions: [] }),
|
||||
log: cap.log,
|
||||
heartbeat: 60_000,
|
||||
onMessage: (msg) => received.push(msg),
|
||||
@@ -263,7 +263,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [{ id: "s1", status: "active", title: "Test" }],
|
||||
getSessions: async () => ({ sessions: [{ id: "s1", status: "active", title: "Test" }] }),
|
||||
log: nolog(),
|
||||
heartbeat: 100,
|
||||
})
|
||||
@@ -292,7 +292,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [],
|
||||
getSessions: async () => ({ sessions: [] }),
|
||||
log: nolog(),
|
||||
heartbeat: 60_000,
|
||||
timeout: 200,
|
||||
@@ -319,7 +319,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [],
|
||||
getSessions: async () => ({ sessions: [] }),
|
||||
log: nolog(),
|
||||
heartbeat: 60_000,
|
||||
timeout: 300,
|
||||
@@ -347,7 +347,7 @@ describe("RemoteWS", () => {
|
||||
conn = RemoteWS.connect({
|
||||
url: server.url,
|
||||
getToken: async () => "tok",
|
||||
getSessions: () => [],
|
||||
getSessions: async () => ({ sessions: [] }),
|
||||
log: nolog(),
|
||||
heartbeat: 60_000,
|
||||
timeout: 100,
|
||||
|
||||
@@ -2382,15 +2382,16 @@ export class Session2 extends HeyApiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set viewed session
|
||||
* Set viewed sessions
|
||||
*
|
||||
* Notify the server which session the user is currently viewing, or clear it.
|
||||
* Notify the server which sessions the user is currently viewing, or clear all.
|
||||
*/
|
||||
public viewed<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
sessionID?: string
|
||||
focused?: Array<string>
|
||||
open?: Array<string>
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -2401,7 +2402,8 @@ export class Session2 extends HeyApiClient {
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "sessionID" },
|
||||
{ in: "body", key: "focused" },
|
||||
{ in: "body", key: "open" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -994,6 +994,14 @@ export type EventWorktreeFailed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventKiloSessionsRemoteStatusChanged = {
|
||||
type: "kilo-sessions.remote-status-changed"
|
||||
properties: {
|
||||
enabled: boolean
|
||||
connected: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| EventInstallationUpdated
|
||||
| EventInstallationUpdateAvailable
|
||||
@@ -1043,6 +1051,7 @@ export type Event =
|
||||
| EventPtyDeleted
|
||||
| EventWorktreeReady
|
||||
| EventWorktreeFailed
|
||||
| EventKiloSessionsRemoteStatusChanged
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory: string
|
||||
@@ -3992,7 +4001,8 @@ export type PermissionRespondResponse = PermissionRespondResponses[keyof Permiss
|
||||
|
||||
export type SessionViewedData = {
|
||||
body?: {
|
||||
sessionID?: string
|
||||
focused?: Array<string>
|
||||
open?: Array<string>
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
@@ -4004,7 +4014,7 @@ export type SessionViewedData = {
|
||||
|
||||
export type SessionViewedResponses = {
|
||||
/**
|
||||
* Viewed session updated
|
||||
* Viewed sessions updated
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
@@ -4358,11 +4358,11 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": "Set viewed session",
|
||||
"description": "Notify the server which session the user is currently viewing, or clear it.",
|
||||
"summary": "Set viewed sessions",
|
||||
"description": "Notify the server which sessions the user is currently viewing, or clear all.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Viewed session updated",
|
||||
"description": "Viewed sessions updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -4378,8 +4378,17 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
"focused": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"open": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12373,6 +12382,34 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.kilo-sessions.remote-status-changed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "kilo-sessions.remote-status-changed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"connected": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"connected"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"properties"
|
||||
]
|
||||
},
|
||||
"Event": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -12518,6 +12555,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.worktree.failed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.kilo-sessions.remote-status-changed"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
* - packages/opencode/src/kilocode/**
|
||||
* - packages/opencode/test/kilocode/**
|
||||
* - Any path containing "kilocode" in directory or filename
|
||||
* - Any path with a directory starting with "kilo-" (e.g. kilo-sessions/)
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process"
|
||||
@@ -52,7 +53,7 @@ function changedFiles() {
|
||||
|
||||
function isExempt(file: string) {
|
||||
const norm = file.replaceAll("\\", "/").toLowerCase()
|
||||
return norm.split("/").some((part) => part.includes("kilocode"))
|
||||
return norm.split("/").some((part) => part.includes("kilocode") || part.startsWith("kilo-"))
|
||||
}
|
||||
|
||||
function isSource(file: string) {
|
||||
@@ -180,6 +181,7 @@ console.error(
|
||||
" - packages/opencode/src/kilocode/**",
|
||||
" - packages/opencode/test/kilocode/**",
|
||||
" - Any path containing 'kilocode' in the directory or filename",
|
||||
" - Any directory starting with 'kilo-' (e.g. kilo-sessions/)",
|
||||
"",
|
||||
"See AGENTS.md for details.",
|
||||
].join("\n"),
|
||||
|
||||
Reference in New Issue
Block a user