Merge pull request #13521 from Kilo-Org/sturdy-reef

feat(jetbrains): improve worktree session UX
This commit is contained in:
Kirill Kalishev
2026-08-27 18:02:59 -04:00
committed by GitHub
50 changed files with 1650 additions and 98 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Show a branch-aware tip on the empty session screen: suggest running work in a worktree with a link when you're on a plain checkout, and confirm isolation when you're already in one.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Label the Kilo tool window's create buttons as + Session and + Worktree with a compact plus icon.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Add a toggle to show or hide the session list in a worktree editor tab. The choice is remembered per worktree, and while the list is hidden the toggle shows the session count or flags a session that needs your attention.
@@ -87,7 +87,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
val items = managedWorktrees(parseWorktreeList(res.stdout))
val alive = items.filter { it.main || Files.isDirectory(Path.of(it.path)) }
val store = worktreeNameStore(alive)
val state = store?.let { syncWorktreeState(it, worktreePaths(alive)) } ?: WorktreeState()
val state = store?.let { syncWorktreeState(it, worktreePaths(alive), livePaths(alive)) } ?: WorktreeState()
val named = overlayWorktreeNames(alive, state.names)
WorktreeListDto(orderWorktrees(named, state.worktreeOrder))
}
@@ -454,7 +454,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
val target = items.firstOrNull { samePath(it.path, path) && !it.main }
?: return@withContext RenameWorktreeResultDto(error = "Worktree not found")
return@withContext try {
val state = readWorktreeState(store).reconcile(worktreePaths(items))
val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items))
val names = state.names.toMutableMap()
names[target.path] = title
writeWorktreeState(store, state.copy(names = names))
@@ -478,7 +478,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
val target = items.firstOrNull { samePath(it.path, path) && !it.main }
?: return@withContext RenameWorktreeResultDto(error = "Worktree not found")
return@withContext try {
val state = readWorktreeState(store).reconcile(worktreePaths(items))
val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items))
val names = state.names.toMutableMap()
// Only adopt while the worktree is still default. A recorded name means the user (or a
// prior adoption) already titled it, so leave it untouched and report a no-op.
@@ -502,7 +502,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
val store = worktreeNameStore(items) ?: return@withContext false
return@withContext try {
val state = readWorktreeState(store)
writeWorktreeState(store, state.copy(worktreeOrder = paths).reconcile(worktreePaths(items)))
writeWorktreeState(store, state.copy(worktreeOrder = paths).reconcile(worktreePaths(items), livePaths(items)))
true
} catch (e: Exception) {
LOG.warn("worktree reorder failed: dir=$directory message=${e.message}", e)
@@ -510,6 +510,35 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
}
}
override suspend fun sessionList(directory: String): Boolean? =
withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext null
val items = managedWorktrees(parseWorktreeList(res.stdout))
val store = worktreeNameStore(items) ?: return@withContext null
val target = items.firstOrNull { samePath(it.path, directory) } ?: return@withContext null
readWorktreeState(store).sessionList[target.path]
}
override suspend fun setSessionList(directory: String, visible: Boolean): Boolean =
withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext false
val items = managedWorktrees(parseWorktreeList(res.stdout))
val store = worktreeNameStore(items) ?: return@withContext false
val target = items.firstOrNull { samePath(it.path, directory) } ?: return@withContext false
return@withContext try {
val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items))
writeWorktreeState(store, state.copy(sessionList = state.sessionList + (target.path to visible)))
true
} catch (e: Exception) {
LOG.warn("worktree session list state failed: dir=$directory message=${e.message}", e)
false
}
}
private data class Timed<T>(val time: Long, val value: T)
private fun runGit(base: Path, vararg args: String): CmdOut = runGit(base, args.toList())
@@ -741,17 +770,26 @@ private const val WORKTREE_NAMES_FILE = "jetbrains.json"
private data class WorktreeNamesFile(
val names: Map<String, String> = emptyMap(),
val worktreeOrder: List<String> = emptyList(),
val sessionList: Map<String, Boolean> = emptyMap(),
)
internal data class WorktreeState(
val names: Map<String, String> = emptyMap(),
val worktreeOrder: List<String> = emptyList(),
val sessionList: Map<String, Boolean> = emptyMap(),
) {
fun reconcile(paths: List<String>): WorktreeState {
/**
* Drops state for worktrees git no longer reports. Names and order cover linked worktrees only
* ([paths]), while the session list is also kept for the main working tree, which has a worktree
* editor of its own — hence the wider [live] set.
*/
fun reconcile(paths: List<String>, live: List<String>): WorktreeState {
val set = paths.toSet()
val all = live.toSet()
val order = (worktreeOrder.filter { it in set } + paths.filter { it !in worktreeOrder }).distinct()
val next = names.filterKeys { it in set }
return WorktreeState(next, order)
val visible = sessionList.filterKeys { it in all }
return WorktreeState(next, order, visible)
}
}
@@ -843,9 +881,13 @@ internal fun readWorktreeState(file: Path): WorktreeState {
return try {
val raw = Files.readString(file)
val element = json.parseToJsonElement(raw)
if (element is JsonObject && ("names" in element || "worktreeOrder" in element)) {
if (element is JsonObject && ("names" in element || "worktreeOrder" in element || "sessionList" in element)) {
val data = json.decodeFromJsonElement<WorktreeNamesFile>(element)
return WorktreeState(data.names.filterValues { it.isNotBlank() }, data.worktreeOrder.filter { it.isNotBlank() })
return WorktreeState(
data.names.filterValues { it.isNotBlank() },
data.worktreeOrder.filter { it.isNotBlank() },
data.sessionList.filterKeys { it.isNotBlank() },
)
}
val names = json.decodeFromJsonElement(codec, element).filterValues { it.isNotBlank() }
WorktreeState(names, names.keys.toList())
@@ -856,8 +898,8 @@ internal fun readWorktreeState(file: Path): WorktreeState {
}
internal fun writeWorktreeNames(file: Path, names: Map<String, String>) {
val order = readWorktreeState(file).worktreeOrder
writeWorktreeState(file, WorktreeState(names, order))
val state = readWorktreeState(file)
writeWorktreeState(file, state.copy(names = names))
}
internal fun writeWorktreeState(file: Path, state: WorktreeState) {
@@ -865,6 +907,7 @@ internal fun writeWorktreeState(file: Path, state: WorktreeState) {
val data = WorktreeNamesFile(
names = state.names.filterValues { it.isNotBlank() },
worktreeOrder = state.worktreeOrder.filter { it.isNotBlank() }.distinct(),
sessionList = state.sessionList.filterKeys { it.isNotBlank() },
)
val tmp = Files.createTempFile(file.parent, ".worktree-names", ".tmp")
try {
@@ -879,9 +922,9 @@ internal fun writeWorktreeState(file: Path, state: WorktreeState) {
}
}
private fun syncWorktreeState(file: Path, paths: List<String>): WorktreeState {
private fun syncWorktreeState(file: Path, paths: List<String>, live: List<String>): WorktreeState {
val state = readWorktreeState(file)
val next = state.reconcile(paths)
val next = state.reconcile(paths, live)
if (next == state) return next
try {
writeWorktreeState(file, next)
@@ -903,14 +946,19 @@ private fun removeWorktreeState(file: Path, path: String) {
val state = readWorktreeState(file)
val names = state.names.filterKeys { !samePath(it, path) }
val order = state.worktreeOrder.filter { !samePath(it, path) }
if (names == state.names && order == state.worktreeOrder) return
writeWorktreeState(file, state.copy(names = names, worktreeOrder = order))
val visible = state.sessionList.filterKeys { !samePath(it, path) }
if (names == state.names && order == state.worktreeOrder && visible == state.sessionList) return
writeWorktreeState(file, state.copy(names = names, worktreeOrder = order, sessionList = visible))
}
private fun worktreePaths(items: List<WorktreeDto>): List<String> {
return items.filter { !it.main }.map { it.path }
}
private fun livePaths(items: List<WorktreeDto>): List<String> {
return items.map { it.path }
}
private fun worktreeNameStore(items: List<WorktreeDto>): Path? {
val main = items.firstOrNull { it.main } ?: return null
return Path.of(main.path).normalize().resolve(".kilo").resolve(WORKTREE_NAMES_FILE)
@@ -384,6 +384,88 @@ class KiloWorktreeRpcApiImplTest {
assertEquals(listOf(second.path), state.worktreeOrder)
}
@Test
fun `session list visibility round trips beside names and order`() {
val file = repo.resolve(".kilo").resolve("jetbrains.json")
val path = "/repo/.kilo/worktrees/feature-x"
writeWorktreeState(file, WorktreeState(mapOf(path to "Feature"), listOf(path), mapOf(path to true, "/repo" to false)))
val state = readWorktreeState(file)
assertEquals(mapOf(path to true, "/repo" to false), state.sessionList)
assertEquals(mapOf(path to "Feature"), state.names)
assertEquals(listOf(path), state.worktreeOrder)
// A file that only ever recorded visibility must not be mistaken for the legacy name map.
Files.writeString(file, """{"sessionList":{"$path":true}}""")
assertEquals(mapOf(path to true), readWorktreeState(file).sessionList)
assertTrue(readWorktreeState(file).names.isEmpty())
}
@Test
fun `reconcile drops visibility for vanished worktrees but keeps the main tree`() {
val main = "/repo"
val live = "/repo/.kilo/worktrees/live"
val dead = "/repo/.kilo/worktrees/dead"
val state = WorktreeState(sessionList = mapOf(main to true, live to false, dead to true))
val next = state.reconcile(listOf(live), listOf(main, live))
assertEquals(mapOf(main to true, live to false), next.sessionList)
}
@Test
fun `session list visibility is unknown until set and then persists per worktree`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
val main = api.list(repo.toString()).worktrees.single { it.main }
assertNull(api.sessionList(created.path), "a fresh worktree has no stored choice")
assertNull(api.sessionList(main.path))
assertTrue(api.setSessionList(created.path, true))
assertTrue(api.setSessionList(main.path, false))
assertEquals(true, api.sessionList(created.path))
assertEquals(false, api.sessionList(main.path))
// The main working tree keeps its entry across a list, which reconciles the file.
api.list(repo.toString())
assertEquals(false, api.sessionList(main.path))
}
@Test
fun `create and list never record session list visibility`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
api.list(repo.toString())
assertTrue(readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).sessionList.isEmpty())
assertNull(api.sessionList(created.path))
}
@Test
fun `remove prunes session list visibility`() = runBlocking {
initRepo()
val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree)
val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree)
assertTrue(api.setSessionList(first.path, true))
assertTrue(api.setSessionList(second.path, true))
assertTrue(api.remove(repo.toString(), first.path, first.branch).ok)
assertEquals(
mapOf(second.path to true),
readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).sessionList,
)
}
@Test
fun `session list visibility reports nothing outside a repo`() = runBlocking {
assertNull(api.sessionList(repo.toString()))
assertFalse(api.setSessionList(repo.toString(), true))
}
@Test
fun `rename persists a custom worktree name and list overlays it`() = runBlocking {
initRepo()
@@ -19,6 +19,7 @@ import ai.kilocode.log.KiloLog
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.actionSystem.Separator
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
@@ -178,6 +179,7 @@ internal class KiloToolWindowSetupService(
val actions = listOfNotNull(
ActionManager.getInstance().getAction("Kilo.NewSession"),
ActionManager.getInstance().getAction("Kilo.NewWorktree"),
Separator.create(),
ActionManager.getInstance().getAction("Kilo.History"),
)
toolWindow.setTitleActions(actions)
@@ -0,0 +1,8 @@
package ai.kilocode.client.actions
import com.intellij.openapi.util.IconLoader
import javax.swing.Icon
internal object KiloActionIcons {
val add: Icon = IconLoader.getIcon("/icons/add-small.svg", KiloActionIcons::class.java)
}
@@ -5,15 +5,15 @@ import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.project.DumbAware
class NewSessionAction : AnAction(
KiloBundle.message("action.Kilo.NewSession.text"),
KiloBundle.message("action.Kilo.NewSession.description"),
AllIcons.General.Add,
KiloActionIcons.add,
), DumbAware {
override fun actionPerformed(e: AnActionEvent) {
Telemetry.send("New Session Clicked", mapOf("surface" to "tool_window"))
@@ -23,5 +23,9 @@ class NewSessionAction : AnAction(
override fun update(e: AnActionEvent) {
e.presentation.isVisible = e.getData(SidePanelKeys.MODE) != SidePanelMode.AGENT_MANAGER
e.presentation.isEnabled = e.getData(SessionManager.KEY) != null
e.presentation.icon = KiloActionIcons.add
if (!e.isFromActionToolbar) return
e.presentation.text = KiloBundle.message("action.Kilo.NewSession.toolbar")
e.presentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true)
}
}
@@ -2,23 +2,31 @@ package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.project.DumbAware
/**
* `+` toolbar action shown in Agent Manager mode. Opens the New Worktree dialog (New + Import tabs).
*/
class NewWorktreeAction : AnAction(), DumbAware {
class NewWorktreeAction : AnAction(
KiloBundle.message("action.Kilo.NewWorktree.text"),
KiloBundle.message("action.Kilo.NewWorktree.description"),
KiloActionIcons.add,
), DumbAware {
override fun getActionUpdateThread() = ActionUpdateThread.BGT
override fun update(e: AnActionEvent) {
e.presentation.isVisible = e.getData(SidePanelKeys.MODE) == SidePanelMode.AGENT_MANAGER
e.presentation.isEnabled = e.getData(SidePanelKeys.WORKTREE_PANEL) != null
e.presentation.icon = AllIcons.General.Add
e.presentation.icon = KiloActionIcons.add
if (!e.isFromActionToolbar) return
e.presentation.text = KiloBundle.message("action.Kilo.NewWorktree.toolbar")
e.presentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true)
}
override fun actionPerformed(e: AnActionEvent) {
@@ -152,4 +152,18 @@ class KiloWorktreeService internal constructor(
LOG.warn("worktree reorder failed for $directory", e)
false
}
suspend fun sessionList(directory: String): Boolean? = try {
call { sessionList(directory) }
} catch (e: Exception) {
LOG.warn("worktree session list state failed for $directory", e)
null
}
suspend fun setSessionList(directory: String, visible: Boolean): Boolean = try {
call { setSessionList(directory, visible) }
} catch (e: Exception) {
LOG.warn("worktree session list state write failed for $directory", e)
false
}
}
@@ -12,6 +12,21 @@ internal fun aggregateWorktreeActivity(
internal fun normalizeWorktreePath(path: String): String = normalize(path)
/**
* Activity a collapsed session list should surface: the top-ranked session that needs the user, from
* sessions other than [current] (whose state the open chat already shows) and not being deleted.
* Running work is not an attention state — it would only put a spinner in the header.
*/
internal fun attention(
activity: Map<String, SessionActivityKind>,
current: String? = null,
deleting: Set<String> = emptySet(),
): SessionActivityKind? = activity
.filterKeys { it != current && it !in deleting }
.values
.filter { it != SessionActivityKind.RUNNING }
.minByOrNull(::rank)
private fun normalize(path: String): String = path.trimEnd('/')
/**
@@ -11,7 +11,6 @@ import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistorySection
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
@@ -25,7 +24,11 @@ import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.client.ui.list.ActiveListSurface
import ai.kilocode.client.ui.list.ActiveListWeight
import ai.kilocode.client.ui.list.activeListToolWindowBackground
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.SessionDto
@@ -48,7 +51,6 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.wm.IdeFocusManager
@@ -60,6 +62,8 @@ import com.intellij.ui.OnePixelSplitter
import com.intellij.ui.SideBorder
import com.intellij.ui.awt.RelativePoint
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBInsets
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import org.jetbrains.plugins.terminal.TerminalToolWindowFactory
@@ -67,9 +71,11 @@ import java.awt.BorderLayout
import java.awt.Color
import java.awt.Frame
import javax.swing.JComponent
import javax.swing.Icon
import javax.swing.JSeparator
import javax.swing.ListSelectionModel
import javax.swing.JPanel
import javax.swing.SwingConstants
import javax.swing.border.Border
import javax.swing.event.ListDataEvent
import javax.swing.event.ListDataListener
@@ -82,12 +88,19 @@ class WorktreeSessionEditorPanel(
private val confirm: ((RelativePoint, ActiveListDeleteOptions, () -> Unit) -> Unit)? = null,
private val edit: ((RelativePoint, ActiveListEditOptions, (String) -> Unit) -> Unit)? = null,
private val openWorktree: ((String) -> Unit)? = null,
// Persisted per-worktree session list visibility; null means the user has not chosen yet.
private val load: ((Boolean?) -> Unit) -> Unit = { done ->
service<WorktreeSessionListVisibility>().load(worktree.directory, done)
},
private val save: (Boolean) -> Unit = { value ->
service<WorktreeSessionListVisibility>().save(worktree.directory, value)
},
) : BorderLayoutPanel(), Disposable, UiDataProvider {
private val add = NewAction()
private val rename = RenameAction()
private val delete = DeleteAction()
private val toggle = ToggleAction()
private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(toggle, add, rename, delete), true)
private val toggle = WorktreeSessionListToggle { flip() }
private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(add, rename, delete), true)
private val group = ActionManager.getInstance().getAction("Kilo.WorktreeSession.RowMenu") as? ActionGroup ?: DefaultActionGroup()
private val list = ActiveList(
KiloBundle.message("worktree.session.list.empty"),
@@ -112,6 +125,10 @@ class WorktreeSessionEditorPanel(
private var started = false
private var stats: WorktreeStatsDto? = null
private var pr: WorktreePrDto? = null
// Last known persisted visibility, and whether it is known at all: until the stored value arrives
// (or the user clicks) the list stays hidden and nothing is written.
private var pref: Boolean? = null
private var ready = false
init {
Disposer.register(parent, this)
@@ -122,11 +139,11 @@ class WorktreeSessionEditorPanel(
toolbar.component.isOpaque = false
syncToolbar()
list.installPopup(group)
splitter.firstComponent = list
// The list starts detached: a worktree opens with its sessions hidden until a stored choice or
// a second session says otherwise.
splitter.secondComponent = manager.component
addToTop(top())
addToCenter(splitter)
syncExpanded(KiloPluginSettings.getWorktreeSessionListExpanded())
bindModel()
manager.onPresent = { key -> key?.let { list.select(it) } }
manager.onListChanged = {
@@ -145,6 +162,7 @@ class WorktreeSessionEditorPanel(
}
bindStatus()
sync()
load(::restore)
}
override fun getBackground(): Color = activeListToolWindowBackground()
@@ -249,18 +267,88 @@ class WorktreeSessionEditorPanel(
private fun toolbarPanel(): JComponent {
return object : JPanel(BorderLayout()) {
override fun getBackground(): Color = activeListToolWindowBackground()
// The padding and the divider colour both come from the theme, so they are re-read on
// Look-and-Feel changes instead of being captured once at construction.
override fun updateUI() {
super.updateUI()
border = toolbarPanelBorder()
}
}.apply {
border = IdeBorderFactory.createBorder(SideBorder.RIGHT)
// The toggle is centred at its own height instead of tracking the strip, so its hover
// box keeps the strip's padding above and below it like a regular toolbar button.
add(
Stack.horizontal(gap = UiStyle.Gap.sm())
.next(toggle.align(HAlign.LEFT, VAlign.CENTER))
.next(JSeparator(SwingConstants.VERTICAL)),
BorderLayout.WEST,
)
add(toolbar.component, BorderLayout.CENTER)
}
}
@RequiresEdt
private fun toggleExpanded() {
syncExpanded(!expanded())
KiloPluginSettings.setWorktreeSessionListExpanded(expanded())
/**
* Standard horizontal-toolbar padding on the three free sides. The right edge stays flush so the
* divider still sits directly against the header content beside it.
*
* The theme insets arrive pre-scaled while [JBUI.Borders.empty] scales what it is handed, so the
* unscaled values are read back to avoid scaling twice on HiDPI.
*/
private fun toolbarPanelBorder(): Border {
val ins = (JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets() as? JBInsets)?.unscaled
return JBUI.Borders.merge(
JBUI.Borders.empty(ins?.top ?: STRIP_PAD, ins?.left ?: STRIP_PAD, ins?.bottom ?: STRIP_PAD, 0),
IdeBorderFactory.createBorder(SideBorder.RIGHT),
true,
)
}
@RequiresEdt
private fun flip() {
syncExpanded(!expanded())
ready = true
pref = expanded()
save(expanded())
}
/**
* Applies the stored visibility once the backend answers. A click that landed first already
* decided, so a late answer must not overwrite it.
*/
@RequiresEdt
private fun restore(value: Boolean?) {
if (ready) return
ready = true
pref = value
value?.let(::syncExpanded)
resolve()
}
/**
* Shows the list the first time this worktree holds more than one session. Only that promotion is
* persisted, so a worktree the user never touched keeps writing nothing while it has one session.
*/
@RequiresEdt
private fun resolve() {
if (!ready || pref != null || count() < AUTO) return
syncExpanded(true)
pref = true
save(true)
}
@RequiresEdt
private fun count(): Int {
val deleting = manager.deleting()
return controller.sessions().count { it.id !in deleting }
}
@RequiresEdt
private fun syncToggle() = toggle.update(
expanded(),
count(),
attention(manager.activity(), manager.currentKey(), manager.deleting()),
)
@RequiresEdt
private fun expanded(): Boolean = splitter.firstComponent != null
@@ -281,6 +369,7 @@ class WorktreeSessionEditorPanel(
val changed = expanded() != value
if (changed) splitter.firstComponent = if (value) list else null
syncToolbar()
syncToggle()
if (!changed) return
splitter.revalidate()
splitter.repaint()
@@ -413,6 +502,8 @@ class WorktreeSessionEditorPanel(
// the list hold that key until a refresh brings it in.
val shown = if (pending) SessionHost.NEW else key
list.update(rows, shown?.let { ActiveListSelection.Key(it) } ?: ActiveListSelection.Preserve)
resolve()
syncToggle()
}
@RequiresEdt
@@ -481,20 +572,6 @@ class WorktreeSessionEditorPanel(
manager.onListChanged = null
}
private inner class ToggleAction : AnAction() {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
val expanded = expanded()
e.presentation.text = KiloBundle.message(if (expanded) "worktree.session.list.collapse" else "worktree.session.list.expand")
e.presentation.icon = if (expanded) LAYOUT_FULL else LAYOUT_PARTIAL
}
override fun actionPerformed(e: AnActionEvent) {
toggleExpanded()
}
}
private inner class NewAction : AnAction(
KiloBundle.message("worktree.session.new.action"),
null,
@@ -552,8 +629,12 @@ class WorktreeSessionEditorPanel(
private companion object {
private val LOG = KiloLog.create(WorktreeSessionEditorPanel::class.java)
private val TERMINAL_DIR = Key.create<String>("kilo.worktree.terminal.dir")
val LAYOUT_PARTIAL: Icon = IconLoader.getIcon("/icons/layout-left-partial.svg", WorktreeSessionEditorPanel::class.java)
val LAYOUT_FULL: Icon = IconLoader.getIcon("/icons/layout-left-full.svg", WorktreeSessionEditorPanel::class.java)
/** Sessions a worktree must hold before the list shows itself without being asked. */
private const val AUTO = 2
/** Classic UI leaves the toolbar inset key unset; matches the platform's own fallback. */
private const val STRIP_PAD = 2
}
private inner class SessionRow(
@@ -0,0 +1,165 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.ActionEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.AbstractAction
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.KeyStroke
/**
* Shows/hides the worktree editor's session list. Sits left of the toolbar and, while the list is
* hidden, carries the session count in a neutral badge — or the activity icon of a background session
* that needs the user, so a pending question is visible with the list collapsed.
*
* Not a [ai.kilocode.client.ui.HoverIcon]: that hosts a single icon and pins icon-only buttons to
* 24x24, which would clip the trailing badge. The hover treatment is reproduced here instead.
*/
internal class WorktreeSessionListToggle(
private val onClick: () -> Unit,
) : JPanel(null) {
private val glyph = JBLabel(LAYOUT_PARTIAL)
private val badge = JBLabel()
private val row = Stack.horizontal(gap = UiStyle.Gap.sm()).next(glyph).next(badge)
private var state = State()
private var over = false
init {
isOpaque = false
isFocusable = true
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
badge.isVisible = false
label(KiloBundle.message("worktree.session.list.expand"))
add(row)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(event: MouseEvent) = hover(true)
override fun mouseExited(event: MouseEvent) = hover(false)
override fun mouseClicked(event: MouseEvent) {
if (isEnabled) onClick()
}
})
// Keep the toggle reachable without a mouse, the way the toolbar action it replaced was.
val action = object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
if (isEnabled) onClick()
}
}
getInputMap(JComponent.WHEN_FOCUSED).apply {
put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ACTIVATE)
put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTIVATE)
}
actionMap.put(ACTIVATE, action)
}
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.empty(JBUI.CurrentTheme.Toolbar.toolbarButtonInsets())
}
@RequiresEdt
fun update(expanded: Boolean, count: Int, kind: SessionActivityKind?) {
val next = State(expanded, count, kind)
if (next == state) return
state = next
glyph.icon = if (expanded) LAYOUT_FULL else LAYOUT_PARTIAL
val icon = badge(next)
badge.icon = icon
badge.isVisible = icon != null
label(KiloBundle.message(if (expanded) "worktree.session.list.collapse" else "worktree.session.list.expand"))
revalidate()
repaint()
}
override fun getPreferredSize(): Dimension {
val ins = insets
val size = row.preferredSize
return Dimension(size.width + ins.left + ins.right, JBUI.scale(24))
}
override fun getMinimumSize(): Dimension = preferredSize
override fun getMaximumSize(): Dimension = preferredSize
override fun doLayout() {
val ins = insets
val w = maxOf(0, width - ins.left - ins.right)
val h = maxOf(0, height - ins.top - ins.bottom)
val size = row.preferredSize
val rowW = minOf(size.width, w)
val rowH = minOf(size.height, h)
row.setBounds(ins.left, ins.top + (h - rowH) / 2, rowW, rowH)
}
override fun paintComponent(g: Graphics) {
if (over && isEnabled) paintHover(g)
super.paintComponent(g)
}
/**
* Trailing badge while the list is hidden: a session that needs the user outranks the count, and a
* lone session needs no count at all.
*/
private fun badge(state: State): Icon? = when {
state.expanded -> null
state.kind != null -> state.kind.icon()
state.count >= COUNT -> FilledBadgeIcon(state.count.toString(), UiStyle.Badge.Secondary)
else -> null
}
private fun label(text: String) {
toolTipText = text
// getAccessibleContext() lazily creates the context; the field itself is still null here.
getAccessibleContext().accessibleName = text
}
private fun hover(value: Boolean) {
if (over == value) return
over = value
repaint()
}
private fun paintHover(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = UiStyle.Colors.actionHoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
private data class State(
val expanded: Boolean = false,
val count: Int = 0,
val kind: SessionActivityKind? = null,
)
private companion object {
const val ACTIVATE = "kilo.worktree.sessionList.activate"
/** A single session is the norm, so the count only earns a badge from the second one on. */
const val COUNT = 2
private val OWNER = WorktreeSessionListToggle::class.java
val LAYOUT_PARTIAL: Icon = IconLoader.getIcon("/icons/layout-left-partial.svg", OWNER)
val LAYOUT_FULL: Icon = IconLoader.getIcon("/icons/layout-left-full.svg", OWNER)
}
}
@@ -0,0 +1,29 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.util.edt
import ai.kilocode.log.KiloLog
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Service(Service.Level.APP)
internal class WorktreeSessionListVisibility(private val cs: CoroutineScope) {
fun load(path: String, done: (Boolean?) -> Unit) {
cs.launch {
val value = service<KiloWorktreeService>().sessionList(path)
edt { done(value) }
}
}
fun save(path: String, visible: Boolean) {
cs.launch {
val ok = service<KiloWorktreeService>().setSessionList(path, visible)
if (!ok) LOG.warn("worktree session list state write was not persisted: path=$path visible=$visible")
}
}
private companion object {
val LOG = KiloLog.create(WorktreeSessionListVisibility::class.java)
}
}
@@ -7,7 +7,6 @@ object KiloPluginSettings {
private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext"
private const val SHOW_APPROVAL_REASON_KEY = "kilo.session.showApprovalReason"
private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded"
private const val WORKTREE_SESSION_LIST_EXPANDED_KEY = "kilo.worktree.sessionListExpanded"
fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false)
@@ -48,14 +47,4 @@ object KiloPluginSettings {
internal fun unsetPermissionRulesExpanded() {
PropertiesComponent.getInstance().unsetValue(PERMISSION_RULES_EXPANDED_KEY)
}
fun getWorktreeSessionListExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(WORKTREE_SESSION_LIST_EXPANDED_KEY, true)
fun setWorktreeSessionListExpanded(value: Boolean) {
PropertiesComponent.getInstance().setValue(WORKTREE_SESSION_LIST_EXPANDED_KEY, value.toString())
}
internal fun unsetWorktreeSessionListExpanded() {
PropertiesComponent.getInstance().unsetValue(WORKTREE_SESSION_LIST_EXPANDED_KEY)
}
}
@@ -101,6 +101,7 @@ abstract class SessionHost(
activity = { activity() },
titles = { titles() },
timers = timers,
newWorktree = if (supportsNewWorktree) ({ newWorktree() }) else null,
)
@RequiresEdt
@@ -52,6 +52,7 @@ interface SessionManager {
history = { showHistory() },
activity = { activity() },
titles = { titles() },
newWorktree = if (supportsNewWorktree) ({ newWorktree() }) else null,
)
fun openSession(session: SessionDto) {
@@ -72,6 +72,7 @@ import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.ModelLimitDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.PromptDto
@@ -213,6 +214,12 @@ class SessionUi(
private lateinit var load: LoadingPanel
private lateinit var migrationWizard: MigrationWizardPanel
private var empty: EmptySessionPanel? = null
/**
* Last observed branch/worktree status. Retained so an empty panel created after the fetch shows
* its tip immediately instead of waiting for the next refresh.
*/
private var branch: BranchStatusDto? = null
private var modalFocus: (() -> JComponent)? = null
private var style = SessionEditorStyle.current()
private val selection = SessionSelection()
@@ -247,8 +254,8 @@ class SessionUi(
dock?.let {
syncDock()
refreshBranchChanges()
refreshBranch()
}
refreshBranch()
loaded?.let(::finishOpen)
}
@@ -613,6 +620,7 @@ class SessionUi(
val panel = manager?.emptyPanel(this, controller)
?: EmptySessionPanel(this, controller, controller.recents(), timers = timers)
empty = panel
panel.setBranch(branch)
scroll.show(panel.view)
}
@@ -1023,7 +1031,9 @@ class SessionUi(
* split mode — so the PR always matches the branch checked out in this session's directory.
*/
private fun refreshBranch() {
val dock = dock ?: return
// Also feeds the empty panel's branch/worktree tip, so this runs even on surfaces without a
// dock (worktree editor tabs). Read-only surfaces show no tip and get no fetch.
if (readonly) return
branchJob?.cancel()
branchJob = cs.launch {
val status = runCatching { service<KiloWorktreeService>().branchStatus(workspace.directory) }
@@ -1034,7 +1044,9 @@ class SessionUi(
}
withContext(Dispatchers.Main) {
if (disposed || project.isDisposed) return@withContext
dock.setBranch(status)
branch = status
dock?.setBranch(status)
empty?.setBranch(status)
}
}
}
@@ -14,12 +14,16 @@ import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.util.text.HtmlChunk
import com.intellij.openapi.util.text.StringUtil
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.Centerizer
@@ -39,6 +43,8 @@ import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.SwingUtilities
import javax.swing.event.HyperlinkEvent
import javax.swing.event.HyperlinkListener
/**
* Empty-session panel.
@@ -56,6 +62,7 @@ class EmptySessionPanel(
private val browse: (String) -> Unit = BrowserUtil::browse,
private val timers: UiTimerSource = UiTimers,
private val minimal: Boolean = false,
private val newWorktree: (() -> Unit)? = null,
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
private var style = SessionEditorStyle.current()
val view: Align = align(
@@ -71,6 +78,12 @@ class EmptySessionPanel(
addActionListener { history() }
}
/**
* Branch/worktree status behind the tip under the logo. Null until [setBranch] delivers it, so
* the first paint falls back to the generic welcome rather than flashing a wrong claim.
*/
private var branch: BranchStatusDto? = null
private val feedback = EmptySessionFeedback(browse)
private val logo = JBLabel(
@@ -79,10 +92,21 @@ class EmptySessionPanel(
horizontalAlignment = JBLabel.CENTER
}
private val welcomeLabel = JBLabel(welcomeHtml()).apply {
/**
* Text is set by [syncTip], which picks the generic welcome or a branch/worktree tip. Copyable
* mode swaps the label's internals for an HTML pane, which is what makes the inline worktree
* link clickable; auto-wrapping must be set first so that pane's CSS allows line breaks.
*/
private val welcomeLabel = object : JBLabel() {
override fun createHyperlinkListener() = HyperlinkListener { e ->
if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@HyperlinkListener
if (e.description == WORKTREE_HREF) newWorktree?.invoke()
}
}.apply {
foreground = SessionUiStyle.Text.Secondary.foreground()
horizontalAlignment = JBLabel.CENTER
setAllowAutoWrapping(true)
setCopyable(true)
}
private val description = object : BorderLayoutPanel() {
@@ -101,6 +125,13 @@ class EmptySessionPanel(
add(welcomeLabel, BorderLayout.CENTER)
}
private val descriptionSlot = description.align(HAlign.CENTER, VAlign.CENTER)
private val header = BorderLayoutPanel(0, UiStyle.Gap.pad()).apply {
isOpaque = false
add(logo, BorderLayout.NORTH)
}
init {
Disposer.register(parent, this)
Disposer.register(this, feedback)
@@ -119,12 +150,6 @@ class EmptySessionPanel(
val gap = UiStyle.Gap.pad()
layout = BorderLayout(0, gap)
val header = BorderLayoutPanel(0, gap).apply {
isOpaque = false
add(logo, BorderLayout.NORTH)
if (!minimal) add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
}
val actions = Stack.vertical(gap = UiStyle.Gap.lg())
if (!minimal) actions.next(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL))
actions.next(Centerizer(feedback.button, Centerizer.TYPE.HORIZONTAL))
@@ -136,6 +161,68 @@ class EmptySessionPanel(
add(header, BorderLayout.NORTH)
if (!minimal && recent.hasSessions()) add(recent, BorderLayout.CENTER)
add(south, BorderLayout.SOUTH)
syncTip()
}
/**
* Applies the branch/worktree status behind the tip under the logo. Called again whenever the
* status is refreshed, so it must stay a no-op when nothing changed.
*/
@RequiresEdt
fun setBranch(status: BranchStatusDto?) {
if (branch == status) return
branch = status
syncTip()
}
/**
* The tip under the logo, as an HTML fragment: an isolation reminder on a worktree, a nudge
* towards one on a plain checkout, where "run it in a worktree" is an inline link. Null when the
* status is unknown, git is missing, or no branch is checked out — the generic welcome covers
* those rather than asserting something wrong.
*/
private fun tip(): String? {
val status = branch ?: return null
if (status.availability == GhAvailability.GIT_MISSING) return null
val name = name()?.let { XmlStringUtil.escapeString(it) }
if (status.worktree) {
return name?.let { KiloBundle.message("session.empty.worktree", it) }
?: KiloBundle.message("session.empty.worktree.unknown")
}
if (name == null) return null
return KiloBundle.message("session.empty.branch", name, worktreePhrase())
}
/**
* "run it in a worktree" as a link, or as plain text on surfaces that cannot open the flow, so
* the sentence reads the same either way.
*/
private fun worktreePhrase(): String {
val phrase = KiloBundle.message("session.empty.branch.link")
if (newWorktree == null) return XmlStringUtil.escapeString(phrase)
return HtmlChunk.link(WORKTREE_HREF, phrase).toString()
}
/** Branch name trimmed to fit the fixed-width description, or null when there is no branch. */
private fun name(): String? {
val value = branch?.branch?.trim().orEmpty()
if (value.isEmpty() || value == DETACHED) return null
return StringUtil.shortenTextWithEllipsis(value, BRANCH_MAX, 0, true)
}
@RequiresEdt
private fun syncTip() {
val tip = tip()
welcomeLabel.text = centeredHtml(
tip ?: XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome")),
)
// Minimal surfaces (worktree/subagent editor tabs) skip the generic blurb but still want a
// state-specific tip, so the slot is attached on demand instead of once at construction.
val described = tip != null || !minimal
if (described && descriptionSlot.parent == null) header.add(descriptionSlot, BorderLayout.CENTER)
if (!described && descriptionSlot.parent != null) header.remove(descriptionSlot)
revalidate()
repaint()
}
internal fun recentCount() = recent.count()
@@ -184,6 +271,16 @@ class EmptySessionPanel(
internal fun explanationText() = KiloBundle.message("session.empty.welcome")
/** The tip as the user reads it, with the inline link's markup stripped. */
internal fun tipText() = tip()?.let { StringUtil.removeHtmlTags(it) }
/** The plain text currently under the logo: the state-specific tip, or the generic welcome. */
internal fun descriptionText() = tipText() ?: KiloBundle.message("session.empty.welcome")
internal fun worktreeLinked() = tip()?.contains("href=\"$WORKTREE_HREF\"") == true
internal fun worktreeHref() = WORKTREE_HREF
internal fun welcomeLabelAlignment() = welcomeLabel.horizontalAlignment
internal fun descriptionPreferredSize() = description.preferredSize
@@ -272,11 +369,19 @@ class EmptySessionPanel(
repaint()
}
private fun welcomeHtml() = XmlStringUtil.wrapInHtml(
"<div style='text-align:center'>${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}</div>"
/** [body] must already be escaped or generated HTML — this only wraps and centers it. */
private fun centeredHtml(body: String) = XmlStringUtil.wrapInHtml(
"<div style='text-align:center'>$body</div>"
)
private companion object {
const val ACTIVITY_MS = 3_000
/** Keeps a long branch name from wrapping the fixed-width description into a wall of text. */
const val BRANCH_MAX = 28
const val DETACHED = "(detached)"
/** Href of the inline worktree link; matched in the label's hyperlink listener. */
const val WORKTREE_HREF = "worktree"
}
}
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 4H8V7H11V8H8V11H7V8H4V7H7V4Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 202 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7 4H8V7H11V8H8V11H7V8H4V7H7V4Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 202 B

@@ -42,6 +42,10 @@ session.connection.warning.config=Configuration warnings
notification.group.kilo=Kilo Code
session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.
session.empty.branch=You''re working directly on {0}. Start a task, or {1} to keep changes isolated.
session.empty.branch.link=run it in a worktree
session.empty.worktree=You''re in an isolated worktree on {0}. Work freely — your main checkout stays untouched.
session.empty.worktree.unknown=You're in an isolated worktree. Work freely — your main checkout stays untouched.
session.account.balance=Balance: {0}
session.account.switcher=Switch account
session.empty.loading=Loading...
@@ -472,8 +476,10 @@ action.Kilo.Settings.text=Settings
action.Kilo.Settings.description=Kilo Code settings
action.Kilo.NewSession.text=New Session
action.Kilo.NewSession.description=Start a new Kilo session
action.Kilo.NewSession.toolbar=Session
action.Kilo.NewWorktree.text=New Worktree
action.Kilo.NewWorktree.description=Create a new git worktree
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=History
action.Kilo.History.description=Show session history
action.Kilo.ShowProfile.text=Profile
@@ -17,6 +17,10 @@ session.connection.unsupported.options=الخيار 1: افتح المشروع
session.connection.warning.config=تحذيرات التكوين
session.empty.welcome=Kilo Code هو مساعد برمجة بالذكاء الاصطناعي. اطلب منه بناء ميزات أو إصلاح أخطاء أو شرح قاعدة الكود.
session.empty.branch=تعمل مباشرةً على {0}. ابدأ مهمة، أو {1} لإبقاء التغييرات معزولة.
session.empty.branch.link=شغّلها في worktree
session.empty.worktree=أنت في worktree معزول على {0}. اعمل بحرية — تبقى نسختك الرئيسية دون تغيير.
session.empty.worktree.unknown=أنت في worktree معزول. اعمل بحرية — تبقى نسختك الرئيسية دون تغيير.
session.empty.loading=جاري التحميل…
session.empty.recent=الحديثة
session.showHistory=عرض السجل
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=الإعدادات
action.Kilo.Settings.description=إعدادات Kilo Code
action.Kilo.NewSession.text=جلسة جديدة
action.Kilo.NewSession.description=بدء جلسة Kilo جديدة
action.Kilo.NewSession.toolbar=جلسة
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=السجل
action.Kilo.History.description=عرض سجل الجلسات
action.Kilo.SendPrompt.text=إرسال الطلب
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Opcija 1: Otvorite projekat u kontejneru
session.connection.warning.config=Upozorenja konfiguracije
session.empty.welcome=Kilo Code je AI asistent za kodiranje. Zatražite od njega da gradi funkcije, ispravlja greške ili objašnjava vašu bazu koda.
session.empty.branch=Radite direktno na {0}. Započnite zadatak ili {1} da izmjene ostanu izolovane.
session.empty.branch.link=pokrenite ga u worktree-u
session.empty.worktree=Nalazite se u izolovanom worktree-u na {0}. Radite slobodno — vaš glavni checkout ostaje nepromijenjen.
session.empty.worktree.unknown=Nalazite se u izolovanom worktree-u. Radite slobodno — vaš glavni checkout ostaje nepromijenjen.
session.empty.loading=Učitavanje…
session.empty.recent=NEDAVNO
session.showHistory=Prikaži historiju
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Postavke
action.Kilo.Settings.description=Postavke Kilo Code
action.Kilo.NewSession.text=Nova sesija
action.Kilo.NewSession.description=Pokrenite novu Kilo sesiju
action.Kilo.NewSession.toolbar=Sesija
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Historija
action.Kilo.History.description=Prikaži historiju sesija
action.Kilo.SendPrompt.text=Pošalji upit
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Mulighed 1: Åbn projektet i containeren
session.connection.warning.config=Konfigurationsadvarsler
session.empty.welcome=Kilo Code er en AI-kodningsassistent. Bed den om at bygge funktioner, rette fejl eller forklare din kodebase.
session.empty.branch=Du arbejder direkte på {0}. Start en opgave, eller {1} for at holde ændringer isoleret.
session.empty.branch.link=kør den i et worktree
session.empty.worktree=Du er i et isoleret worktree på {0}. Arbejd frit — dit primære checkout forbliver urørt.
session.empty.worktree.unknown=Du er i et isoleret worktree. Arbejd frit — dit primære checkout forbliver urørt.
session.empty.loading=Indlæser…
session.empty.recent=SENESTE
session.showHistory=Vis historik
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Indstillinger
action.Kilo.Settings.description=Kilo Code-indstillinger
action.Kilo.NewSession.text=Ny session
action.Kilo.NewSession.description=Start en ny Kilo-session
action.Kilo.NewSession.toolbar=Session
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Historik
action.Kilo.History.description=Vis sessionshistorik
action.Kilo.SendPrompt.text=Send prompt
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Option 1: Öffnen Sie das Projekt im Cont
session.connection.warning.config=Konfigurationswarnungen
session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Funktionen zu erstellen, Fehler zu beheben oder Ihre Codebasis zu erklären.
session.empty.branch=Sie arbeiten direkt auf {0}. Starten Sie eine Aufgabe oder {1}, um Änderungen isoliert zu halten.
session.empty.branch.link=führen Sie sie in einem Worktree aus
session.empty.worktree=Sie sind in einem isolierten Worktree auf {0}. Sie können hier gefahrlos experimentieren — Ihr Haupt-Checkout bleibt unberührt.
session.empty.worktree.unknown=Sie sind in einem isolierten Worktree. Sie können hier gefahrlos experimentieren — Ihr Haupt-Checkout bleibt unberührt.
session.empty.loading=Wird geladen…
session.empty.recent=ZULETZT
session.showHistory=Verlauf anzeigen
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Einstellungen
action.Kilo.Settings.description=Kilo Code Einstellungen
action.Kilo.NewSession.text=Neue Sitzung
action.Kilo.NewSession.description=Neue Kilo-Sitzung starten
action.Kilo.NewSession.toolbar=Sitzung
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Verlauf
action.Kilo.History.description=Sitzungsverlauf anzeigen
action.Kilo.SendPrompt.text=Anfrage senden
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Opción 1: Abre el proyecto en el contene
session.connection.warning.config=Advertencias de configuración
session.empty.welcome=Kilo Code es un asistente de codificación con IA. Pida que construya funciones, corrija errores o explique su base de código.
session.empty.branch=Estás trabajando directamente en {0}. Inicia una tarea o {1} para mantener los cambios aislados.
session.empty.branch.link=ejecútala en un worktree
session.empty.worktree=Estás en un worktree aislado en {0}. Trabaja con libertad — tu checkout principal no se toca.
session.empty.worktree.unknown=Estás en un worktree aislado. Trabaja con libertad — tu checkout principal no se toca.
session.empty.loading=Cargando…
session.empty.recent=RECIENTE
session.showHistory=Mostrar historial
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Configuración
action.Kilo.Settings.description=Configuración de Kilo Code
action.Kilo.NewSession.text=Nueva sesión
action.Kilo.NewSession.description=Iniciar una nueva sesión de Kilo
action.Kilo.NewSession.toolbar=Sesión
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Historial
action.Kilo.History.description=Mostrar el historial de sesiones
action.Kilo.SendPrompt.text=Enviar mensaje
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Option 1 : ouvrez le projet dans le conte
session.connection.warning.config=Avertissements de configuration
session.empty.welcome=Kilo Code est un assistant de codage IA. Demandez-lui de créer des fonctionnalités, corriger des bugs ou expliquer votre base de code.
session.empty.branch=Vous travaillez directement sur {0}. Lancez une tâche ou {1} pour isoler vos modifications.
session.empty.branch.link=exécutez-la dans un worktree
session.empty.worktree=Vous êtes dans un worktree isolé sur {0}. Travaillez librement — votre checkout principal reste intact.
session.empty.worktree.unknown=Vous êtes dans un worktree isolé. Travaillez librement — votre checkout principal reste intact.
session.empty.loading=Chargement…
session.empty.recent=RÉCENT
session.showHistory=Afficher l'historique
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Paramètres
action.Kilo.Settings.description=Paramètres de Kilo Code
action.Kilo.NewSession.text=Nouvelle session
action.Kilo.NewSession.description=Démarrer une nouvelle session Kilo
action.Kilo.NewSession.toolbar=Session
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Historique
action.Kilo.History.description=Afficher l'historique des sessions
action.Kilo.SendPrompt.text=Envoyer l'invite
@@ -17,6 +17,10 @@ session.connection.unsupported.options=オプション1: JetBrains Gateway を
session.connection.warning.config=設定の警告
session.empty.welcome=Kilo CodeはAIコーディングアシスタントです。機能の作成、バグの修正、またはコードベースの説明を依頼できます。
session.empty.branch={0} で直接作業しています。タスクを開始するか、{1}すると変更を隔離できます。
session.empty.branch.link=worktree で実行
session.empty.worktree={0} の独立した worktree にいます。メインのチェックアウトには影響しないので自由に作業できます。
session.empty.worktree.unknown=独立した worktree にいます。メインのチェックアウトには影響しないので自由に作業できます。
session.empty.loading=読み込み中…
session.empty.recent=最近
session.showHistory=履歴を表示
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=設定
action.Kilo.Settings.description=Kilo Codeの設定
action.Kilo.NewSession.text=新しいセッション
action.Kilo.NewSession.description=新しいKiloセッションを開始
action.Kilo.NewSession.toolbar=セッション
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=履歴
action.Kilo.History.description=セッション履歴を表示
action.Kilo.SendPrompt.text=プロンプトを送信
@@ -17,6 +17,10 @@ session.connection.unsupported.options=옵션 1: JetBrains Gateway로 컨테이
session.connection.warning.config=구성 경고
session.empty.welcome=Kilo Code는 AI 코딩 어시스턴트입니다. 기능 구축, 버그 수정, 코드베이스 설명을 요청하세요.
session.empty.branch={0}에서 직접 작업하고 있습니다. 작업을 시작하거나 {1}하여 변경 사항을 분리하세요.
session.empty.branch.link=worktree에서 실행
session.empty.worktree={0}의 격리된 worktree에 있습니다. 메인 체크아웃은 영향을 받지 않으니 자유롭게 작업하세요.
session.empty.worktree.unknown=격리된 worktree에 있습니다. 메인 체크아웃은 영향을 받지 않으니 자유롭게 작업하세요.
session.empty.loading=로딩 중…
session.empty.recent=최근
session.showHistory=기록 보기
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=설정
action.Kilo.Settings.description=Kilo Code 설정
action.Kilo.NewSession.text=새 세션
action.Kilo.NewSession.description=새 Kilo 세션 시작
action.Kilo.NewSession.toolbar=세션
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=기록
action.Kilo.History.description=세션 기록 표시
action.Kilo.SendPrompt.text=프롬프트 전송
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Optie 1: Open het project in de container
session.connection.warning.config=Configuratiewaarschuwingen
session.empty.welcome=Kilo Code is een AI-codeerassistent. Vraag het om functies te bouwen, bugs te repareren of uw codebase uit te leggen.
session.empty.branch=Je werkt direct op {0}. Start een taak of {1} om wijzigingen geïsoleerd te houden.
session.empty.branch.link=voer hem uit in een worktree
session.empty.worktree=Je zit in een geïsoleerde worktree op {0}. Werk vrij — je hoofdcheckout blijft ongemoeid.
session.empty.worktree.unknown=Je zit in een geïsoleerde worktree. Werk vrij — je hoofdcheckout blijft ongemoeid.
session.empty.loading=Laden…
session.empty.recent=RECENT
session.showHistory=Geschiedenis weergeven
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Instellingen
action.Kilo.Settings.description=Kilo Code-instellingen
action.Kilo.NewSession.text=Nieuwe sessie
action.Kilo.NewSession.description=Een nieuwe Kilo-sessie starten
action.Kilo.NewSession.toolbar=Sessie
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Geschiedenis
action.Kilo.History.description=Sessiegeschiedenis weergeven
action.Kilo.SendPrompt.text=Prompt verzenden
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Alternativ 1: Åpne prosjektet i containe
session.connection.warning.config=Konfigurasjonsadvarsler
session.empty.welcome=Kilo Code er en AI-kodingsassistent. Be den om å bygge funksjoner, fikse feil eller forklare kodebasen din.
session.empty.branch=Du jobber direkte på {0}. Start en oppgave, eller {1} for å holde endringene isolert.
session.empty.branch.link=kjør den i et worktree
session.empty.worktree=Du er i et isolert worktree på {0}. Jobb fritt — hovedutsjekkingen din er urørt.
session.empty.worktree.unknown=Du er i et isolert worktree. Jobb fritt — hovedutsjekkingen din er urørt.
session.empty.loading=Laster…
session.empty.recent=NYLIGE
session.showHistory=Vis historikk
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Innstillinger
action.Kilo.Settings.description=Kilo Code-innstillinger
action.Kilo.NewSession.text=Ny økt
action.Kilo.NewSession.description=Start en ny Kilo-økt
action.Kilo.NewSession.toolbar=Økt
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Historikk
action.Kilo.History.description=Vis økthistorikk
action.Kilo.SendPrompt.text=Send forespørsel
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Opcja 1: Otwórz projekt w kontenerze lub
session.connection.warning.config=Ostrzeżenia konfiguracji
session.empty.welcome=Kilo Code to asystent kodowania AI. Poproś go o tworzenie funkcji, naprawianie błędów lub wyjaśnianie bazy kodu.
session.empty.branch=Pracujesz bezpośrednio na {0}. Rozpocznij zadanie lub {1}, aby odizolować zmiany.
session.empty.branch.link=uruchom je w worktree
session.empty.worktree=Jesteś w odizolowanym worktree na {0}. Pracuj swobodnie — twój główny checkout pozostaje nietknięty.
session.empty.worktree.unknown=Jesteś w odizolowanym worktree. Pracuj swobodnie — twój główny checkout pozostaje nietknięty.
session.empty.loading=Ładowanie…
session.empty.recent=OSTATNIE
session.showHistory=Pokaż historię
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Ustawienia
action.Kilo.Settings.description=Ustawienia Kilo Code
action.Kilo.NewSession.text=Nowa sesja
action.Kilo.NewSession.description=Rozpocznij nową sesję Kilo
action.Kilo.NewSession.toolbar=Sesja
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Historia
action.Kilo.History.description=Pokaż historię sesji
action.Kilo.SendPrompt.text=Wyślij monit
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Opção 1: Abra o projeto no contêiner o
session.connection.warning.config=Avisos de configuração
session.empty.welcome=Kilo Code é um assistente de codificação com IA. Peça que construa funcionalidades, corrija bugs ou explique sua base de código.
session.empty.branch=Você está trabalhando diretamente em {0}. Inicie uma tarefa ou {1} para manter as alterações isoladas.
session.empty.branch.link=execute-a em um worktree
session.empty.worktree=Você está em um worktree isolado em {0}. Trabalhe livremente — seu checkout principal permanece intacto.
session.empty.worktree.unknown=Você está em um worktree isolado. Trabalhe livremente — seu checkout principal permanece intacto.
session.empty.loading=Carregando…
session.empty.recent=RECENTE
session.showHistory=Mostrar histórico
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Configurações
action.Kilo.Settings.description=Configurações do Kilo Code
action.Kilo.NewSession.text=Nova sessão
action.Kilo.NewSession.description=Iniciar uma nova sessão do Kilo
action.Kilo.NewSession.toolbar=Sessão
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Histórico
action.Kilo.History.description=Exibir histórico de sessões
action.Kilo.SendPrompt.text=Enviar prompt
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Вариант 1: откройте про
session.connection.warning.config=Предупреждения конфигурации
session.empty.welcome=Kilo Code — это AI-ассистент по программированию. Попросите его разработать функции, исправить ошибки или объяснить кодовую базу.
session.empty.branch=Вы работаете напрямую в {0}. Начните задачу или {1}, чтобы изолировать изменения.
session.empty.branch.link=запустите её в worktree
session.empty.worktree=Вы в изолированном worktree на {0}. Работайте свободно — основная рабочая копия не затрагивается.
session.empty.worktree.unknown=Вы в изолированном worktree. Работайте свободно — основная рабочая копия не затрагивается.
session.empty.loading=Загрузка…
session.empty.recent=НЕДАВНИЕ
session.showHistory=Показать историю
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Настройки
action.Kilo.Settings.description=Настройки Kilo Code
action.Kilo.NewSession.text=Новая сессия
action.Kilo.NewSession.description=Запустить новую сессию Kilo
action.Kilo.NewSession.toolbar=Сессия
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=История
action.Kilo.History.description=Показать историю сессий
action.Kilo.SendPrompt.text=Отправить запрос
@@ -17,6 +17,10 @@ session.connection.unsupported.options=ตัวเลือกที่ 1: เ
session.connection.warning.config=คำเตือนการกำหนดค่า
session.empty.welcome=Kilo Code คือผู้ช่วยเขียนโค้ด AI ขอให้สร้างฟีเจอร์แก้สันข้อผิดพลาด หรืออธิบายโค้ดเบสของคุณ
session.empty.branch=คุณกำลังทำงานบน {0} โดยตรง เริ่มงานใหม่ หรือ {1} เพื่อแยกการเปลี่ยนแปลงออกจากกัน
session.empty.branch.link=รันใน worktree
session.empty.worktree=คุณอยู่ใน worktree ที่แยกอิสระบน {0} ทำงานได้อย่างอิสระ — เช็คเอาต์หลักของคุณไม่ถูกแตะต้อง
session.empty.worktree.unknown=คุณอยู่ใน worktree ที่แยกอิสระ ทำงานได้อย่างอิสระ — เช็คเอาต์หลักของคุณไม่ถูกแตะต้อง
session.empty.loading=กำลังโหลด…
session.empty.recent=ล่าสุด
session.showHistory=แสดงประวัติ
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=การตั้งค่า
action.Kilo.Settings.description=การตั้งค่า Kilo Code
action.Kilo.NewSession.text=เซสชันใหม่
action.Kilo.NewSession.description=เริ่มเซสชัน Kilo ใหม่
action.Kilo.NewSession.toolbar=เซสชัน
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=ประวัติ
action.Kilo.History.description=แสดงประวัติเซสชัน
action.Kilo.SendPrompt.text=ส่งคำขอ
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Seçenek 1: Projeyi JetBrains Gateway ile
session.connection.warning.config=Yapılandırma uyarıları
session.empty.welcome=Kilo Code, bir yapay zeka kodlama asistanıdır. Özellik oluşturmasını, hata düzeltirlmesi veya kod tabanınızı açıklamasını isteyin.
session.empty.branch=Doğrudan {0} üzerinde çalışıyorsunuz. Bir görev başlatın ya da değişiklikleri izole tutmak için {1}.
session.empty.branch.link=bir worktree içinde çalıştırın
session.empty.worktree={0} üzerinde izole bir worktree içindesiniz. Rahatça çalışın — ana checkout''unuz olduğu gibi kalır.
session.empty.worktree.unknown=İzole bir worktree içindesiniz. Rahatça çalışın — ana checkout'unuz olduğu gibi kalır.
session.empty.loading=Yükleniyor…
session.empty.recent=SON
session.showHistory=Geçmişi göster
@@ -168,6 +172,8 @@ action.Kilo.Settings.text=Ayarlar
action.Kilo.Settings.description=Kilo Code ayarları
action.Kilo.NewSession.text=Yeni oturum
action.Kilo.NewSession.description=Yeni bir Kilo oturumu başlat
action.Kilo.NewSession.toolbar=Oturum
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Geçmiş
action.Kilo.History.description=Oturum geçmişini göster
action.Kilo.SendPrompt.text=İstem gönder
@@ -17,6 +17,10 @@ session.connection.unsupported.options=Варіант 1: відкрийте пр
session.connection.warning.config=Попередження конфігурації
session.empty.welcome=Kilo Code — це AI-асистент для програмування. Попросіть його створити функції, виправити помилки або пояснити ваш код.
session.empty.branch=Ви працюєте безпосередньо в {0}. Почніть завдання або {1}, щоб ізолювати зміни.
session.empty.branch.link=запустіть його в worktree
session.empty.worktree=Ви в ізольованому worktree на {0}. Працюйте вільно — основна робоча копія залишається незмінною.
session.empty.worktree.unknown=Ви в ізольованому worktree. Працюйте вільно — основна робоча копія залишається незмінною.
session.empty.loading=Завантаження…
session.empty.recent=НЕДАВНІ
session.showHistory=Показати історію
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=Налаштування
action.Kilo.Settings.description=Налаштування Kilo Code
action.Kilo.NewSession.text=Нова сесія
action.Kilo.NewSession.description=Розпочати нову сесію Kilo
action.Kilo.NewSession.toolbar=Сесія
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=Історія
action.Kilo.History.description=Показати історію сесій
action.Kilo.SendPrompt.text=Надіслати запит
@@ -17,6 +17,10 @@ session.connection.unsupported.options=选项 1:使用 JetBrains Gateway 在
session.connection.warning.config=配置警告
session.empty.welcome=Kilo Code 是一个 AI 编程助手。可请它构建功能、修复错误或解释您的代码库。
session.empty.branch=你正在 {0} 上直接工作。开始一个任务,或{1}以隔离改动。
session.empty.branch.link=在 worktree 中运行
session.empty.worktree=你在 {0} 的独立 worktree 中。可以放心工作 — 主工作副本不会受到影响。
session.empty.worktree.unknown=你在独立的 worktree 中。可以放心工作 — 主工作副本不会受到影响。
session.empty.loading=加载中…
session.empty.recent=最近
session.showHistory=显示历史
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=设置
action.Kilo.Settings.description=Kilo Code 设置
action.Kilo.NewSession.text=新建会话
action.Kilo.NewSession.description=开始新的 Kilo 会话
action.Kilo.NewSession.toolbar=会话
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=历史
action.Kilo.History.description=显示会话历史
action.Kilo.SendPrompt.text=发送提示
@@ -17,6 +17,10 @@ session.connection.unsupported.options=選項 1:使用 JetBrains Gateway 在
session.connection.warning.config=設定警告
session.empty.welcome=Kilo Code 是 AI 程式輔助。可請它建置功能、修復錯誤或解釋您的程式程式庫。
session.empty.branch=你正在 {0} 上直接工作。開始一個任務,或{1}以隔離變更。
session.empty.branch.link=在 worktree 中執行
session.empty.worktree=你在 {0} 的獨立 worktree 中。可以放心工作 — 主工作副本不會受到影響。
session.empty.worktree.unknown=你在獨立的 worktree 中。可以放心工作 — 主工作副本不會受到影響。
session.empty.loading=載入中…
session.empty.recent=最近
session.showHistory=顯示歷史
@@ -163,6 +167,8 @@ action.Kilo.Settings.text=設定
action.Kilo.Settings.description=Kilo Code 設定
action.Kilo.NewSession.text=新建工作階段
action.Kilo.NewSession.description=開始新的 Kilo 工作階段
action.Kilo.NewSession.toolbar=會話
action.Kilo.NewWorktree.toolbar=Worktree
action.Kilo.History.text=歷史
action.Kilo.History.description=顯示工作階段歷史
action.Kilo.SendPrompt.text=發送提示
@@ -1,7 +1,11 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.session.SessionRef
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionUiKind
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.Presentation
@@ -29,19 +33,61 @@ class NewSessionActionTest : BasePlatformTestCase() {
val action = NewSessionAction()
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
ActionUtil.updateAction(action, AnActionEvent.createFromDataContext("", presentation) { null })
ActionUtil.updateAction(action, event(action, presentation = presentation))
assertFalse(presentation.isEnabled)
}
fun `test toolbar presentation uses short text`() {
val manager = FakeManager()
val action = NewSessionAction()
val event = event(action, manager = manager, ui = ActionUiKind.TOOLBAR)
ActionUtil.updateAction(action, event)
assertEquals("Session", event.presentation.text)
assertEquals(true, event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR))
assertSame(KiloActionIcons.add, event.presentation.icon)
}
fun `test non-toolbar presentation keeps full text`() {
val manager = FakeManager()
val action = NewSessionAction()
val event = event(action, manager = manager)
ActionUtil.updateAction(action, event)
assertEquals("New Session", event.presentation.text)
assertNull(event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR))
}
fun `test action hidden on agent manager tab`() {
val manager = FakeManager()
val action = NewSessionAction()
val event = event(action, manager = manager, mode = SidePanelMode.AGENT_MANAGER)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isVisible)
}
private fun event(manager: SessionManager): AnActionEvent {
val presentation = Presentation().apply {
copyFrom(NewSessionAction().templatePresentation)
}
return event(NewSessionAction(), manager = manager)
}
private fun event(
action: NewSessionAction,
manager: SessionManager? = null,
mode: SidePanelMode? = null,
ui: ActionUiKind = ActionUiKind.NONE,
presentation: Presentation = Presentation().apply { copyFrom(action.templatePresentation) },
): AnActionEvent {
val context = DataContext { id ->
if (SessionManager.KEY.`is`(id)) manager else null
if (SessionManager.KEY.`is`(id)) return@DataContext manager
if (SidePanelKeys.MODE.`is`(id)) return@DataContext mode
null
}
return AnActionEvent.createFromDataContext("", presentation, context)
return AnActionEvent.createEvent(context, presentation, ActionPlaces.TOOLWINDOW_TITLE, ui, null)
}
private class FakeManager : SessionManager {
@@ -0,0 +1,75 @@
package ai.kilocode.client.actions
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionUiKind
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.Presentation
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@Suppress("UnstableApiUsage")
class NewWorktreeActionTest : BasePlatformTestCase() {
fun `test toolbar presentation uses short text`() {
val action = NewWorktreeAction()
val event = event(action, mode = SidePanelMode.AGENT_MANAGER, ui = ActionUiKind.TOOLBAR)
ActionUtil.updateAction(action, event)
assertEquals("Worktree", event.presentation.text)
assertEquals(true, event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR))
assertSame(KiloActionIcons.add, event.presentation.icon)
}
fun `test non-toolbar presentation keeps full text`() {
val action = NewWorktreeAction()
val event = event(action, mode = SidePanelMode.AGENT_MANAGER)
ActionUtil.updateAction(action, event)
assertEquals("New Worktree", event.presentation.text)
assertNull(event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR))
}
fun `test action visible on agent manager tab`() {
val action = NewWorktreeAction()
val event = event(action, mode = SidePanelMode.AGENT_MANAGER)
ActionUtil.updateAction(action, event)
assertTrue(event.presentation.isVisible)
}
fun `test action hidden on chat tab`() {
val action = NewWorktreeAction()
val event = event(action, mode = SidePanelMode.CHAT)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isVisible)
}
fun `test action disabled without worktree panel`() {
val action = NewWorktreeAction()
val event = event(action, mode = SidePanelMode.AGENT_MANAGER)
ActionUtil.updateAction(action, event)
assertFalse(event.presentation.isEnabled)
}
private fun event(
action: NewWorktreeAction,
mode: SidePanelMode? = null,
ui: ActionUiKind = ActionUiKind.NONE,
): AnActionEvent {
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
val context = DataContext { id ->
if (SidePanelKeys.MODE.`is`(id)) return@DataContext mode
null
}
return AnActionEvent.createEvent(context, presentation, ActionPlaces.TOOLWINDOW_TITLE, ui, null)
}
}
@@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.SessionActivityDto
import ai.kilocode.rpc.dto.SessionActivityKindDto
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class WorktreeActivityTest {
@Test
@@ -46,6 +47,32 @@ class WorktreeActivityTest {
assertEquals(SessionActivityKind.QUESTION, questionOverError["/repo/wt"])
}
@Test
fun `attention ranks waiting sessions and ignores running work`() {
val kinds = mapOf(
"ses_run" to SessionActivityKind.RUNNING,
"ses_error" to SessionActivityKind.ERROR,
"ses_question" to SessionActivityKind.QUESTION,
"ses_permission" to SessionActivityKind.PERMISSION,
)
assertEquals(SessionActivityKind.PERMISSION, attention(kinds))
assertEquals(SessionActivityKind.QUESTION, attention(kinds, current = "ses_permission"))
assertNull(attention(mapOf("ses_run" to SessionActivityKind.RUNNING)))
assertNull(attention(emptyMap()))
}
@Test
fun `attention skips the current and deleting sessions`() {
val kinds = mapOf(
"ses_open" to SessionActivityKind.QUESTION,
"ses_gone" to SessionActivityKind.PERMISSION,
)
assertNull(attention(kinds, current = "ses_open", deleting = setOf("ses_gone")))
assertEquals(SessionActivityKind.QUESTION, attention(kinds, deleting = setOf("ses_gone")))
}
@Test
fun `normalizes trailing slashes`() {
val result = aggregateWorktreeActivity(mapOf(
@@ -15,7 +15,8 @@ import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.pumpEdt
import ai.kilocode.client.testing.fire
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.activeListSectionTitle
@@ -37,16 +38,22 @@ import com.intellij.ui.OnePixelSplitter
import com.intellij.ui.SearchTextField
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.UIUtil
import java.awt.Color
import com.intellij.util.ui.JBUI
import java.awt.Container
import javax.swing.JPanel
import javax.swing.JSeparator
import javax.swing.SwingConstants
import java.awt.Point
import java.awt.event.ActionEvent
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseEvent
import javax.swing.Icon
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.KeyStroke
@@ -61,29 +68,44 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
private lateinit var controller: WorktreeSessionListController
private lateinit var manager: FakeManager
private lateinit var panel: WorktreeSessionEditorPanel
private val saves = mutableListOf<Boolean>()
private val workspace = Workspace(DIR, kotlinx.coroutines.flow.MutableStateFlow(ai.kilocode.rpc.dto.KiloWorkspaceStateDto(ai.kilocode.rpc.dto.KiloWorkspaceStatusDto.READY)), {}, {})
override fun setUp() {
super.setUp()
KiloPluginSettings.unsetWorktreeSessionListExpanded()
coroutines = TestCoroutines()
rpc = FakeSessionRpcApi()
sessions = KiloSessionService(project, coroutines.scope, rpc)
controller = WorktreeSessionListController(sessions, DIR, coroutines.scope)
manager = FakeManager()
panel = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, confirm = { _, _, run -> run() }) }
// Most tests inspect the session list, so the shared panel starts from a stored "visible".
panel = view(stored = true)
}
override fun tearDown() {
try {
TestDialogManager.setTestDialog(TestDialog.DEFAULT)
KiloPluginSettings.unsetWorktreeSessionListExpanded()
coroutines.close(::pump)
} finally {
super.tearDown()
}
}
private fun view(
stored: Boolean? = null,
load: ((Boolean?) -> Unit) -> Unit = { done -> done(stored) },
): WorktreeSessionEditorPanel = edt {
WorktreeSessionEditorPanel(
testRootDisposable,
manager,
controller,
workspace,
confirm = { _, _, run -> run() },
load = load,
save = { saves += it },
)
}
fun `test panel builds splitter toolbar list and right component`() {
edt {
val splitter = UIUtil.findComponentOfType(panel, OnePixelSplitter::class.java)!!
@@ -92,7 +114,8 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertEquals(0.25f, splitter.proportion, 0.01f)
assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java))
val buttons = components(panel).filterIsInstance<ActionButton>().mapNotNull { it.presentation.text }
assertTrue(buttons.contains("Hide sessions"))
assertEquals("Hide sessions", toggle().toolTipText)
assertFalse(buttons.contains("Hide sessions"))
assertTrue(buttons.contains("New session"))
assertTrue(buttons.contains("Rename session"))
assertTrue(buttons.contains("Delete session"))
@@ -152,38 +175,230 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertTrue(shown("Rename session"))
assertTrue(shown("Delete session"))
toggle().click()
click(toggle())
assertNull(splitter.firstComponent)
assertEquals("Show sessions", toggle().toolTipText)
assertTrue(shown("New session"))
assertFalse(shown("Rename session"))
assertFalse(shown("Delete session"))
assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java))
assertFalse(KiloPluginSettings.getWorktreeSessionListExpanded())
assertEquals(listOf(false), saves)
toggle().click()
click(toggle())
assertSame(list, splitter.firstComponent)
assertEquals("Hide sessions", toggle().toolTipText)
assertTrue(shown("Rename session"))
assertTrue(shown("Delete session"))
assertTrue(KiloPluginSettings.getWorktreeSessionListExpanded())
assertEquals(listOf(false, true), saves)
}
}
fun `test collapsed state persists for new panels`() {
edt { toggle().click() }
val view = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, confirm = { _, _, run -> run() }) }
fun `test stored visibility decides whether the session list is shown`() {
val off = view(stored = false)
val on = view(stored = true)
edt {
val splitter = UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!
assertNull(splitter.firstComponent)
assertTrue(shown(view, "New session"))
assertFalse(shown(view, "Rename session"))
assertFalse(shown(view, "Delete session"))
assertNull(UIUtil.findComponentOfType(off, OnePixelSplitter::class.java)!!.firstComponent)
assertNotNull(UIUtil.findComponentOfType(on, OnePixelSplitter::class.java)!!.firstComponent)
assertFalse(shown(off, "Rename session"))
assertTrue(shown(on, "Rename session"))
assertTrue(saves.isEmpty())
}
}
fun `test a worktree without a stored choice stays hidden for a single session`() {
val view = view()
rpc.listed += session("ses_1", 1.0)
edt { controller.reload() }
flush()
edt {
assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
assertNull(badge(view))
assertTrue(saves.isEmpty())
}
}
fun `test a second session shows the list once and stores that choice`() {
val view = view()
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
edt {
assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
assertEquals(listOf(true), saves)
}
rpc.listed += session("ses_3", 3.0)
edt { controller.reload() }
flush()
assertEquals(listOf(true), saves)
}
fun `test a stored hidden list survives extra sessions`() {
val view = view(stored = false)
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
edt {
assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
assertTrue(saves.isEmpty())
}
}
fun `test a click before the stored value arrives wins`() {
var answer: ((Boolean?) -> Unit)? = null
val view = view(load = { done -> answer = done })
edt { click(toggle(view)) }
edt { answer!!(false) }
edt {
assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
assertEquals(listOf(true), saves)
}
}
fun `test sessions arriving before the stored answer never force the list open`() {
var answer: ((Boolean?) -> Unit)? = null
val view = view(load = { done -> answer = done })
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
edt { assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) }
assertTrue(saves.isEmpty())
edt { answer!!(false) }
edt {
assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
assertTrue(saves.isEmpty())
}
}
fun `test an empty stored answer promotes a worktree that already holds two sessions`() {
var answer: ((Boolean?) -> Unit)? = null
val view = view(load = { done -> answer = done })
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
edt { answer!!(null) }
edt {
assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent)
assertEquals(listOf(true), saves)
}
}
fun `test hidden toggle badges the session count from the second session on`() {
val view = view(stored = false)
rpc.listed += session("ses_1", 1.0)
edt { controller.reload() }
flush()
assertNull(edt { badge(view) })
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
val icon = edt { badge(view) as FilledBadgeIcon }
assertEquals("2", icon.text)
assertSame(UiStyle.Badge.Secondary, icon.style)
}
fun `test shown session list drops the count badge`() {
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
assertNull(edt { badge() })
}
fun `test hidden toggle surfaces a session waiting on the user instead of the count`() {
val view = view(stored = false)
manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.QUESTION)
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) })
}
fun `test toolbar strip pads three sides and keeps the divider flush`() {
val standard = JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets()!!
val ins = edt { strip().insets }
assertEquals(standard.top, ins.top)
assertEquals(standard.left, ins.left)
assertEquals(standard.bottom, ins.bottom)
// Right carries the divider line only: padding there would push it off the header content.
assertEquals(1, ins.right)
}
fun `test a vertical separator follows the toggle in the toolbar strip`() {
val kids = edt { row().components.toList() }
assertEquals(2, kids.size)
assertTrue(SwingUtilities.isDescendingFrom(toggle(), kids.first()))
assertEquals(SwingConstants.VERTICAL, (kids.last() as JSeparator).orientation)
}
fun `test toggle keeps its own height inside a taller toolbar strip`() {
edt {
val strip = strip()
strip.setSize(JBUI.scale(400), JBUI.scale(48))
lay(strip)
}
// Tracking the strip height would push the hover box against the strip's top and bottom.
assertEquals(JBUI.scale(24), edt { toggle().height })
assertTrue(edt { toggle().y } > 0)
}
fun `test attention badge returns after showing and hiding the list again`() {
val view = view(stored = false)
manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.QUESTION)
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) })
edt { click(toggle(view)) }
assertNull(edt { badge(view) })
edt { click(toggle(view)) }
assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) })
}
fun `test hidden toggle keeps the count while sessions only run`() {
val view = view(stored = false)
manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.RUNNING)
rpc.listed += session("ses_1", 1.0)
rpc.listed += session("ses_2", 2.0)
edt { controller.reload() }
flush()
assertEquals("2", edt { (badge(view) as FilledBadgeIcon).text })
}
fun `test editor kind delegates preferred focus to panel`() {
edt {
assertSame(UIUtil.findComponentOfType(panel, JBList::class.java), panel.preferredFocus())
@@ -550,9 +765,26 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
return out
}
private fun toggle(root: java.awt.Component = panel): ActionButton {
val actions = setOf("New session", "Rename session", "Delete session")
return components(root).filterIsInstance<ActionButton>().first { it.presentation.text !in actions }
private fun toggle(root: java.awt.Component = panel): WorktreeSessionListToggle =
components(root).filterIsInstance<WorktreeSessionListToggle>().single()
/** The row holding the toggle and its separator, left of the toolbar. */
private fun row(): JPanel = edt { toggle().parent.parent as JPanel }
/** The strip panel holding that row plus the action toolbar. */
private fun strip(): JPanel = edt { row().parent as JPanel }
/** Lays a detached subtree out top-down, since validate() is a no-op without a peer. */
private fun lay(root: java.awt.Component) {
if (root !is Container) return
root.doLayout()
root.components.forEach(::lay)
}
/** Trailing badge icon of the toggle, or null while it carries none. */
private fun badge(root: java.awt.Component = panel): Icon? {
val label = components(toggle(root)).filterIsInstance<JBLabel>().getOrNull(1) ?: return null
return if (label.isVisible) label.icon else null
}
private fun shown(text: String): Boolean = shown(panel, text)
@@ -0,0 +1,101 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.util.edtWait
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.UIUtil
import java.awt.Component
import java.awt.Container
import java.awt.Point
import java.awt.event.ActionEvent
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.KeyStroke
class WorktreeSessionListToggleTest : BasePlatformTestCase() {
private var clicks = 0
private lateinit var toggle: WorktreeSessionListToggle
override fun setUp() {
super.setUp()
clicks = 0
toggle = edt { WorktreeSessionListToggle { clicks++ } }
}
fun `test the badge follows count expansion and activity without rebuilding labels`() {
val glyph = labels()[0]
val badge = labels()[1]
assertFalse(edt { badge.isVisible })
assertEquals("Show sessions", edt { toggle.toolTipText })
edt { toggle.update(expanded = false, count = 1, kind = null) }
assertFalse(edt { badge.isVisible })
edt { toggle.update(expanded = false, count = 3, kind = null) }
assertEquals("3", edt { (badge.icon as FilledBadgeIcon).text })
edt { toggle.update(expanded = false, count = 3, kind = SessionActivityKind.PERMISSION) }
assertSame(SessionActivityKind.PERMISSION.icon(), edt { badge.icon })
edt { toggle.update(expanded = true, count = 3, kind = SessionActivityKind.PERMISSION) }
assertFalse(edt { badge.isVisible })
assertEquals("Hide sessions", edt { toggle.toolTipText })
// The retained tree is mutated in place, never rebuilt.
assertSame(glyph, labels()[0])
assertSame(badge, labels()[1])
assertEquals(2, labels().size)
}
fun `test the glyph swaps between the collapsed and expanded icons`() {
val glyph = labels()[0]
val collapsed = edt { glyph.icon }
edt { toggle.update(expanded = true, count = 0, kind = null) }
val expanded = edt { glyph.icon }
assertNotSame(collapsed, expanded)
edt { toggle.update(expanded = false, count = 0, kind = null) }
assertSame(collapsed, edt { glyph.icon })
}
fun `test mouse and keyboard both activate the toggle`() {
edt {
toggle.size = toggle.preferredSize
val point = Point(toggle.width / 2, toggle.height / 2)
toggle.dispatchEvent(
MouseEvent(toggle, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, point.x, point.y, 1, false, MouseEvent.BUTTON1),
)
}
UIUtil.dispatchAllInvocationEvents()
assertEquals(1, clicks)
edt {
val key = toggle.getInputMap(JComponent.WHEN_FOCUSED).get(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0))
toggle.actionMap.get(key).actionPerformed(ActionEvent(toggle, ActionEvent.ACTION_PERFORMED, ""))
}
assertEquals(2, clicks)
}
private fun labels(): List<JBLabel> = edt { components(toggle).filterIsInstance<JBLabel>() }
private fun components(root: Component): List<Component> {
val out = mutableListOf<Component>()
fun visit(item: Component) {
out += item
if (item is Container) item.components.forEach { visit(it) }
}
visit(root)
return out
}
private fun <T> edt(block: () -> T): T = edtWait(block)
}
@@ -0,0 +1,98 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.testing.FakeWorktreeRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.pumpEdt
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.testFramework.replaceService
@Suppress("UnstableApiUsage")
class WorktreeSessionListVisibilityTest : BasePlatformTestCase() {
private lateinit var coroutines: TestCoroutines
private lateinit var rpc: FakeWorktreeRpcApi
private lateinit var visibility: WorktreeSessionListVisibility
override fun setUp() {
super.setUp()
coroutines = TestCoroutines()
rpc = FakeWorktreeRpcApi()
ApplicationManager.getApplication()
.replaceService(KiloWorktreeService::class.java, KiloWorktreeService(coroutines.scope, rpc), testRootDisposable)
visibility = WorktreeSessionListVisibility(coroutines.scope)
}
override fun tearDown() {
try {
coroutines.close(::pump)
} finally {
super.tearDown()
}
}
fun `test load answers with the stored value on the edt`() {
rpc.sessionLists[DIR] = true
val values = mutableListOf<Boolean?>()
val threads = mutableListOf<Boolean>()
visibility.load(DIR) { value ->
values += value
threads += ApplicationManager.getApplication().isDispatchThread
}
drain()
assertEquals(listOf(DIR), rpc.sessionListReads.toList())
assertEquals(listOf<Boolean?>(true), values)
assertEquals(listOf(true), threads)
}
fun `test load answers with nothing for a worktree without a stored choice`() {
val values = mutableListOf<Boolean?>()
visibility.load(DIR) { values += it }
drain()
assertEquals(listOf<Boolean?>(null), values)
}
fun `test load degrades to nothing when the backend fails`() {
rpc.sessionLists[DIR] = true
rpc.sessionListThrows = RuntimeException("backend down")
val values = mutableListOf<Boolean?>()
visibility.load(DIR) { values += it }
drain()
assertEquals(listOf<Boolean?>(null), values)
}
fun `test save records the visibility a later load reads back`() {
visibility.save(DIR, false)
drain()
assertEquals(listOf(DIR to false), rpc.sessionListWrites.toList())
val values = mutableListOf<Boolean?>()
visibility.load(DIR) { values += it }
drain()
assertEquals(listOf<Boolean?>(false), values)
}
fun `test save survives a failing backend`() {
rpc.sessionListThrows = RuntimeException("backend down")
visibility.save(DIR, true)
drain()
assertEquals(listOf(DIR to true), rpc.sessionListWrites.toList())
}
private fun drain() = coroutines.drain(::pump)
private fun pump() = pumpEdt()
private companion object {
const val DIR = "/repo/.kilo/worktrees/feature-x"
}
}
@@ -0,0 +1,100 @@
package ai.kilocode.client.plugin
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.text.MessageFormat
import java.util.Locale
import java.util.Properties
/**
* Guards the localized empty-session tip and toolbar labels.
*
* The tip keys carry `{0}`/`{1}`, so they go through [MessageFormat], where a lone apostrophe
* silently swallows the surrounding text and `''` collapses to one. Translations are easy to get
* wrong here, so every locale is formatted for real rather than compared as a raw string.
*/
class KiloBundleLocaleTest : BasePlatformTestCase() {
fun `test parameterized tips format cleanly in every locale`() {
for (locale in LOCALES) {
val props = load(locale)
val branch = props.getProperty("session.empty.branch")
assertNotNull("$locale: missing session.empty.branch", branch)
assertEscaped(locale, "session.empty.branch", branch!!)
val rendered = format(branch, "main", "LINK_PHRASE")
assertTrue("$locale: branch tip dropped the branch name -> $rendered", rendered.contains("main"))
assertTrue("$locale: branch tip dropped the link -> $rendered", rendered.contains("LINK_PHRASE"))
assertClean(locale, "session.empty.branch", rendered)
val worktree = props.getProperty("session.empty.worktree")
assertNotNull("$locale: missing session.empty.worktree", worktree)
assertEscaped(locale, "session.empty.worktree", worktree!!)
val tree = format(worktree, "feature/x")
assertTrue("$locale: worktree tip dropped the branch name -> $tree", tree.contains("feature/x"))
assertClean(locale, "session.empty.worktree", tree)
}
}
fun `test plain keys are present and carry no placeholders`() {
for (locale in LOCALES) {
val props = load(locale)
for (key in PLAIN) {
val value = props.getProperty(key)
assertNotNull("$locale: missing $key", value)
assertTrue("$locale: $key is blank", value!!.isNotBlank())
assertFalse("$locale: $key should not contain a placeholder -> $value", value.contains("{0}"))
assertFalse(
"$locale: $key has no placeholders so apostrophes must not be doubled -> $value",
value.contains("''"),
)
}
}
}
private fun format(pattern: String, vararg args: String) =
MessageFormat(pattern, Locale.ROOT).format(args)
/**
* Every apostrophe in a MessageFormat pattern must be doubled. A lone one opens a quoted run
* that silently eats itself (and any placeholder it spans), which formatting alone will not
* always reveal — so the raw pattern is checked directly.
*/
private fun assertEscaped(locale: String, key: String, pattern: String) {
for (run in Regex("'+").findAll(pattern)) {
assertTrue(
"$locale: $key has an unescaped apostrophe, double it -> $pattern",
run.value.length % 2 == 0,
)
}
}
/** After formatting, MessageFormat has consumed its quoting — leftovers mean a bad pattern. */
private fun assertClean(locale: String, key: String, rendered: String) {
assertFalse("$locale: $key still has a doubled apostrophe -> $rendered", rendered.contains("''"))
assertFalse("$locale: $key left an unformatted placeholder -> $rendered", rendered.contains("{"))
}
private fun load(locale: String): Properties {
val name = if (locale == "en") "/messages/KiloBundle.properties" else "/messages/KiloBundle_$locale.properties"
val stream = javaClass.getResourceAsStream(name)
assertNotNull("$locale: $name not on the classpath", stream)
return Properties().apply {
InputStreamReader(stream!!, StandardCharsets.UTF_8).use { load(it) }
}
}
private companion object {
val LOCALES = listOf(
"en", "ar", "bs", "da", "de", "es", "fr", "ja", "ko", "nl",
"no", "pl", "pt_BR", "ru", "th", "tr", "uk", "zh_CN", "zh_TW",
)
val PLAIN = listOf(
"session.empty.branch.link",
"session.empty.worktree.unknown",
"action.Kilo.NewSession.toolbar",
"action.Kilo.NewWorktree.toolbar",
)
}
}
@@ -15,6 +15,8 @@ import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
@@ -34,6 +36,8 @@ import kotlinx.coroutines.runBlocking
import java.awt.BorderLayout
import java.awt.Cursor
import javax.swing.JButton
import javax.swing.JEditorPane
import javax.swing.event.HyperlinkEvent
@Suppress("UnstableApiUsage")
class EmptySessionPanelTest : BasePlatformTestCase() {
@@ -159,6 +163,135 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
)
}
// ---- branch/worktree tip under the logo ----
fun `test no tip until branch status arrives`() {
val panel = panel(newWorktree = {})
assertNull(panel.tipText())
assertFalse(panel.worktreeLinked())
assertEquals(panel.explanationText(), panel.descriptionText())
}
fun `test branch promotes running the task in a worktree`() {
val panel = panel(newWorktree = {})
panel.setBranch(BranchStatusDto(branch = "main", worktree = false))
assertEquals(
"You're working directly on main. Start a task, or run it in a worktree to keep changes isolated.",
panel.tipText(),
)
assertTrue(panel.worktreeLinked())
}
fun `test worktree hints isolation and links nothing`() {
val panel = panel(newWorktree = {})
panel.setBranch(BranchStatusDto(branch = "feature/x", worktree = true))
assertEquals(
"You're in an isolated worktree on feature/x. Work freely — your main checkout stays untouched.",
panel.tipText(),
)
assertFalse(panel.worktreeLinked())
}
fun `test worktree without a branch name falls back to the generic worktree tip`() {
val panel = panel()
panel.setBranch(BranchStatusDto(branch = "(detached)", worktree = true))
assertEquals(
"You're in an isolated worktree. Work freely — your main checkout stays untouched.",
panel.tipText(),
)
}
fun `test detached plain checkout keeps the generic welcome`() {
val panel = panel(newWorktree = {})
panel.setBranch(BranchStatusDto(branch = "(detached)", worktree = false))
assertNull(panel.tipText())
assertFalse(panel.worktreeLinked())
assertEquals(panel.explanationText(), panel.descriptionText())
}
fun `test missing git keeps the generic welcome`() {
val panel = panel(newWorktree = {})
panel.setBranch(
BranchStatusDto(branch = "main", worktree = false, availability = GhAvailability.GIT_MISSING),
)
assertNull(panel.tipText())
assertFalse(panel.worktreeLinked())
}
fun `test long branch name is shortened`() {
val panel = panel()
panel.setBranch(BranchStatusDto(branch = "feature/a-very-long-branch-name-that-keeps-going"))
val tip = panel.tipText().orEmpty()
assertTrue(tip, tip.contains(""))
assertFalse(tip, tip.contains("keeps-going"))
}
fun `test minimal surface shows a worktree tip but no generic welcome`() {
val panel = panel(minimal = true)
assertFalse(panel.descriptionVisible())
panel.setBranch(BranchStatusDto(branch = "feature/x", worktree = true))
assertTrue(panel.descriptionVisible())
}
fun `test phrase stays plain text without a callback`() {
val panel = panel()
panel.setBranch(BranchStatusDto(branch = "main", worktree = false))
assertEquals(
"You're working directly on main. Start a task, or run it in a worktree to keep changes isolated.",
panel.tipText(),
)
assertFalse(panel.worktreeLinked())
}
fun `test activating the inline link invokes the callback`() {
var fired = 0
val panel = panel(newWorktree = { fired++ })
panel.setBranch(BranchStatusDto(branch = "main", worktree = false))
activateLink(panel)
assertEquals(1, fired)
}
fun `test activating the inline link ignores other hrefs`() {
var fired = 0
val panel = panel(newWorktree = { fired++ })
panel.setBranch(BranchStatusDto(branch = "main", worktree = false))
activateLink(panel, href = "https://example.test")
assertEquals(0, fired)
}
/**
* Fires the activation through the editor pane that `setCopyable(true)` installs, so the real
* listener wiring is exercised rather than a stand-in.
*/
private fun activateLink(panel: EmptySessionPanel, href: String = panel.worktreeHref()) {
val pane = UIUtil.uiTraverser(panel).filter(JEditorPane::class.java).first()
assertNotNull(pane)
val event = HyperlinkEvent(pane, HyperlinkEvent.EventType.ACTIVATED, null, href)
pane!!.hyperlinkListeners.forEach { it.hyperlinkUpdate(event) }
}
fun `test selecting recent session does not open it`() {
val panel = panel(listOf(session("ses_1"), session("ses_2")))
@@ -373,7 +506,17 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
activity: () -> Map<String, SessionActivityKind> = { sessions.activitySnapshot() },
titles: () -> Map<String, String> = { emptyMap() },
minimal: Boolean = false,
) = EmptySessionPanel(testRootDisposable, controller, recents, history, activity, titles, minimal = minimal)
newWorktree: (() -> Unit)? = null,
) = EmptySessionPanel(
testRootDisposable,
controller,
recents,
history,
activity,
titles,
minimal = minimal,
newWorktree = newWorktree,
)
private fun flush() = runBlocking {
delay(100)
@@ -15,6 +15,7 @@ import ai.kilocode.rpc.dto.WorktreePrListDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
/**
@@ -41,6 +42,12 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
val adopts = CopyOnWriteArrayList<Triple<String, String, String>>()
val reorders = CopyOnWriteArrayList<List<String>>()
var reorderResult = true
/** Stored session-list visibility per worktree path, plus the calls that touched it. */
val sessionLists = ConcurrentHashMap<String, Boolean>()
val sessionListReads = CopyOnWriteArrayList<String>()
val sessionListWrites = CopyOnWriteArrayList<Pair<String, Boolean>>()
/** When set, both session-list calls throw it instead of answering. */
var sessionListThrows: Exception? = null
val opens = CopyOnWriteArrayList<String>()
val ghCalls = CopyOnWriteArrayList<String>()
var beforeCreate: suspend () -> Unit = {}
@@ -153,4 +160,19 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
reorders.add(paths)
return reorderResult
}
override suspend fun sessionList(directory: String): Boolean? {
assertNotEdt("sessionList")
sessionListReads.add(directory)
sessionListThrows?.let { throw it }
return sessionLists[directory]
}
override suspend fun setSessionList(directory: String, visible: Boolean): Boolean {
assertNotEdt("setSessionList")
sessionListWrites.add(directory to visible)
sessionListThrows?.let { throw it }
sessionLists[directory] = visible
return true
}
}
@@ -87,4 +87,13 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
* `git worktree list` (unknown paths dropped, missing ones appended). Returns true when written.
*/
suspend fun reorder(directory: String, paths: List<String>): Boolean
/**
* Returns the persisted session-list visibility for [directory], or null when the user has not
* chosen a value yet.
*/
suspend fun sessionList(directory: String): Boolean?
/** Records the session-list visibility for [directory]. Returns true when written. */
suspend fun setSessionList(directory: String, visible: Boolean): Boolean
}