feat(jetbrains): add legacy migration wizard UI

Add the JetBrains frontend migration wizard for the legacy migration core. The wizard renders as a MODAL_LAYER blocking overlay inside every visible SessionUi and is driven by a shared KiloMigrationService app service.

- Shared: MigrationDto.kt with full DTO set, KiloMigrationRpcApi with status/detect/migrate/skip/finalize/cleanup
- Backend: KiloBackendLegacyMigrationStoreService (PropertiesComponent status persistence), MigrationRpcMapper (pure bidirectional mapper), KiloMigrationRpcApiImpl (channelFlow streaming off Dispatchers.IO), KiloMigrationRpcApiProvider; extend KiloBackendMigrationManager with status/mark
- Frontend service: KiloMigrationService with idempotent check, in-flight guard, progress collection from streamed events, skip/finish/force re-import; MigrationSelectionBuilder mirrors VS Code preselection rules
- SessionRootPanel: new Blocker class at MODAL_LAYER with setBlocker/setBlocked helpers; existing scroll overlay at PALETTE_LAYER unchanged
- Swing wizard: MigrationWizardPanel (What's New + Migrate screens, retained component tree), MigrationItemRow, MigrationStatusIcon, SessionMigrationProgressPanel, SessionMigrationSummaryPanel, MigrationOverlayPanel
- SessionUi: wired to MigrationUiController via coroutine state collection on Dispatchers.Main; check() called from addNotify() and AppChanged READY
- Tests: SessionRootPanelTest, SessionUiLayoutTest, KiloMigrationServiceTest, SessionUiMigrationTest, FakeMigrationRpcApi, FakeMigrationUiController
This commit is contained in:
kirillk
2026-05-24 13:24:29 -04:00
parent 100bcc29b4
commit cf38cfb01e
28 changed files with 2611 additions and 16 deletions
@@ -9,6 +9,7 @@ import ai.kilocode.backend.migration.LegacyMigrationHttpBackend
import ai.kilocode.backend.migration.LegacyMigrationReport
import ai.kilocode.backend.migration.LegacyMigrationSelections
import ai.kilocode.backend.migration.LegacyMigrationSink
import ai.kilocode.backend.migration.LegacyMigrationStatus
import ai.kilocode.backend.migration.LegacyMigrationStore
import okhttp3.OkHttpClient
@@ -26,6 +27,12 @@ class KiloBackendMigrationManager(
private fun base() = "http://127.0.0.1:$port"
private fun httpBackend(): LegacyMigrationBackend = LegacyMigrationHttpBackend(client, base())
fun status(store: LegacyMigrationStore): LegacyMigrationStatus? =
LegacyMigrationEngine(store, httpBackend()).status()
fun mark(store: LegacyMigrationStore, status: LegacyMigrationStatus) =
LegacyMigrationEngine(store, httpBackend()).mark(status)
fun detect(store: LegacyMigrationStore): LegacyMigrationDetection =
LegacyMigrationEngine(store, httpBackend()).detect()
@@ -0,0 +1,50 @@
package ai.kilocode.backend.migration
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
/**
* Provides the production [LegacyMigrationStore] for use by the migration RPC implementation.
*
* Status persistence uses [PropertiesComponent] (app-level JetBrains persistent store).
* Raw legacy source acquisition is not yet implemented; the store returns null for all
* data accessors, so [LegacyMigrationEngine.detect] will report hasData=false and the
* migration wizard will remain hidden until a real source adapter is plugged in.
*/
@Service(Service.Level.APP)
class KiloBackendLegacyMigrationStoreService {
companion object {
private const val STATUS_KEY = "kilo.legacyMigrationStatus"
fun getInstance(): KiloBackendLegacyMigrationStoreService = service()
}
fun store(): LegacyMigrationStore = PersistentStatusStore()
private inner class PersistentStatusStore : LegacyMigrationStore {
override fun status(): LegacyMigrationStatus? {
val raw = PropertiesComponent.getInstance().getValue(STATUS_KEY) ?: return null
return runCatching { LegacyMigrationStatus.valueOf(raw) }.getOrNull()
}
override fun mark(status: LegacyMigrationStatus) {
PropertiesComponent.getInstance().setValue(STATUS_KEY, status.name)
}
// Legacy source adapters — not yet implemented; return null to suppress migration UI.
override fun providerProfilesRaw(): String? = null
override fun oauthRaw(key: String): String? = null
override fun mcpSettingsRaw(): String? = null
override fun customModesRaw(): String? = null
override fun customModePromptsRaw(): String? = null
override fun autocompleteRaw(): String? = null
override fun globalStateValue(key: String) = null
override fun taskHistoryRaw(): String? = null
override fun taskConversationRaw(id: String): String? = null
override fun cleanup(targets: LegacyCleanupTargets): LegacyCleanupReport =
LegacyCleanupReport(cleaned = emptyList(), errors = emptyList())
}
}
@@ -0,0 +1,104 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.backend.rpc
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService
import ai.kilocode.backend.migration.LegacyMigrationResultItem
import ai.kilocode.backend.migration.LegacyMigrationSink
import ai.kilocode.backend.migration.LegacyMigrationStatus
import ai.kilocode.backend.migration.MigrationItemCategory
import ai.kilocode.backend.migration.MigrationItemStatus
import ai.kilocode.rpc.KiloMigrationRpcApi
import ai.kilocode.rpc.dto.LegacyCleanupReportDto
import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationEventDto
import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto
import ai.kilocode.rpc.dto.LegacyMigrationStatusDto
import ai.kilocode.backend.app.KiloBackendMigrationManager
import com.intellij.openapi.components.service
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.withContext
class KiloMigrationRpcApiImpl : KiloMigrationRpcApi {
private val app: KiloBackendAppService get() = service()
private val storeService: KiloBackendLegacyMigrationStoreService get() = service()
private fun manager(): KiloBackendMigrationManager {
val http = app.http ?: throw IllegalStateException("Not connected")
val port = app.port
return KiloBackendMigrationManager(http, port)
}
override suspend fun status(): LegacyMigrationStatusDto? {
val mgr = manager()
val store = storeService.store()
val status = mgr.status(store) ?: return null
return MigrationRpcMapper.toDto(status)
}
override suspend fun detect(): LegacyMigrationDetectionDto {
val mgr = manager()
val store = storeService.store()
val detection = withContext(Dispatchers.IO) { mgr.detect(store) }
return MigrationRpcMapper.toDto(detection)
}
override suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow<LegacyMigrationEventDto> {
val mgr = manager()
val domainSelections = MigrationRpcMapper.fromDto(selections)
val store = storeService.store()
return channelFlow {
withContext(Dispatchers.IO) {
val sink = object : LegacyMigrationSink {
override fun item(progress: ai.kilocode.backend.migration.LegacyMigrationItemProgress) {
trySendBlocking(LegacyMigrationEventDto.Item(MigrationRpcMapper.toDto(progress)))
}
override fun session(progress: ai.kilocode.backend.migration.LegacyMigrationSessionProgress) {
trySendBlocking(LegacyMigrationEventDto.Session(MigrationRpcMapper.toDto(progress)))
}
}
val report = runCatching {
mgr.migrate(store, domainSelections, sink)
}.getOrElse { e ->
val msg = e.message ?: "Migration failed"
val errItem = LegacyMigrationResultItem(
item = "Migration",
category = MigrationItemCategory.settings,
status = MigrationItemStatus.error,
message = msg,
)
trySendBlocking(LegacyMigrationEventDto.Complete(listOf(MigrationRpcMapper.toDto(errItem))))
return@withContext
}
trySendBlocking(LegacyMigrationEventDto.Complete(report.items.map(MigrationRpcMapper::toDto)))
}
}
}
override suspend fun skip() {
val mgr = manager()
val store = storeService.store()
mgr.mark(store, LegacyMigrationStatus.Skipped)
}
override suspend fun finalize(status: LegacyMigrationStatusDto) {
val mgr = manager()
val store = storeService.store()
val domain = MigrationRpcMapper.fromDto(status)
if (domain == LegacyMigrationStatus.Skipped) return
mgr.mark(store, domain)
}
override suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto {
val mgr = manager()
val store = storeService.store()
val report = withContext(Dispatchers.IO) { mgr.cleanup(store, MigrationRpcMapper.fromDto(targets)) }
return MigrationRpcMapper.toDto(report)
}
}
@@ -0,0 +1,15 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.KiloMigrationRpcApi
import com.intellij.platform.rpc.backend.RemoteApiProvider
import fleet.rpc.remoteApiDescriptor
internal class KiloMigrationRpcApiProvider : RemoteApiProvider {
override fun RemoteApiProvider.Sink.remoteApis() {
remoteApi(remoteApiDescriptor<KiloMigrationRpcApi>()) {
KiloMigrationRpcApiImpl()
}
}
}
@@ -0,0 +1,230 @@
package ai.kilocode.backend.rpc
import ai.kilocode.backend.migration.LegacyAutocompleteSettings
import ai.kilocode.backend.migration.LegacyCleanupReport
import ai.kilocode.backend.migration.LegacyCleanupTargets
import ai.kilocode.backend.migration.LegacyMigrationDetection
import ai.kilocode.backend.migration.LegacyMigrationItemProgress
import ai.kilocode.backend.migration.LegacyMigrationResultItem
import ai.kilocode.backend.migration.LegacyMigrationSelections
import ai.kilocode.backend.migration.LegacyMigrationSessionProgress
import ai.kilocode.backend.migration.LegacyMigrationStatus
import ai.kilocode.backend.migration.LegacySettings
import ai.kilocode.backend.migration.MigrationAutoApprovalSelections
import ai.kilocode.backend.migration.MigrationCustomModeInfo
import ai.kilocode.backend.migration.MigrationDefaultModelInfo
import ai.kilocode.backend.migration.MigrationItemCategory
import ai.kilocode.backend.migration.MigrationItemProgressStatus
import ai.kilocode.backend.migration.MigrationItemStatus
import ai.kilocode.backend.migration.MigrationMcpServerInfo
import ai.kilocode.backend.migration.MigrationProviderInfo
import ai.kilocode.backend.migration.MigrationSessionInfo
import ai.kilocode.backend.migration.MigrationSessionPhase
import ai.kilocode.backend.migration.MigrationSettingsSelections
import ai.kilocode.backend.migration.MigrationSessionSelection
import ai.kilocode.rpc.dto.LegacyAutocompleteSettingsDto
import ai.kilocode.rpc.dto.LegacyCleanupReportDto
import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationItemProgressDto
import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto
import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto
import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto
import ai.kilocode.rpc.dto.LegacyMigrationStatusDto
import ai.kilocode.rpc.dto.LegacySettingsDto
import ai.kilocode.rpc.dto.MigrationAutoApprovalSelectionsDto
import ai.kilocode.rpc.dto.MigrationCustomModeInfoDto
import ai.kilocode.rpc.dto.MigrationDefaultModelInfoDto
import ai.kilocode.rpc.dto.MigrationItemCategoryDto
import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto
import ai.kilocode.rpc.dto.MigrationItemStatusDto
import ai.kilocode.rpc.dto.MigrationMcpServerInfoDto
import ai.kilocode.rpc.dto.MigrationProviderInfoDto
import ai.kilocode.rpc.dto.MigrationSessionInfoDto
import ai.kilocode.rpc.dto.MigrationSessionPhaseDto
import ai.kilocode.rpc.dto.MigrationSessionSelectionDto
import ai.kilocode.rpc.dto.MigrationSettingsSelectionsDto
internal object MigrationRpcMapper {
// -----------------------------------------------------------------------
// Status
// -----------------------------------------------------------------------
fun toDto(status: LegacyMigrationStatus): LegacyMigrationStatusDto = when (status) {
LegacyMigrationStatus.Completed -> LegacyMigrationStatusDto.completed
LegacyMigrationStatus.CompletedWithErrors -> LegacyMigrationStatusDto.completed_with_errors
LegacyMigrationStatus.Skipped -> LegacyMigrationStatusDto.skipped
}
fun fromDto(dto: LegacyMigrationStatusDto): LegacyMigrationStatus = when (dto) {
LegacyMigrationStatusDto.completed -> LegacyMigrationStatus.Completed
LegacyMigrationStatusDto.completed_with_errors -> LegacyMigrationStatus.CompletedWithErrors
LegacyMigrationStatusDto.skipped -> LegacyMigrationStatus.Skipped
}
// -----------------------------------------------------------------------
// Detection
// -----------------------------------------------------------------------
fun toDto(detection: LegacyMigrationDetection): LegacyMigrationDetectionDto =
LegacyMigrationDetectionDto(
providers = detection.providers.map(::toDto),
mcpServers = detection.mcpServers.map(::toDto),
customModes = detection.customModes.map(::toDto),
sessions = detection.sessions.map(::toDto),
defaultModel = detection.defaultModel?.let(::toDto),
settings = detection.settings?.let(::toDto),
hasData = detection.hasData,
)
private fun toDto(p: MigrationProviderInfo): MigrationProviderInfoDto =
MigrationProviderInfoDto(
profileName = p.profileName,
provider = p.provider,
model = p.model,
hasApiKey = p.hasApiKey,
supported = p.supported,
newProviderName = p.newProviderName,
)
private fun toDto(m: MigrationMcpServerInfo): MigrationMcpServerInfoDto =
MigrationMcpServerInfoDto(name = m.name, type = m.type, disabled = m.disabled)
private fun toDto(c: MigrationCustomModeInfo): MigrationCustomModeInfoDto =
MigrationCustomModeInfoDto(name = c.name, slug = c.slug, nativeSlug = c.nativeSlug)
fun toDto(s: MigrationSessionInfo): MigrationSessionInfoDto =
MigrationSessionInfoDto(id = s.id, title = s.title, directory = s.directory, time = s.time)
private fun toDto(d: MigrationDefaultModelInfo): MigrationDefaultModelInfoDto =
MigrationDefaultModelInfoDto(provider = d.provider, model = d.model)
private fun toDto(s: LegacySettings): LegacySettingsDto =
LegacySettingsDto(
autoApprovalEnabled = s.autoApprovalEnabled,
allowedCommands = s.allowedCommands,
deniedCommands = s.deniedCommands,
alwaysAllowReadOnly = s.alwaysAllowReadOnly,
alwaysAllowReadOnlyOutsideWorkspace = s.alwaysAllowReadOnlyOutsideWorkspace,
alwaysAllowWrite = s.alwaysAllowWrite,
alwaysAllowExecute = s.alwaysAllowExecute,
alwaysAllowMcp = s.alwaysAllowMcp,
alwaysAllowModeSwitch = s.alwaysAllowModeSwitch,
alwaysAllowSubtasks = s.alwaysAllowSubtasks,
language = s.language,
autocomplete = s.autocomplete?.let(::toDto),
)
private fun toDto(a: LegacyAutocompleteSettings): LegacyAutocompleteSettingsDto =
LegacyAutocompleteSettingsDto(
enableAutoTrigger = a.enableAutoTrigger,
enableSmartInlineTaskKeybinding = a.enableSmartInlineTaskKeybinding,
enableChatAutocomplete = a.enableChatAutocomplete,
)
// -----------------------------------------------------------------------
// Selections (DTO → domain)
// -----------------------------------------------------------------------
fun fromDto(dto: LegacyMigrationSelectionsDto): LegacyMigrationSelections =
LegacyMigrationSelections(
providers = dto.providers,
mcpServers = dto.mcpServers,
customModes = dto.customModes,
sessions = dto.sessions.map(::fromDto),
defaultModel = dto.defaultModel,
settings = fromDto(dto.settings),
)
private fun fromDto(dto: MigrationSessionSelectionDto): MigrationSessionSelection =
MigrationSessionSelection(id = dto.id, force = dto.force)
private fun fromDto(dto: MigrationSettingsSelectionsDto): MigrationSettingsSelections =
MigrationSettingsSelections(
autoApproval = fromDto(dto.autoApproval),
language = dto.language,
autocomplete = dto.autocomplete,
)
private fun fromDto(dto: MigrationAutoApprovalSelectionsDto): MigrationAutoApprovalSelections =
MigrationAutoApprovalSelections(
commandRules = dto.commandRules,
readPermission = dto.readPermission,
writePermission = dto.writePermission,
executePermission = dto.executePermission,
mcpPermission = dto.mcpPermission,
taskPermission = dto.taskPermission,
)
// -----------------------------------------------------------------------
// Progress / result
// -----------------------------------------------------------------------
fun toDto(p: LegacyMigrationItemProgress): LegacyMigrationItemProgressDto =
LegacyMigrationItemProgressDto(item = p.item, status = toDto(p.status), message = p.message)
fun toDto(p: LegacyMigrationSessionProgress): LegacyMigrationSessionProgressDto =
LegacyMigrationSessionProgressDto(
session = p.session?.let(::toDto),
index = p.index,
total = p.total,
phase = toDto(p.phase),
error = p.error,
)
fun toDto(r: LegacyMigrationResultItem): LegacyMigrationResultItemDto =
LegacyMigrationResultItemDto(
item = r.item,
category = toDto(r.category),
status = toDto(r.status),
message = r.message,
)
private fun toDto(s: MigrationItemProgressStatus): MigrationItemProgressStatusDto = when (s) {
MigrationItemProgressStatus.migrating -> MigrationItemProgressStatusDto.migrating
MigrationItemProgressStatus.success -> MigrationItemProgressStatusDto.success
MigrationItemProgressStatus.warning -> MigrationItemProgressStatusDto.warning
MigrationItemProgressStatus.error -> MigrationItemProgressStatusDto.error
}
private fun toDto(p: MigrationSessionPhase): MigrationSessionPhaseDto = when (p) {
MigrationSessionPhase.preparing -> MigrationSessionPhaseDto.preparing
MigrationSessionPhase.storing -> MigrationSessionPhaseDto.storing
MigrationSessionPhase.skipped -> MigrationSessionPhaseDto.skipped
MigrationSessionPhase.done -> MigrationSessionPhaseDto.done
MigrationSessionPhase.summary -> MigrationSessionPhaseDto.summary
MigrationSessionPhase.error -> MigrationSessionPhaseDto.error
}
private fun toDto(c: MigrationItemCategory): MigrationItemCategoryDto = when (c) {
MigrationItemCategory.provider -> MigrationItemCategoryDto.provider
MigrationItemCategory.mcpServer -> MigrationItemCategoryDto.mcpServer
MigrationItemCategory.customMode -> MigrationItemCategoryDto.customMode
MigrationItemCategory.session -> MigrationItemCategoryDto.session
MigrationItemCategory.defaultModel -> MigrationItemCategoryDto.defaultModel
MigrationItemCategory.settings -> MigrationItemCategoryDto.settings
}
private fun toDto(s: MigrationItemStatus): MigrationItemStatusDto = when (s) {
MigrationItemStatus.success -> MigrationItemStatusDto.success
MigrationItemStatus.warning -> MigrationItemStatusDto.warning
MigrationItemStatus.error -> MigrationItemStatusDto.error
}
// -----------------------------------------------------------------------
// Cleanup
// -----------------------------------------------------------------------
fun fromDto(dto: LegacyCleanupTargetsDto): LegacyCleanupTargets =
LegacyCleanupTargets(
providerProfiles = dto.providerProfiles,
mcpSettings = dto.mcpSettings,
customModes = dto.customModes,
globalState = dto.globalState,
taskHistory = dto.taskHistory,
)
fun toDto(r: LegacyCleanupReport): LegacyCleanupReportDto =
LegacyCleanupReportDto(cleaned = r.cleaned, errors = r.errors)
}
@@ -9,5 +9,7 @@
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloAppRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloProjectRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloSessionRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloMigrationRpcApiProvider"/>
<applicationService serviceImplementation="ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService"/>
</extensions>
</idea-plugin>
@@ -0,0 +1,331 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.client.migration
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.KiloMigrationRpcApi
import ai.kilocode.rpc.dto.LegacyMigrationEventDto
import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto
import ai.kilocode.rpc.dto.LegacyMigrationStatusDto
import ai.kilocode.rpc.dto.MigrationItemCategoryDto
import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto
import ai.kilocode.rpc.dto.MigrationItemStatusDto
import ai.kilocode.rpc.dto.MigrationSessionPhaseDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import fleet.rpc.client.durable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* Interface exposed to session UI components.
*/
interface MigrationUiController {
val state: StateFlow<MigrationUiState>
fun check()
fun start(selections: MigrationUiSelections)
fun force(ids: List<String>)
fun skip()
fun finish()
}
/**
* App-level service that manages migration wizard state shared across all session UIs.
*
* Detects and runs legacy migration via [KiloMigrationRpcApi].
* All Swing interactions must happen on EDT; service coroutines run off EDT.
*/
@Service(Service.Level.APP)
class KiloMigrationService internal constructor(
private val cs: CoroutineScope,
private val rpc: KiloMigrationRpcApi?,
) : MigrationUiController {
/** Platform constructor — resolves RPC lazily. */
constructor(cs: CoroutineScope) : this(cs, null)
companion object {
private val LOG = KiloLog.create(KiloMigrationService::class.java)
fun getInstance(): KiloMigrationService = service()
}
private val _state = MutableStateFlow<MigrationUiState>(MigrationUiState.Hidden)
override val state: StateFlow<MigrationUiState> = _state.asStateFlow()
private val checking = AtomicBoolean(false)
private val migrating = AtomicBoolean(false)
private val migrateJob = AtomicReference<Job?>(null)
// ------ RPC helper ------
private suspend fun <T> call(block: suspend KiloMigrationRpcApi.() -> T): T {
val api = rpc
return if (api != null) block(api) else durable { block(KiloMigrationRpcApi.getInstance()) }
}
// ------ MigrationUiController ------
/**
* Check if migration is needed. Idempotent and in-flight guarded.
* Calls status first; if status exists, hides. Then calls detect; if no data, hides.
* Detection failures log and leave state unchanged.
*/
override fun check() {
if (!checking.compareAndSet(false, true)) return
cs.launch {
try {
val status = try {
call { status() }
} catch (e: Exception) {
LOG.warn("migration status check failed", e)
checking.set(false)
return@launch
}
if (status != null) {
_state.value = MigrationUiState.Hidden
checking.set(false)
return@launch
}
val detection = try {
call { detect() }
} catch (e: Exception) {
LOG.warn("migration detect failed", e)
checking.set(false)
return@launch
}
_state.value = if (detection.hasData) MigrationUiState.Needed(detection) else MigrationUiState.Hidden
} finally {
checking.set(false)
}
}
}
/**
* Start migration for the given user selections.
*/
override fun start(selections: MigrationUiSelections) {
val current = _state.value as? MigrationUiState.Needed ?: return
if (!migrating.compareAndSet(false, true)) return
val dto = MigrationSelectionBuilder.toDto(selections)
val initialProgress = buildInitialProgress(selections, current.detection)
_state.value = current.copy(
phase = MigrationUiPhase.migrating,
running = true,
progress = initialProgress,
sessionProgress = null,
sessionSummary = SessionMigrationSummary(),
results = emptyList(),
)
val job = cs.launch {
try {
val flow = try {
call { migrate(dto) }
} catch (e: Exception) {
LOG.warn("migration start failed", e)
finishWithError(e.message ?: "Migration failed")
return@launch
}
flow.collect { event -> handleEvent(event) }
} finally {
migrating.set(false)
}
}
migrateJob.set(job)
}
/**
* Force re-import selected sessions (skipped sessions).
*/
override fun force(ids: List<String>) {
val current = _state.value as? MigrationUiState.Needed ?: return
if (!migrating.compareAndSet(false, true)) return
val dto = MigrationSelectionBuilder.forceSessionsDto(ids)
val initialProgress = ids.map {
MigrationItemUiProgress(it, MigrationItemCategoryDto.session, MigrationItemProgressStatusDto.migrating)
}
// Keep non-session results, reset session progress
val nonSession = current.progress.filter { it.category != MigrationItemCategoryDto.session }
_state.value = current.copy(
phase = MigrationUiPhase.migrating,
running = true,
progress = nonSession + initialProgress,
sessionProgress = null,
sessionSummary = SessionMigrationSummary(),
)
val job = cs.launch {
try {
val flow = try {
call { migrate(dto) }
} catch (e: Exception) {
LOG.warn("force migration start failed", e)
finishWithError(e.message ?: "Migration failed")
return@launch
}
flow.collect { event -> handleEvent(event) }
} finally {
migrating.set(false)
}
}
migrateJob.set(job)
}
/**
* Skip migration — marks status and hides for all observers.
*/
override fun skip() {
cs.launch {
try {
call { skip() }
} catch (e: Exception) {
LOG.warn("migration skip failed", e)
}
_state.value = MigrationUiState.Hidden
}
}
/**
* Finalize migration — marks completed/completed_with_errors and hides.
*/
override fun finish() {
val current = _state.value as? MigrationUiState.Needed ?: run {
_state.value = MigrationUiState.Hidden
return
}
val hasErrors = current.results.any { it.status == MigrationItemStatusDto.error }
val status = if (hasErrors) LegacyMigrationStatusDto.completed_with_errors else LegacyMigrationStatusDto.completed
cs.launch {
try {
call { finalize(status) }
} catch (e: Exception) {
LOG.warn("migration finalize failed", e)
}
_state.value = MigrationUiState.Hidden
}
}
// ------ Internal event handling ------
private fun handleEvent(event: LegacyMigrationEventDto) {
val current = _state.value as? MigrationUiState.Needed ?: return
when (event) {
is LegacyMigrationEventDto.Item -> {
val p = event.progress
val updated = current.progress.map {
if (it.item == p.item) it.copy(status = p.status, message = p.message) else it
}
_state.value = current.copy(progress = updated)
}
is LegacyMigrationEventDto.Session -> {
val sp = event.progress
val phase = sp.phase
// Update session summary buckets
val summary = when (phase) {
MigrationSessionPhaseDto.done -> {
val item = LegacyMigrationResultItemDto(
item = sp.session?.id ?: "",
category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.session,
status = MigrationItemStatusDto.success,
)
current.sessionSummary.copy(imported = current.sessionSummary.imported + item)
}
MigrationSessionPhaseDto.skipped -> {
val item = LegacyMigrationResultItemDto(
item = sp.session?.id ?: "",
category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.session,
status = MigrationItemStatusDto.success,
message = "skipped",
)
current.sessionSummary.copy(skipped = current.sessionSummary.skipped + item)
}
MigrationSessionPhaseDto.error -> {
val item = LegacyMigrationResultItemDto(
item = sp.session?.id ?: "",
category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.session,
status = MigrationItemStatusDto.error,
message = sp.error,
)
current.sessionSummary.copy(errored = current.sessionSummary.errored + item)
}
else -> current.sessionSummary
}
_state.value = current.copy(sessionProgress = sp, sessionSummary = summary)
}
is LegacyMigrationEventDto.Complete -> {
val items = event.items
val hasErrors = items.any { it.status == MigrationItemStatusDto.error }
val phase = if (hasErrors) MigrationUiPhase.error else MigrationUiPhase.done
_state.value = current.copy(
running = false,
phase = phase,
results = items,
)
}
is LegacyMigrationEventDto.Error -> {
finishWithError(event.message)
}
}
}
private fun finishWithError(msg: String) {
val current = _state.value as? MigrationUiState.Needed ?: return
val errItem = LegacyMigrationResultItemDto(
item = "Migration",
category = ai.kilocode.rpc.dto.MigrationItemCategoryDto.settings,
status = MigrationItemStatusDto.error,
message = msg,
)
_state.value = current.copy(
running = false,
phase = MigrationUiPhase.error,
results = listOf(errItem),
)
}
private fun buildInitialProgress(
selections: MigrationUiSelections,
detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto,
): List<MigrationItemUiProgress> {
val list = mutableListOf<MigrationItemUiProgress>()
for (id in selections.providers) {
list.add(MigrationItemUiProgress(id, MigrationItemCategoryDto.provider))
}
for (name in selections.mcpServers) {
list.add(MigrationItemUiProgress(name, MigrationItemCategoryDto.mcpServer))
}
for (slug in selections.customModes) {
val info = detection.customModes.find { it.slug == slug }
list.add(MigrationItemUiProgress(info?.name ?: slug, MigrationItemCategoryDto.customMode))
}
for (id in selections.sessions) {
list.add(MigrationItemUiProgress(id, MigrationItemCategoryDto.session))
}
if (selections.defaultModel) {
list.add(MigrationItemUiProgress("Default model", MigrationItemCategoryDto.defaultModel))
}
// Settings sub-items
val ap = selections.settings.autoApproval
if (ap.commandRules) list.add(MigrationItemUiProgress("Command rules", MigrationItemCategoryDto.settings))
if (ap.readPermission) list.add(MigrationItemUiProgress("Read permission", MigrationItemCategoryDto.settings))
if (ap.writePermission) list.add(MigrationItemUiProgress("Write permission", MigrationItemCategoryDto.settings))
if (ap.executePermission) list.add(MigrationItemUiProgress("Execute permission", MigrationItemCategoryDto.settings))
if (ap.mcpPermission) list.add(MigrationItemUiProgress("MCP permission", MigrationItemCategoryDto.settings))
if (ap.taskPermission) list.add(MigrationItemUiProgress("Task permission", MigrationItemCategoryDto.settings))
if (selections.settings.language) list.add(MigrationItemUiProgress("Language preference", MigrationItemCategoryDto.settings))
if (selections.settings.autocomplete) list.add(MigrationItemUiProgress("Autocomplete settings", MigrationItemCategoryDto.settings))
return list
}
}
@@ -0,0 +1,110 @@
package ai.kilocode.client.migration
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto
import ai.kilocode.rpc.dto.MigrationAutoApprovalSelectionsDto
import ai.kilocode.rpc.dto.MigrationSessionSelectionDto
import ai.kilocode.rpc.dto.MigrationSettingsSelectionsDto
/**
* Builds default preselections from detection data, mirroring VS Code behavior.
* Also converts UI selections to wire DTOs for RPC.
*/
object MigrationSelectionBuilder {
/**
* Build default selections mirroring VS Code preselection logic:
* - Providers: supported providers with API keys
* - MCP: all if any servers exist
* - Modes: all if any custom modes exist
* - Sessions: all if any sessions exist
* - Default model: if present
* - Auto-approval: subfields if corresponding data exists
* - Language: if present
* - Autocomplete: if present
*/
fun defaults(detection: LegacyMigrationDetectionDto): MigrationUiSelections {
val providers = detection.providers
.filter { it.supported && it.hasApiKey }
.map { it.profileName }
val mcpServers = if (detection.mcpServers.isNotEmpty()) detection.mcpServers.map { it.name } else emptyList()
val customModes = if (detection.customModes.isNotEmpty()) detection.customModes.map { it.slug } else emptyList()
val sessions = if (detection.sessions.isNotEmpty()) detection.sessions.map { it.id } else emptyList()
val defaultModel = detection.defaultModel != null
val settings = detection.settings
val ap = MigrationAutoApprovalUiSelections(
commandRules = settings?.let {
!it.allowedCommands.isNullOrEmpty() || !it.deniedCommands.isNullOrEmpty()
} ?: false,
readPermission = settings?.alwaysAllowReadOnly != null || settings?.alwaysAllowReadOnlyOutsideWorkspace != null,
writePermission = settings?.alwaysAllowWrite != null,
executePermission = settings?.alwaysAllowExecute != null,
mcpPermission = settings?.alwaysAllowMcp != null,
taskPermission = settings?.alwaysAllowModeSwitch != null || settings?.alwaysAllowSubtasks != null,
)
val settingsSel = MigrationSettingsUiSelections(
autoApproval = ap,
language = !settings?.language.isNullOrEmpty(),
autocomplete = settings?.autocomplete != null,
)
return MigrationUiSelections(
providers = providers,
mcpServers = mcpServers,
customModes = customModes,
sessions = sessions,
defaultModel = defaultModel,
settings = settingsSel,
)
}
/**
* Convert UI selections into the wire DTO, taking only supported+apiKey providers.
*/
fun toDto(
selections: MigrationUiSelections,
sessionForce: Boolean = false,
): LegacyMigrationSelectionsDto = LegacyMigrationSelectionsDto(
providers = selections.providers,
mcpServers = selections.mcpServers,
customModes = selections.customModes,
sessions = selections.sessions.map { MigrationSessionSelectionDto(it, force = sessionForce) },
defaultModel = selections.defaultModel,
settings = MigrationSettingsSelectionsDto(
autoApproval = MigrationAutoApprovalSelectionsDto(
commandRules = selections.settings.autoApproval.commandRules,
readPermission = selections.settings.autoApproval.readPermission,
writePermission = selections.settings.autoApproval.writePermission,
executePermission = selections.settings.autoApproval.executePermission,
mcpPermission = selections.settings.autoApproval.mcpPermission,
taskPermission = selections.settings.autoApproval.taskPermission,
),
language = selections.settings.language,
autocomplete = selections.settings.autocomplete,
),
)
/**
* Build a session-only selection with specific IDs and force=true.
*/
fun forceSessionsDto(ids: List<String>): LegacyMigrationSelectionsDto = LegacyMigrationSelectionsDto(
providers = emptyList(),
mcpServers = emptyList(),
customModes = emptyList(),
sessions = ids.map { MigrationSessionSelectionDto(it, force = true) },
defaultModel = false,
settings = MigrationSettingsSelectionsDto(
autoApproval = MigrationAutoApprovalSelectionsDto(
commandRules = false,
readPermission = false,
writePermission = false,
executePermission = false,
mcpPermission = false,
taskPermission = false,
),
language = false,
autocomplete = false,
),
)
}
@@ -0,0 +1,117 @@
package ai.kilocode.client.migration
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto
import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto
import ai.kilocode.rpc.dto.MigrationItemCategoryDto
import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto
import ai.kilocode.rpc.dto.MigrationItemStatusDto
import ai.kilocode.rpc.dto.MigrationSessionPhaseDto
// ---------------------------------------------------------------------------
// User selections for UI
// ---------------------------------------------------------------------------
data class MigrationAutoApprovalUiSelections(
val commandRules: Boolean = false,
val readPermission: Boolean = false,
val writePermission: Boolean = false,
val executePermission: Boolean = false,
val mcpPermission: Boolean = false,
val taskPermission: Boolean = false,
)
data class MigrationSettingsUiSelections(
val autoApproval: MigrationAutoApprovalUiSelections = MigrationAutoApprovalUiSelections(),
val language: Boolean = false,
val autocomplete: Boolean = false,
)
data class MigrationUiSelections(
val providers: List<String> = emptyList(),
val mcpServers: List<String> = emptyList(),
val customModes: List<String> = emptyList(),
val sessions: List<String> = emptyList(),
val defaultModel: Boolean = false,
val settings: MigrationSettingsUiSelections = MigrationSettingsUiSelections(),
)
// ---------------------------------------------------------------------------
// Progress tracking per item
// ---------------------------------------------------------------------------
data class MigrationItemUiProgress(
val item: String,
val category: MigrationItemCategoryDto,
val status: MigrationItemProgressStatusDto = MigrationItemProgressStatusDto.migrating,
val message: String? = null,
)
// ---------------------------------------------------------------------------
// Session summary buckets
// ---------------------------------------------------------------------------
data class SessionMigrationSummary(
val imported: List<LegacyMigrationResultItemDto> = emptyList(),
val skipped: List<LegacyMigrationResultItemDto> = emptyList(),
val errored: List<LegacyMigrationResultItemDto> = emptyList(),
)
// ---------------------------------------------------------------------------
// Migration phase for the overall UI
// ---------------------------------------------------------------------------
enum class MigrationUiPhase {
/** Wizard showing selection checkboxes. */
selecting,
/** Migration is running. */
migrating,
/** Migration finished with no errors. */
done,
/** Migration finished with errors. */
error,
}
// ---------------------------------------------------------------------------
// Top-level shared state emitted by KiloMigrationService
// ---------------------------------------------------------------------------
sealed class MigrationUiState {
/** Migration overlay should not be shown. */
object Hidden : MigrationUiState()
/** Migration data was detected; show the wizard. */
data class Needed(
val detection: LegacyMigrationDetectionDto,
val phase: MigrationUiPhase = MigrationUiPhase.selecting,
val running: Boolean = false,
val progress: List<MigrationItemUiProgress> = emptyList(),
val sessionProgress: LegacyMigrationSessionProgressDto? = null,
val sessionSummary: SessionMigrationSummary = SessionMigrationSummary(),
val results: List<LegacyMigrationResultItemDto> = emptyList(),
) : MigrationUiState()
}
// ---------------------------------------------------------------------------
// Derived helpers on state
// ---------------------------------------------------------------------------
fun MigrationItemProgressStatusDto.toResultStatus(): MigrationItemStatusDto? = when (this) {
MigrationItemProgressStatusDto.success -> MigrationItemStatusDto.success
MigrationItemProgressStatusDto.warning -> MigrationItemStatusDto.warning
MigrationItemProgressStatusDto.error -> MigrationItemStatusDto.error
MigrationItemProgressStatusDto.migrating -> null
}
/** Derive group-level status from item progress entries in a category. */
fun groupStatus(items: List<MigrationItemUiProgress>): MigrationItemProgressStatusDto {
if (items.any { it.status == MigrationItemProgressStatusDto.error }) return MigrationItemProgressStatusDto.error
if (items.any { it.status == MigrationItemProgressStatusDto.warning }) return MigrationItemProgressStatusDto.warning
if (items.all { it.status == MigrationItemProgressStatusDto.success }) return MigrationItemProgressStatusDto.success
if (items.any { it.status == MigrationItemProgressStatusDto.migrating }) return MigrationItemProgressStatusDto.migrating
return MigrationItemProgressStatusDto.migrating
}
/** True if the session summary phase is currently showing. */
fun LegacyMigrationSessionProgressDto.isSummary(): Boolean =
phase == MigrationSessionPhaseDto.summary
@@ -0,0 +1,76 @@
package ai.kilocode.client.migration.ui
import ai.kilocode.client.migration.MigrationItemUiProgress
import ai.kilocode.client.migration.MigrationUiPhase
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.MigrationItemCategoryDto
import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.FlowLayout
import javax.swing.JPanel
/**
* A single row in the migration item list.
* Shows a checkbox in [MigrationUiPhase.selecting] or a status icon otherwise.
*/
class MigrationItemRow(
private val label: String,
private val category: MigrationItemCategoryDto,
) : BorderLayoutPanel() {
private val check = JBCheckBox(label)
private val statusIcon = MigrationStatusIcon()
private val nameLabel = JBLabel(label)
private val messageLabel = JBLabel().apply {
foreground = UiStyle.Colors.weak()
border = JBUI.Borders.emptyLeft(UiStyle.Gap.sm())
}
private val selectRow = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
isOpaque = false
add(check)
}
private val progressRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(statusIcon)
add(nameLabel)
add(messageLabel)
}
var selected: Boolean
get() = check.isSelected
set(v) { check.isSelected = v }
var onSelectionChanged: ((Boolean) -> Unit)? = null
init {
isOpaque = false
border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
check.isOpaque = false
check.addActionListener { onSelectionChanged?.invoke(check.isSelected) }
addToCenter(selectRow)
progressRow.isVisible = false
add(progressRow, java.awt.BorderLayout.SOUTH)
}
fun updatePhase(phase: MigrationUiPhase) {
val selecting = phase == MigrationUiPhase.selecting
selectRow.isVisible = selecting
progressRow.isVisible = !selecting
}
fun updateProgress(progress: MigrationItemUiProgress?) {
if (progress == null) {
statusIcon.update(MigrationItemProgressStatusDto.migrating)
messageLabel.text = null
return
}
statusIcon.update(progress.status)
messageLabel.text = progress.message
}
}
@@ -0,0 +1,52 @@
package ai.kilocode.client.migration.ui
import ai.kilocode.client.migration.MigrationUiSelections
import ai.kilocode.client.migration.MigrationUiState
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBPanel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import javax.swing.JComponent
/**
* Outer container for the migration wizard rendered inside the blocker layer.
*
* Wraps [MigrationWizardPanel] in a bordered overlay with a panel background.
* Build once; call [update] on every state change.
*/
class MigrationOverlayPanel : JBPanel<MigrationOverlayPanel>(BorderLayout()) {
private val wizard = MigrationWizardPanel()
var onSkip: (() -> Unit)?
get() = wizard.onSkip
set(v) { wizard.onSkip = v }
var onStart: ((MigrationUiSelections) -> Unit)?
get() = wizard.onStart
set(v) { wizard.onStart = v }
var onForce: ((List<String>) -> Unit)?
get() = wizard.onForce
set(v) { wizard.onForce = v }
var onDone: (() -> Unit)?
get() = wizard.onDone
set(v) { wizard.onDone = v }
var onContinueFromError: (() -> Unit)?
get() = wizard.onContinueFromError
set(v) { wizard.onContinueFromError = v }
init {
withBackground(UiStyle.Colors.bg())
border = JBUI.Borders.customLine(com.intellij.ui.JBColor.border(), 1)
add(wizard, BorderLayout.CENTER)
}
fun update(state: MigrationUiState.Needed) {
wizard.update(state)
}
fun preferredFocusComponent(): JComponent = wizard.preferredFocusComponent()
}
@@ -0,0 +1,22 @@
package ai.kilocode.client.migration.ui
import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto
import com.intellij.icons.AllIcons
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.components.JBLabel
import javax.swing.Icon
/** Shows an animated spinner (migrating), success, warning, or error icon. */
class MigrationStatusIcon : JBLabel() {
fun update(status: MigrationItemProgressStatusDto) {
icon = iconFor(status)
}
private fun iconFor(status: MigrationItemProgressStatusDto): Icon = when (status) {
MigrationItemProgressStatusDto.migrating -> AnimatedIcon.Default()
MigrationItemProgressStatusDto.success -> AllIcons.General.InspectionsOK
MigrationItemProgressStatusDto.warning -> AllIcons.General.Warning
MigrationItemProgressStatusDto.error -> AllIcons.General.Error
}
}
@@ -0,0 +1,344 @@
package ai.kilocode.client.migration.ui
import ai.kilocode.client.migration.MigrationItemUiProgress
import ai.kilocode.client.migration.MigrationSelectionBuilder
import ai.kilocode.client.migration.MigrationSettingsUiSelections
import ai.kilocode.client.migration.MigrationUiPhase
import ai.kilocode.client.migration.MigrationUiSelections
import ai.kilocode.client.migration.MigrationUiState
import ai.kilocode.client.migration.SessionMigrationSummary
import ai.kilocode.client.migration.groupStatus
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto
import ai.kilocode.rpc.dto.MigrationItemCategoryDto
import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto
import ai.kilocode.rpc.dto.MigrationSessionPhaseDto
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.FlowLayout
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import javax.swing.JButton
import javax.swing.JPanel
import javax.swing.JButton as JBtn
private const val CARD_WHATS_NEW = "whats-new"
private const val CARD_MIGRATE = "migrate"
/**
* Two-screen migration wizard: "What's New" → "Migrate Your Settings".
*
* Build once; call [update] for every state change. Does not rebuild the component tree.
*/
class MigrationWizardPanel : JPanel(BorderLayout()) {
// ------ Callbacks ------
var onSkip: (() -> Unit)? = null
var onStart: ((MigrationUiSelections) -> Unit)? = null
var onForce: ((List<String>) -> Unit)? = null
var onDone: (() -> Unit)? = null
var onContinueFromError: (() -> Unit)? = null
// ------ Card layout ------
private val cards = CardLayout()
private val cardPanel = JPanel(cards)
// ------ What's New screen ------
private val whatsNewPanel = buildWhatsNewPanel()
// ------ Migrate screen row state ------
private val rows = mutableMapOf<MigrationItemCategoryDto, MigrationItemRow>()
private val settingsRow = MigrationItemRow(KiloBundle.message("migration.row.settings"), MigrationItemCategoryDto.settings)
private val providerRow = MigrationItemRow(KiloBundle.message("migration.row.providers"), MigrationItemCategoryDto.provider)
private val mcpRow = MigrationItemRow(KiloBundle.message("migration.row.mcp"), MigrationItemCategoryDto.mcpServer)
private val modesRow = MigrationItemRow(KiloBundle.message("migration.row.modes"), MigrationItemCategoryDto.customMode)
private val sessionsRow = MigrationItemRow(KiloBundle.message("migration.row.sessions"), MigrationItemCategoryDto.session)
private val modelRow = MigrationItemRow(KiloBundle.message("migration.row.model"), MigrationItemCategoryDto.defaultModel)
private val sessionProgress = SessionMigrationProgressPanel()
private val sessionSummary = SessionMigrationSummaryPanel()
private val migrateBtn = JButton(KiloBundle.message("migration.button.migrate"))
private val backBtn = JButton(KiloBundle.message("migration.button.back"))
private val skipBtn = JButton(KiloBundle.message("migration.button.skip"))
private val doneBtn = JButton(KiloBundle.message("migration.button.done"))
private val continueBtn = JButton(KiloBundle.message("migration.button.continue"))
private val emptyLabel = JBLabel(KiloBundle.message("migration.empty")).apply {
foreground = UiStyle.Colors.weak()
}
private var detection: LegacyMigrationDetectionDto? = null
private var selections = MigrationUiSelections()
init {
isOpaque = false
rows[MigrationItemCategoryDto.provider] = providerRow
rows[MigrationItemCategoryDto.mcpServer] = mcpRow
rows[MigrationItemCategoryDto.customMode] = modesRow
rows[MigrationItemCategoryDto.session] = sessionsRow
rows[MigrationItemCategoryDto.defaultModel] = modelRow
rows[MigrationItemCategoryDto.settings] = settingsRow
for (row in rows.values) {
row.onSelectionChanged = { _ -> updateMigrateButtonEnabled() }
}
migrateBtn.addActionListener { onStart?.invoke(currentSelections()) }
backBtn.addActionListener { cards.show(cardPanel, CARD_WHATS_NEW) }
skipBtn.addActionListener { onSkip?.invoke() }
doneBtn.addActionListener { onDone?.invoke() }
continueBtn.addActionListener { onContinueFromError?.invoke() }
sessionSummary.onForceReimport = { ids -> onForce?.invoke(ids) }
cardPanel.isOpaque = false
cardPanel.add(whatsNewPanel, CARD_WHATS_NEW)
cardPanel.add(buildMigratePanel(), CARD_MIGRATE)
add(cardPanel, BorderLayout.CENTER)
cards.show(cardPanel, CARD_WHATS_NEW)
}
// ------ Public update ------
fun update(state: MigrationUiState.Needed) {
val det = state.detection
// Detection and default selections are set once on first update and are stable for the
// lifetime of a single wizard session. The service never re-detects mid-session;
// force re-import uses existing session IDs without changing detection data.
if (detection == null || detection != det) {
detection = det
selections = MigrationSelectionBuilder.defaults(det)
applyDefaults(det)
}
val phase = state.phase
// Update row visibility based on what data exists
providerRow.isVisible = det.providers.any { it.supported }
mcpRow.isVisible = det.mcpServers.isNotEmpty()
modesRow.isVisible = det.customModes.isNotEmpty()
sessionsRow.isVisible = det.sessions.isNotEmpty()
modelRow.isVisible = det.defaultModel != null
settingsRow.isVisible = det.settings != null
emptyLabel.isVisible = !det.hasData
// Update phase for all rows
for (row in rows.values) {
row.updatePhase(phase)
}
// Update progress for each row category
updateRowProgress(MigrationItemCategoryDto.provider, state.progress)
updateRowProgress(MigrationItemCategoryDto.mcpServer, state.progress)
updateRowProgress(MigrationItemCategoryDto.customMode, state.progress)
updateRowProgress(MigrationItemCategoryDto.session, state.progress)
updateRowProgress(MigrationItemCategoryDto.defaultModel, state.progress)
updateRowProgress(MigrationItemCategoryDto.settings, state.progress)
// Session progress/summary
val sp = state.sessionProgress
if (sp != null && sp.phase != MigrationSessionPhaseDto.summary) {
sessionProgress.update(sp)
sessionProgress.isVisible = true
sessionSummary.isVisible = false
} else if (sp != null && sp.phase == MigrationSessionPhaseDto.summary) {
sessionSummary.update(state.sessionSummary)
sessionProgress.isVisible = false
sessionSummary.isVisible = true
} else {
sessionProgress.isVisible = false
sessionSummary.isVisible = false
}
updateButtons(phase, state.running)
updateMigrateButtonEnabled()
}
fun preferredFocusComponent() = migrateBtn
// ------ Internal helpers ------
private fun applyDefaults(det: LegacyMigrationDetectionDto) {
val defaults = MigrationSelectionBuilder.defaults(det)
providerRow.selected = defaults.providers.isNotEmpty()
mcpRow.selected = defaults.mcpServers.isNotEmpty()
modesRow.selected = defaults.customModes.isNotEmpty()
sessionsRow.selected = defaults.sessions.isNotEmpty()
modelRow.selected = defaults.defaultModel
settingsRow.selected = defaults.settings.autoApproval.commandRules ||
defaults.settings.autoApproval.readPermission ||
defaults.settings.autoApproval.writePermission ||
defaults.settings.autoApproval.executePermission ||
defaults.settings.autoApproval.mcpPermission ||
defaults.settings.autoApproval.taskPermission ||
defaults.settings.language ||
defaults.settings.autocomplete
}
private fun updateRowProgress(category: MigrationItemCategoryDto, items: List<MigrationItemUiProgress>) {
val row = rows[category] ?: return
val categoryItems = items.filter { it.category == category }
if (categoryItems.isEmpty()) {
row.updateProgress(null)
return
}
val status = groupStatus(categoryItems)
row.updateProgress(MigrationItemUiProgress(category.name, category, status))
}
private fun updateButtons(phase: MigrationUiPhase, running: Boolean) {
backBtn.isVisible = phase == MigrationUiPhase.selecting
skipBtn.isVisible = phase == MigrationUiPhase.selecting
migrateBtn.isVisible = phase == MigrationUiPhase.selecting || phase == MigrationUiPhase.migrating
migrateBtn.isEnabled = !running && phase == MigrationUiPhase.selecting
migrateBtn.text = if (running) KiloBundle.message("migration.button.migrating") else KiloBundle.message("migration.button.migrate")
doneBtn.isVisible = phase == MigrationUiPhase.done
continueBtn.isVisible = phase == MigrationUiPhase.error
}
private fun updateMigrateButtonEnabled() {
val any = rows.values.any { it.isVisible && it.selected }
migrateBtn.isEnabled = any && migrateBtn.text == KiloBundle.message("migration.button.migrate")
}
private fun currentSelections(): MigrationUiSelections {
val det = detection ?: return MigrationUiSelections()
val providers = if (providerRow.selected) det.providers.filter { it.supported && it.hasApiKey }.map { it.profileName } else emptyList()
val mcpServers = if (mcpRow.selected) det.mcpServers.map { it.name } else emptyList()
val modes = if (modesRow.selected) det.customModes.map { it.slug } else emptyList()
val sessions = if (sessionsRow.selected) det.sessions.map { it.id } else emptyList()
val defaults = MigrationSelectionBuilder.defaults(det)
return MigrationUiSelections(
providers = providers,
mcpServers = mcpServers,
customModes = modes,
sessions = sessions,
defaultModel = modelRow.selected,
settings = if (settingsRow.selected) defaults.settings else MigrationSettingsUiSelections(),
)
}
private fun buildWhatsNewPanel(): JPanel {
val panel = JPanel(BorderLayout()).apply { isOpaque = false }
val title = JBLabel(KiloBundle.message("migration.whats_new.title")).apply {
font = JBFont.h2().asBold()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
}
val subtitle = JBLabel(KiloBundle.message("migration.whats_new.subtitle")).apply {
foreground = UiStyle.Colors.weak()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.md())
}
val features = listOf(
KiloBundle.message("migration.whats_new.feature.performance"),
KiloBundle.message("migration.whats_new.feature.interface"),
KiloBundle.message("migration.whats_new.feature.agent_manager"),
KiloBundle.message("migration.whats_new.feature.foundation"),
)
val featurePanel = JPanel(GridBagLayout()).apply { isOpaque = false }
val gc = GridBagConstraints().apply {
fill = GridBagConstraints.HORIZONTAL
weightx = 1.0
gridx = 0
}
for (f in features) {
val row = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.xs(), 0)).apply {
isOpaque = false
add(JBLabel(AllIcons.General.InspectionsOK))
add(JBLabel(f))
}
featurePanel.add(row, gc)
}
val content = JPanel(GridBagLayout()).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.pad())
val c = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 }
add(title, c)
add(subtitle, c)
add(featurePanel, c)
}
val continueWnBtn = JButton(KiloBundle.message("migration.button.continue_to_migrate")).apply {
addActionListener { cards.show(cardPanel, CARD_MIGRATE) }
}
val footer = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(continueWnBtn)
}
panel.add(JBScrollPane(content).apply { border = JBUI.Borders.empty() }, BorderLayout.CENTER)
panel.add(footer, BorderLayout.SOUTH)
return panel
}
private fun buildMigratePanel(): JPanel {
val panel = JPanel(BorderLayout()).apply { isOpaque = false }
val title = JBLabel(KiloBundle.message("migration.migrate.title")).apply {
font = JBFont.h2().asBold()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
}
val subtitle = JBLabel(KiloBundle.message("migration.migrate.subtitle")).apply {
foreground = UiStyle.Colors.weak()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.md())
}
val sectionLabel = JBLabel(KiloBundle.message("migration.migrate.section")).apply {
font = JBFont.medium()
border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs())
}
val rowsPanel = JPanel(GridBagLayout()).apply {
isOpaque = false
val gc = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 }
add(emptyLabel, gc)
add(providerRow, gc)
add(mcpRow, gc)
add(modesRow, gc)
add(sessionsRow, gc)
add(modelRow, gc)
add(settingsRow, gc)
add(sessionProgress, gc)
add(sessionSummary, gc)
}
val content = JPanel(GridBagLayout()).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.pad())
val gc = GridBagConstraints().apply { fill = GridBagConstraints.HORIZONTAL; weightx = 1.0; gridx = 0 }
add(title, gc)
add(subtitle, gc)
add(sectionLabel, gc)
add(rowsPanel, gc)
}
val footer = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(backBtn)
add(skipBtn)
add(migrateBtn)
add(doneBtn)
add(continueBtn)
}
panel.add(JBScrollPane(content).apply { border = JBUI.Borders.empty() }, BorderLayout.CENTER)
panel.add(footer, BorderLayout.SOUTH)
return panel
}
}
@@ -0,0 +1,101 @@
package ai.kilocode.client.migration.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.LegacyMigrationSessionProgressDto
import ai.kilocode.rpc.dto.MigrationSessionPhaseDto
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import java.text.SimpleDateFormat
import java.util.Date
import javax.swing.JPanel
/**
* Shows live session migration progress (current/total, metadata, step labels).
* Summary phase delegates rendering to [SessionMigrationSummaryPanel].
*/
class SessionMigrationProgressPanel : JPanel(GridBagLayout()) {
private val header = JBLabel().apply { font = font.deriveFont(java.awt.Font.BOLD) }
private val directory = JBLabel().apply { foreground = UiStyle.Colors.weak() }
private val title = JBLabel()
private val date = JBLabel().apply { foreground = UiStyle.Colors.weak() }
private val preparingIcon = MigrationStatusIcon()
private val storingIcon = MigrationStatusIcon()
private val preparingLabel = JBLabel(KiloBundle.message("migration.session.step.preparing"))
private val storingLabel = JBLabel(KiloBundle.message("migration.session.step.storing"))
private val dateFormat = SimpleDateFormat("HH:mm MM/dd/yyyy")
init {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.sm())
val gc = GridBagConstraints().apply {
fill = GridBagConstraints.HORIZONTAL
weightx = 1.0
gridx = 0
}
add(header, gc)
add(directory, gc)
add(title, gc)
add(date, gc)
val stepPanel = JPanel(java.awt.FlowLayout(java.awt.FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
}
val stepPanel2 = JPanel(java.awt.FlowLayout(java.awt.FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
}
stepPanel.add(preparingIcon)
stepPanel.add(preparingLabel)
stepPanel2.add(storingIcon)
stepPanel2.add(storingLabel)
val stepsPanel = BorderLayoutPanel().apply {
isOpaque = false
addToTop(stepPanel)
addToCenter(stepPanel2)
}
add(stepsPanel, gc)
}
fun update(progress: LegacyMigrationSessionProgressDto) {
header.text = KiloBundle.message("migration.session.header", progress.index + 1, progress.total)
val info = progress.session
directory.text = info?.directory?.takeIf { it.isNotEmpty() } ?: KiloBundle.message("migration.session.unknown.path")
title.text = info?.title?.takeIf { it.isNotEmpty() } ?: KiloBundle.message("migration.session.unknown.title")
date.text = if ((info?.time ?: 0L) > 0L) dateFormat.format(Date(info!!.time)) else KiloBundle.message("migration.session.unknown.date")
val phase = progress.phase
when (phase) {
MigrationSessionPhaseDto.preparing -> {
preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.migrating)
storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.migrating)
storingLabel.foreground = UiStyle.Colors.weak()
}
MigrationSessionPhaseDto.storing -> {
preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.success)
storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.migrating)
storingLabel.foreground = UiStyle.Colors.fg()
}
MigrationSessionPhaseDto.skipped -> {
preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.success)
storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.warning)
storingLabel.text = KiloBundle.message("migration.session.step.skipped")
}
MigrationSessionPhaseDto.done -> {
preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.success)
storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.success)
}
MigrationSessionPhaseDto.error -> {
preparingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.error)
storingIcon.update(ai.kilocode.rpc.dto.MigrationItemProgressStatusDto.error)
}
MigrationSessionPhaseDto.summary -> { /* handled by summary panel */ }
}
}
}
@@ -0,0 +1,140 @@
package ai.kilocode.client.migration.ui
import ai.kilocode.client.migration.SessionMigrationSummary
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.FlowLayout
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import java.awt.datatransfer.StringSelection
import javax.swing.JButton
import javax.swing.JPanel
/**
* Renders session summary after migration completes.
* Shows imported/skipped/errored buckets, re-import checkbox, and copy report button.
*/
class SessionMigrationSummaryPanel : JPanel(BorderLayout()) {
private val imported = JBLabel()
private val skipped = JBLabel()
private val errored = JBLabel()
private val reimportAll = JBCheckBox(KiloBundle.message("migration.session.summary.reimport.all"))
private val copyBtn = JButton(KiloBundle.message("migration.session.summary.copy.report"), AllIcons.Actions.Copy)
private var skippedItems: List<LegacyMigrationResultItemDto> = emptyList()
private var selectedForReimport: MutableSet<String> = mutableSetOf()
private val reimportCheckboxes = mutableListOf<Pair<JBCheckBox, String>>()
private val skippedPanel = JPanel(GridBagLayout()).apply { isOpaque = false }
private val feedbackLabel = JBLabel(KiloBundle.message("migration.session.summary.copied")).apply {
isVisible = false
foreground = UiStyle.Colors.weak()
}
var onForceReimport: ((List<String>) -> Unit)? = null
private val forceBtn = JButton(KiloBundle.message("migration.session.summary.force.reimport")).apply {
isEnabled = false
}
init {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.sm())
val statsPanel = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(imported)
add(skipped)
add(errored)
}
reimportAll.isOpaque = false
reimportAll.addActionListener { toggleAll(reimportAll.isSelected) }
forceBtn.addActionListener { onForceReimport?.invoke(selectedForReimport.toList()) }
val btnRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(forceBtn)
add(copyBtn)
add(feedbackLabel)
}
copyBtn.addActionListener {
CopyPasteManager.getInstance().setContents(StringSelection(buildReport()))
feedbackLabel.isVisible = true
}
val center = JPanel(GridBagLayout()).apply {
isOpaque = false
val gc = GridBagConstraints().apply {
fill = GridBagConstraints.HORIZONTAL
weightx = 1.0
gridx = 0
}
add(statsPanel, gc)
add(skippedPanel, gc)
add(reimportAll, gc)
add(btnRow, gc)
}
add(center, BorderLayout.CENTER)
}
fun update(summary: SessionMigrationSummary) {
imported.text = KiloBundle.message("migration.session.summary.imported", summary.imported.size)
skipped.text = KiloBundle.message("migration.session.summary.skipped", summary.skipped.size)
errored.text = KiloBundle.message("migration.session.summary.errored", summary.errored.size)
skippedItems = summary.skipped
selectedForReimport.clear()
skippedPanel.removeAll()
reimportCheckboxes.clear()
val gc = GridBagConstraints().apply {
fill = GridBagConstraints.HORIZONTAL
weightx = 1.0
gridx = 0
}
for (item in summary.skipped) {
val cb = JBCheckBox(item.item)
cb.isOpaque = false
cb.addActionListener {
if (cb.isSelected) selectedForReimport.add(item.item) else selectedForReimport.remove(item.item)
forceBtn.isEnabled = selectedForReimport.isNotEmpty()
reimportAll.isSelected = selectedForReimport.size == skippedItems.size
}
reimportCheckboxes.add(cb to item.item)
skippedPanel.add(cb, gc)
}
reimportAll.isVisible = summary.skipped.isNotEmpty()
forceBtn.isEnabled = false
feedbackLabel.isVisible = false
skippedPanel.revalidate()
skippedPanel.repaint()
}
private fun toggleAll(checked: Boolean) {
for ((cb, id) in reimportCheckboxes) {
cb.isSelected = checked
if (checked) selectedForReimport.add(id) else selectedForReimport.remove(id)
}
forceBtn.isEnabled = checked && reimportCheckboxes.isNotEmpty()
}
private fun buildReport(): String {
val sb = StringBuilder()
sb.appendLine(KiloBundle.message("migration.session.summary.imported", skippedItems.size))
for (item in skippedItems) {
sb.appendLine(" - ${item.item}: ${item.message ?: "skipped"}")
}
return sb.toString()
}
}
@@ -3,6 +3,10 @@ package ai.kilocode.client.session
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.migration.KiloMigrationService
import ai.kilocode.client.migration.MigrationUiController
import ai.kilocode.client.migration.MigrationUiState
import ai.kilocode.client.migration.ui.MigrationOverlayPanel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.scroll.SessionScroll
@@ -25,14 +29,19 @@ import ai.kilocode.client.session.views.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.colors.EditorColorsListener
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import javax.swing.BoxLayout
import javax.swing.JComponent
@@ -53,6 +62,7 @@ class SessionUi(
ref: SessionRef? = null,
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
private val manager: SessionManager? = null,
private val migration: MigrationUiController = service<KiloMigrationService>(),
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget {
companion object {
@@ -61,6 +71,9 @@ class SessionUi(
private val project = project
private val app = app
private val cs = cs
private val sessions = sessions
private val workspace = workspace
private var opening = ref != null
private var pending = false
private var loaded: Boolean? = null
@@ -102,6 +115,7 @@ class SessionUi(
private lateinit var prompt: PromptPanel
private lateinit var load: LoadingPanel
private lateinit var migrationOverlay: MigrationOverlayPanel
private var style = SessionEditorStyle.current()
init {
@@ -109,6 +123,7 @@ class SessionUi(
scroll.show(body(controller.model.state))
bindUi()
bindStyle()
bindMigration()
applyStyle(style)
onStateChanged(controller.model.state)
loaded?.let(::finishOpen)
@@ -116,6 +131,7 @@ class SessionUi(
override fun addNotify() {
super.addNotify()
migration.check()
resumeOpen()
}
@@ -132,11 +148,26 @@ class SessionUi(
internal fun currentStyle() = style
val defaultFocusedComponent: JComponent get() = prompt.defaultFocusedComponent
val defaultFocusedComponent: JComponent get() {
val state = migration.state.value
if (state !is MigrationUiState.Hidden && root.blocker.isVisible) {
return migrationOverlay.preferredFocusComponent()
}
return prompt.defaultFocusedComponent
}
private fun buildUi() {
root = SessionRootPanel()
migrationOverlay = MigrationOverlayPanel().apply {
onSkip = { migration.skip() }
onDone = { migration.finish(); sessions.refresh(workspace.directory) }
onContinueFromError = { migration.finish(); sessions.refresh(workspace.directory) }
onStart = { sel -> migration.start(sel) }
onForce = { ids -> migration.force(ids) }
}
root.setBlocker(migrationOverlay)
sessionContent = JPanel(BorderLayout())
blankBody = JPanel(BorderLayout()).apply {
@@ -228,7 +259,13 @@ class SessionUi(
scroll.show(messageBody)
}
is SessionControllerEvent.AppChanged,
is SessionControllerEvent.AppChanged -> {
prompt.setReady(controller.model.isReady())
if (app.state.value.status == KiloAppStatusDto.READY) {
migration.check()
}
}
is SessionControllerEvent.WorkspaceChanged -> {
prompt.setReady(controller.model.isReady())
}
@@ -262,6 +299,28 @@ class SessionUi(
}
}
private fun bindMigration() {
cs.launch {
migration.state.collect { state ->
withContext(Dispatchers.Main) {
applyMigrationState(state)
}
}
}
}
private fun applyMigrationState(state: MigrationUiState) {
when (state) {
is MigrationUiState.Hidden -> {
root.setBlocked(false)
}
is MigrationUiState.Needed -> {
migrationOverlay.update(state)
root.setBlocked(true)
}
}
}
private fun bindStyle() {
val bus = ApplicationManager.getApplication().messageBus.connect(this)
bus.subscribe(EditorColorsManager.TOPIC, EditorColorsListener {
@@ -14,18 +14,37 @@ class SessionRootPanel : JLayeredPane() {
val overlay = Overlay()
val blocker = Blocker()
init {
layout = null
add(content)
setLayer(content, DEFAULT_LAYER)
add(overlay)
setLayer(overlay, PALETTE_LAYER)
add(blocker)
setLayer(blocker, MODAL_LAYER)
blocker.isVisible = false
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
overlay.addOverlay(child, bounds)
}
fun setBlocker(child: JComponent) {
blocker.removeAll()
blocker.add(child)
blocker.revalidate()
blocker.repaint()
}
fun setBlocked(value: Boolean) {
blocker.isVisible = value
if (value) blocker.requestFocusInWindow()
revalidate()
repaint()
}
override fun doLayout() {
components
.sortedBy { getLayer(it) }
@@ -36,8 +55,9 @@ class SessionRootPanel : JLayeredPane() {
}
override fun getPreferredSize(): Dimension {
val w = components.maxOfOrNull { it.preferredSize.width } ?: 0
val h = components.maxOfOrNull { it.preferredSize.height } ?: 0
// Only content and overlay contribute to preferred size; blocker is invisible by default.
val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0
val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0
return JBDimension(w, h)
}
@@ -77,4 +97,22 @@ class SessionRootPanel : JLayeredPane() {
return JBDimension(w, h)
}
}
/**
* Full-area blocking overlay rendered above the scroll overlay at MODAL_LAYER.
* When visible: consumes all mouse events across the full panel area.
* When hidden: passes all mouse events through (isVisible=false).
*/
class Blocker : JPanel() {
init {
layout = java.awt.BorderLayout()
isOpaque = false
isFocusable = true
}
override fun contains(x: Int, y: Int): Boolean {
if (!isVisible) return false
return super.contains(x, y)
}
}
}
@@ -7,6 +7,8 @@
<resource-bundle>messages.KiloBundle</resource-bundle>
<extensions defaultExtensionNs="com.intellij">
<applicationService serviceImplementation="ai.kilocode.client.migration.KiloMigrationService"/>
<toolWindow id="Kilo Code"
anchor="left"
icon="/icons/kilo.svg"
@@ -148,3 +148,47 @@ action.Kilo.Session.Rename.description=Rename the selected session
action.Kilo.Session.Delete.text=Delete
action.Kilo.Session.Delete.description=Delete the selected session(s)
action.Kilo.History.ContextMenu.text=History Actions
# Migration wizard
migration.whats_new.title=What''s New in Kilo Code
migration.whats_new.subtitle=We''ve rebuilt the extension on a faster, more efficient foundation.
migration.whats_new.feature.performance=Faster Agent Performance
migration.whats_new.feature.interface=Streamlined Interface
migration.whats_new.feature.agent_manager=Agent Manager
migration.whats_new.feature.foundation=Shared Foundation
migration.migrate.title=Migrate Your Settings
migration.migrate.subtitle=We found settings from your previous installation. Here''s what we can bring over.
migration.migrate.section=Select what to migrate
migration.empty=Nothing to migrate was found in the legacy settings.
migration.row.providers=Provider API Keys
migration.row.mcp=MCP Servers
migration.row.modes=Custom Modes / Agents
migration.row.sessions=Chat Sessions & History
migration.row.model=Default Model
migration.row.settings=Auto-Approval, Language & Autocomplete
migration.button.back=Back
migration.button.skip=Skip
migration.button.migrate=Migrate Settings
migration.button.migrating=Migrating...
migration.button.done=Done
migration.button.continue=Continue
migration.button.continue_to_migrate=Continue
migration.session.header=Migrating {0} of {1}
migration.session.step.preparing=Preparing session
migration.session.step.storing=Storing session
migration.session.step.skipped=Session skipped
migration.session.unknown.path=(unknown path)
migration.session.unknown.title=(untitled)
migration.session.unknown.date=(unknown date)
migration.session.summary.imported={0} imported
migration.session.summary.skipped={0} skipped
migration.session.summary.errored={0} errored
migration.session.summary.reimport.all=Re-import all
migration.session.summary.force.reimport=Force Re-Import
migration.session.summary.copy.report=Copy Report
migration.session.summary.copied=Copied report
@@ -0,0 +1,42 @@
package ai.kilocode.client.migration
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Fake [MigrationUiController] for UI tests.
*
* Push state changes by setting [_state].
* Track calls via [checks], [starts], [forces], [skips], [finishes].
*/
class FakeMigrationUiController : MigrationUiController {
val _state = MutableStateFlow<MigrationUiState>(MigrationUiState.Hidden)
override val state: StateFlow<MigrationUiState> = _state
val checks = mutableListOf<Unit>()
val starts = mutableListOf<MigrationUiSelections>()
val forces = mutableListOf<List<String>>()
val skips = mutableListOf<Unit>()
val finishes = mutableListOf<Unit>()
override fun check() {
checks.add(Unit)
}
override fun start(selections: MigrationUiSelections) {
starts.add(selections)
}
override fun force(ids: List<String>) {
forces.add(ids)
}
override fun skip() {
skips.add(Unit)
}
override fun finish() {
finishes.add(Unit)
}
}
@@ -0,0 +1,211 @@
package ai.kilocode.client.migration
import ai.kilocode.client.testing.FakeMigrationRpcApi
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationEventDto
import ai.kilocode.rpc.dto.LegacyMigrationResultItemDto
import ai.kilocode.rpc.dto.LegacyMigrationStatusDto
import ai.kilocode.rpc.dto.MigrationItemCategoryDto
import ai.kilocode.rpc.dto.MigrationItemProgressStatusDto
import ai.kilocode.rpc.dto.MigrationItemStatusDto
import ai.kilocode.rpc.dto.MigrationProviderInfoDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
@Suppress("UnstableApiUsage")
class KiloMigrationServiceTest : BasePlatformTestCase() {
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeMigrationRpcApi
private lateinit var service: KiloMigrationService
override fun setUp() {
super.setUp()
scope = CoroutineScope(SupervisorJob())
rpc = FakeMigrationRpcApi()
service = KiloMigrationService(scope, rpc)
}
override fun tearDown() {
try {
scope.cancel()
} finally {
super.tearDown()
}
}
private fun settle() = runBlocking {
repeat(3) {
delay(50)
UIUtil.dispatchAllInvocationEvents()
}
}
fun `test check calls status before detect`() {
rpc.statusResult = null
rpc.detectResult = FakeMigrationRpcApi.emptyDetection()
service.check()
settle()
assertEquals(1, rpc.statusCalls.size)
assertEquals(1, rpc.detectCalls.size)
}
fun `test existing status hides state and does not call detect`() {
rpc.statusResult = LegacyMigrationStatusDto.completed
service.check()
settle()
assertEquals(1, rpc.statusCalls.size)
assertEquals(0, rpc.detectCalls.size)
assertEquals(MigrationUiState.Hidden, service.state.value)
}
fun `test no data hides state`() {
rpc.statusResult = null
rpc.detectResult = FakeMigrationRpcApi.emptyDetection()
service.check()
settle()
assertEquals(MigrationUiState.Hidden, service.state.value)
}
fun `test detected data sets needed state`() {
rpc.statusResult = null
rpc.detectResult = sampleDetection()
service.check()
settle()
assertTrue("state should be Needed", service.state.value is MigrationUiState.Needed)
}
fun `test duplicate check while in flight makes one rpc call`() {
rpc.statusResult = null
rpc.detectResult = FakeMigrationRpcApi.emptyDetection()
service.check()
service.check()
settle()
// Due to in-flight guard only one pair of calls should happen
assertEquals(1, rpc.statusCalls.size)
}
fun `test skip marks status and hides`() {
rpc.statusResult = null
rpc.detectResult = sampleDetection()
service.check()
settle()
service.skip()
settle()
assertEquals(1, rpc.skipCalls.size)
assertEquals(MigrationUiState.Hidden, service.state.value)
}
fun `test finish calls finalize and hides`() {
rpc.statusResult = null
rpc.detectResult = sampleDetection()
service.check()
settle()
service.finish()
settle()
assertEquals(1, rpc.finalizeCalls.size)
assertEquals(LegacyMigrationStatusDto.completed, rpc.finalizeCalls[0])
assertEquals(MigrationUiState.Hidden, service.state.value)
}
fun `test start emits migrating state and initial pending progress`() = runBlocking {
rpc.statusResult = null
rpc.detectResult = sampleDetection()
service.check()
delay(100)
UIUtil.dispatchAllInvocationEvents()
val selections = MigrationUiSelections(providers = listOf("profile1"))
service.start(selections)
delay(50)
UIUtil.dispatchAllInvocationEvents()
val state = service.state.value
assertTrue("should be Needed after start", state is MigrationUiState.Needed)
val needed = state as MigrationUiState.Needed
assertEquals(MigrationUiPhase.migrating, needed.phase)
assertTrue(needed.running)
assertTrue("should have initial progress entries", needed.progress.isNotEmpty())
}
fun `test complete event without errors sets done phase`() = runBlocking {
rpc.statusResult = null
rpc.detectResult = sampleDetection()
service.check()
delay(100)
UIUtil.dispatchAllInvocationEvents()
val selections = MigrationUiSelections(providers = listOf("profile1"))
service.start(selections)
delay(50)
UIUtil.dispatchAllInvocationEvents()
val items = listOf(LegacyMigrationResultItemDto("profile1", MigrationItemCategoryDto.provider, MigrationItemStatusDto.success))
rpc.events.emit(LegacyMigrationEventDto.Complete(items))
delay(100)
UIUtil.dispatchAllInvocationEvents()
val state = service.state.value as? MigrationUiState.Needed
assertNotNull(state)
assertEquals(MigrationUiPhase.done, state!!.phase)
assertFalse(state.running)
}
fun `test complete event with errors sets error phase`() = runBlocking {
rpc.statusResult = null
rpc.detectResult = sampleDetection()
service.check()
delay(100)
UIUtil.dispatchAllInvocationEvents()
val selections = MigrationUiSelections(providers = listOf("profile1"))
service.start(selections)
delay(50)
UIUtil.dispatchAllInvocationEvents()
val items = listOf(LegacyMigrationResultItemDto("profile1", MigrationItemCategoryDto.provider, MigrationItemStatusDto.error, "bad key"))
rpc.events.emit(LegacyMigrationEventDto.Complete(items))
delay(100)
UIUtil.dispatchAllInvocationEvents()
val state = service.state.value as? MigrationUiState.Needed
assertNotNull(state)
assertEquals(MigrationUiPhase.error, state!!.phase)
}
fun `test force sends only session selections with force true`() = runBlocking {
rpc.statusResult = null
rpc.detectResult = sampleDetection()
service.check()
delay(100)
UIUtil.dispatchAllInvocationEvents()
service.force(listOf("ses_1", "ses_2"))
delay(50)
UIUtil.dispatchAllInvocationEvents()
assertEquals(1, rpc.migrateCalls.size)
val dto = rpc.migrateCalls[0]
assertEquals(emptyList<String>(), dto.providers)
assertEquals(2, dto.sessions.size)
assertTrue(dto.sessions.all { it.force })
assertEquals(listOf("ses_1", "ses_2"), dto.sessions.map { it.id })
}
private fun sampleDetection() = LegacyMigrationDetectionDto(
providers = listOf(
MigrationProviderInfoDto("profile1", "anthropic", "claude-3", true, true, "anthropic"),
),
mcpServers = emptyList(),
customModes = emptyList(),
sessions = emptyList(),
defaultModel = null,
settings = null,
hasData = true,
)
}
@@ -0,0 +1,97 @@
package ai.kilocode.client.migration
import ai.kilocode.client.session.SessionUiTestBase
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.MigrationProviderInfoDto
@Suppress("UnstableApiUsage")
class SessionUiMigrationTest : SessionUiTestBase() {
private lateinit var fakeMigration: FakeMigrationUiController
override fun setUp() {
super.setUp()
// Replace the default UI with one using our observable fake migration controller.
fakeMigration = FakeMigrationUiController()
ui = newUi(migration = fakeMigration)
layout()
}
fun `test addNotify calls migration check`() {
// SessionUi.addNotify should call migration.check().
// Use a fresh UI with the same fakeMigration to track the call.
val fresh = newUi(migration = fakeMigration)
val before = fakeMigration.checks.size
try {
fresh.addNotify()
assertTrue("addNotify should call migration.check()", fakeMigration.checks.size > before)
} finally {
fresh.removeNotify()
com.intellij.openapi.util.Disposer.dispose(fresh)
}
}
fun `test hidden migration state keeps blocker hidden`() {
val root = find<SessionRootPanel>(ui)
fakeMigration._state.value = MigrationUiState.Hidden
settle()
assertFalse(root.blocker.isVisible)
}
fun `test visible migration state shows root blocker`() {
val root = find<SessionRootPanel>(ui)
fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection())
settle()
assertTrue("blocker should be visible", root.blocker.isVisible)
}
fun `test hidden state after visible hides blocker`() {
val root = find<SessionRootPanel>(ui)
fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection())
settle()
assertTrue(root.blocker.isVisible)
fakeMigration._state.value = MigrationUiState.Hidden
settle()
assertFalse(root.blocker.isVisible)
}
fun `test two session UIs sharing one controller both react to state change`() {
val ui2 = newUi(migration = fakeMigration)
ui2.setSize(800, 600)
try {
fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection())
settle()
val root1 = find<SessionRootPanel>(ui)
val root2 = find<SessionRootPanel>(ui2)
assertTrue("ui1 blocker should be visible", root1.blocker.isVisible)
assertTrue("ui2 blocker should be visible", root2.blocker.isVisible)
} finally {
com.intellij.openapi.util.Disposer.dispose(ui2)
}
}
fun `test default focused component is migration overlay when blocked`() {
fakeMigration._state.value = MigrationUiState.Needed(detection = sampleDetection())
settle()
val root = find<SessionRootPanel>(ui)
assertTrue("blocker should be visible for defaultFocused test", root.blocker.isVisible)
// defaultFocusedComponent should not throw and should not be the prompt editor
val focused = ui.defaultFocusedComponent
assertNotNull(focused)
}
private fun sampleDetection() = LegacyMigrationDetectionDto(
providers = listOf(
MigrationProviderInfoDto("profile1", "anthropic", "claude-3", true, true, "anthropic"),
),
mcpServers = emptyList(),
customModes = emptyList(),
sessions = emptyList(),
defaultModel = null,
settings = null,
hasData = true,
)
}
@@ -24,14 +24,17 @@ import javax.swing.JLayeredPane
@Suppress("UnstableApiUsage")
class SessionUiLayoutTest : SessionUiTestBase() {
fun `test root contains content and overlay layers`() {
fun `test root contains content overlay and blocker layers`() {
val root = find<SessionRootPanel>(ui)
assertEquals(2, root.componentCount)
assertEquals(3, root.componentCount)
assertSame(root.content, root.components.first { it === root.content })
assertSame(root.overlay, root.components.first { it === root.overlay })
assertSame(root.blocker, root.components.first { it === root.blocker })
assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content))
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay))
assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker))
assertFalse(root.blocker.isVisible)
}
fun `test bottom stack contains connection and prompt only`() {
@@ -61,7 +64,8 @@ class SessionUiLayoutTest : SessionUiTestBase() {
fun `test header is docked above shared scroll pane and hidden while empty`() {
val root = find<SessionRootPanel>(ui)
val header = find<SessionHeaderPanel>(ui)
val scroll = find<JBScrollPane>(ui)
// Search from root.content to avoid finding the migration wizard scroll panes
val scroll = find<JBScrollPane>(root.content)
assertSame(root.content, header.parent.parent)
assertSame(scroll.parent, header.parent)
@@ -273,14 +277,13 @@ class SessionUiLayoutTest : SessionUiTestBase() {
fun `test existing session history shows header above scroll pane`() {
rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList()))
ui = SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.Local("ses_test"), displayMs = 0).apply {
setSize(800, 600)
}
ui = newUi(id = "ses_test")
settle()
layout()
val root = find<SessionRootPanel>(ui)
val header = find<SessionHeaderPanel>(ui)
val scroll = find<JBScrollPane>(ui)
val scroll = find<JBScrollPane>(root.content)
assertTrue(header.isVisible)
assertTrue(header.y + header.height <= scroll.y)
}
@@ -4,6 +4,8 @@ import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.migration.FakeMigrationUiController
import ai.kilocode.client.migration.MigrationUiController
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.controller.SessionController
@@ -79,6 +81,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
id: String? = null,
displayMs: Long = 0,
open: ((SessionRef) -> Unit)? = null,
migration: MigrationUiController = FakeMigrationUiController(),
): SessionUi {
val manager = open?.let { fn ->
object : SessionManager {
@@ -87,7 +90,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
override fun openSession(ref: SessionRef) = fn(ref)
}
}
return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager).apply {
return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager, migration = migration).apply {
setSize(800, 600)
}
}
@@ -9,17 +9,24 @@ import javax.swing.JLayeredPane
@Suppress("UnstableApiUsage")
class SessionRootPanelTest : BasePlatformTestCase() {
fun `test root owns content and overlay layers`() {
fun `test root owns content overlay and blocker layers`() {
val root = SessionRootPanel()
assertEquals(2, root.componentCount)
assertEquals(3, root.componentCount)
assertSame(root.content, root.components.first { it === root.content })
assertSame(root.overlay, root.components.first { it === root.overlay })
assertSame(root.blocker, root.components.first { it === root.blocker })
assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content))
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay))
assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker))
}
fun `test root layout fills immediate children`() {
fun `test blocker is hidden by default`() {
val root = SessionRootPanel()
assertFalse(root.blocker.isVisible)
}
fun `test root layout fills all immediate children`() {
val root = SessionRootPanel().apply {
setSize(320, 180)
}
@@ -28,9 +35,10 @@ class SessionRootPanelTest : BasePlatformTestCase() {
assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds)
assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds)
assertEquals(Rectangle(0, 0, 320, 180), root.blocker.bounds)
}
fun `test root preferred size is max of immediate children`() {
fun `test root preferred size is max of content and overlay`() {
val root = SessionRootPanel().apply {
content.preferredSize = Dimension(300, 120)
overlay.preferredSize = Dimension(180, 220)
@@ -55,6 +63,35 @@ class SessionRootPanelTest : BasePlatformTestCase() {
assertTrue(child.laid)
}
fun `test setBlocked makes blocker visible and setBlocked false hides it`() {
val root = SessionRootPanel().apply { setSize(200, 100) }
root.doLayout()
assertFalse(root.blocker.isVisible)
root.setBlocked(true)
assertTrue(root.blocker.isVisible)
root.setBlocked(false)
assertFalse(root.blocker.isVisible)
}
fun `test blocker contains returns false when hidden`() {
val root = SessionRootPanel().apply { setSize(200, 100) }
root.doLayout()
root.setBlocked(false)
assertFalse(root.blocker.contains(50, 50))
}
fun `test blocker contains returns true when visible`() {
val root = SessionRootPanel().apply { setSize(200, 100) }
root.doLayout()
root.setBlocked(true)
assertTrue(root.blocker.contains(50, 50))
}
private class Probe : BorderLayoutPanel() {
var laid = false
@@ -0,0 +1,78 @@
package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloMigrationRpcApi
import ai.kilocode.rpc.dto.LegacyCleanupReportDto
import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationEventDto
import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto
import ai.kilocode.rpc.dto.LegacyMigrationStatusDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
/**
* Fake [KiloMigrationRpcApi] for testing.
*
* Supply [statusResult] and [detectResult] before calling check.
* Push events via [events] for migration flows.
* All suspend methods assert they are NOT called on the EDT.
*/
class FakeMigrationRpcApi : KiloMigrationRpcApi {
var statusResult: LegacyMigrationStatusDto? = null
var detectResult: LegacyMigrationDetectionDto = emptyDetection()
val events = MutableSharedFlow<LegacyMigrationEventDto>(extraBufferCapacity = 64)
val statusCalls = mutableListOf<Unit>()
val detectCalls = mutableListOf<Unit>()
val migrateCalls = mutableListOf<LegacyMigrationSelectionsDto>()
val skipCalls = mutableListOf<Unit>()
val finalizeCalls = mutableListOf<LegacyMigrationStatusDto>()
val cleanupCalls = mutableListOf<LegacyCleanupTargetsDto>()
override suspend fun status(): LegacyMigrationStatusDto? {
assertNotEdt("status")
statusCalls.add(Unit)
return statusResult
}
override suspend fun detect(): LegacyMigrationDetectionDto {
assertNotEdt("detect")
detectCalls.add(Unit)
return detectResult
}
override suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow<LegacyMigrationEventDto> {
assertNotEdt("migrate")
migrateCalls.add(selections)
return events
}
override suspend fun skip() {
assertNotEdt("skip")
skipCalls.add(Unit)
}
override suspend fun finalize(status: LegacyMigrationStatusDto) {
assertNotEdt("finalize")
finalizeCalls.add(status)
}
override suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto {
assertNotEdt("cleanup")
cleanupCalls.add(targets)
return LegacyCleanupReportDto(emptyList(), emptyList())
}
companion object {
fun emptyDetection() = LegacyMigrationDetectionDto(
providers = emptyList(),
mcpServers = emptyList(),
customModes = emptyList(),
sessions = emptyList(),
defaultModel = null,
settings = null,
hasData = false,
)
}
}
@@ -0,0 +1,47 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.LegacyCleanupReportDto
import ai.kilocode.rpc.dto.LegacyCleanupTargetsDto
import ai.kilocode.rpc.dto.LegacyMigrationDetectionDto
import ai.kilocode.rpc.dto.LegacyMigrationEventDto
import ai.kilocode.rpc.dto.LegacyMigrationSelectionsDto
import ai.kilocode.rpc.dto.LegacyMigrationStatusDto
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
import kotlinx.coroutines.flow.Flow
/**
* App-level RPC API for legacy migration operations.
*
* All operations are app-scoped. The backend implementation delegates to
* [ai.kilocode.backend.app.KiloBackendMigrationManager] using the active CLI connection.
*/
@Rpc
interface KiloMigrationRpcApi : RemoteApi<Unit> {
companion object {
suspend fun getInstance(): KiloMigrationRpcApi =
RemoteApiProviderService.resolve(remoteApiDescriptor<KiloMigrationRpcApi>())
}
/** Return the persisted migration status, or null if not yet set. */
suspend fun status(): LegacyMigrationStatusDto?
/** Detect legacy data and return a summary of what can be migrated. */
suspend fun detect(): LegacyMigrationDetectionDto
/** Run migration for the given selections, streaming progress events. */
suspend fun migrate(selections: LegacyMigrationSelectionsDto): Flow<LegacyMigrationEventDto>
/** Mark migration as skipped. */
suspend fun skip()
/** Mark migration as completed or completed with errors. */
suspend fun finalize(status: LegacyMigrationStatusDto)
/** Clean up legacy data after migration. */
suspend fun cleanup(targets: LegacyCleanupTargetsDto): LegacyCleanupReportDto
}
@@ -0,0 +1,233 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
@Serializable
enum class LegacyMigrationStatusDto {
@SerialName("completed") completed,
@SerialName("completed_with_errors") completed_with_errors,
@SerialName("skipped") skipped,
}
// ---------------------------------------------------------------------------
// Detection DTOs
// ---------------------------------------------------------------------------
@Serializable
data class MigrationProviderInfoDto(
val profileName: String,
val provider: String,
val model: String?,
val hasApiKey: Boolean,
val supported: Boolean,
val newProviderName: String?,
)
@Serializable
data class MigrationMcpServerInfoDto(
val name: String,
val type: String,
val disabled: Boolean?,
)
@Serializable
data class MigrationCustomModeInfoDto(
val name: String,
val slug: String,
val nativeSlug: String? = null,
)
@Serializable
data class MigrationSessionInfoDto(
val id: String,
val title: String,
val directory: String,
val time: Long,
)
@Serializable
data class MigrationDefaultModelInfoDto(
val provider: String,
val model: String,
)
@Serializable
data class LegacyAutocompleteSettingsDto(
val enableAutoTrigger: Boolean?,
val enableSmartInlineTaskKeybinding: Boolean?,
val enableChatAutocomplete: Boolean?,
)
@Serializable
data class LegacySettingsDto(
val autoApprovalEnabled: Boolean?,
val allowedCommands: List<String>?,
val deniedCommands: List<String>?,
val alwaysAllowReadOnly: Boolean?,
val alwaysAllowReadOnlyOutsideWorkspace: Boolean?,
val alwaysAllowWrite: Boolean?,
val alwaysAllowExecute: Boolean?,
val alwaysAllowMcp: Boolean?,
val alwaysAllowModeSwitch: Boolean?,
val alwaysAllowSubtasks: Boolean?,
val language: String?,
val autocomplete: LegacyAutocompleteSettingsDto?,
)
@Serializable
data class LegacyMigrationDetectionDto(
val providers: List<MigrationProviderInfoDto>,
val mcpServers: List<MigrationMcpServerInfoDto>,
val customModes: List<MigrationCustomModeInfoDto>,
val sessions: List<MigrationSessionInfoDto>,
val defaultModel: MigrationDefaultModelInfoDto?,
val settings: LegacySettingsDto?,
val hasData: Boolean,
)
// ---------------------------------------------------------------------------
// Selection DTOs
// ---------------------------------------------------------------------------
@Serializable
data class MigrationAutoApprovalSelectionsDto(
val commandRules: Boolean,
val readPermission: Boolean,
val writePermission: Boolean,
val executePermission: Boolean,
val mcpPermission: Boolean,
val taskPermission: Boolean,
)
@Serializable
data class MigrationSettingsSelectionsDto(
val autoApproval: MigrationAutoApprovalSelectionsDto,
val language: Boolean,
val autocomplete: Boolean,
)
@Serializable
data class MigrationSessionSelectionDto(
val id: String,
val force: Boolean = false,
)
@Serializable
data class LegacyMigrationSelectionsDto(
val providers: List<String>,
val mcpServers: List<String>,
val customModes: List<String>,
val sessions: List<MigrationSessionSelectionDto>,
val defaultModel: Boolean,
val settings: MigrationSettingsSelectionsDto,
)
// ---------------------------------------------------------------------------
// Result / Progress DTOs
// ---------------------------------------------------------------------------
@Serializable
enum class MigrationItemCategoryDto {
@SerialName("provider") provider,
@SerialName("mcpServer") mcpServer,
@SerialName("customMode") customMode,
@SerialName("session") session,
@SerialName("defaultModel") defaultModel,
@SerialName("settings") settings,
}
@Serializable
enum class MigrationItemStatusDto {
@SerialName("success") success,
@SerialName("warning") warning,
@SerialName("error") error,
}
@Serializable
data class LegacyMigrationResultItemDto(
val item: String,
val category: MigrationItemCategoryDto,
val status: MigrationItemStatusDto,
val message: String? = null,
)
@Serializable
enum class MigrationItemProgressStatusDto {
@SerialName("migrating") migrating,
@SerialName("success") success,
@SerialName("warning") warning,
@SerialName("error") error,
}
@Serializable
enum class MigrationSessionPhaseDto {
@SerialName("preparing") preparing,
@SerialName("storing") storing,
@SerialName("skipped") skipped,
@SerialName("done") done,
@SerialName("summary") summary,
@SerialName("error") error,
}
@Serializable
data class LegacyMigrationItemProgressDto(
val item: String,
val status: MigrationItemProgressStatusDto,
val message: String? = null,
)
@Serializable
data class LegacyMigrationSessionProgressDto(
val session: MigrationSessionInfoDto?,
val index: Int,
val total: Int,
val phase: MigrationSessionPhaseDto,
val error: String? = null,
)
// ---------------------------------------------------------------------------
// Cleanup DTOs
// ---------------------------------------------------------------------------
@Serializable
data class LegacyCleanupTargetsDto(
val providerProfiles: Boolean = false,
val mcpSettings: Boolean = false,
val customModes: Boolean = false,
val globalState: Boolean = false,
val taskHistory: Boolean = false,
)
@Serializable
data class LegacyCleanupReportDto(
val cleaned: List<String>,
val errors: List<String>,
)
// ---------------------------------------------------------------------------
// Migration event sealed class (streamed from migrate())
// ---------------------------------------------------------------------------
@Serializable
sealed class LegacyMigrationEventDto {
@Serializable
@SerialName("item")
data class Item(val progress: LegacyMigrationItemProgressDto) : LegacyMigrationEventDto()
@Serializable
@SerialName("session")
data class Session(val progress: LegacyMigrationSessionProgressDto) : LegacyMigrationEventDto()
@Serializable
@SerialName("complete")
data class Complete(val items: List<LegacyMigrationResultItemDto>) : LegacyMigrationEventDto()
@Serializable
@SerialName("error")
data class Error(val message: String) : LegacyMigrationEventDto()
}