diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1e18055c837..d4c066ead0d 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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 | 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 { // 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() diff --git a/packages/kilo-vscode/src/SettingsEditorProvider.ts b/packages/kilo-vscode/src/SettingsEditorProvider.ts index 922754e319f..493a6fd519b 100644 --- a/packages/kilo-vscode/src/SettingsEditorProvider.ts +++ b/packages/kilo-vscode/src/SettingsEditorProvider.ts @@ -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() private providers = new Map() private tabs = new Map() + 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() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 4b83a2d627c..67ba770cf4f 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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() diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 0a896c955ce..d9d5d22c128 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -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 diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index df9681c0fc1..60b30ecc877 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -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, 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), ) diff --git a/packages/kilo-vscode/src/services/RemoteStatusService.ts b/packages/kilo-vscode/src/services/RemoteStatusService.ts new file mode 100644 index 00000000000..14181005063 --- /dev/null +++ b/packages/kilo-vscode/src/services/RemoteStatusService.ts @@ -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() + 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 { + 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 { + 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 { + 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 { + 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() + } +} diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index beb7fe6ad82..14611ded3d5 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -34,6 +34,7 @@ export class KiloConnectionService { private state: ConnectionState = "disconnected" private connectPromise: Promise | null = null private healthPollTimer: ReturnType | null = null + private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null private readonly eventListeners: Set = new Set() private readonly stateListeners: Set = new Set() @@ -51,6 +52,13 @@ export class KiloConnectionService { */ private readonly messageSessionIdsByMessageId: Map = new Map() + /** Provider key → single focused session ID. */ + private readonly focused: Map = new Map() + /** Provider key → all open (background) session IDs. */ + private readonly opened: Map = new Map() + private debounceTimer: ReturnType | 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() + 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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/ar.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/ar.ts index 7746b9091c0..1221fb0de36 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/ar.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/ar.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/br.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/br.ts index 2c7e85e2d94..c2cb93e6ef1 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/br.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/br.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/bs.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/bs.ts index 56ef3e856dc..87de30b377c 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/bs.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/bs.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/da.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/da.ts index 9070a8786aa..59881a81fec 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/da.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/da.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/de.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/de.ts index e39c0f5e160..df45e47ea27 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/de.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/de.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/en.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/en.ts index ca79ec96d93..0b59af3f34f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/en.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/en.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/es.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/es.ts index c0cc59c6edb..a744fe33455 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/es.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/es.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/fr.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/fr.ts index 73dc429a3db..03f7a3024e9 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/fr.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/fr.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/ja.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/ja.ts index 91545b89b94..58e9c3ee9bd 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/ja.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/ja.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/ko.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/ko.ts index 1cb709c332f..c48eed062e9 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/ko.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/ko.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/nl.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/nl.ts index fd8097f446f..c9cfc6a96e2 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/nl.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/nl.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/no.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/no.ts index 45ac6d32efe..a24895f79e4 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/no.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/no.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/pl.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/pl.ts index 3a76426cd34..7f7415c1378 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/pl.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/pl.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/ru.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/ru.ts index 0063c3e84b0..b9e721e60bf 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/ru.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/ru.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/th.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/th.ts index 10a009bd4f4..aff4ea8f378 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/th.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/th.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/tr.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/tr.ts index 180ba2ab68d..6be397e7a36 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/tr.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/tr.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/uk.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/uk.ts index fe0c703d0e7..315d12fa96d 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/uk.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/uk.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/zh.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/zh.ts index fe025f578c0..72160d167fe 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/zh.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/zh.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/cli-backend/i18n/zht.ts b/packages/kilo-vscode/src/services/cli-backend/i18n/zht.ts index d4d67bf5abb..61baae8020f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/i18n/zht.ts +++ b/packages/kilo-vscode/src/services/cli-backend/i18n/zht.ts @@ -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 diff --git a/packages/kilo-vscode/tests/setup/vscode-mock.ts b/packages/kilo-vscode/tests/setup/vscode-mock.ts index 8fdba932d30..9ab5ff42032 100644 --- a/packages/kilo-vscode/tests/setup/vscode-mock.ts +++ b/packages/kilo-vscode/tests/setup/vscode-mock.ts @@ -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 }), diff --git a/packages/kilo-vscode/tests/unit/remote-status-service.test.ts b/packages/kilo-vscode/tests/unit/remote-status-service.test.ts new file mode 100644 index 00000000000..8c879b6394e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/remote-status-service.test.ts @@ -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 }]) + }) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 98e24b8c49b..d7336d3a299 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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(() => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/open-sessions.ts b/packages/kilo-vscode/webview-ui/agent-manager/open-sessions.ts new file mode 100644 index 00000000000..ffa48b8b853 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/open-sessions.ts @@ -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, + pending: (id: string) => boolean, + managed: Accessor>, + 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 }) + }) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx index 4f060d2e0df..85a36c7a6c8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ExperimentalTab.tsx @@ -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 (
+ {/* Remote control */} +
+
+
{language.t("settings.experimental.remote.title")}
+
{language.t("settings.experimental.remote.description")}
+
+
+
+ {language.t("settings.experimental.remote.current")} + + {active() + ? language.t("settings.experimental.remote.active") + : language.t("settings.experimental.remote.inactive")} + +
+
{language.t("settings.experimental.remote.hint")}
+
+
+ {language.t("settings.experimental.remote.startup")} + { + updateConfig({ remote_control: checked }) + }} + hideLabel + > + {language.t("settings.experimental.remote.startup")} + +
+
+ {/* Share mode */} = [ "instructions", "skills", "snapshot", + "remote_control", "share", "username", "watcher", diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index b546cadfce2..59881ed9f58 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -98,6 +98,14 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): 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 diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 4aaca97a6c0..3d9681c7282 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -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": "الوكيل المستخدم عند عدم التحديد", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 399efbf5530..026f56459af 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index be7bfbaa460..5b1259792e7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 625836b01f8..d51d3ff6be4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 3e9b7e263c0..d7baebbd986 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index d25ab051951..9aaa1198244 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 5b15425315c..01116730185 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index ffcb99b0b91..ac8bc5d085d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -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é", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 89c95a7eaa7..db508143b59 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -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": "指定されていない場合に使用するエージェント", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index a6c1afd36dd..4836dba6a34 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -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": "지정되지 않은 경우 사용할 에이전트", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 77e2aab3b7a..4325df7eaab 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index b06456e47b5..73cf55f8d34 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 856ea582178..4d9f8460914 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 1eff6b800d1..2501ea01fbe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -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": "Агент при отсутствии указания", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index cf6f01dde73..e2859e1ff94 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -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": "เอเจนต์ที่ใช้เมื่อไม่ได้ระบุ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 9f472eb53b0..4ab1a9c78ef 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index c922d79a42b..79c0240a193 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -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": "Агент за замовчуванням", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index a8ec3a8960d..6be3fbd32b7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -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": "未指定时使用的智能体", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index a9cefcac819..279cc6f2964 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index 7784e08a943..d23c0ff40dd 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -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; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index c5a659b3146..4f5b214caba 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 61b64d1a789..cedb4d68d8d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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 diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index d45abad6926..2e408e46b21 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -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 } | undefined let enabling: Promise | undefined let remoteSeq = 0 - let viewedSessionId: string | undefined + const focused = new Set() + const opened = new Set() 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 => !!r) + const sessions = results.filter((r): r is NonNullable => !!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) })) } diff --git a/packages/opencode/src/kilo-sessions/remote-protocol.ts b/packages/opencode/src/kilo-sessions/remote-protocol.ts index 6500319fa81..2972fb0565e 100644 --- a/packages/opencode/src/kilo-sessions/remote-protocol.ts +++ b/packages/opencode/src/kilo-sessions/remote-protocol.ts @@ -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 diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 02f74d064a0..61ba3b1d7da 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -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() // 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 { + 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) { + function dispatchLongRunning(msg: RemoteProtocol.Command, dir: Promise, work: () => Promise) { 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) { + function dispatchQuick(msg: RemoteProtocol.Command, dir: Promise, work: () => Promise) { 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({ diff --git a/packages/opencode/src/kilo-sessions/remote-ws.ts b/packages/opencode/src/kilo-sessions/remote-ws.ts index 0232ec27785..6587e292e46 100644 --- a/packages/opencode/src/kilo-sessions/remote-ws.ts +++ b/packages/opencode/src/kilo-sessions/remote-ws.ts @@ -6,13 +6,15 @@ export namespace RemoteWS { export type Options = { url: string getToken: () => Promise - getSessions: () => SessionInfo[] | Promise + 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?: (fn: () => R) => Promise | 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() } diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 7a6d9bd6a1f..f6c97d40556 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -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) }, ), diff --git a/packages/opencode/test/kilo-sessions/remote-ws.test.ts b/packages/opencode/test/kilo-sessions/remote-ws.test.ts index 7fe6152b23a..df74ed1d2f4 100644 --- a/packages/opencode/test/kilo-sessions/remote-ws.test.ts +++ b/packages/opencode/test/kilo-sessions/remote-ws.test.ts @@ -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, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 10656347925..04dd5ff148e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( parameters?: { directory?: string workspace?: string - sessionID?: string + focused?: Array + open?: Array }, options?: Options, ) { @@ -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" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e6cd4e256bc..0e09b84a086 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 + open?: Array } path?: never query?: { @@ -4004,7 +4014,7 @@ export type SessionViewedData = { export type SessionViewedResponses = { /** - * Viewed session updated + * Viewed sessions updated */ 200: boolean } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index dd5a9164a34..b2d6c8679eb 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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" } ] }, diff --git a/script/check-opencode-annotations.ts b/script/check-opencode-annotations.ts index b953fd44d8b..2b122e67d03 100644 --- a/script/check-opencode-annotations.ts +++ b/script/check-opencode-annotations.ts @@ -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"),